feat(web): add software result rail

This commit is contained in:
caoqianming 2026-08-27 13:25:13 +08:00
parent a219f8a0c0
commit b0a7a86aa8
12 changed files with 383 additions and 144 deletions

View File

@ -6,6 +6,12 @@
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## Unreleased
- 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。
- 修复专业软件已经出图后,对话偶尔仍持续显示“思考中”的问题。
## 0.69.0 — 2026-08-26
- Origin 新增受控科研分析,可进行数据体检、归一化、平滑、求导、积分和线性拟合,并交付结果表、诊断信息及可继续检查的 Origin 工程。

View File

@ -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 R2revision 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` 溯源。

View File

@ -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 安全下载边界;部署环境变量仍是事实源,无数据库迁移、无版本提升。

View File

@ -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"/);

View File

@ -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)

30
tests/test_web_sinks.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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,

View File

@ -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 @@
<div class="pane-head">
<span class="label">对话</span>
<span class="spacer"></span>
<button id="mobile-software-results" class="small" type="button" hidden>软件结果</button>
<button id="btn-done" class="small" disabled>完成</button>
<button id="btn-task-menu" class="small dd-toggle" disabled title="更多任务操作(导出 / 清空 / 废弃 / 删除)"></button>
</div>
@ -2164,6 +2226,8 @@
<div id="wd-concurrent-warn" style="display:none;"></div>
<div id="task-progress-dock"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<!-- 当前对话的软件结果轨Job 账本的轻量投影,不进入主消息流 -->
<div id="software-result-rail" aria-label="当前对话的软件结果" style="display:none;"></div>
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
<div id="msg-outline-rail" style="display:none;"></div>
<form id="chat-form" style="display:none;">

View File

@ -730,6 +730,7 @@ export async function showNewConversationDraft() {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
if (state.taskId) { _flushMediaArtifactCache(); clearAttachTray(); }
state.taskId = null;
document.dispatchEvent(new CustomEvent("task-selection-changed", { detail: { taskId: "" } }));
state.taskMeta = null;
state.concurrentWarnings = [];
state.loadedMessages = [];
@ -806,6 +807,7 @@ export async function selectTask(tid) {
if (state.taskId && state.taskId !== tid) { _flushMediaArtifactCache(); clearAttachTray(); }
state.taskId = tid;
restoreComposerDraft(tid);
document.dispatchEvent(new CustomEvent("task-selection-changed", { detail: { taskId: tid } }));
document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid);
});
@ -2017,9 +2019,9 @@ export function refreshCurrentTaskFiles(taskId) {
if (taskId && state.taskId === taskId) scheduleFilesRefresh();
}
// Software Job 终态可能新增一条固定报告,也可能启动自动分析 run。刷新当前 task
// 并在后者场景主动接上 SSE用户无需切走再切回来才能看到回复
export async function refreshCurrentTaskAfterSoftwareJob(taskId) {
// Software Job 本身只更新左侧结果轨;只有 analyze 才会启动对话 run。这里用刚拉取
// 的 DB 状态清掉历史上可能残留的 live card避免 Job 结果刷新把“思考中”复活
export async function refreshCurrentTaskAfterSoftwareJob(taskId, reloadMessages = false) {
if (!taskId || state.taskId !== taskId) return;
try {
const meta = await api("GET", "/v1/tasks/" + taskId);
@ -2029,8 +2031,11 @@ export async function refreshCurrentTaskAfterSoftwareJob(taskId) {
if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(taskId, `/v1/tasks/${taskId}/events`, meta);
} else {
await loadMessages();
refreshOutline();
discardInactiveLiveRun(taskId);
if (reloadMessages) {
await loadMessages();
refreshOutline();
}
}
scheduleFilesRefresh();
} catch (_) { /* Job 中心下一轮轮询或切换 task 时会恢复 */ }
@ -3012,6 +3017,7 @@ async function createTaskFromDraft() {
model_profile: state.draftModelProfile || "",
});
state.taskId = t.task_id;
document.dispatchEvent(new CustomEvent("task-selection-changed", { detail: { taskId: t.task_id } }));
state.taskMeta = t;
// task 创建与首条消息是两个请求;先把新对话草稿迁到真实 task确保第二步
// 发送失败或此时刷新页面仍能在该对话恢复内容。
@ -3150,11 +3156,28 @@ function streamSse(url, run) {
// EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
// 简单做法:走带 token 的 fetch + ReadableStream 替代 EventSource
if (!run.abortController) run.abortController = new AbortController();
fetchSse(url, run).catch((e) => {
if (e.name === "AbortError" || run.terminal) return;
appendRunError(run, "sse: " + e.message);
});
}
function discardInactiveLiveRun(taskId) {
const run = getLiveRun(taskId);
if (!run) return;
run.terminal = true;
if (run.abortController) run.abortController.abort();
if (run.card) run.card.remove();
state.liveRuns.delete(taskId);
state.streaming = state.liveRuns.size > 0;
syncTaskRowRunIndicator(taskId);
if (state.taskId === taskId) {
setActionMode("idle");
$("chat-hint").textContent = "";
}
}
function appendRunError(run, msg) {
if (state.taskId === run.taskId) {
appendErrorCard(msg);
@ -3265,6 +3288,7 @@ async function pollAutoTitle(taskId, attempts = 6) {
async function consumeSseStream(url, asstCard, ctx) {
const r = await fetch(url, {
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
signal: ctx.abortController?.signal,
});
if (!r.ok) throw new Error(r.status + " " + r.statusText);
const reader = r.body.getReader();

View File

@ -60,6 +60,7 @@ export async function openWebPreview(preview) {
_fpCurrentArtifactId = "";
_fpCurrentWebPreviewId = previewId;
$("fp-name").textContent = current.name || "网页项目预览";
$("fp-download").hidden = false;
$("fp-meta").textContent = `${Number(current.file_count || 0)} 个文件 · ${humanSize(Number(current.size_bytes || 0))}`;
_resetTextModes("fp");
const body = $("fp-body");
@ -347,6 +348,7 @@ export async function openFilePreview(rel, taskId = "", legacy = false, artifact
_fpCurrentArtifactId = artifactId;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-download").hidden = false;
$("fp-meta").textContent = "";
const body = $("fp-body");
_resetTextModes("fp");
@ -398,6 +400,63 @@ export async function openFilePreview(rel, taskId = "", legacy = false, artifact
}
}
// 专业软件 Workspace 预览保存在受保护的 Job 缓存,不一定已经导出为 task 文件。
// 结果轨与 Job 中心共用这个画廊入口URL 仍用 Bearer fetchblob URL 只活在当前
// 预览弹框,关闭时统一回收。预览不是正式 Artifact因此不提供“下载原文件”按钮。
export async function openSoftwarePreviewGallery(title, previews) {
const refs = (Array.isArray(previews) ? previews : []).filter(
(item) => item && item.url && (item.filename || item.path),
);
if (!refs.length) throw new Error("这个任务没有可查看的预览");
_fpCurrentWebPreviewId = "";
_fpCurrentRel = null;
_fpCurrentTaskId = "";
_fpCurrentLegacy = false;
_fpCurrentArtifactId = "";
_flushBlobUrls();
_resetTextModes("fp");
const body = $("fp-body");
_clearZoom(body);
_clearPdfPreview(body);
$("fp-name").textContent = title || "专业软件结果";
$("fp-meta").textContent = `${refs.length} 个预览`;
$("fp-download").hidden = true;
body.className = "body software-preview-gallery";
body.innerHTML = refs.map((item, index) => (
`<figure class="software-preview-item" data-preview-index="${index}">`
+ `<div class="ph">${escapeHtml(item.filename || item.path)} 加载中…</div>`
+ `<figcaption>${escapeHtml(item.filename || item.path)}</figcaption></figure>`
)).join("");
const cf = $("chat-form");
const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0;
$("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px");
$("file-preview-modal").classList.add("show");
document.body.classList.add("fp-open");
await Promise.all(refs.map(async (item, index) => {
const host = body.querySelector(`[data-preview-index="${index}"]`);
try {
const response = await fetch(item.url, {
headers: { "Authorization": "Bearer " + state.token },
});
if (!response.ok) throw new Error("HTTP " + response.status);
const blob = await response.blob();
if (!host || !host.isConnected) return;
const media = document.createElement("img");
media.className = "preview-img";
media.alt = item.filename || item.path;
media.src = _trackBlobUrl(
blob,
/\.svg$/i.test(item.filename || item.path) ? "image/svg+xml" : "",
);
host.querySelector(".ph").replaceWith(media);
} catch (error) {
if (!host || !host.isConnected) return;
host.querySelector(".ph").textContent = `加载失败:${error.message || error}`;
}
}));
}
function _showImage(blob) {
// SVG 在 <img> 里必须 Content-Type=image/svg+xml 才渲染;下载接口靠服务端
// mimetypes 猜类型,部分部署环境(Linux)未注册 .svg → octet-stream → 不显示。
@ -790,6 +849,7 @@ export function closeFilePreview() {
_fpCurrentLegacy = false;
_fpCurrentArtifactId = "";
_fpCurrentWebPreviewId = "";
$("fp-download").hidden = false;
}
let _mpCurrentRel = null;

View File

@ -10,6 +10,7 @@ import {
} from "./chat.js";
import { dialogConfirm, message } from "./dialog.js";
import { mqPhone, setMobileView, setRightPanelTab } from "./layout.js";
import { openSoftwarePreviewGallery } from "./preview.js";
const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
@ -23,6 +24,9 @@ let nextCursor = null;
let loadingMore = false;
let initialized = false;
let loadError = "";
let taskJobs = [];
let taskJobsTaskId = "";
let taskJobsLoadSeq = 0;
const unreadTerminal = new Set();
const statusLabel = {
@ -62,8 +66,21 @@ export function initSoftwareJobs() {
if (initialized || !$("software-job-list")) return;
initialized = true;
$("software-job-refresh").onclick = refreshSoftwareJobs;
const mobileResults = $("mobile-software-results");
if (mobileResults) {
mobileResults.onclick = () => {
setRightPanelTab("jobs");
setMobileView("mv-right", "jobs");
};
}
$("software-job-list").addEventListener("scroll", onListScroll);
document.addEventListener("software-job-submitted", refreshSoftwareJobs);
document.addEventListener("software-job-submitted", () => {
if (state.taskId) void refreshTaskSoftwareResults(state.taskId);
});
document.addEventListener("task-selection-changed", (event) => {
void refreshTaskSoftwareResults(event.detail?.taskId || "");
});
document.addEventListener("right-panel-changed", (event) => {
if (event.detail?.tab === "jobs") {
unreadTerminal.clear();
@ -71,6 +88,118 @@ export function initSoftwareJobs() {
}
});
refreshSoftwareJobs();
void refreshTaskSoftwareResults(state.taskId || "");
}
async function refreshTaskSoftwareResults(taskId) {
const seq = ++taskJobsLoadSeq;
taskJobsTaskId = taskId || "";
if (!taskJobsTaskId) {
taskJobs = [];
renderTaskResultRail();
return;
}
try {
const params = new URLSearchParams({ task_id: taskJobsTaskId, limit: "50" });
const data = await api("GET", `/v1/software-jobs?${params}`);
if (seq !== taskJobsLoadSeq || state.taskId !== taskId) return;
taskJobs = data.results || [];
} catch (_) {
if (seq !== taskJobsLoadSeq) return;
taskJobs = [];
}
renderTaskResultRail();
}
function mergeTaskJobs(currentPage) {
const taskId = state.taskId || "";
if (!taskId || taskJobsTaskId !== taskId) return;
const updates = new Map(
(currentPage || []).filter((job) => job.task_id === taskId).map((job) => [job.job_id, job]),
);
if (!updates.size) return;
const seen = new Set();
taskJobs = taskJobs.map((job) => {
const updated = updates.get(job.job_id);
if (updated) seen.add(job.job_id);
return updated || job;
});
for (const [jobId, job] of updates) {
if (!seen.has(jobId)) taskJobs.unshift(job);
}
taskJobs.sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
renderTaskResultRail();
}
function taskResultLabel(job) {
const summary = job.request_summary || {};
return summary.title || summary.display_name || job.capability || "专业软件任务";
}
function taskResultMeta(job) {
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest.length : 0;
if (job.status === "succeeded") {
return previews ? `${previews} 个预览 · 点击查看` : "已完成 · 点击查看";
}
if (job.status === "failed") return "执行失败 · 点击查看详情";
if (job.status === "cancelled") return "已取消 · 点击查看详情";
return `${statusLabel[job.status] || "处理中"}${job.progress ? ` · ${job.progress}%` : ""}`;
}
function renderTaskResultRail() {
const rail = $("software-result-rail");
const mobileResults = $("mobile-software-results");
if (!rail) return;
if (!state.taskId || taskJobsTaskId !== state.taskId || !taskJobs.length) {
rail.innerHTML = "";
rail.style.display = "none";
if (mobileResults) mobileResults.hidden = true;
return;
}
rail.innerHTML = taskJobs.map((job) => `
<button type="button" class="sr-item ${escapeHtml(job.status)}"
data-result-job-id="${escapeHtml(job.job_id)}"
title="${escapeHtml(taskResultLabel(job))} · ${escapeHtml(taskResultMeta(job))}">
<span class="sr-label"><strong>${escapeHtml(taskResultLabel(job))}</strong>
<small>${escapeHtml(taskResultMeta(job))}</small></span>
</button>`).join("");
rail.style.display = "flex";
if (mobileResults) {
mobileResults.hidden = false;
mobileResults.textContent = `软件结果 ${taskJobs.length}`;
}
rail.querySelectorAll("[data-result-job-id]").forEach((button) => {
button.onclick = () => void openTaskResult(button.dataset.resultJobId);
});
}
async function openTaskResult(jobId) {
const job = taskJobs.find((item) => item.job_id === jobId)
|| jobs.find((item) => item.job_id === jobId);
if (!job) return;
const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : [];
if (job.status === "succeeded" && previews.length) {
try {
await openSoftwarePreviewGallery(taskResultLabel(job), previews);
} catch (error) {
message(error.message || "预览打开失败", "error");
}
return;
}
if (job.status === "succeeded" && !job.workspace_id) {
await openSoftwareJobResults(job.task_id, job.output_dir || "");
setRightPanelTab("files");
if (mqPhone.matches) setMobileView("mv-right", "files");
return;
}
setRightPanelTab("jobs");
if (mqPhone.matches) setMobileView("mv-right", "jobs");
requestAnimationFrame(() => {
const card = document.querySelector(
`#software-job-list [data-job-id="${CSS.escape(jobId)}"]`,
);
if (card) card.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
function pagePath(cursor = null) {
@ -97,7 +226,9 @@ export async function refreshSoftwareJobs() {
&& previous.followup_status !== job.followup_status
&& ["running", "completed", "failed"].includes(job.followup_status)
) {
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
const reloadMessages = job.completion_action === "analyze"
&& job.followup_status === "completed";
void refreshCurrentTaskAfterSoftwareJob(job.task_id, reloadMessages);
}
if (
job.followup_status === "running"
@ -115,6 +246,7 @@ export async function refreshSoftwareJobs() {
const hadLoadedMore = previousJobs.length > PAGE_SIZE;
const firstPageIds = new Set(next.map((job) => job.job_id));
jobs = [...next, ...previousJobs.filter((job) => !firstPageIds.has(job.job_id))];
mergeTaskJobs(next);
if (!hadLoadedMore) nextCursor = data.next_cursor;
render();
} catch (_) {
@ -173,6 +305,7 @@ function render() {
? `${active.length} 个进行中 · ${statusLabel[active[0].status]}`
: (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无作业");
renderList();
renderTaskResultRail();
}
function renderList() {
@ -229,7 +362,7 @@ function followupLabel(job) {
if (job.followup_status === "failed") return "自动分析失败";
return "报告并分析";
}
return job.followup_status === "completed" ? "已报告结果" : "仅报告结果";
return job.followup_status === "completed" ? "结果已就绪" : "仅展示结果";
}
function jobActions(job, active) {
@ -325,7 +458,13 @@ async function handleAction(event, button) {
if (action === "results") {
const selected = jobs.find((item) => item.job_id === jobId);
if (selected?.workspace_id) {
await selectTask(taskId);
const previews = Array.isArray(selected.preview_manifest) ? selected.preview_manifest : [];
if (previews.length) {
try { await openSoftwarePreviewGallery(taskResultLabel(selected), previews); }
catch (error) { message(error.message || "预览打开失败", "error"); }
} else {
await selectTask(taskId);
}
} else {
await openSoftwareJobResults(taskId, selected?.output_dir || "");
setRightPanelTab("files");
@ -356,5 +495,6 @@ function notifyTerminal(job) {
const summary = job.request_summary || {};
if (!document.body.classList.contains("right-tab-jobs")) unreadTerminal.add(job.job_id);
message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
if (state.taskId === job.task_id) void refreshTaskSoftwareResults(job.task_id);
void refreshCurrentTaskAfterSoftwareJob(job.task_id, false);
}