feat(web): add software result rail
This commit is contained in:
parent
a219f8a0c0
commit
b0a7a86aa8
|
|
@ -6,6 +6,12 @@
|
|||
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 专业软件任务的进度与结果改为在当前对话左侧集中堆叠,不再用多条结果消息打断正文;可直接打开 Blender 等软件的多张预览,手机端从“软件结果”入口统一查看。
|
||||
|
||||
- 修复专业软件已经出图后,对话偶尔仍持续显示“思考中”的问题。
|
||||
|
||||
## 0.69.0 — 2026-08-26
|
||||
|
||||
- Origin 新增受控科研分析,可进行数据体检、归一化、平滑、求导、积分和线性拟合,并交付结果表、诊断信息及可继续检查的 Origin 工程。
|
||||
|
|
|
|||
|
|
@ -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` 溯源。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 安全下载边界;部署环境变量仍是事实源,无数据库迁移、无版本提升。
|
||||
|
|
|
|||
|
|
@ -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"/);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 fetch,blob 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue