feat(software): automate job completion follow-ups

This commit is contained in:
caoqianming 2026-08-17 12:12:27 +08:00
parent 30c12f9808
commit 927d059388
20 changed files with 759 additions and 35 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- 专业软件任务完成后可按用户意图仅报告产物或自动读取图表和数据并给出分析Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。

View File

@ -484,6 +484,8 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠
第六阶段增加用户级 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 中心的手动深入分析通过同一入口重新排入分析,而不是模拟点击或只预填输入框。
后续仍需实现 Token 轮换;不得以任意命令或脚本接口临时代替。当前 Job 中心采用轮询而非用户事件推送,单活与 offer 选择仍只覆盖单 Web 进程;生产启用多实例前必须增加 Redis/PG fencing 或固定路由到单一控制面实例。
---

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-17(Origin 多面板自动布局与导出基线修复完成,未发版)
最后更新:2026-08-17(专业软件 Job 完成自动报告/分析与结果中心优化完成,未发版)
---
@ -20,6 +20,8 @@
---
## 已完成关键能力
- **08-17 / Unreleased / Software Job 完成闭环与结果中心优化**`software_job_submit` 新增默认 `report`、可选 `analyze` 的完成策略0034 在原 Job 账本加入可恢复 follow-up 状态;成功任务可直接向原对话报告产物,或在 task 空闲后复用单活锁和 SSE 自动续跑分析,内部完成事件不冒充用户消息,手动深入/重新分析走同一幂等入口。Job 中心改为结果优先布局,显示短 Job ID、输入输出、完成方式和耗时折叠展示完整 ID/输出目录/执行版本,并支持复制、查看结果及状态化分析按钮。相关专项 unittest、Python/JavaScript 语法、Ruff 致命规则和 diff 检查通过;未连接生产 DB、未执行 migration。
### 2026-08-17
- **08-17 / Unreleased / Origin 多面板自动布局与导出基线修复**adapter 提升至 0.9.2,多面板图改用 Origin `layarrange` 根据行列、页边距和间距统一排列图层,并读取 Origin 计算后的实际图层位置放置标题与图例;共享 X 轴时仅底行保留标题和刻度标签,图例新增 `auto` 位置以调用 Origin 智能避让。每次任务显式设置 `@U=1`,消除 Origin 默认打印基线在中文标题、轴标题和图例上形成的异常横线,且不修改用户机器的持久配置。

6
RUN.md
View File

@ -1054,7 +1054,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
### Windows Node 内网 MVP开发中
先执行 `.venv/Scripts/python.exe main.py db upgrade head` 创建 `software_node_enrollments`、`software_nodes`、`software_jobs`为 artifact 增加专业软件来源字段。不要在未确认目标数据库时运行迁移;本机 `.env``ZCBOT_DB_URL` 可能是生产隧道。
先执行 `.venv/Scripts/python.exe main.py db upgrade head` 创建 `software_node_enrollments`、`software_nodes`、`software_jobs`,为 artifact 增加专业软件来源字段,并加入 Job 完成策略与续跑状态。不要在未确认目标数据库时运行迁移;本机 `.env``ZCBOT_DB_URL` 可能是生产隧道。
0032 旧版曾把 `tasks.working_dir` 二次拼进 user root已完成 Job 的文件和 artifact 路径可能位于重复的 `workspace/users/<uid>/...` 子树。升级到 0033 后,用显式迁移库地址先 dry-run确认所有目标目录无冲突后再加 `--apply`。脚本不加载 `.env`,文件和数据库更新均按 Job manifest 精确处理,目标目录同时存在时会停止,不能覆盖。
@ -1070,7 +1070,7 @@ $env:ZCBOT_MIGRATION_DB_URL="postgresql+psycopg://user:pass@host:5432/zcbot"
- Node `POST /v1/software-nodes/enroll` 注册并一次性取得 `node_id`、`node_token`
- Node 携带 `Authorization: Bearer <node_token>``X-Node-Id` 连接 `WS /v1/software-nodes/connect`
- 用户或 Agent 通过 `POST /v1/tasks/{task_id}/software-jobs` 提交专业软件任务;
- 用户通过 `GET /v1/software-jobs` 查看本人跨对话任务,可用 `task_id`、`active_only` 和 `limit` 筛选,`POST /v1/software-jobs/{job_id}/cancel` 请求停止;
- 用户通过 `GET /v1/software-jobs` 查看本人跨对话任务,可用 `task_id`、`active_only` 和 `limit` 筛选,`POST /v1/software-jobs/{job_id}/cancel` 请求停止`POST /v1/software-jobs/{job_id}/analyze` 对成功结果发起或重新发起分析
- 管理员 `GET /v1/admin/software-nodes` 查看节点,`PATCH /v1/admin/software-nodes/{node_id}` 启停节点,`DELETE /v1/admin/software-nodes/{node_id}` 永久删除节点身份。
Node API 只能绑定受控内网地址并由安全组限制来源 IP。当前 HTTP/WS 链路不加密;跨安全域、公网或不可信终端接入前,必须先升级 HTTPS/WSS。多 Web 实例部署时Node API 暂时固定路由到单一实例,直至 Connection Manager 增加跨实例 fencing。
@ -1100,7 +1100,7 @@ install-windows-node.bat
若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,优先设置机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `adapters/origin.plot@v2/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin/<job_id>/`plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise` 和 `software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 116 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`Agent可在该目录内搜索并分析正式输出的 artifact 带 `software_job_id`供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务或完成后的自动分析约 4 秒刷新一次,空闲时降为约 30 秒;卡片优先显示状态、所属对话、输入输出、完成策略和耗时Job ID、输出目录、执行节点及软件版本收在“任务详情”中。成功任务可查看结果目录、复制完整 Job ID、深入分析或重新分析。停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status`、`software_job_revise` 和 `software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs` 和可选的 `completion_action=report|analyze`:只要求生成或保存时用默认 `report`任务成功后平台向原对话写入固定产物报告;明确要求解释、总结或结论时用 `analyze`,平台在原 task 空闲后自动续跑 Agent。两者均不阻塞提交轮次。请求支持 116 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。成功状态提供 `output_dir`,正式输出的 artifact 带 `software_job_id`供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2`panels` 数量为 14两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind``line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列;顶层 `x_axis/y_axis/legend` 作为全部 panel 的默认值panel 内同名设置可单独覆盖,`right_y_axis` 仍按 panel 设置。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名也不支持把等高线、3D 曲面、三元图或热图混入 panel。

View File

@ -92,6 +92,8 @@ def _job_dict(row: SoftwareJob) -> dict:
"execution_runtime": _job_execution_runtime(row),
"error": row.error,
"artifact_manifest": row.artifact_manifest,
"completion_action": getattr(row, "completion_action", "report"),
"followup_status": getattr(row, "followup_status", "none"),
"output_dir": f"{contract.output_namespace}/{row.job_id}",
"created_at": row.created_at.isoformat() if row.created_at else None,
"started_at": row.started_at.isoformat() if row.started_at else None,
@ -224,10 +226,13 @@ def create_job(
idempotency_key: str,
capability: str,
request: dict,
completion_action: str = "report",
) -> tuple[dict, bool]:
key = idempotency_key.strip()
if not key or len(key) > 200:
raise SoftwareJobError("idempotency_key must contain 1 to 200 characters")
if completion_action not in {"report", "analyze"}:
raise SoftwareJobError("completion_action must be report or analyze")
try:
contract = get_contract(capability)
normalized, digest = contract.normalize_request(request)
@ -296,6 +301,7 @@ def create_job(
existing.task_id != task_id
or existing.capability != capability
or existing.request_digest != digest
or getattr(existing, "completion_action", "report") != completion_action
):
raise SoftwareJobError("idempotency key was already used for a different request")
return _job_dict(existing), False
@ -313,6 +319,8 @@ def create_job(
metrics={},
error={},
artifact_manifest=[],
completion_action=completion_action,
followup_status="none",
)
try:
with session.begin_nested():
@ -330,6 +338,7 @@ def create_job(
existing.task_id != task_id
or existing.capability != capability
or existing.request_digest != digest
or getattr(existing, "completion_action", "report") != completion_action
):
raise SoftwareJobError(
"idempotency key was already used for a different request"
@ -381,6 +390,7 @@ def revise_job(
if source is None:
raise SoftwareJobError("source software job not found")
capability = source.capability
completion_action = getattr(source, "completion_action", "report")
source_request = source.request
inputs = deepcopy(source_request.get("inputs") or [])
schema_version = get_contract(capability).request_schema[
@ -391,6 +401,7 @@ def revise_job(
task_id,
idempotency_key=idempotency_key,
capability=capability,
completion_action=completion_action,
request={
"schema_version": schema_version,
"inputs": inputs,
@ -400,6 +411,25 @@ def revise_job(
)
def request_job_analysis(user_id: UUID, job_id: UUID) -> dict:
"""为已成功任务排入一次分析续跑pending/running 请求保持幂等。"""
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == job_id,
SoftwareJob.user_id == user_id,
).with_for_update()
).scalar_one_or_none()
if job is None:
raise SoftwareJobError("job not found")
if job.status != "succeeded":
raise SoftwareJobError("only a succeeded job can be analyzed")
job.completion_action = "analyze"
if job.followup_status not in {"pending", "running"}:
job.followup_status = "pending"
return _job_dict(job)
def offer_next_job(node_ids: set[UUID]) -> dict | None:
"""选择最早可执行的 JobNode 组合,避免跨 capability 队首阻塞。"""
if not node_ids:
@ -827,6 +857,8 @@ def record_job_terminal(node_id: UUID, payload: dict) -> None:
job.error = error
job.artifact_manifest = manifest
job.terminal_at = now
if terminal_status == "succeeded":
job.followup_status = "pending"
def _is_uuid(value: str) -> bool:

View File

@ -478,6 +478,7 @@ class SoftwareJob(Base):
UniqueConstraint("user_id", "idempotency_key", name="uq_software_jobs_user_idempotency"),
Index("ix_software_jobs_status_created", "status", "created_at"),
Index("ix_software_jobs_node_status", "node_id", "status"),
Index("ix_software_jobs_followup", "followup_status", "terminal_at"),
)
job_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
@ -503,6 +504,14 @@ class SoftwareJob(Base):
metrics: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
error: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
artifact_manifest: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
# 0034:专业软件完成后的用户侧动作。report 只发布固定结果通知analyze 会在
# 原 task 空闲后自动续跑 Agent。followup_status 同时充当可恢复的轻量 outbox。
completion_action: Mapped[str] = mapped_column(
Text, nullable=False, default="report", server_default="report"
)
followup_status: Mapped[str] = mapped_column(
Text, nullable=False, default="none", server_default="none"
)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
terminal_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(

View File

@ -0,0 +1,41 @@
"""Add durable completion actions for professional software jobs.
Revision ID: 0034
Revises: 0033
Create Date: 2026-08-17
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0034"
down_revision: str | None = "0033"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"software_jobs",
sa.Column(
"completion_action", sa.Text(), nullable=False, server_default="report"
),
)
op.add_column(
"software_jobs",
sa.Column(
"followup_status", sa.Text(), nullable=False, server_default="none"
),
)
op.create_index(
"ix_software_jobs_followup",
"software_jobs",
["followup_status", "terminal_at"],
)
def downgrade() -> None:
op.drop_index("ix_software_jobs_followup", table_name="software_jobs")
op.drop_column("software_jobs", "followup_status")
op.drop_column("software_jobs", "completion_action")

View File

@ -0,0 +1,124 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
from web.software_followups import claim_followup
class _Result:
def __init__(self, value=None):
self.value = value
def scalar_one_or_none(self):
return self.value
class _Session:
def __init__(self, results):
self.results = list(results)
self.added = []
self.statements = []
def execute(self, statement):
self.statements.append(statement)
return _Result(self.results.pop(0) if self.results else None)
def add(self, row):
self.added.append(row)
def _job(action: str):
job_id = uuid4()
task_id = uuid4()
return SimpleNamespace(
job_id=job_id,
task_id=task_id,
user_id=uuid4(),
capability="origin.plot@v2",
status="succeeded",
followup_status="pending",
completion_action=action,
artifact_manifest=[
{
"artifact_id": str(uuid4()),
"filename": "figure.png",
"path": f"origin/{job_id}/figure.png",
},
{
"artifact_id": None,
"filename": "plot-spec.json",
"path": f"origin/{job_id}/.meta/plot-spec.json",
},
],
)
class SoftwareFollowupTests(unittest.TestCase):
def test_report_claim_persists_fixed_assistant_message_with_artifacts(self):
job = _job("report")
task = SimpleNamespace(task_id=job.task_id, run_status="idle")
session = _Session([job.task_id, task, job])
@contextmanager
def scope():
yield session
with (
patch("web.software_followups.session_scope", scope),
patch("web.software_followups.allocate_message_idx", return_value=7),
):
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")
def test_analyze_claim_persists_internal_turn_and_locks_task(self):
job = _job("analyze")
task = SimpleNamespace(task_id=job.task_id, run_status="idle")
session = _Session([job.task_id, task, job, None])
@contextmanager
def scope():
yield session
with (
patch("web.software_followups.session_scope", scope),
patch("web.software_followups.allocate_message_idx", return_value=8),
):
claim = claim_followup(job.job_id)
self.assertEqual(claim.action, "analyze")
self.assertEqual(job.followup_status, "running")
self.assertEqual(session.added[0].payload["role"], "user")
self.assertIn("software_job_status", session.added[0].payload["content"])
self.assertGreaterEqual(len(session.statements), 4)
def test_busy_task_leaves_followup_pending(self):
job = _job("analyze")
task = SimpleNamespace(task_id=job.task_id, run_status="running")
session = _Session([job.task_id, task])
@contextmanager
def scope():
yield session
with patch("web.software_followups.session_scope", scope):
claim = claim_followup(job.job_id)
self.assertIsNone(claim)
self.assertEqual(job.followup_status, "pending")
self.assertFalse(session.added)
if __name__ == "__main__":
unittest.main()

View File

@ -61,6 +61,7 @@ class SoftwareJobToolTests(unittest.TestCase):
self.assertTrue(result["created"])
self.assertEqual(create.call_args.args[:2], (self.user_id, self.task_id))
self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2")
self.assertEqual(create.call_args.kwargs["completion_action"], "report")
self.assertEqual(
create.call_args.kwargs["request"],
{
@ -96,6 +97,25 @@ class SoftwareJobToolTests(unittest.TestCase):
self.assertIn("outputs", required)
self.assertNotIn("input_path", SoftwareJobSubmitTool.parameters["properties"])
self.assertNotIn("request", SoftwareJobSubmitTool.parameters["properties"])
self.assertEqual(
SoftwareJobSubmitTool.parameters["properties"]["completion_action"]["enum"],
["report", "analyze"],
)
def test_submit_can_request_automatic_analysis(self):
artifact_id = uuid4()
with patch(
"tools.software_jobs.create_job",
return_value=({"job_id": str(uuid4())}, True),
) as create:
SoftwareJobSubmitTool(self.user_id, self.task_id).execute(
"origin.plot@v2",
inputs=[{"key": "sample", "artifact_id": str(artifact_id)}],
operation={"plot": {"type": "line", "series": []}},
outputs=[{"key": "figure_png", "type": "figure", "format": "png"}],
completion_action="analyze",
)
self.assertEqual(create.call_args.kwargs["completion_action"], "analyze")
def test_status_and_cancel_reject_cross_task_job(self):
foreign = {"job_id": str(uuid4()), "task_id": str(uuid4())}
@ -201,6 +221,7 @@ class SoftwareJobToolTests(unittest.TestCase):
)
self.assertEqual(result, (created, True))
self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2")
self.assertEqual(create.call_args.kwargs["completion_action"], "report")
self.assertEqual(create.call_args.kwargs["request"], {
"schema_version": 2,
"inputs": source.request["inputs"],

View File

@ -155,6 +155,25 @@ class SoftwareNodeMigrationTests(unittest.TestCase):
self.assertIn("ix_artifacts_software_job_id", rendered)
self.assertIn("jsonb_array_elements", rendered)
def test_0034_adds_software_job_followup_state(self) -> None:
statements: list[str] = []
def capture(sql, *multiparams, **params):
statements.append(str(sql.compile(dialect=postgresql.dialect())))
engine = create_mock_engine("postgresql+psycopg://", capture)
operations = Operations(MigrationContext.configure(engine.connect()))
migration = importlib.import_module(
"db.migrations.versions.20260817_1000_0034_software_job_followups"
)
with patch.object(migration, "op", operations):
migration.upgrade()
rendered = "\n".join(statements)
self.assertIn("completion_action", rendered)
self.assertIn("followup_status", rendered)
self.assertIn("ix_software_jobs_followup", rendered)
class SoftwareJobProtocolTests(unittest.TestCase):
def test_succeeded_replay_uses_persisted_manifest_across_layout_versions(self) -> None:
@ -738,6 +757,41 @@ class SoftwareJobProtocolTests(unittest.TestCase):
})
self.assertEqual(job.status, "failed")
@patch("core.software_jobs._published_output_is_valid", return_value=True)
@patch("core.software_jobs.validate_output_manifest")
@patch("core.software_jobs.session_scope")
def test_successful_terminal_queues_completion_followup(
self, session_scope, validate_manifest, _published
) -> None:
session = session_scope.return_value.__enter__.return_value
node_id = uuid4()
lease_id = uuid4()
digest = "d" * 64
manifest = [{"source_artifact_id": "figure_png"}]
validate_manifest.return_value = manifest
job = type("Job", (), {})()
job.job_id = uuid4()
job.node_id = node_id
job.lease_id = lease_id
job.request_digest = digest
job.status = "running"
job.capability = "origin.plot@v2"
job.request = {}
job.progress = 80
session.execute.return_value.scalar_one_or_none.return_value = job
record_job_terminal(node_id, {
"job_id": str(uuid4()),
"lease_id": str(lease_id),
"request_digest": digest,
"status": "succeeded",
"error": {},
"artifact_manifest": manifest,
})
self.assertEqual(job.status, "succeeded")
self.assertEqual(job.followup_status, "pending")
@patch("core.software_jobs.session_scope")
def test_disconnect_does_not_requeue_active_jobs(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value

View File

@ -43,16 +43,19 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("before_job_id", source)
self.assertIn("onListScroll", source)
self.assertIn("/cancel`", source)
self.assertIn("分析结果", source)
self.assertIn("refreshCurrentTaskFiles(job.task_id)", source)
self.assertIn("深入分析", source)
self.assertIn("查看结果", source)
self.assertIn("Job ID", source)
self.assertIn("refreshCurrentTaskAfterSoftwareJob(job.task_id)", source)
self.assertIn("job.execution_runtime", source)
self.assertIn("执行版本未记录", source)
self.assertIn("版本:${escapeHtml(jobRuntimeText(job))}", source)
self.assertIn("执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}", source)
self.assertIn(
'document.addEventListener("software-job-submitted", refreshSoftwareJobs)',
source,
)
self.assertIn("export function refreshCurrentTaskFiles(taskId)", chat_source)
self.assertIn("export async function openSoftwareJobResults", chat_source)
self.assertIn(
'document.dispatchEvent(new Event("software-job-submitted"))',
chat_source,

View File

@ -80,6 +80,15 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
"inputs": _contract_property_schema("inputs"),
"operation": _contract_property_schema("operation"),
"outputs": _contract_property_schema("outputs"),
"completion_action": {
"type": "string",
"enum": ["report", "analyze"],
"default": "report",
"description": (
"Use report to notify with output files only. Use analyze when the "
"user also asked for interpretation, conclusions, or data analysis."
),
},
"idempotency_key": {
"type": "string",
"description": (
@ -106,6 +115,7 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
inputs: list[dict] | None = None,
operation: dict | None = None,
outputs: list[dict] | None = None,
completion_action: str = "report",
idempotency_key: str = "",
) -> str:
try:
@ -134,6 +144,7 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
self.task_id,
idempotency_key=idempotency_key.strip() or str(uuid4()),
capability=capability,
completion_action=completion_action,
request=normalized_request,
)
return json.dumps({**job, "created": created}, ensure_ascii=False)

View File

@ -49,7 +49,6 @@ from .background import (
from .broker import broker
from .routers.asr import register_asr_routes
from .routers.authroutes import register_auth_routes
from .routers.software_nodes import register_software_node_routes
from .routers.external_systems import register_external_system_routes
from .routers.files import register_file_routes
from .routers.kb import register_kb_routes
@ -58,9 +57,14 @@ from .routers.misc import register_misc_routes
from .routers.models import register_model_routes
from .routers.schedules import register_schedule_routes
from .routers.skills_memory import register_skill_memory_routes
from .routers.software_nodes import register_software_node_routes
from .routers.tasks import register_task_routes
from .routers.wechat import register_wechat_routes
from .scheduler_runner import start_scheduler
from .software_followups import (
requeue_stale_analysis_followups,
start_software_followup_dispatcher,
)
from .static_files import NoCacheStaticFiles
from .wechat_runner import start_wechat_inbound
@ -113,6 +117,9 @@ def create_app() -> FastAPI:
# 启动钩子 + 后台协程群(本体见 web/background.py 等;None=该项未启用)
reap_stale_runs()
requeued_followups = requeue_stale_analysis_followups()
if requeued_followups:
print(f"[startup] requeued {requeued_followups} software followup(s)")
disk_scanner_task = start_disk_scanner(_cfg)
stats_logger_task = start_stats_logger(app, run_max_workers)
toolfail_task = start_toolfail_scanner()
@ -120,12 +127,14 @@ def create_app() -> FastAPI:
wechat_task, wechat_stop = start_wechat_inbound(app)
sandbox_reaper_task = init_sandbox(app, _cfg)
proc_sweeper_task = start_proc_sweeper(_cfg)
software_followup_task = start_software_followup_dispatcher(app)
try:
yield
finally:
# 先拒新 run + drain in-flight(细节见 background.drain_inflight)
app.state.draining.set()
await cancel_and_wait(software_followup_task)
await drain_inflight(app, drain_timeout, cancel_grace)
await cancel_and_wait(disk_scanner_task)

View File

@ -102,6 +102,7 @@ def register_message_routes(app, *, require_user) -> None:
Message.tokens_out, Message.model_profile, Message.created_at,
Message.artifact_refs,
Message.attachment_refs,
Message.kind,
)
if limit is None:
# 旧行为:升序全量
@ -153,6 +154,7 @@ def register_message_routes(app, *, require_user) -> None:
"created_at": iso(r.created_at),
"artifact_refs": r.artifact_refs,
"attachment_refs": r.attachment_refs,
"kind": r.kind,
}
for r in rows
]
@ -177,6 +179,7 @@ def register_message_routes(app, *, require_user) -> None:
.where(
Message.task_id == tid,
Message.payload["role"].astext == "user",
Message.kind.is_distinct_from("software_job_followup"),
)
.order_by(Message.idx)
).all()

View File

@ -22,7 +22,11 @@ from fastapi.responses import FileResponse
from core.artifact_lifecycle import register_published_artifacts
from core.paths import from_db_path
from core.software_contracts import SoftwareContractError, default_capabilities, get_contract
from core.software_contracts import (
SoftwareContractError,
default_capabilities,
get_contract,
)
from core.software_jobs import (
MAX_OUTPUT_ARTIFACT_BYTES,
MAX_OUTPUT_TOTAL_BYTES,
@ -38,6 +42,7 @@ from core.software_jobs import (
pending_node_cancellations,
record_job_terminal,
replay_succeeded_outputs,
request_job_analysis,
request_job_cancel,
respond_to_offer,
software_job_output_path,
@ -62,6 +67,7 @@ from web.schemas import (
SoftwareNodeDisableRequest,
SoftwareNodeEnrollRequest,
)
from web.software_followups import dispatch_followup
from web.userfiles import load_user_root, safe_join
@ -461,6 +467,7 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
"artifact_manifest": published,
}
await asyncio.to_thread(record_job_terminal, node_id, terminal)
await dispatch_followup(request.app, job_id)
except (SoftwareJobError, KeyError, TypeError) as exc:
raise HTTPException(409, str(exc)) from exc
return {"status": "succeeded", "artifact_manifest": published}
@ -654,6 +661,20 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None:
)
return job
@app.post("/v1/software-jobs/{job_id}/analyze", tags=["software-jobs"])
async def analyze_software_job(
job_id: UUID,
request: Request,
user_id: UUID = Depends(require_user), # noqa: B008
):
try:
await asyncio.to_thread(request_job_analysis, user_id, job_id)
except SoftwareJobError as exc:
detail = str(exc)
raise HTTPException(404 if detail == "job not found" else 409, detail) from exc
await dispatch_followup(request.app, job_id)
return get_job(user_id, job_id)
@app.get("/v1/software-jobs/{job_id}", tags=["software-jobs"])
def read_software_job(
job_id: UUID,

View File

@ -1,7 +1,7 @@
"""/v1 请求体 Pydantic 模型(从 app.py 析出,2026-07-23 拆分)。"""
from __future__ import annotations
from typing import Optional
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
@ -141,3 +141,4 @@ class SoftwareJobCreateRequest(BaseModel):
idempotency_key: str
capability: str = Field(default_factory=lambda: default_capabilities()[0])
request: dict = Field(default_factory=dict)
completion_action: Literal["report", "analyze"] = "report"

239
web/software_followups.py Normal file
View File

@ -0,0 +1,239 @@
"""Software Job 完成后的固定报告与 Agent 自动续跑。"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy import select, update
from core.software_contracts import get_contract
from core.storage import session_scope
from core.storage.message_index import allocate_message_idx
from core.storage.models import Message, SoftwareJob, Task
from .common import INSTANCE
from .run_lifecycle import RunScheduleError, schedule_claimed_run
@dataclass(frozen=True)
class FollowupClaim:
job_id: UUID
task_id: UUID
user_id: UUID
action: str
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 _report_text(job: SoftwareJob) -> str:
contract = get_contract(job.capability)
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)
output_dir = f"{contract.output_namespace}/{job.job_id}"
return (
"[专业软件任务完成事件]\n"
f"任务 {job.job_id}{contract.display_name})已成功完成,"
f"输出目录为 {output_dir}。请调用 software_job_status 获取完整产物清单,"
"读取输出图表和输入数据,向用户报告结果并分析主要趋势、结论及必要的限制。"
)
def pending_followup_ids(limit: int = 20) -> list[UUID]:
with session_scope() as session:
return list(session.execute(
select(SoftwareJob.job_id)
.where(
SoftwareJob.status == "succeeded",
SoftwareJob.followup_status == "pending",
)
.order_by(SoftwareJob.terminal_at, SoftwareJob.job_id)
.limit(limit)
).scalars())
def requeue_stale_analysis_followups() -> int:
"""启动 reaper 已确认 run 随进程丢失时,把自动分析退回可重试状态。"""
with session_scope() as session:
job_ids = list(session.execute(
select(SoftwareJob.job_id)
.join(Task, Task.task_id == SoftwareJob.task_id)
.where(
SoftwareJob.followup_status == "running",
Task.run_status == "error",
Task.run_error == "server restarted before run finished",
)
).scalars())
if job_ids:
session.execute(
update(SoftwareJob)
.where(SoftwareJob.job_id.in_(job_ids))
.values(followup_status="pending")
)
return len(job_ids)
def claim_followup(job_id: UUID) -> FollowupClaim | None:
"""task 空闲时原子领取回调report 在事务内直接落固定 assistant 消息。"""
with session_scope() as session:
candidate = session.execute(
select(SoftwareJob.task_id).where(SoftwareJob.job_id == job_id)
).scalar_one_or_none()
if candidate is None:
return 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"}:
return None
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
if (
job is None
or job.status != "succeeded"
or job.followup_status != "pending"
):
return None
next_idx = allocate_message_idx(session, task.task_id, locked_task=task)
if job.completion_action == "report":
session.add(Message(
task_id=task.task_id,
idx=next_idx,
payload={"role": "assistant", "content": _report_text(job)},
artifact_refs=_artifact_refs(job.artifact_manifest),
kind="software_job_report",
))
job.followup_status = "completed"
return FollowupClaim(
job_id=job.job_id,
task_id=task.task_id,
user_id=job.user_id,
action="report",
)
prompt = _analysis_prompt(job)
session.add(Message(
task_id=task.task_id,
idx=next_idx,
payload={"role": "user", "content": prompt},
kind="software_job_followup",
))
session.execute(update(Task).where(Task.task_id == task.task_id).values(
run_status="running",
run_error=None,
run_owner=INSTANCE or None,
))
job.followup_status = "running"
return FollowupClaim(
job_id=job.job_id,
task_id=task.task_id,
user_id=job.user_id,
action="analyze",
prompt=prompt,
)
def mark_followup_failed(job_id: UUID) -> None:
with session_scope() as session:
session.execute(
update(SoftwareJob)
.where(
SoftwareJob.job_id == job_id,
SoftwareJob.followup_status.in_({"pending", "running"}),
)
.values(followup_status="failed")
)
def finish_analysis_followup(job_id: UUID) -> None:
with session_scope() as session:
job = session.execute(
select(SoftwareJob).where(SoftwareJob.job_id == job_id).with_for_update()
).scalar_one_or_none()
if job is None or job.followup_status != "running":
return
task_status = session.execute(
select(Task.run_status).where(Task.task_id == job.task_id)
).scalar_one_or_none()
job.followup_status = (
"failed" if task_status in {"error", "cancelled"} else "completed"
)
def _track(app, task: asyncio.Task) -> None:
app.state.aux_tasks.add(task)
task.add_done_callback(app.state.aux_tasks.discard)
async def dispatch_followup(app, job_id: UUID) -> bool:
claim = await asyncio.to_thread(claim_followup, job_id)
if claim is None:
return False
if claim.action == "report":
return True
try:
run_task = schedule_claimed_run(
app,
claim.task_id,
claim.user_id,
claim.prompt,
scheduled=False,
)
except RunScheduleError:
await asyncio.to_thread(mark_followup_failed, claim.job_id)
return False
async def finish() -> None:
try:
await run_task
finally:
await asyncio.to_thread(finish_analysis_followup, claim.job_id)
_track(app, asyncio.create_task(finish()))
return True
async def dispatch_pending_followups(app) -> None:
for job_id in await asyncio.to_thread(pending_followup_ids):
if app.state.draining.is_set():
return
await dispatch_followup(app, job_id)
def start_software_followup_dispatcher(app) -> asyncio.Task:
async def loop() -> None:
while True:
await dispatch_pending_followups(app)
await asyncio.sleep(3)
return asyncio.create_task(loop(), name="software-followups")

View File

@ -1096,7 +1096,7 @@
#software-job-backdrop.open { opacity: 1; visibility: visible; }
#software-job-panel {
position: fixed; z-index: 130; top: 0; right: 0; bottom: 0;
width: min(420px, 92vw); display: flex; flex-direction: column; background: #fff;
width: min(480px, 94vw); display: flex; flex-direction: column; background: #fff;
box-shadow: -8px 0 28px rgba(0,0,0,.16); transform: translateX(105%);
visibility: hidden; transition: transform .2s ease, visibility .2s ease;
}
@ -1111,6 +1111,9 @@
.sj-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.sj-title-main { min-width: 0; display: flex; align-items: center; gap: 7px; }
.sj-title-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sj-context { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 5px; color: #555; font-size: 11px; }
.sj-context span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sj-context code { flex-shrink: 0; color: var(--muted); font-size: 10px; }
.sj-status { flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; border-radius: 999px; color: #6b7280; background: #f3f4f6; font-size: 11px; white-space: nowrap; }
.sj-status svg { width: 12px; height: 12px; }
.sj-card.running .sj-status, .sj-card.dispatched .sj-status, .sj-card.offered .sj-status, .sj-card.queued .sj-status { color: #9b4316; background: #fff3e7; }
@ -1125,11 +1128,28 @@
.sj-sub svg, .sj-meta svg { width: 12px; height: 12px; flex-shrink: 0; vertical-align: -2px; }
.sj-progress { height: 4px; margin-top: 7px; border-radius: 4px; background: var(--panel-muted); overflow: hidden; }
.sj-progress i { display: block; height: 100%; background: var(--accent); transition: width .25s ease; }
.sj-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 9px; }
.sj-facts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 4px 12px; margin-top: 7px; padding: 7px 8px; border-radius: 6px; background: #f8f8f8; color: #62666d; font-size: 11px; }
.sj-facts span { min-width: 0; overflow-wrap: anywhere; }
.sj-details { margin-top: 6px; font-size: 11px; }
.sj-details > summary { width: fit-content; color: var(--muted); cursor: pointer; user-select: none; }
.sj-details dl { margin: 7px 0 0; padding: 7px 8px; border: 1px solid var(--border-soft); border-radius: 6px; background: #fcfcfc; }
.sj-details dl > div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 8px; padding: 3px 0; }
.sj-details dt { color: var(--muted); }
.sj-details dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.sj-details code { font-size: 10px; }
.sj-copy { margin-left: 6px; padding: 1px 5px; border: 0; border-radius: 4px; background: transparent; color: var(--muted); cursor: pointer; font-size: 10px; }
.sj-copy:hover { color: var(--accent); background: var(--accent-soft); }
.sj-copy svg { width: 10px; height: 10px; vertical-align: -2px; }
.sj-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin-top: 9px; }
.sj-actions button { display: inline-flex; align-items: center; gap: 4px; }
.sj-actions button:disabled { cursor: default; opacity: .62; }
.sj-actions svg { width: 12px; height: 12px; }
.sj-empty { padding: 22px; text-align: center; color: var(--muted); }
.sj-loading { padding: 14px; text-align: center; color: var(--muted); font-size: 11px; }
@media (max-width: 520px) {
.sj-facts { grid-template-columns: 1fr; }
.sj-actions button { flex: 1 1 auto; justify-content: center; }
}
/* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */
.tool-banner {
display: inline-flex; flex-wrap: wrap; gap: 6px;

View File

@ -1654,6 +1654,7 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
const m = msgs[mi];
const p = m.payload || {};
const role = p.role || "?";
if (m.kind === "software_job_followup") continue;
if (role === "system") continue; // 不显示 system
if (role === "assistant" && m.model_profile && m.model_profile !== lastAsstModel) {
const dn = (state.models.find(x => x.profile === m.model_profile) || {}).display_name || m.model_profile;
@ -1880,6 +1881,34 @@ export function refreshCurrentTaskFiles(taskId) {
if (taskId && state.taskId === taskId) scheduleFilesRefresh();
}
// Software Job 终态可能新增一条固定报告,也可能启动自动分析 run。刷新当前 task
// 并在后者场景主动接上 SSE用户无需切走再切回来才能看到回复。
export async function refreshCurrentTaskAfterSoftwareJob(taskId) {
if (!taskId || state.taskId !== taskId) return;
try {
const meta = await api("GET", "/v1/tasks/" + taskId);
if (state.taskId !== taskId) return;
state.taskMeta = meta;
renderChatMeta();
if (meta.run_status === "running" || meta.run_status === "cancelling") {
ensureRunningTaskSubscribed(taskId, `/v1/tasks/${taskId}/events`, meta);
} else {
await loadMessages();
refreshOutline();
}
scheduleFilesRefresh();
} catch (_) { /* Job 中心下一轮轮询或切换 task 时会恢复 */ }
}
export async function openSoftwareJobResults(taskId, outputDir) {
if (!taskId) return;
await selectTask(taskId);
const workingDir = state.taskMeta?.working_dir || "";
const base = workingDir.split("/").filter(Boolean).pop() || "";
state.filesPath = [base, outputDir].filter(Boolean).join("/");
await loadFiles();
}
function chatAction() {
if (isCurrentTaskStreaming()) { cancelCurrentTask(); return; }
if (hasRunningProc(state.taskId)) { killTaskProcs(state.taskId); return; }

View File

@ -3,7 +3,11 @@ import { api } from "./api.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { escapeHtml } from "./format.js";
import { refreshCurrentTaskFiles, selectTask } from "./chat.js";
import {
openSoftwareJobResults,
refreshCurrentTaskAfterSoftwareJob,
selectTask,
} from "./chat.js";
import { dialogConfirm, message } from "./dialog.js";
const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
@ -38,6 +42,7 @@ const icons = {
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
failed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5m0 3h.01"/></svg>',
open: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 5h5v5m0-5-8 8"/><path d="M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg>',
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
pending: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
running: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg>',
succeeded: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/></svg>',
@ -81,8 +86,26 @@ export async function refreshSoftwareJobs() {
const next = data.results || [];
next.forEach((job) => {
const previous = known.get(job.job_id);
if (previous && ACTIVE.has(previous) && TERMINAL.has(job.status)) notifyTerminal(job);
known.set(job.job_id, job.status);
if (previous && ACTIVE.has(previous.status) && TERMINAL.has(job.status)) {
notifyTerminal(job);
} else if (
previous
&& previous.followup_status !== job.followup_status
&& ["running", "completed", "failed"].includes(job.followup_status)
) {
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
}
if (
job.followup_status === "running"
&& state.taskId === job.task_id
&& !state.liveRuns.has(job.task_id)
) {
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
}
known.set(job.job_id, {
status: job.status,
followup_status: job.followup_status,
});
});
const previousJobs = jobs;
const hadLoadedMore = previousJobs.length > PAGE_SIZE;
@ -113,7 +136,9 @@ async function loadMore() {
function schedule() {
if (timer) clearTimeout(timer);
const delay = jobs.some((job) => ACTIVE.has(job.status)) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
const delay = jobs.some((job) => (
ACTIVE.has(job.status) || ["pending", "running"].includes(job.followup_status)
)) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
timer = setTimeout(refreshSoftwareJobs, document.hidden ? Math.max(delay, 30000) : delay);
}
@ -178,28 +203,86 @@ function jobRuntimeText(job) {
: "执行版本未记录";
}
function fmtDate(value) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
}
function elapsedText(job) {
const start = job.started_at || job.created_at;
const end = job.terminal_at;
if (!start || !end) return "";
const seconds = Math.max(0, Math.round((new Date(end) - new Date(start)) / 1000));
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
return seconds % 60 ? `${minutes}${seconds % 60}` : `${minutes} 分钟`;
}
function followupLabel(job) {
if (job.completion_action === "analyze") {
if (job.followup_status === "pending") return "等待自动分析";
if (job.followup_status === "running") return "正在自动分析";
if (job.followup_status === "completed") return "已报告并分析";
if (job.followup_status === "failed") return "自动分析失败";
return "报告并分析";
}
return job.followup_status === "completed" ? "已报告结果" : "仅报告结果";
}
function jobActions(job, active) {
const task = escapeHtml(job.task_id);
if (active) return `
<button class="small" data-job-action="open" data-task-id="${task}">${icons.open}打开对话</button>
${job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.cancel}停止</button>` : ""}`;
if (job.status !== "succeeded") {
return `<button class="small" data-job-action="open" data-task-id="${task}">${icons.open}${job.status === "failed" ? "查看错误" : "打开对话"}</button>`;
}
const view = `<button class="small primary" data-job-action="results" data-task-id="${task}">${icons.open}查看结果</button>`;
if (["pending", "running"].includes(job.followup_status) && job.completion_action === "analyze") {
const label = job.followup_status === "pending" ? "等待分析" : "分析中";
return `${view}<button class="small" disabled>${icons.analyze}${label}</button>`;
}
if (job.completion_action === "analyze" && job.followup_status === "completed") {
return `${view}<button class="small" data-job-action="open" data-task-id="${task}">${icons.analyze}查看分析</button><button class="small" data-job-action="analyze" data-task-id="${task}">重新分析</button>`;
}
const label = job.followup_status === "failed" ? "重试分析" : "深入分析";
return `${view}<button class="small" data-job-action="analyze" data-task-id="${task}">${icons.analyze}${label}</button>`;
}
function jobCard(job) {
const summary = job.request_summary || {};
const inputs = Array.isArray(job.input) ? job.input : [];
const inputNames = inputs.map((item) => item.filename).filter(Boolean).join("、");
const allInputNames = inputs.map((item) => item.filename).filter(Boolean);
const inputNames = allInputNames.slice(0, 2).join("、")
+ (allInputNames.length > 2 ? `${allInputNames.length} 个文件` : "");
const active = ACTIVE.has(job.status);
const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
const detail = stageLabel[job.stage] || statusLabel[job.status] || "正在处理";
const error = job.error && (job.error.detail || job.error.code);
const opened = job.created_at;
const openedText = opened ? new Date(opened).toLocaleString("zh-CN", { hour12: false }) : "";
const artifacts = Array.isArray(job.artifact_manifest)
? job.artifact_manifest.filter((item) => item && item.artifact_id) : [];
const formats = (summary.formats || []).filter(Boolean).join("、");
const elapsed = elapsedText(job);
const shortId = String(job.job_id || "").slice(0, 8);
return `<article class="sj-card ${escapeHtml(job.status)}" data-job-id="${escapeHtml(job.job_id)}">
<div class="sj-title"><div class="sj-title-main"><strong>${escapeHtml(summary.display_name || job.capability)}</strong></div>
<span class="sj-status">${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
<div class="sj-context"><span>${escapeHtml(job.task_name || "未命名对话")}</span><code>Job ${escapeHtml(shortId)}</code></div>
<div class="sj-sub">${icons.activity}<span>${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</span></div>
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-meta">${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${inputNames ? ` · ${escapeHtml(inputNames)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}</div>
<div class="sj-meta">版本${escapeHtml(jobRuntimeText(job))}</div>
<div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">${icons.open}打开对话</button>
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">${icons.analyze}分析结果</button>` : ""}
${active && job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.cancel}停止</button>` : ""}
<div class="sj-facts">
${inputNames ? `<span>输入:${escapeHtml(inputNames)}</span>` : ""}
${job.status === "succeeded" ? `<span>输出:${artifacts.length} 个文件${formats ? ` · ${escapeHtml(formats)}` : ""}</span>` : ""}
<span>完成方式${escapeHtml(followupLabel(job))}</span>
<span>${active ? `创建于 ${escapeHtml(fmtDate(job.created_at))}` : `${elapsed ? `耗时 ${escapeHtml(elapsed)} · ` : ""}${job.terminal_at ? `完成于 ${escapeHtml(fmtDate(job.terminal_at))}` : `创建于 ${escapeHtml(fmtDate(job.created_at))}`}`}</span>
</div>
<details class="sj-details"><summary>任务详情</summary><dl>
<div><dt>Job ID</dt><dd><code>${escapeHtml(job.job_id)}</code><button type="button" class="sj-copy" data-job-action="copy">${icons.copy}</button></dd></div>
<div><dt>Capability</dt><dd><code>${escapeHtml(job.capability)}</code></dd></div>
<div><dt>输出目录</dt><dd><code>${escapeHtml(job.output_dir || "")}</code></dd></div>
<div><dt>执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}</dd></div>
<div><dt>时间</dt><dd> ${escapeHtml(fmtDate(job.created_at))}<br> ${escapeHtml(fmtDate(job.started_at))}<br> ${escapeHtml(fmtDate(job.terminal_at))}</dd></div>
</dl></details>
<div class="sj-actions">${jobActions(job, active)}</div>
</article>`;
}
@ -208,6 +291,13 @@ async function handleAction(event, button) {
const card = button.closest("[data-job-id]");
const jobId = card.dataset.jobId;
const action = button.dataset.jobAction;
if (action === "copy") {
try {
await navigator.clipboard.writeText(jobId);
message("Job ID 已复制", "success");
} catch (_) { message("复制失败,请手动复制", "error"); }
return;
}
if (action === "cancel") {
if (!await dialogConfirm({
title: "停止专业软件任务",
@ -221,17 +311,28 @@ async function handleAction(event, button) {
return;
}
const taskId = button.dataset.taskId;
if (action === "results") {
await openSoftwareJobResults(taskId, jobs.find((item) => item.job_id === jobId)?.output_dir || "");
closeDrawer();
return;
}
if (action === "analyze") {
button.disabled = true;
try {
await api("POST", `/v1/software-jobs/${jobId}/analyze`);
if (taskId) await selectTask(taskId);
await refreshCurrentTaskAfterSoftwareJob(taskId);
message("已排队分析结果", "success");
closeDrawer();
refreshSoftwareJobs();
} catch (error) {
button.disabled = false;
message(error.message || "启动分析失败", "error");
}
return;
}
if (taskId) await selectTask(taskId);
closeDrawer();
if (action === "analyze") {
setTimeout(() => {
const input = $("chat-input");
if (!input) return;
input.value = `请分析专业软件任务 ${jobId} 的结果,结合输出图表和输入数据总结主要结论。`;
input.focus();
input.dispatchEvent(new Event("input", { bubbles: true }));
}, 400);
}
}
function notifyTerminal(job) {
@ -239,5 +340,5 @@ function notifyTerminal(job) {
const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败");
const summary = job.request_summary || {};
message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
refreshCurrentTaskFiles(job.task_id);
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
}