diff --git a/CHANGELOG.md b/CHANGELOG.md index 54601e8..9fa5153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## Unreleased + +- 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。 + +- 修复专业软件已经出图后,对话偶尔仍持续显示“思考中”的问题。 + ## 0.69.0 — 2026-08-26 - Origin 新增受控科研分析,可进行数据体检、归一化、平滑、求导、积分和线性拟合,并交付结果表、诊断信息及可继续检查的 Origin 工程。 diff --git a/DESIGN.md b/DESIGN.md index 6bb08b2..d90db3a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -505,7 +505,7 @@ Workspace capability 的默认完成协议不发布正式产物。Node 只自动 第六阶段增加用户级 Job 中心与 Agent typed tools。`software_capability_list` 只暴露固定能力及当前在线空闲节点数,`software_job_submit/status/cancel` 在构造时绑定当前 user/task,模型不能跨用户或跨对话指定归属。`software_job_submit` 的唯一入口为通用 `inputs[] + operation + outputs[]`;输入和输出使用任务内稳定 key,具体 selector、type、format 和 options 由 capability 校验。`register_artifact` 是普通文件获得输入身份的唯一入口,并明确返回 UUID。右下角 Job 中心按用户聚合各对话任务,活动期短轮询、空闲期降频;终态变化通知用户,成功任务可回到原对话发起分析。取消采用协作协议:未派发任务直接终止,已派发任务先进入 `cancelling`,云端通过 WebSocket 发送并在心跳时重放 `job_cancel`,Node 杀死固定 Worker 进程树后回报 `cancelled`;终态写入仍由云端账本裁决。 -Job 完成后的对话闭环复用 `software_jobs` 账本,不增加通用事件表。提交时用 `completion_action=report|analyze` 区分“只报告产物”和“报告并分析”,未明确分析时默认 `report`;成功终态将 `followup_status` 置为 `pending`。分发器只在原 task 空闲时领取:`report` 直接写入带 artifact refs 的固定 assistant 消息,不产生模型费用;`analyze` 写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复回复;Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。 +Job 完成后的交互闭环复用 `software_jobs` 账本,不增加通用事件表,也不把每个软件结果复制为新的聊天消息。提交时用 `completion_action=report|analyze` 区分“只展示结果”和“结果完成后继续分析”,未明确分析时默认 `report`;终态将 `followup_status` 置为 `pending`。`report` 不依赖原 task 是否空闲,分发器只完成回调账本;当前 task 的左侧结果轨按 Job 独立堆叠运行状态和结果,成功预览可直接打开,移动端收敛为结果入口。右侧 Job 中心仍是跨 task 的完整管理面,主消息流只保留用户与 Agent 的叙事;历史 `software_job_report` 消息继续兼容读取,但不再新增。只有 `analyze` 需要等待原 task 空闲,随后写入前端隐藏的内部完成事件,并复用 task 单活锁、持久消息和 SSE run 自动续跑 Agent。`pending/running/completed/failed` 状态使服务重启、对话繁忙和重复完成回执均不会造成重复分析;Job 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。 ANSYS 能力固定面向 Windows 上的 Mechanical 2024 R2(revision 242),并以共享 GeoData 层拆成检查和求解两个声明式入口。`ansys.geometry.inspect@v1` 只导入一份已登记 STEP/Parasolid/IGES,枚举 assembly/part/body/face、几何单位、包围盒、面质心/法向/面积、已有 Named Selection,并输出 JSON manifest、整体预览和溯源;它不划分网格或求解。`ansys.mechanical.static_structural@v2` 接受显式单位、内置 Structural Steel、全局网格、固定支撑、力/压力和三类结果,边界区域可引用导入的 Named Selection,也可用全局轴极值平面或带法向/偏置/容差的平面查询;查询只在 Mechanical 导入后的真实 GeoData 上解析,按实体 ID 创建项目内 Named Selection,可用 `expected_count` 硬断言选择数量,并把最终面、来源和选区预览写入结果。两者都不接受 Mechanical 工程、Python、APDL、Journal、URL 或本机路径;静力输出固定为 MECHDAT、汇总/结果表、选区及应力/变形图、solver log 和 `.meta` 溯源。 diff --git a/PROGRESS.md b/PROGRESS.md index 5adc6bd..5ebc353 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-26(Origin 科研分析与平台来源升级,bump 0.69.0) +最后更新:2026-08-27(专业软件结果轨与 Web 终态收敛,未发版) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-27 / Unreleased / 专业软件结果轨与终态收敛**:当前对话的专业软件 Job 按独立状态点堆叠在聊天左侧,悬停展示名称与进度,成功的 Workspace 任务可直接打开多预览画廊,移动端收敛为“软件结果”入口;主消息流只保留用户与 Agent 叙事,默认 `report` 完成后只更新 Job 账本和视图,显式 `analyze` 才重新进入 Agent 对话。Web SSE 终态统一延后到任务状态落库之后发送,结果刷新会清理不再活动的 live card,避免软件已出图但“思考中”被重新点亮。既有历史软件报告消息继续兼容显示,无 schema、migration 或 HTTP API 变化。 + - **08-26 / 0.69.0 / Origin 科研分析 MVP**:新增非默认的 `origin.analysis@v1` / adapter 0.1.0,与稳定的 `origin.plot@v2` 分离;首批提供数据体检、最大值/面积/参考点归一化、固定局部多项式平滑、一/二阶导数、区间/累计梯形积分和带 95% 置信区间的线性拟合,固定输出可编辑 OPJU、CSV、诊断、分析规格与溯源,可选 XLSX,线性拟合工程同时保留 Origin 原生报告。六个合成真值用例在 Origin 2024 / originpro 1.1.15 / numpy 2.2.6 上完成生产 Worker、数值 oracle、OPJU 重开和进程释放门;前三例来自组合执行的逐例通过记录,后三例分别通过,目标机 COM 完整退出约需 2 分钟。Origin/合同/Job/Node 专项 159 项 unittest、Release .NET build、完整 Node publish 与独立 adapter ZIP 校验通过,未连接或写入数据库。 - **08-26 / 0.69.0 / platform_sources bounded context**:将 paper_server、内部材料库和 Materials Project 收敛为独立顶层包,以显式可信 Provider 列表统一可用性与生命周期,单来源配置/装配失败不阻断其他来源;`core/tool_registry.py` 只保留一个构建入口,与用户连接 `external_systems` 完全独立。模型工具统一改为来源明确的 `paper_server_*`、`materials_library_*`、`materials_project_*`,保留材料库批量检索、MP 专用 SDK/CIF/entries 语义及 paper_server 安全下载边界;部署环境变量仍是事实源,无数据库迁移、无版本提升。 diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs index 007f525..e5950ab 100644 --- a/tests/frontend_files.test.mjs +++ b/tests/frontend_files.test.mjs @@ -5,6 +5,7 @@ import test from "node:test"; const filesJs = fs.readFileSync("web/static/js/files.js", "utf8"); const layoutJs = fs.readFileSync("web/static/js/layout.js", "utf8"); const jobsJs = fs.readFileSync("web/static/js/software_jobs.js", "utf8"); +const previewJs = fs.readFileSync("web/static/js/preview.js", "utf8"); const pageHtml = fs.readFileSync("web/static/dev.html", "utf8"); test("file menu exposes an explicit confirmed replacement action", () => { @@ -48,6 +49,22 @@ test("software jobs share the embed-safe right workspace", () => { assert.match(jobsJs, /作业列表加载失败,请点击刷新重试/); }); +test("current task software jobs render in a dedicated result rail", () => { + assert.match(pageHtml, /id="software-result-rail"/); + assert.match(pageHtml, /id="mobile-software-results"/); + assert.match(jobsJs, /task_id: taskJobsTaskId/); + assert.match(jobsJs, /renderTaskResultRail/); + assert.match(jobsJs, /task-selection-changed/); + assert.match(jobsJs, /openSoftwarePreviewGallery/); +}); + +test("software preview gallery uses authenticated fetch and hides artifact download", () => { + assert.match(previewJs, /export async function openSoftwarePreviewGallery/); + assert.match(previewJs, /"Authorization": "Bearer " \+ state\.token/); + assert.match(previewJs, /\$\("fp-download"\)\.hidden = true/); + assert.match(pageHtml, /software-preview-gallery/); +}); + test("mobile navigation distinguishes list, files, and software jobs", () => { assert.match(pageHtml, /id="mv-tab-left"[^>]*>\s*列表/); assert.match(pageHtml, /id="mv-tab-right"[^>]*data-right-tab="files"/); diff --git a/tests/test_software_followups.py b/tests/test_software_followups.py index 4ba85f2..9f2b812 100644 --- a/tests/test_software_followups.py +++ b/tests/test_software_followups.py @@ -59,7 +59,7 @@ def _job(action: str, *, status: str = "succeeded"): class SoftwareFollowupTests(unittest.TestCase): - def test_report_claim_persists_fixed_assistant_message_with_artifacts(self): + def test_report_claim_completes_without_persisting_chat_message(self): job = _job("report") task = SimpleNamespace(task_id=job.task_id, run_status="idle") session = _Session([job.task_id, task, job]) @@ -68,20 +68,12 @@ class SoftwareFollowupTests(unittest.TestCase): def scope(): yield session - with ( - patch("web.software_followups.session_scope", scope), - patch("web.software_followups.allocate_message_idx", return_value=7), - ): + with patch("web.software_followups.session_scope", scope): claim = claim_followup(job.job_id) self.assertEqual(claim.action, "report") self.assertEqual(job.followup_status, "completed") - self.assertEqual(len(session.added), 1) - message = session.added[0] - self.assertEqual(message.payload["role"], "assistant") - self.assertIn(str(job.job_id), message.payload["content"]) - self.assertEqual(len(message.artifact_refs), 1) - self.assertEqual(message.artifact_refs[0]["label"], "figure.png") + self.assertEqual(session.added, []) def test_analyze_claim_persists_internal_turn_and_locks_task(self): job = _job("analyze") @@ -120,7 +112,7 @@ class SoftwareFollowupTests(unittest.TestCase): self.assertEqual(job.followup_status, "pending") self.assertFalse(session.added) - def test_failed_job_persists_fixed_assistant_message_without_artifacts(self): + def test_failed_job_completes_without_persisting_chat_message(self): job = _job("analyze", status="failed") job.error = {"code": "COLUMN_MISSING", "detail": " Y2\n does not exist "} task = SimpleNamespace(task_id=job.task_id, run_status="idle") @@ -130,21 +122,14 @@ class SoftwareFollowupTests(unittest.TestCase): def scope(): yield session - with ( - patch("web.software_followups.session_scope", scope), - patch("web.software_followups.allocate_message_idx", return_value=9), - ): + with patch("web.software_followups.session_scope", scope): claim = claim_followup(job.job_id) self.assertEqual(claim.action, "report") self.assertEqual(job.followup_status, "completed") - message = session.added[0] - self.assertEqual(message.payload["role"], "assistant") - self.assertIn("专业软件任务执行失败", message.payload["content"]) - self.assertIn("Y2 does not exist", message.payload["content"]) - self.assertEqual(message.artifact_refs, []) + self.assertEqual(session.added, []) - def test_cancelled_job_persists_fixed_assistant_message(self): + def test_cancelled_job_completes_without_persisting_chat_message(self): job = _job("analyze", status="cancelled") task = SimpleNamespace(task_id=job.task_id, run_status="idle") session = _Session([job.task_id, task, job]) @@ -153,19 +138,17 @@ class SoftwareFollowupTests(unittest.TestCase): def scope(): yield session - with ( - patch("web.software_followups.session_scope", scope), - patch("web.software_followups.allocate_message_idx", return_value=10), - ): + with patch("web.software_followups.session_scope", scope): claim = claim_followup(job.job_id) self.assertEqual(claim.action, "report") - self.assertIn("专业软件任务已取消", session.added[0].payload["content"]) + self.assertEqual(job.followup_status, "completed") + self.assertEqual(session.added, []) - def test_busy_task_leaves_failed_notification_pending(self): + def test_busy_task_does_not_delay_non_chat_result(self): job = _job("report", status="failed") task = SimpleNamespace(task_id=job.task_id, run_status="running") - session = _Session([job.task_id, task]) + session = _Session([job.task_id, task, job]) @contextmanager def scope(): @@ -174,8 +157,8 @@ class SoftwareFollowupTests(unittest.TestCase): with patch("web.software_followups.session_scope", scope): claim = claim_followup(job.job_id) - self.assertIsNone(claim) - self.assertEqual(job.followup_status, "pending") + self.assertEqual(claim.action, "report") + self.assertEqual(job.followup_status, "completed") self.assertFalse(session.added) diff --git a/tests/test_web_sinks.py b/tests/test_web_sinks.py new file mode 100644 index 0000000..ac822cf --- /dev/null +++ b/tests/test_web_sinks.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import unittest +from uuid import uuid4 + +from web.sinks import WebEventSink + + +class _Broker: + def __init__(self) -> None: + self.events: list[tuple] = [] + + def emit(self, task_id, event) -> None: + self.events.append((task_id, event)) + + +class WebEventSinkTests(unittest.TestCase): + def test_done_is_deferred_to_worker_close(self) -> None: + broker = _Broker() + task_id = uuid4() + sink = WebEventSink(broker, task_id) + + sink.emit({"type": "text", "delta": "ok"}) + sink.emit({"type": "done"}) + + self.assertEqual(broker.events, [(task_id, {"type": "text", "delta": "ok"})]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/sinks.py b/web/sinks.py index 40c7b0a..059aaf8 100644 --- a/web/sinks.py +++ b/web/sinks.py @@ -4,11 +4,13 @@ broker.emit(task_id, event)。sink 实例由 web 层在启 run 时创建,传进 AgentLoop; loop 完全不知 web 存在(§5 Less Scaffolding)。 """ + from __future__ import annotations from typing import Any from uuid import UUID + class WebEventSink: def __init__(self, broker, task_id: UUID) -> None: # broker: LocalRunBroker | RedisRunBroker(鸭子类型同接口,web/broker.py) @@ -16,4 +18,10 @@ class WebEventSink: self._task_id = task_id def emit(self, event: dict[str, Any]) -> None: + # AgentLoop 的 done 早于 web worker 把 tasks.run_status 落回 idle。若把这个 + # 早到终态直接发给浏览器,客户端收尾回读可能仍看到 running,继而重建一张 + # 永不该存在的“思考中”卡。Web run 的唯一终态由 run_agent_bg finally 中的 + # broker.close() 在 DB 状态写完后发送;CLI 等非 Web sink 不受影响。 + if event.get("type") == "done": + return self._broker.emit(self._task_id, event) diff --git a/web/software_followups.py b/web/software_followups.py index e04ea5c..4f30d5a 100644 --- a/web/software_followups.py +++ b/web/software_followups.py @@ -1,4 +1,4 @@ -"""Software Job 终态后的固定报告与 Agent 自动续跑。""" +"""Software Job 终态后的结果账本收尾与 Agent 自动续跑。""" from __future__ import annotations import asyncio @@ -27,89 +27,6 @@ class FollowupClaim: prompt: str = "" -def _artifact_refs(manifest: list) -> list[dict]: - refs: list[dict] = [] - for item in manifest: - if not isinstance(item, dict) or not item.get("artifact_id") or not item.get("path"): - continue - refs.append({ - "path": item["path"], - "label": item.get("filename") or item["path"].rsplit("/", 1)[-1], - "artifact_id": item["artifact_id"], - "version": 2, - }) - return refs - - -def _preview_refs(manifest: list) -> list[dict]: - return [ - { - "path": item.get("path") or item["filename"], - "label": item["filename"], - "preview_url": item["url"], - "kind": "software_preview", - } - for item in manifest - if isinstance(item, dict) - and item.get("filename") - and item.get("url") - ] - - -def _report_text(job: SoftwareJob) -> str: - contract = get_contract(job.capability) - if job.status == "failed": - error = job.error if isinstance(job.error, dict) else {} - code = str(error.get("code") or "SOFTWARE_JOB_FAILED")[:100] - detail = " ".join(str(error.get("detail") or "未提供具体错误信息").split())[:500] - return ( - f"专业软件任务执行失败:{contract.display_name}\n\n" - f"- Job ID:`{job.job_id}`\n" - f"- 错误代码:`{code}`\n" - f"- 原因:{detail}\n" - "- 结果:任务未生成正式产物;调整输入或运行环境后可重新提交" - ) - if job.status == "cancelled": - return ( - f"专业软件任务已取消:{contract.display_name}\n\n" - f"- Job ID:`{job.job_id}`\n" - "- 结果:任务已停止,未完成的中间输出不会作为正式产物发布" - ) - if contract.workspace is not None and getattr(job, "workspace_id", None) is not None: - exported = [ - str(item.get("filename")) - for item in job.artifact_manifest - if isinstance(item, dict) and item.get("artifact_id") and item.get("filename") - ] - if exported: - return ( - f"专业软件文件已导出:{contract.display_name}\n\n" - f"- Job ID:`{job.job_id}`\n" - f"- 导出文件:{'、'.join(exported)}\n" - "- Workspace 工程状态仍保留在 Windows Node,可继续加工" - ) - return ( - f"专业软件任务已完成:{contract.display_name}\n\n" - f"- Job ID:`{job.job_id}`\n" - f"- Workspace ID:`{job.workspace_id}`\n" - "- 工程状态:已保存在 Windows Node,可继续加工\n" - "- 交付方式:需要源文件或最终格式时请明确提出导出" - ) - output_dir = f"{contract.output_namespace}/{job.job_id}" - names = [ - str(item.get("filename")) - for item in job.artifact_manifest - if isinstance(item, dict) and item.get("artifact_id") and item.get("filename") - ] - files = "、".join(names) if names else "无可发布文件" - return ( - f"专业软件任务已完成:{contract.display_name}\n\n" - f"- Job ID:`{job.job_id}`\n" - f"- 输出目录:`{output_dir}`\n" - f"- 结果文件:{files}" - ) - - def _analysis_prompt(job: SoftwareJob) -> str: contract = get_contract(job.capability) if contract.workspace is not None and getattr(job, "workspace_id", None) is not None: @@ -164,7 +81,7 @@ def requeue_stale_analysis_followups() -> int: def claim_followup(job_id: UUID) -> FollowupClaim | None: - """task 空闲时原子领取回调;report 在事务内直接落固定 assistant 消息。""" + """原子领取回调;report 只完成账本,analyze 等 task 空闲后进入对话。""" with session_scope() as session: candidate = session.execute( select(SoftwareJob.task_id).where(SoftwareJob.job_id == job_id) @@ -174,7 +91,7 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None: task = session.execute( select(Task).where(Task.task_id == candidate).with_for_update() ).scalar_one_or_none() - if task is None or task.run_status in {"running", "cancelling"}: + if task is None: return None job = session.execute( select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update() @@ -186,23 +103,7 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None: ): return None - next_idx = allocate_message_idx(session, task.task_id, locked_task=task) if job.status != "succeeded" or job.completion_action == "report": - session.add(Message( - task_id=task.task_id, - idx=next_idx, - payload={"role": "assistant", "content": _report_text(job)}, - artifact_refs=( - [] - if job.status != "succeeded" - else ( - _artifact_refs(job.artifact_manifest) - if job.artifact_manifest - else _preview_refs(job.preview_manifest) - ) - ), - kind="software_job_report", - )) job.followup_status = "completed" return FollowupClaim( job_id=job.job_id, @@ -211,7 +112,11 @@ def claim_followup(job_id: UUID) -> FollowupClaim | None: action="report", ) + if task.run_status in {"running", "cancelling"}: + return None + prompt = _analysis_prompt(job) + next_idx = allocate_message_idx(session, task.task_id, locked_task=task) session.add(Message( task_id=task.task_id, idx=next_idx, diff --git a/web/static/dev.html b/web/static/dev.html index f333a1e..e1341e1 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -873,6 +873,50 @@ } .ol-dot:hover::after { background: var(--muted); } .ol-dot.active::after { background: var(--accent); width: 20px; } + /* 专业软件结果轨:与右侧消息目录对称,按 Job 独立堆叠。折叠态只露状态点, + hover/focus 展开标题;结果与运行状态来自 software_jobs,不复制进聊天消息。 */ + #software-result-rail { + position: absolute; left: 4px; top: 50%; transform: translateY(-50%); + max-height: 72%; display: flex; flex-direction: column; align-items: flex-start; + gap: 7px; padding: 8px 4px; overflow-y: auto; overflow-x: hidden; z-index: 6; + pointer-events: none; scrollbar-width: none; + } + #software-result-rail::-webkit-scrollbar { display: none; } + #mobile-software-results { display: none; } + .sr-item { + display: flex; align-items: center; gap: 8px; max-width: 24px; min-height: 24px; + padding: 0; border: 0; background: transparent; color: var(--text); cursor: pointer; + pointer-events: auto; transition: max-width .18s ease; + } + #software-result-rail:hover .sr-item, + #software-result-rail:focus-within .sr-item { max-width: 260px; } + .sr-item::before { + content: ""; flex: none; width: 10px; height: 10px; margin-left: 3px; + border: 2px solid var(--panel); border-radius: 50%; background: var(--border); + box-shadow: 0 0 0 1px var(--border); + } + .sr-item.running::before, .sr-item.queued::before, .sr-item.dispatched::before, + .sr-item.offered::before, .sr-item.disconnected::before, .sr-item.cancelling::before { + background: var(--accent); box-shadow: 0 0 0 1px var(--accent); + animation: sr-pulse 1.4s ease-in-out infinite; + } + .sr-item.succeeded::before { background: #2f8f62; box-shadow: 0 0 0 1px #2f8f62; } + .sr-item.failed::before { background: #c43d35; box-shadow: 0 0 0 1px #c43d35; } + .sr-item.cancelled::before { background: #8a929b; box-shadow: 0 0 0 1px #8a929b; } + @keyframes sr-pulse { 50% { opacity: .45; transform: scale(.82); } } + .sr-label { + min-width: 0; max-width: 0; opacity: 0; overflow: hidden; white-space: nowrap; + border: 1px solid transparent; border-radius: 10px; padding: 4px 0; + background: var(--panel); box-shadow: 0 2px 8px rgba(0,0,0,.08); + text-align: left; transition: max-width .18s ease, opacity .18s ease, padding .18s ease; + } + #software-result-rail:hover .sr-label, + #software-result-rail:focus-within .sr-label { + max-width: 228px; opacity: 1; padding: 4px 8px; border-color: var(--border); + } + .sr-label strong, .sr-label small { display: block; overflow: hidden; text-overflow: ellipsis; } + .sr-label strong { font-size: 12px; font-weight: 600; } + .sr-label small { margin-top: 1px; color: var(--muted); font-size: 10px; } /* 阅读宽度:assistant/system/tool 限到 ~48rem(约 60-80 字/行,长文不至于满屏铺开难回扫); user 气泡更窄(36rem)。宽屏下提升可读性,窄屏 92% 仍生效(min 取小者) */ .msg { border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 12px; max-width: min(92%, 48rem); animation: msg-in .22s cubic-bezier(.2,.7,.2,1); scroll-margin-top: 16px; } @@ -1516,6 +1560,20 @@ } .zoom-badge.show { opacity: 1; } #file-preview-modal .body.center { display: flex; align-items: safe center; justify-content: safe center; } + #file-preview-modal .body.software-preview-gallery { + display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); + align-content: start; gap: 14px; background: #f7f8fa; + } + .software-preview-item { + min-width: 0; margin: 0; padding: 10px; border: 1px solid var(--border); + border-radius: 10px; background: var(--panel); + } + .software-preview-item .ph { min-height: 160px; display: grid; place-items: center; } + .software-preview-item img.preview-img { width: 100%; max-height: 68vh; object-fit: contain; } + .software-preview-item figcaption { + margin-top: 7px; color: var(--muted); font-size: 11px; text-align: center; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } #file-preview-modal .body .ph { color: var(--muted); font-size: 13px; text-align: center; } .preview-spinner { width: 22px; height: 22px; border-radius: 50%; margin: 0 auto 10px; @@ -1697,6 +1755,8 @@ .msg { max-width: 96%; } /* 手机端窄屏:目录轨道会挡正文 / 滚动,直接藏(覆盖 JS 的 inline display) */ #msg-outline-rail { display: none !important; } + /* 软件结果仍可从右侧“作业”页查看;窄屏不让悬浮轨遮挡正文。 */ + #software-result-rail { display: none !important; } #chat-meta { padding: 6px 10px; gap: 6px; font-size: 11px; } #chat-meta .tid { display: none; } #chat-meta .desc { @@ -1713,6 +1773,7 @@ #pane-mid > .pane-head > .label, #pane-mid > .pane-head > .spacer { display: none; } #pane-mid > .pane-head > button { white-space: nowrap; padding: 3px 8px; } + #mobile-software-results:not([hidden]) { display: inline-flex; } #chat-form { padding: 8px; } .art-media img, .art-media video { max-width: 100%; } .file-row { padding: 8px 12px; } @@ -2157,6 +2218,7 @@