feat(software): automate job completion follow-ups
This commit is contained in:
parent
30c12f9808
commit
927d059388
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 专业软件任务完成后可按用户意图仅报告产物,或自动读取图表和数据并给出分析;Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。
|
||||
|
||||
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。
|
||||
|
||||
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。
|
||||
|
|
|
|||
|
|
@ -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 或固定路由到单一控制面实例。
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
6
RUN.md
|
|
@ -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`,支持 1–16 个输入、跨输入系列和多个显式输出;`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。两者均不阻塞提交轮次。请求支持 1–16 个输入、跨输入系列和多个显式输出;`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` 数量为 1–4;两个 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。
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""选择最早可执行的 Job–Node 组合,避免跨 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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
11
web/app.py
11
web/app.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue