版本:2026-08-11
适用范围:新任务、任务确认、/task/running、数据准备、建模方案、实验结果、论文编辑、最终成果。
事实来源:代码、JSON Schema、OpenAPI 与自动化测试;规划项单独标记为“待实现”。
本规范解决四个一致性问题:
- 首页输入如何经登录续接直接创建真实 Project/TaskRun(
/confirm作为直接访问入口共用同一流程); - 后端 TaskRun 当前阶段如何恢复为可导航的页面语义;
- Agent 左栏的步骤、摘要和按钮如何与右侧页面保持一致;
- Artifact 如何进入现有实验/成果文件表,而不破坏视觉基线。
本批次不重做页面,不把所有演示正文一次性替换为真实结果,也不把 Agent 文本当成页面结构化数据。
| 层 | 已实现 | 事实文件 |
|---|---|---|
| 契约 | ModelingWorkspaceView JSON Schema、Fixture、Python/TS 生成类型 |
packages/contracts/schemas/v1/modeling-workspace-view.schema.json |
| API | GET /api/v1/task-runs/{run_id}/workspace |
backend/api/omm_api/routers/workspace.py |
| 投影 | 节点→页面、页面状态、Agent 动作、Artifact 映射 | backend/api/omm_api/workspace_view.py |
| Web API | Project/TaskRun 创建、workspace 查询、真实 actions、错误信封、幂等键 | apps/web/src/integration/modeling-workspace-api.ts |
| 新任务控制器 | 草稿、直接创建、登录续接、幂等重试、demo 隔离 | apps/web/src/integration/task-start-controller.ts、task-start-state.ts |
| Web 控制器 | URL 恢复、DOM 渲染、SSE、清理、跨页身份、Artifact 文件行 | apps/web/src/integration/modeling-workspace-controller.ts |
| 页面接线 | activateScreen() 末尾挂载;现有 DOM 增加语义槽位 |
apps/web/src/legacy/openmathmodel-ui.ts |
| 开发代理 | /api 默认 8000,可用 OMM_API_PROXY_TARGET 覆盖 |
apps/web/vite.config.ts |
新任务切片覆盖:首页保存结构化草稿,发送时直接完成登录续接、创建 Project、以稳定幂等键创建 TaskRun,并携带 run_id/project_id 进入现有执行页。/confirm 不在首页发送链路上,保留为直接访问时的草稿复核入口,启动时执行同一套提交流程;无草稿确认页显式进入 demo,不创建后端资源。
工作台切片以已存在且归属当前用户的 run_id 为入口,覆盖:workspace 快照、六阶段状态、Agent 时间线/摘要/主操作、模型方案 Approval、SSE 通知后刷新、真实 Artifact 文件清单,以及 READY 文件下载。
附件切片覆盖:输入框拖拽/粘贴/点击三入口、浏览器内即时解析、二进制随任务创建上传为项目产物,以及服务端按需抽取的权威正文(见 2.3)。
当前尚未覆盖:五类页面详细正文契约、数据清洗确认/采用实验结果/论文完成交付等业务动作、可见暂停入口、独立 Worker 调度和完整 Skills 节点。当前详细正文继续使用页面模板,不应据此宣称 Agent 已生成真实指标、图表或论文。
首页草稿使用 openmathmodel.taskDraft.v1 保存:
description / task_type / selected_model
attachments[] = name / size / type / last_modified
+ format / parse_status / characters / excerpt / artifact_id?
project_id? / run_request_token?
草稿只带受控长度的 excerpt(单文件 4000 字、合计 24000 字):它落在 sessionStorage 里、还会随任务参数进数据库和事件流,几十万字的正文塞进去会直接把草稿写失败。完整正文留在服务端,由 2.3 的接口提供。
首页发送与确认页“开始任务”共用同一套提交流程,按固定顺序执行:
- 校验草稿(确认页先从 sessionStorage 恢复草稿;无草稿时显示 demo 说明);
fetchMe(true)确认 Cookie 身份;未登录或 401 时停在当前页打开现有登录模态,成功回调重新进入同一启动函数;POST /api/v1/projects,将返回的proj_<32hex>写回草稿;- 等待浏览器内解析结束后逐个
POST /api/v1/projects/{id}/artifacts上传附件二进制,把返回的art_<32hex>写回草稿;已有artifact_id的跳过,因此失败重试不会重复上传,任何一个附件上传失败都中止创建; POST /api/v1/task-runs,使用草稿内稳定run_request_token生成Idempotency-Key;- 任务参数包含
task_type、selected_model与attachment_metadata,并按实际结果写入attachment_upload_state(none/uploaded/partial/metadata_only); - 校验 Project/TaskRun 身份和归属后写入 active sessionStorage,再进入运行页。
附件必须赶在第 5 步之前落地:auto_start 的任务一创建 Agent 就开跑,晚到的附件进不了第一轮上下文。
创建请求失败时保留草稿与已写回的项目 ID:重新发送未修改的同一份草稿视为重试,沿用 project_id 与 run_request_token,不重复创建 Project/TaskRun;修改任务内容后发送视为新提交,两个标识重置。?demo=1 是显式演示身份,优先于历史 sessionStorage 中的 active run。
两侧各司其职,Agent 与论文环节一律以服务端结果为准:
浏览器(apps/web/src/attachments/) |
服务端(omm_api/doc_text.py) |
|
|---|---|---|
| 目的 | 拖进来立刻看到页数、字数与解析状态 | 供 Agent 消费的完整正文 |
| 触发 | 加入附件时 | 首次 GET /api/v1/artifacts/{id}/text,结果落 artifact_texts 长期复用 |
| 依赖 | pdfjs-dist(PDF)+ fflate(OOXML/ODF/zip 解压) | 标准库 zipfile/ElementTree + pypdf;旧版格式与 OCR 走可选依赖 |
| 上限 | 单文件 16MB、正文 20 万字、PDF 200 页 | 单文件 32MB、正文 40 万字、PDF 500 页 |
解析状态是五档,empty / unsupported / failed 同样是正常响应——调用方需要的是原因而不是错误码:
ready完整抽出;partial触顶截断;empty文件正常但没有文字(扫描版 PDF 落在这里);unsupported缺少可选依赖或格式不支持;failed文件损坏或抽取出错。
浏览器解不动的(旧版 .doc/.xls/.ppt、RTF、图片 OCR、超限文件)在卡片上显示为“等待服务端解析”而不是失败。服务端的可选依赖与配置:legacy-docs 附加项提供 .doc/.xls/RTF;图片与扫描件 PDF 的识别走远程 OCR(讯飞星辰 MaaS 上的 PaddleOCR,OpenAI 兼容协议,配置 OMM_OCR_API_KEY 启用;本地 paddle 栈已于 2026-08-30 移除),输出逐页 Markdown(公式→LaTeX、表格→表格标记,engine="paddleocr-api"),扫描件 PDF 另需 pdf-ocr 附加项(pypdfium2 逐页渲染);未配置 key 时图片回落 ocr 附加项 + 系统 Tesseract。缺失时接口如实返回 unsupported/empty 并说明原因。
正文抽取只拿得到文字层,文档里的图对纯文本模型是不可见的。两侧解析因此都统计图片数并如实展示:
- 服务端
Extraction.images/ 接口字段images(artifact_texts.images缓存列):PDF 按页扫/Resources//XObject数/Image并按间接引用去重(Form 不递归,近似值);docx/pptx 数word/media/*、ppt/media/*(精确);独立图片附件恒为 1;其余格式与计数失败为null。计数绝不拖垮正文抽取。 - 浏览器侧
ParseOutcome.images:PDF 为字节扫描/Subtype /Image的近似值(压缩对象流内的字典扫不到,卡片显示「约 N 张图」),OOXML 按 media 条目精确计数;随草稿进入任务参数(TaskAttachmentDraft.images)。 - 单模态提醒:附件含图且生效模型判定为纯文本时,composer 附件托盘内显示提醒行(
integration/model-modality.ts按模型名模式分类;auto/endpoint-<id>经 llm-config 解析主接口模型;未登录、未配置或模态 unknown 一律沉默——宁可漏报不可误报)。
后续批次(视觉解析后端、消息桥 image 内容块)见 ADR-0010。
任务页对话区输入框的附件走独立于 §2.2 的轻量链路——不建产物、不落库:对话历史本就只保存在页面内存,随消息提供的附件保持同样的隐私姿态。
- 服务端
POST /api/v1/artifacts/parse(需登录):multipart 单文件,同步返回AttachmentParseResult(与ArtifactText同一套 status/engine/characters/images/text 语义);超过大小上限返回unsupported并说明原因。抽取链路与产物正文完全相同,含可选 OCR/VL,图片与扫描件因此能转成 Markdown+LaTeX。 - 发送消息时
attachments/conversation-context.ts把附件折算成上下文块并入用户消息(单附件 8000 字、合计 20000 字预算):浏览器已抽到文字的直接用;解不动的(图片/扫描件/旧格式/自动解析被关)现场调即席解析,权威结果如实回写附件卡片。 agent-chat.ts的sendConversationTurn({ attachmentContext })只把上下文块并入请求内容,不进气泡展示;用户气泡下方以纸夹徽标展示附件名。发送成功后清空托盘,失败保留以便重试。- §2.4 的单模态提醒在对话框内同样生效。首页新建任务的附件仍走 §2.2 上传建产物链路,两条链路互不影响。
- 任务附件同样进入对话:
attachments/task-attachment-context.ts读取工作台artifacts中无producer_node的 READY 产物,取GET /artifacts/{id}/text权威正文(与随消息附件同预算),按解析就绪进度逐轮并入运行页对话(含开场分析);发送成功才标记已注入,未就绪的附件在上下文中如实标注「仍在本机解析中」。图片/扫描件的解析全部发生在本机 API 进程(可选 VL),不出网。 - 任务附件只属于对话绑定的运行(2026-09-07 修):
sendConversationTurn仅在当前归属是run_…时调用collectTaskAttachmentContext(runId),run id 显式传入;首页对话(chat_…)与演示态不带任务附件。清单缓存按 run 分键,configureConversation换绑时resetTaskAttachmentContext()。此前该模块自己从标签页级sessionStorage.openmathmodel.activeRunId取身份,首页新开对话会把上一个任务页的附件正文并进第一条消息(见变更日志 2026-09-07 第二条)。
| 字段 | 含义 | 消费者规则 |
|---|---|---|
run_id |
运行身份 | URL 与所有动作的主键 |
project_id/project_name |
项目上下文 | 顶部项目名、跨页参数 |
goal |
本次运行目标 | 后续任务详情与恢复 |
workflow_version |
工作流定义版本 | 未知版本必须容忍新节点 |
run_status |
生命周期轴 | QUEUED/RUNNING/WAITING_APPROVAL/PAUSED/COMPLETED/FAILED/CANCELLED |
active_node |
领域阶段轴 | 与生命周期状态分开解释 |
active_page |
当前页面语义 key | 由后端唯一映射 |
suggested_route |
当前阶段建议路由 | 仅作为导航目标;控制器不自动跳转,也不由此执行阶段动作 |
agent |
左栏 ViewModel | 文本只能按纯文本渲染 |
pages |
六个页面状态 | 同时驱动时间线和右侧状态 |
artifacts |
带状态的真实文件元数据 | 只有 READY 且存储引用完整时提供下载 URL;下载仍由 API 做所有权与哈希校验 |
pending_approval |
当前待审批项 | 只在 PENDING 时出现 |
latest_event_sequence |
已投影事件水位 | SSE 从该 sequence 之后订阅 |
updated_at |
快照更新时间 | 展示与诊断,不做并发控制 |
{
"state": "WAITING_APPROVAL",
"title": "确认建模方案后继续实验",
"summary": "确认后,Agent 将从当前检查点继续执行。",
"current_step": "等待确认:确认建模方案后继续实验",
"action": {
"kind": "approve",
"label": "确认并继续",
"target_route": "/workspace/model-plan",
"approval_id": "appr_<32hex>",
"option_id": "approve"
}
}约束:
summary是可显示纯文本,不允许内嵌 HTML。action是服务端允许动作,不是视觉按钮状态的猜测。action使用按kind判别的联合契约:navigate必须有目标路由且没有审批字段;approve必须有目标路由和审批 ID;supplement(题意解析因信息不足 E610 失败时代替retry)必须有目标路由且没有审批字段;none的路由、审批与 option 字段都必须为空。- 只有待审批项恰好存在一个非
reject选项时,后端才自动填入option_id;存在多个候选时保持为空,由接入真实PlanProposal的页面显式提交用户选择,禁止静默采用第一项。 - 取消或不可操作终态使用
none,前端禁用按钮。
页面状态枚举:
PENDING | RUNNING | WAITING_APPROVAL | PAUSED | SUCCEEDED | FAILED | CANCELLED
固定页面 key:
running | data | model | experiments | editor | complete
experiments 同时承接 EXPERIMENTING 与 VALIDATING。页面状态由当前节点、运行状态和每节点最新 attempt 共同推导,重试的旧失败 attempt 不覆盖新成功 attempt。
| workflow node | page key | 路由 | Agent 重点 | 右侧最终数据来源 |
|---|---|---|---|---|
CREATED |
running |
/task/running |
任务已创建/排队 | TaskRun + 输入 |
PROBLEM_ANALYSIS |
running |
/task/running |
目标、约束、子问题 | 待扩展 ProblemFrame |
DATA_PREPARATION |
data |
/workspace/data |
数据质量与清洗 | 待实现 DatasetProfile |
MODEL_PLANNING |
model |
/workspace/model-plan |
方案比较、审批 | 待实现 PlanProposal |
EXPERIMENTING |
experiments |
/workspace/experiments |
运行与产物 | 待实现 ExperimentSummary |
VALIDATING |
experiments |
/workspace/experiments |
指标与稳健性 | 同上 |
PAPER_WRITING |
editor |
/workspace/paper-editor |
论文生成与检查 | 待实现 DocumentDraft |
COMPLETED |
complete |
/task/complete |
交付完整性 | 待实现 DeliveryManifest |
未知节点回退 running,Agent 摘要显示节点名但页面保持可用。增加节点时先改契约/投影测试,再改页面。
优先级:
URL.searchParams.demo=1
> URL.searchParams.run_id
> sessionStorage.openmathmodel.activeRunId
> demo 模式
合法格式为 run_<32hex>。实际分支如下:
- URL 显式
demo=1:进入 demo 状态,不读取历史 active run; - URL 中存在且合法:使用 URL 值,并写入
sessionStorage.openmathmodel.activeRunId; - URL 没有
run_id参数:读取同一标签页中格式合法的 sessionStorage 值; - URL 显式存在但不合法:不读取 sessionStorage,不请求 workspace API,进入 demo 状态;
- 两处都没有合法值:不请求 workspace API,进入 demo 状态。
成功快照把 run_id/project_id 写回 sessionStorage,并装饰流程页链接。
sequenceDiagram
participant Page as 现有流程页面
participant Controller as WorkspaceController
participant API as FastAPI
participant Events as SSE
Page->>Controller: activateScreen(screen)
Controller->>Controller: 解析 run_id / 创建 AbortController
Controller->>API: GET /task-runs/{run_id}/workspace
API-->>Controller: ModelingWorkspaceView
Controller->>Page: 同步渲染项目/Agent/页面状态/Artifact
Controller->>Events: GET /events?after=latest_event_sequence
Events-->>Controller: run/step/approval/artifact event
Controller->>Controller: 80ms 合并刷新
Controller->>API: GET workspace
API-->>Controller: 新快照
Controller->>Page: 原位更新
进入其他页面或 pagehide 时:
AbortController.abort();EventSource.close();- 清除刷新定时器;
- 移除 root 捕获监听器。
同一时间只允许一个工作台控制器实例。
当前浏览器路由不会因 suggested_route 自动跳转。若当前 screen 与快照的 active_page 不同:
- 仍在当前页面渲染同一运行的项目名、Agent 时间线、摘要、页面状态和适用的 Artifact 清单;
[data-agent-cta]被转换为navigate,目标为suggested_route;- 只有用户点击导航按钮后才带
run_id/project_id进入目标页; - 错页加载与导航本身都不调用
/actions,不审批、不暂停、不恢复、不重试,也不推进状态机。
五个阶段面板同存于一个合并工作台页面,阶段间跳转是软切换而非整页导航:
- 切换由模板层
showWorkspaceStage完成(面板显隐、顶栏返回键、history.pushState别名 URL、标题更新);控制器经omm:show-stage/omm:stage-shown事件与其协作,SSE 连接与工作台快照跨切换存活; - 左栏时间线的非当前阶段行带
data-go:点击或键盘触发纯导航软切换,不调用/actions; agent.action为navigate且目标是工作台路由时软切换;目标为/task/running或首页时整页导航;- 方案确认(
approve且option_id ≠ reject)成功后自动软切换到实验面板——这是用户显式确认动作的延续,§5.4 禁止的仍是"加载时自动跳页"; popstate在工作台路径之间换面板,路径离开工作台时整页导航兜底;- 顶部返回箭头统一指向任务执行页;成果面板例外指向首页。
有归属(任务运行 run_… / 首页对话 chat_…)的对话不再走无状态的 /api/chat,而是服务端托管轮:一轮 = chat_turns 表的一条记录 + API 进程内的一个后台生成线程(omm_api/chat_turns.py ChatTurnHub)。页面只是观众——建轮即返回,经 SSE 附着直播,断了按序号续接;切任务、刷新、关标签页都不影响生成。
接口(routers/chat.py,全部要求登录):
| 方法 | 路径 | 说明 |
|---|---|---|
POST |
/api/chat/turns |
建轮,202 {turn}。body = ChatRequest + scope_id / text / opening / attachments;同一归属已有 running 轮 → 409 CHAT_TURN_IN_PROGRESS;run_… 归属校验运行所有权 |
GET |
/api/chat/turns/{id}/events?after=N |
SSE:id: <seq> / data: {...,"seq"};事件与 /api/chat 同形(`meta → delta*/reasoning* → done |
POST |
/api/chat/turns/{id}/stop |
停止生成,半截正文保留(stopped) |
GET / PATCH |
/api/chat/turns/{id} |
轮视图 / 补写页面侧执行轨迹行 {trace} |
PUT |
/api/chat/turns/{id}/feedback |
回复右下角的赞 / 踩:`{feedback: "up" |
GET |
/api/chat/scopes/{scope}/turns |
该归属全部轮(按时间;running 的带半截 reply/reasoning 与 last_seq) |
DELETE |
/api/chat/scopes/{scope} |
删该归属全部轮,运行中的先停 |
轮视图字段:id, scope_id, status(running|completed|failed|stopped|interrupted), opening, text, attachments, reply, reasoning, meta{host,model,route,usage,elapsed_ms,error?}, error{code,message}?, trace, feedback(up|down|null), last_seq, live, persisted, created_at, updated_at, ended_at。
服务端行为:正文/思考每秒批量回写库;终态后事件缓冲保留 10 分钟供续接;stop 立即定格并关闭上游连接;进程启动时把遗留的 running 标成 interrupted(服务重启是唯一会中断生成的情形);用量记账只在正常完成时发生。
「保存任务历史」:开启 → 落库;关闭 → 只在内存托管(同样的直播窗口,重启即无);由开到关 → 服务端删该用户全部轮。删除项目级联删其运行的轮;删除首页对话前端先 DELETE /scopes/{chat}。
页面侧(integration/agent-chat.ts / chat-turns-api.ts):
sendConversationTurn有归属时startChatTurn→onTurnStarted(turn)→ 附着events;AbortSignal触发服务端stop(暂停键真的停生成);SSE 断线按after=seq重连(最多 5 次),失败则拉一次轮视图补齐正文;- 重进:
modeling-workspace-controller在首次renderWorkspace之前hydrateConversation(run)并广播omm:conversation-restore{runId, goal, turns}(保证规划阶段的omm:run-planning不重复发起开场分析);页面层先渲染本机旧记录(只读兜底),再按时间重建服务端的轮,running的那一轮原位续接直播(attachConversationTurn:半截先上屏、随后接实时增量、暂停键照常生效)。首页restoreHomeChat同理; - 模型看到的上下文(history)仍由页面维护并随每次发起携带:托管轮只托管「这一轮」的生成与记录;
running的与无正文的轮不进上下文(保住 user/assistant 严格交替); - 本机 localStorage 记录(
tasks/conversation-log.ts)降级为只读兜底,不再写入;上一轮的「在途轮」(chatPending)与pagehide落定机制已移除。
没有归属的页面(演示态)仍走无状态 POST /api/chat,行为不变。非目标:不把整页导航改成软路由(界面基线不动)。
设置中心「模型厂商」卡片的型号、「配置」一键填入的默认模型、「默认模型 ID」补全与「用量监控」的单价,不再写死在前端 / 后端代码里,而是服务端 omm_api/model_catalog.py ModelCatalog 从公共目录 models.dev(api.json,无需密钥)按 TTL(默认 6 h)同步、裁剪、落文件缓存(data/model-catalog.json)后提供:
| 方法 | 路径 | 说明 |
|---|---|---|
GET |
/api/llm/catalog |
目录视图,永不阻塞在出网上:`source(catalog |
POST |
/api/llm/catalog/refresh |
立即同步后返回新视图;拉不到 502 MODEL_CATALOG_UNREACHABLE(旧数据保留);服务端关闭同步 409 MODEL_CATALOG_DISABLED;两次强制刷新至少间隔 60 s |
providers[] 按厂商预设顺序(openai / anthropic / google / deepseek / qwen / kimi / zhipu / xai / ollama):id, label, logo, protocol, base_url, alt_hosts, subtitle, source(catalog|builtin|none), highlights[](最新三款正式版), models[]{id, name, release_date, reasoning, vision, context, status("" | beta | preview), input_usd, output_usd, alias}。裁剪规则与配置项见 ADR-0017。
页面侧:integration/model-catalog.ts(一次会话拉一次,refresh 后替换缓存)、integration/model-catalog-view.ts(纯函数:按厂商取亮点 / 型号、新鲜度文案、目录模态)。legacy/openmathmodel-ui.ts 打开设置时填卡片副标题与「型号目录同步于 … 来源 models.dev」状态行,新增「同步型号」按钮;「配置」默认模型取亮点首位;「默认模型 ID」补全先铺目录再合并接口自报清单。integration/llm-providers.ts 只保留品牌骨架(名字 / 图标 / 协议 / 地址 / 备用域名),不再含模型名。model-modality.ts 的模态判定先查目录 vision,未收录再回落命名规则。后端 usage.model_pricing 先按模型 ID 精确命中目录单价 × OMM_USD_CNY_RATE,未收录再走手写 PRICING 前缀表。
任务归属(run_…)的托管轮在调用模型之前先经运行控制步骤(omm_api/run_control.py run_control_step):读运行状态 → 按状态机算出合法动作集(与按钮同一套规则:FAILED→retry;WAITING_APPROVAL→approve / reject / cancel;PAUSED→resume / cancel;RUNNING / QUEUED→pause / cancel;COMPLETED 且修订未满 3 轮→revision;CANCELLED→无)→ 两级意图判定(本地词表规则先判;拿不准、≤200 字且带与合法动作相关的命令气味时,用接口池里最弱的模型做一次 10 s 限时 JSON 判定;判定异常 / 结果不在合法集内一律回落为普通对话,绝不误执行)→ 走既有路径执行(actions.execute_action / engine_glue.accept_revision / 运行备注)→ 产出 action 事件并把【当前运行状态】【本轮已执行的操作】注入系统提示词,再生成回复。
分级:retry(同事务把这句话落成 global 备注注入重跑阶段)/ resume / pause / approve(点名选项:判别性标签片段、「方案 B」、「第二个」、已知别名;不点名的「同意 / 确认 / 继续」≤12 字且非问句时选预选项——唯一 recommended 或唯一正向)/ reject / revision(已完成运行上点名阶段 + 修改动词本地直判,否则交判定;受理后照开 ADR-0013 修订门,点名阶段标 recommended 预选,下一句「确认」即重跑)直接执行;cancel 先发提案(status:"proposed"),只对紧接着的下一轮有效:「确认 / 好 / 取消吧」执行,「算了 / 不要 / 不取消」→ dismissed,其它文本作废提案按新意图处理。问句(为什么 / 吗 / ?…)不算命令;带命令词的客气问句(「能不能修复一下?」)按命令。无动作且运行非终态、非问句的文本照旧静默落成备注(不发事件)。chat_… 归属与开场轮不经控制步骤。
SSE / 轮视图新增:
{"type":"action","seq":1,"kind":"retry","status":"executed","stage":"EXPERIMENTING","stage_label":"实验运行","note_id":"note_…","message":"已重试「实验运行」阶段,并把你的要求作为备注注入该阶段的执行提示词"}
{"type":"action","seq":1,"kind":"cancel","status":"proposed","message":"要取消这个任务吗?…","confirm_hint":"回复「确认」或点下方按钮执行;回复其它内容则不取消"}
{"type":"action","seq":1,"kind":"approve","status":"executed","approval_id":"appr_…","approval_title":"确认建模方案","option_id":"adopt:plan_b","option_label":"…"}
{"type":"action","seq":1,"kind":"revision","status":"executed","round":1,"approval_id":"appr_…","note_id":"note_…","stage":"DATA_PREPARATION","stage_label":"数据准备"}
{"type":"action","seq":1,"kind":"cancel","status":"rejected","code":"INVALID_ACTION","message":"…"}
action 事件先于 meta / 正文;同时并入轮视图 meta.actions[] 随轮落库。status ∈ executed | proposed | rejected | dismissed。判定模型的用量按 source="route" 记账。
页面侧:chat-turns-api.ts RunControlAction / ChatMeta.actions;agent-chat.ts ChatHandlers.onAction(applyEvent 处理 action,重进续接时按视图 meta.actions 逐条重放;entryFromTurn 在轮没来得及补写 trace 时由回执重建轨迹行);integration/run-control-view.ts 纯函数把回执变成轨迹行(静态标题「已重试阶段」「已选定审批选项」「已受理修改要求」「等待你确认操作」「当前状态不允许该操作」「已放弃提案」…,阶段 / 选项名进后缀,服务端说明进详情);legacy/openmathmodel-ui.ts 的回复呈现器渲染回执行、proposed 行下挂「确认执行」按钮(等价于发一句「确认」)、executed 后广播 omm:run-reopened 让工作台控制器立即重拉快照并重接事件流;恢复态最后一轮仍留待确认提案时按钮照挂。页面不再在发送前 POST /notes,也撤下了「任务已结束,本条按问答处理 / 按这条要求继续修改」那条死路轨迹行;/notes 与 /revisions HTTP 端点保留(与控制面共用 record_run_note / accept_revision)。
动作生效后运行事件的落点(2026-09-07 补):executed 回执让控制器重接事件流后,重跑阶段的 run.node_changed / run.log / step.* 紧跟着到达,此刻对话末尾正是这条(往往还在生成中的)回复块。控制器 tailTraceHost 的三条规则:尾部是轨迹块 → 续写;尾部是对话回复块 → 在该回复块内部(正文与复制按钮之后)挂「收起执行步骤」折叠头 + .agent-stream.run-trace 继续写,不另起带署名的 Agent 块;尾部是用户消息等其它元素 → 另起一个与首条 Agent 消息同构的轨迹块。回复与它触发的执行步骤是同一轮的两半,页面上是同一条 Agent 消息;用户再发消息时后续事件按时间顺序落到新的尾部。配套:回复的复制按钮改为紧跟 .analysis-copy 插入(步骤常比回复先到);toggle-activity 折叠头优先折叠紧随其后的列表(回复块里同时有 .reply-trace 与 .run-trace)。回复口径随之分三档(run_control.reply_rules):本轮有 retry / resume / redo / approve / reject / revision 已执行 → 交接口径(两三句告知已做什么、运行接下来做什么、进度看执行步骤,不在对话里替运行算题 / 建模 / 写论文);有待确认提案 → 说明代价请用户确认;其余(无动作 / rejected / dismissed / pause / cancel)→ 原口径「告知操作再回答问题」。run.log{kind:"user_note"} 在活动流里改为叙述行(原先落进原始 JSON 兜底)。
非目标(见 ADR-0018 §5):不给对话模型开工具调用;不改引擎状态机(失败运行仍只能重试当前阶段,「失败运行选起点重做」留待下一刀)——已由 §5.9 / ADR-0019 修订。
用户追问(上一刀之后):「你现在只是通过固定的关键词来识别判断吗?agent 自己没有相关的意图识别吗?我希望不管说什么,只要是需要执行的,都会出现真正的修改,而且这个修改显示的进度,也不一定要接着上一次的显示」。拍板:气味词只做第一层门槛,模型识别必须有;修改要求落成同一运行从选定阶段重做(上游成果保留、进度接原时间线);重做先发提案、确认后执行。
判定顺序(替换 §5.8 的两级判定):① 上一轮留有提案 → 只看确认 / 放弃词;② 本地规则命中且原文 ≤12 字 → 直判(省一次调用);③ 其余一律送判定模型(≤800 字;不再有气味词 / 问句门槛),提示词带运行状态、六阶段进度(已完成 / 失败于此 / 等待确认 / 暂停于此 / 正在执行 / 未开始)、失败原因、待确认选项、合法动作、最近三轮对话(用户 / 助手各截断)与这句原话;④ 模型答 none 或异常 → 回落规则结果(长句里的「继续想办法」仍是 retry);>800 字只落备注。判定的 redo 若点名尚未开始的阶段 → 视为 none(没有东西可重做,静默落备注,该阶段执行时读到);点名的正是 FAILED 的失败阶段 → 折成 retry(直接执行)。代价如实说明:任务页每句非短句对话前多一次弱模型调用(10 s 限时,source="route" 记账)。
redo 动作:合法状态 RUNNING / PAUSED / WAITING_APPROVAL(节点自提的闸门;ADR-0013 修订门上六个起点已是选项,不开 redo)/ FAILED;COMPLETED 仍走 revision;QUEUED / CANCELLED 不开。流程:提案 {"type":"action","kind":"redo","status":"proposed","stage":"MODEL_PLANNING","stage_label":"建模方案","text":"<用户原话>","message":"要从「建模方案」重做吗?…","confirm_hint":"…"} → 下一句「确认」/ 点按钮 → engine_glue.redo_run:原话落 global 备注 → 引擎 RUN_REDO 事件(reducer 关掉在途 RUNNING 步骤为 CANCELLED、清 review / failure / paused、状态回到目标阶段并 force_rerun、丢弃目标及下游旧产出)→ 投影:待审批门 CANCELLED(契约 resolution 留空,作废原因记内部 evidence.superseded_by)、在途步骤行 CANCELLED(detail「已被「从「X」重做」取代」)、清 failure_* / paused_from_status / ended_at、current_node 回目标阶段、状态 RUNNING → 回执 {"kind":"redo","status":"executed","stage","stage_label","note_id","message"},run.log 记 redo_requested。「不 / 算了」→ dismissed(「已放弃从「X」重做,运行状态不变。」)。
在途让位:RUNNING 下确认重做时,正在执行的节点不会被打断(同一运行同一时刻只有一个工作线程在推进、无协作式中断),其收尾写事件会撞 run_domain_events(run_id, seq) 唯一约束——推进器 WorkflowAdvancer.advance 只把这一种完整性错误当作让位契约(warning「in-flight step superseded by a control action」并丢弃结果),其它 IntegrityError 照常抛出;下一 tick 重放日志从目标阶段起跑,被取代的步骤不会再被 heal_interrupted 判成 executor lost。
页面侧只加文案:run-control-view.ts 新增 redo 的标题「已从阶段重做」与类别「从阶段重做」(提案 / 放弃行后缀带回到的阶段名),RunControlAction.text 字段;提案按钮沿用 status:"proposed" 通道,无 DOM 结构变化。
当前事件类型:
run.created
run.status_changed
run.node_changed
run.log
step.started
step.succeeded
step.failed
approval.requested
approval.resolved
artifact.published
- SSE
id等于AgentEvent.sequence。 - 显式
after优先于Last-Event-ID。 - heartbeat 是注释
: ping,不进入历史。 - 终态无增量时发送
stream.end;Web 最后刷新一次并关闭连接。 - 终态之后运行再次离开终态(
retry把 FAILED 置回 RUNNING、修订受理把 COMPLETED 置回 WAITING_APPROVAL),Web 必须重新连接(after=最后序号只接增量):控制器在任何一次快照刷新看到非终态而流已收尾时重连,不依赖是哪个动作触发的。 - 事件只作为“快照可能变化”的通知,不直接把 payload 注入页面正文。
未来增加 stage.output.updated 时,payload 只带 stage/version/content_hash,完整对象仍从读接口获取。
sequenceDiagram
participant User as 用户
participant Web as 现有方案页
participant API as /actions
participant Runner as 状态机
User->>Web: 点击“确认并继续”
Web->>Web: 禁用按钮,显示提交中
Web->>API: approve + approval_id + option_id + Idempotency-Key
API->>Runner: 解决审批并从检查点恢复
API-->>Web: TaskRun
Web->>API: GET workspace
Runner-->>Web: approval.resolved / node_changed SSE
Web->>API: GET workspace
Web-->>User: 真实后端状态
失败时保持原页面,Agent 摘要区显示统一错误文案;不执行下一页跳转。
底层 /actions、共享契约与 Web 控制器支持 pause、resume、retry,并复用幂等键。当前 workspace 投影只会在 PAUSED 时产生 resume、在 FAILED 时产生 retry(题意解析因信息不足 E610 失败时改为 supplement:前端把焦点送进同页对话输入框、不调用后端,补充内容由对话控制面改判为带题面补充的重试);运行中不会产生 pause,现有页面也没有可见暂停入口。因此暂停是接口能力,不是当前工作台已经交付的用户操作。运行状态确认前不改页面步骤为成功。navigate 不调用后端,但必须保留 run_id/project_id。
API 层 /actions 另支持 cancel(按状态机校验);当前 workspace 投影与页面主操作不暴露该动作,前端也不自行构造。
数据清洗确认、采用实验结果、论文完成交付目前没有独立后端动作。生产接入前需决定:
- 是否作为新的 Approval decision_type;
- 是否属于具体阶段输出保存接口;
- 或继续由自动工作流推进。
在决策前,前端不自创新 action 字符串。
| 数据 | DOM | 渲染规则 |
|---|---|---|
pages[] |
[data-agent-steps] |
重建六行,状态映射图标与文字 |
agent.title/summary |
[data-agent-summary] |
使用 textContent,防止注入 |
agent.action |
[data-agent-cta] |
移除演示 data-go,绑定真实动作 |
project_name |
[data-bind="project-name"] |
原位替换标题 |
完成步骤使用当前绿色完成样式;当前、审批、暂停和失败步骤使用现有 current 样式,不另造颜色体系。
六个流程页面当前都提供 Agent 时间线与摘要槽位;/task/running 和五个聚焦工作台页面也都提供 [data-agent-cta]。主操作必须经过 actionForScreen():错页只能得到 navigate,同页才允许消费后端投影的 approve/resume/retry 等动作。
工作台根节点与右侧阶段容器分别写入:
[data-modeling-shell] data-active-page=<active_page>
[data-modeling-shell] data-stage-status=<viewed_page.status>
.focused-stage-pane/.modeling-stage-pane data-workspace-page=<current_screen>
.focused-stage-pane/.modeling-stage-pane data-stage-status=<viewed_page.status>
因此后端活动阶段与用户当前查看页面不会混为一个属性:错页时左侧仍显示全局真实进度,右侧仍保留当前模板并呈现该页面自己的阶段状态。页签仍由原有 bindScreen() 切换;控制器不重新创建页签或替换正文结构。
这里的“右侧渲染”目前仅指 data-workspace-page、data-stage-status 和适用的 Artifact 文件行。数据质量指标、角色化方案、实验图表、论文正文和成果摘要尚未由 ModelingWorkspaceView 提供。它们必须等待五类独立正文契约,不可用阶段状态属性冒充真实内容接入。
真实文件行沿用 .deliverable:
data-artifact-id标识真实 Artifact;- 名称来自投影
name; - 类型来自
kind; - 大小按 B/KB/MB/GB 格式化;
- 下载按钮使用
data-artifact-download。
状态行为:
READY且download_url存在:显示“可下载”,按钮可用;PENDING、STALE、DELETED:显示对应真实状态,下载按钮禁用且不设置data-artifact-download;- workspace API 仅在 Artifact 为 READY 且
uri/sha256完整时返回download_url。
面板分组:
| 页面面板 | kind |
|---|---|
| 实验/结果图表 | figure |
| 实验/结果表 | table, dataset |
| 实验/运行日志 | log |
| 实验/模型代码 | code, model |
| 完成/最终成果 | 全部 |
| 完成/论文文件 | paper, report |
| 完成/数据与代码 | dataset, code, model |
| 完成/交付记录 | log, other |
没有匹配文件时显示“该阶段尚未发布产物”,不回退到演示文件。底部“导出文件清单”导出名称、类型、状态、大小与下载地址的 TXT 清单;它不是 ZIP、归档包或多文件合并下载。
控制器在根节点上维护机器可读的接入状态,浏览器测试应以这些属性为断言锚点:
[data-modeling-shell]的data-integration-state:loading → ready / error / demo四值生命周期;快照成功时同时写入data-workspace-source="api"。[data-task-start-root]的data-task-start-state:draft / ready / loading / auth-required / created / error / demo;data-task-start-source标记local / draft / demo来源。
build_modeling_workspace_view() 每次请求:
- 已由路由完成运行归属校验;
- 查询全部 StepRun,按 node 选最新 attempt;
- 查询 run Artifact,并用 producer step 反查 producer node;
- 查询最新 PENDING Approval;
- 查询 AgentEvent 最大 sequence;
- 由当前节点和生命周期计算六页状态;
- 生成 Agent 摘要与允许动作;
- 由 Pydantic 生成类型校验响应,再交给 FastAPI response_model。
该接口不新增表,不改变状态机,不持久化派生页面状态。
- workspace 与 actions 都使用同源 Cookie 身份。
- 非本人运行与不存在运行统一 404。
- Agent 文案按纯文本渲染。
- 下载仍走现有 Artifact endpoint,服务端重新计算 SHA-256。
- 未知
workflow_version/current_node不使前端崩溃。 - 401/404/409/网络错误保留原页面,并在 Agent 区原位呈现。
- workspace 返回 404(运行不存在或非本人)时,控制器同时清除 sessionStorage 中的活动
run_id/project_id:当前页保留错误提示,其余流程页回到演示态自愈,避免本地数据库重置后整个标签页持续报错。 - URL 显式提供非法
run_id,或 URL 未提供且 sessionStorage 也没有合法值时,不发 workspace 请求,支持 UI 独立预览与视觉回归。
完整联调从仓库根启动:
npm run dev统一入口启动或复用 127.0.0.1:8000 的 API;健康检查必须返回成功状态码(2xx)、JSON 且 status: ok,随后才启动 Web。登录、账户设置与带 run_id 的工作台都需要 API;npm run dev:web 只启动 Vite,主要用于静态页面预览或与手工启动的 API 配合。Vite 参数通过 npm run dev -- --host <HOST> --port <PORT> 传入。这样拉起的 API 不带热重载:改了后端或智能体代码要把 API 整个停掉再启动。复用已运行的 API 时会打印它的代码版本与启动时间(/api/system 的 code),它启动之后源码又改过就醒目提醒;运行中每一步也会记下执行代码的身份(见 2026-10-03 记录)。
默认 Vite 代理:
/api → http://127.0.0.1:8000
隔离联调可设置:
$env:OMM_API_PROXY_TARGET='http://127.0.0.1:8010'
npm run dev --workspace @openmathmodel/web -- --port 5175该变量只影响开发/预览代理,不进入浏览器构建产物。
统一入口只会自动启动本机 loopback HTTP API;OMM_API_PROXY_TARGET 指向远程或非 loopback 地址时,目标必须已经健康,脚本不会在本机冒充该服务。
开发/预览服务器还内置两个中间件,均不进入生产构建产物:
GET /api/account/me:无会话 Cookie 或 API 不可达时由 Vite 直接降级返回 401 访客态 JSON,不产生代理错误。单独运行npm run dev:web时页面因此显示访客而不是报错;调试登录问题时先确认 API 是否在运行。/paper-files/<年份>/<题组>/<文件>.pdf:优先读取本地datasets/raw/sources/github/zhanwen-MathModel/papers缓存,未命中再按固定 revision 回源 GitHub raw。论文阅读页依赖该路由;脱离 Vite 的静态部署需要另行提供同路径静态服务。
端到端诊断顺序固定为:
GET /api/health
→ 登录并保留 Cookie
→ POST /api/v1/projects
→ POST /api/v1/task-runs(记录返回的 run_id)
→ GET /api/v1/task-runs/{run_id}/workspace
→ 在现有页面 URL 携带 run_id/project_id
可直接复制的 PowerShell 请求见根 README 的“用真实 run_id 验证工作台”。若健康检查失败,先修复 API 启动;Vite 代理错误不作为页面渲染问题处理。
| 层 | 用例 | 预期 |
|---|---|---|
| Schema | valid fixture | JSON Schema 通过 |
| 生成物 | TS/Python check | 与 Schema 完全同步 |
| API | 待审批 workspace | active=model,action=approve |
| API | 完成 workspace | 六页完成,含实验/论文 Artifact |
| API | 跨账户 | 404 |
| Web | 首页发送真实启动 | 登录续接后 Project/TaskRun 只创建一次,直接进入执行页且 URL 携带合法 run_id/project_id;创建过程中发送键禁用 |
| Web | 首页未登录发送 | 停在首页并打开现有登录模态;登录成功后续接同一次创建 |
| Web | 失败后重复发送 | 未修改内容时沿用 project_id 与幂等 token,不重复创建;修改内容后视为新提交 |
| Web | 确认页直接访问 | 草稿、任务类型、模型与附件元数据恢复;“开始任务”走同一提交流程且重试不重复创建 |
| Web | 无草稿确认页 | 显式 demo,不请求创建接口、不复用旧 active run |
| Web | workspace 404 | 当前页原位报错并清除活动运行;其余流程页回到演示态 |
| Web | URL 与 sessionStorage 都无合法 run_id | demo 状态,无 API 请求错误 |
| Web | URL 显式非法 run_id | 不回退 sessionStorage,保持 demo |
| Web | 待审批 run | 六阶段真实时间线,按钮可提交 |
| Web | 点击审批 | 后端动作成功,SSE/快照刷新 |
| Web | 错页打开 run | 只提供导航;不自动跳页、不请求 /actions、不改变运行状态 |
| Web | 跨页 | 查询参数保留 |
| Web | 工作台内切换阶段 | 无整页重载;URL、标题与投影同步更新 |
| Web | 浏览器后退/前进 | 工作台面板间穿梭不重载;离开工作台整页导航 |
| Web | 方案确认成功 | 自动软切换至实验面板 |
| Web | 论文页 | 只保留大纲、工具栏、正文 |
| Web | 完成页 | 真实 Artifact 或空状态;仅 READY 可下载 |
| Web | 导出文件清单 | 生成 TXT 清单,不生成压缩包 |
| Build | typecheck/check/build | 全部退出码 0 |
| API | 删除 / 清扫链路(PostgreSQL) | 凡改动 privacy.py 的删除逻辑、或新增带外键指向 task_runs / projects / artifacts 的表,本地必须用 OMM_TEST_DATABASE_URL=postgresql+psycopg://openmathmodel:openmathmodel@127.0.0.1:5433/openmathmodel_test 指向 tools/pg-dev.ps1 的测试库再跑一遍相关用例;并在用例里逐表数行断言从属行已清空 |
测试夹具默认用 SQLite,而 SQLite 默认不检查外键、时间列取回是 naive。两类只有 PostgreSQL 才会暴露的缺陷因此在本地全绿、到线上才炸:
- 外键漏删(2026-09-02,B8):
stage_outputs(0017)、run_notes(0018)、paper_exports(0016)三张表加入后,privacy._delete_runs一直没有连带删除;SQLite 上DELETE /projects照样 204,PostgreSQL 上则ForeignKeyViolation——侧栏「删除任务」与「任务保留」清扫对任何有阶段产出 / 追问备注 / 论文导出的运行一律 500。修复后新增的用例除了断言 204,还逐表数行,让 SQLite 也能抓到漏删; - 时区比较(此前 CI
api-postgres作业翻车):ended_at在 SQLite 取回 naive、PostgreSQL 取回 aware,裸比较在 PG 抛 TypeError,见privacy._expired_run_ids的as_utc。
CI 的 api-postgres 作业会在真实 PostgreSQL 上跑全量 API 测试,但它只能拦住有用例覆盖的路径;新增带外键的表时,删除链路的用例要一并补上,否则 CI 也是假绿。
记录日期:2026-08-11;2026-08-12 增量见本节末尾。标题不携带日期,保证跨文档锚点稳定。
以下命令/测试已经实际执行并通过:
| 证据 | 结果 |
|---|---|
npm run check --workspace @openmathmodel/contracts |
通过 |
npm run check --workspace @openmathmodel/web |
TypeScript 与 ESLint 通过 |
npm run build --workspace @openmathmodel/web |
生产构建通过,Vite 转换 88 个模块 |
python packages/contracts/validate.py |
8 个 Schema、28 个 Fixture 通过 |
python packages/contracts/check_compat.py |
兼容性检查通过 |
python packages/contracts/scripts/export_openapi.py --check |
OpenAPI 基线通过;公共 Timestamp component 保持兼容 |
pytest backend/api/tests -q |
66 个用例通过;其中 workspace 覆盖排队、待审批、动作字段不变量、审批选项歧义、完成聚合、非 READY 与存储可读性、跨项目异常关联、跨账户与 OpenAPI 兼容(2026-08-12 增至 75 个,见下) |
node --test apps/web/src/integration/task-start-state.test.mjs |
4 个新任务状态纯函数用例通过:草稿解析、输入归一化、项目名派生、运行 URL |
| 新任务相关 API 回归 | Project、TaskRun 幂等创建和 workspace 投影共 14 个相关用例通过 |
| 关键流程人工浏览器验收 | 首页→确认、草稿恢复、访客登录拦截、无草稿 demo 隔离,以及 /task/running、方案审批、跨页身份、论文页、完成页与真实 Artifact 行通过;控制台无错误 |
这些证据验证了契约、类型、生产构建、任务启动状态函数、workspace API 核心投影及关键人工流程。真实登录浏览器中的最终 Project/TaskRun 写入尚未在本轮 IAB 会话落库;两个创建接口、幂等语义和 workspace 投影已有后端回归覆盖。Web 控制器 DOM、SSE 重连、错页导航、其余 URL/sessionStorage 分支与非 READY 下载禁用态仍缺自动化浏览器覆盖,全部 14 条路由的自动视觉回归也尚待补齐。
行为与代码变化:
- 首页发送链路由「跳
/confirm」改为「直接创建并进入执行页」;/confirm退出首页发送链路,保留为直接访问的草稿复核入口,与首页共用同一套提交流程; - 修复重试幂等:失败后重新发送未修改的草稿(首页)或再次点击“开始任务”(确认页)沿用已写回的
project_id与run_request_token,不再重复创建;任一内容变化即重置两个标识,避免旧幂等键携带新内容触发 409; - 修复 workspace 404 自愈:清除 sessionStorage 活动运行,当前页原位报错、其余流程页回到演示态;
- 被中止的动作请求不再向 Agent 摘要区渲染错误文案;
agent-event.schema.json描述由“PostgreSQL 事件表”修正为“数据库事件表(当前默认 SQLite,目标部署 PostgreSQL)”,并重新生成 TypeScript/Python 模型与 OpenAPI 基线。
当日重跑并通过:契约 check、Web check、Web 生产构建(88 个模块)、validate.py(8 Schema/28 Fixture)、check_compat.py、export_openapi.py --check、pytest backend/api/tests(66 个用例)、node --test 新任务状态用例(4 个)。
尚未重新验收:第 12 节中“首页发送真实启动”“首页未登录发送”“失败后重复发送”“确认页直接访问”“workspace 404”等浏览器用例需要在真实浏览器中重新执行;上表 2026-08-11 的“首页→确认”人工验收记录对应旧链路,不再代表当前行为。
设置中心「账户与安全 → 编辑资料」支持更换和移除头像:
- 后端新增
POST/DELETE/GET /api/account/avatar;内容按 sha256 存入独立头像目录,users表只存引用;格式按文件魔数判定(PNG/JPEG/WebP/GIF),读取按当前会话返回本人头像并带nosniff;user.avatar_url携带摘要查询串做缓存失效。 - 前端交互在账户与安全面板的头像本身:鼠标悬停(或键盘聚焦)时头像浮出相机图标,点击直接选图,本地居中裁剪压缩到 256×256 后立即上传并就地预览,完成后以服务端快照收尾;已设置头像时身份区提供“移除头像”。编辑资料弹窗保持原有的名称/邮箱/密码三项,不承载头像。侧栏、设置账户卡与安全面板三处头像统一由
avatar_url渲染,未设置时回落姓名首字母。 - SQLite 开发库在启动时补齐模型新增可空列,已有
dev.db无需删库(详见 API README)。
当日执行并通过:pytest backend/api/tests(75 个用例,含 8 个头像用例与 1 个开发库补列用例)、npm run check --workspace @openmathmodel/web、npm run check --workspace @openmathmodel/contracts、npm run build --workspace @openmathmodel/web、export_openapi.py --check(基线已随新路由刷新)、对真实 uvicorn 实例的 12 项 HTTP 链路检查(注册→上传→读取→越权→移除,含伪装成 PNG 的 SVG 被拒)。
尚未验收:换头像的浏览器视觉走查(悬停遮罩、三处头像同步、暗色主题、侧栏折叠态)需在真实浏览器中执行并留存截图。
设置中心「高级设置 · 网络与运行」原有六个控件全部只有外观。本轮按可行性分级处理:
- 做实「请求超时」:新增
apps/web/src/preferences/network-preferences.ts读取器(默认 120 秒,夹紧到 5–600 秒),工作台客户端与账户客户端的全部 JSON 请求统一挂AbortSignal.timeout,超时报出可操作的中文提示;SSE 长连接与附件上传刻意豁免。 - 做实「最大并发任务」:上限存服务端而非浏览器——
users表新增可空列max_concurrent_runs(迁移 0009,NULL=默认 3),新增GET/PUT /api/account/preferences;创建任务时后端只统计排队与执行中的运行(等待审批/已暂停不占位),超限返回 409CONCURRENCY_LIMIT。前端打开设置面板用服务端值回填显示,保存时异步推送,未登录时提示登录后生效。 - 如实标注其余四项:代理两项注明"将随模型服务接入后生效"(后端目前没有任何出站调用);下载目录与临时文件目录是桌面端形态,网页版禁用并注明由浏览器/部署配置管理。
当日执行并通过:pytest backend/api/tests(97 个用例,含 9 个偏好与并发闸门用例)、export_openapi.py --check(基线随新路由刷新)、npm run check --workspace @openmathmodel/web、npm run build --workspace @openmathmodel/web、node --test(34 项,含超时读取器与并发解析 7 项)。
尚未验收:高级设置面板的浏览器走查(提示文案排版、禁用态样式、并发上限回填与 409 提示、暗色主题)。
设置中心的「界面语言」与「桌面通知」两个此前只有外观的控件已经真正生效:
- 界面语言新增
apps/web/src/i18n/(词典 + DOM 适配层 + locale 存取),策略与边界见 ADR-0008。切换即时生效、未保存关闭即还原、启动前应用、<html lang>同步;真实数据与contenteditable正文不参与翻译。 - 桌面通知新增
apps/web/src/notifications/desktop-notifications.ts,接入工作台快照的状态变化:待确认、完成、失败各提醒一次,首屏不提醒,用户正注视页面时不打扰;权限在开关的点击手势里申请,被拒绝时开关自动拨回。
当日执行并通过:node --test apps/web/src/i18n/en-US.test.mjs(5 项词典门禁:非空、键已修剪、无原样返回、无残留中文、无重复键)、npm run check --workspace @openmathmodel/web、npm run build --workspace @openmathmodel/web。词典对已抽取的 1232 条界面文案覆盖 1202 条(97.6%);未覆盖的 30 条是源码注释与由变量拼接的提示词片段,其中工作台运行元信息一行已改为逐段 t()。
尚未验收:中英切换与桌面通知的浏览器走查(见 Web 页面基线 §10.3)。方法库与知识库的中文内容数据按 ADR-0008 保持原语言,不属于漏译。
设置中心「外观与显示」原有六个控件,其中只有主题真正生效。本轮按产品判断做了取舍:
- 删除「界面密度」与「代码字体」。前者是持续税——每新增页面都要维护三档间距;后者是从编辑器类产品照搬的选项,在任务型工具里几乎无人使用。分区标题相应从「显示密度」改为「正文与可读性」,侧栏副标题也同步修正,避免文案继续宣传已不存在的功能。
- 做实「正文字号」「减少动态效果」「增强文字对比度」。新增
apps/web/src/accessibility.css(表现)与apps/web/src/preferences/display-preferences.ts(状态),在main.tsx中最后引入;两张受保护样式表零改动,仅被叠加覆盖。状态以--omm-text-scale、data-reduce-motion="on"、data-contrast="high"写在<html>,与主题data-theme同一机制,并同样支持即时预览、未保存还原、启动应用。
三项的实现边界:
- 减少动效无条件跟随系统
prefers-reduced-motion,应用内开关只加强、不取消系统偏好;过渡时长压到0.001ms而非none,transitionend/animationend仍会触发,依赖这些事件的交互不受影响。 - 正文字号作用于继承
body的阅读文本,外加 Agent 摘要与论文编辑器正文两处硬编码 px 的主要阅读面;标题、标签、徽章保持设计尺寸(与 Slack、Notion 的字号设置同一取向)。滑块基准由 15 修正为 14,与样式表实际基准一致。 - 增强对比度修正的是真实缺陷:默认调色板中
--faint(#a1a19d) 与白底对比度约 2.6:1、--muted(#777773) 约 4.5:1,均未达 WCAG AA 对正文的 4.5:1 要求。开关打开后重定义--ink/--muted/--faint/--line,浅色与暗色各一套。
当日执行并通过:node --test apps/web/src/preferences/display-preferences.test.mjs(5 项取值规范化用例,其中一项当场抓出空字符串被 Number("") 变成 0 再夹成最小字号 13px 的缺陷并已修复)、node --test apps/web/src/i18n/en-US.test.mjs(5 项)、npm run check 与 npm run build --workspace @openmathmodel/web,并确认三项的 CSS 均已进入构建产物。
尚未验收:三项的浏览器走查,以及暗色主题下高对比度的观感。
补记:2026-08-12 下午曾把「正文字号」改为基于 #root zoom 的「界面缩放」,当日按产品决定回滚,恢复上述正文字号方案;--omm-text-scale、设置文案与 JS 定位逻辑均已还原,无残留。
设置中心「任务与文件」的三项此前只是落盘的布尔值,本轮全部接上真实行为。读取器集中在 apps/web/src/preferences/task-preferences.ts,与面板初始状态一致(未保存过设置视为开启),每次使用时重新读取,改动后无需刷新即可生效。
- 自动保存任务(
autoSave):apps/web/src/tasks/task-autosave.ts,activateScreen末尾挂载。工作台六屏(running/data/model/experiments/editor/complete)每 30 秒落盘两类现场:论文编辑器正文(localStorage,按project_id区分,跨会话),工作台输入框未发送的对话草稿(sessionStorage,按屏幕与run_id区分);pagehide时补一次落盘,回到页面时自动恢复,编辑器顶栏芯片显示「已自动保存 HH:MM」。首页输入框不归它管——新任务草稿在 task-start-controller 里逐键即时保存。 - 启动时恢复上次任务(
restoreSession):workspace 每次成功渲染真实运行时把run_id/project_id写入openmathmodel.lastTask.v1(tasks/last-task-record.ts),运行 404 时清除;main.tsx在本标签页会话的首次加载落在/时读取记录并location.replace到运行工作台(tasks/restore-last-task.ts),已跳转则跳过首页渲染。同会话内再回首页不拦截(sessionStorage 一次性标记)。 - 自动解析上传文件(
autoOpenFiles):attachments/store.ts的解析入口按开关短路——关闭时不在浏览器里抽取内容,附件直接置为「等待服务端解析」,settled()、上传与草稿流程不受影响,服务端解析后仍出权威结果。
当日执行并通过:node --test apps/web/src/tasks/last-task-record.test.mjs(3 项记录解析用例)、npm run check --workspace @openmathmodel/web、npm run build --workspace @openmathmodel/web。
尚未验收(需浏览器):编辑论文正文离开再回来是否恢复并显示保存时间;新标签页打开 / 是否直达最近任务、点击 Logo 回首页不再被拦;关闭自动解析后附件卡片是否显示「等待服务端解析」且发送流程正常。
深色主题长期只覆盖 styles.css(约 320 条规则),workflow-refresh.css 的聚焦工作台五个页面从未有深色规则,夜间模式实际不可用。当日下午曾尝试以补全皮肤(theme-dark.css,约 700 行)修复,因整体配色质量不达标、且每个新组件都要持续维护深色对应,按产品决定改为整体下线主题切换功能,界面固定浅色:
- 设置中心「外观与显示」移除「界面主题」分区,侧栏副标题同步改为「正文字号与可读性」;正文字号、减少动效、增强对比度三项保留不变。
- 移除
applyTheme/normalizeTheme/savedTheme与主题的保存/回填/即时预览/恢复默认逻辑;openmathmodelSettings里历史残留的theme键读取时静默跳过。theme-dark.css已删除。 - 保留而未清理的部分:styles.css 与 attachments.css 中既有的
html[data-theme="dark"]规则成为不可达死代码(受保护基线,未做大规模删除);renderCharts的按主题取色分支保留(data-theme永不再置位,恒走浅色)。如后续重启深色主题,从这两处加上 git 历史里的 theme-dark.css 可恢复。(已于 2026-09-18 全部删除,见下文同名条目。) initInterfaceLocale之外不再有主题相关启动逻辑;增强对比度的暗色变体选择器(accessibility.css)同样不可达,保留。
当日执行并通过:node --test(en-US 词典 5 项、显示偏好 5 项、任务记录 3 项)、npm run check --workspace @openmathmodel/web、npm run build --workspace @openmathmodel/web。
尚未验收(需浏览器):设置中心外观分区只剩可读性三项、保存/恢复默认不再改动主题、历史保存过深色偏好的浏览器打开后界面为浅色。
高级设置的诊断分区此前三个按钮全是假动作(定时器伪装诊断、toast 假装打开目录、剪贴板写死字符串)。本轮全部接真:
- 后端新增
GET /api/system(ops,免登录,同/api/health):返回服务名、版本、Python 版本、数据库方言名、runner 开关与服务器时间;刻意不暴露连接串、路径与凭据(test_system_endpoint.py断言序列化结果不含://与文件路径)。OpenAPI 基线已重新导出并通过--check。 - 前端新增
apps/web/src/diagnostics/system-diagnostics.ts:并发探测/api/health(连通性与延迟)、/api/system(后端信息,404 归为提示以兼容旧后端)、/api/account/me(登录态,401 视为接口正常),5 秒超时;报告文本汇总前端环境(UA/语言/视口/在线状态)与后端信息。 - 「运行网络诊断」把三项结果渲染为分区内的状态行(通过/提示/异常三色图标);「打开日志目录」浏览器内无真实语义,改为「导出诊断报告」(下载 txt);「复制系统信息」复制同一份报告,剪贴板不可用时提示改用导出。三个按钮执行期间禁用并显示进行中文案。
- 图标排版修正:
.diagnostic-actions button改 inline-flex 居中 + 7px 间距,图标统一 15px,不再随文字基线漂移。 - 英文词典同步:移除“打开日志目录”等失效词条,新增诊断结果与提示语翻译。
当日执行并通过:pytest backend/api/tests -q(98 个用例)、export_openapi.py --check、node --test(en-US 词典 5 项)、npm run check 与 npm run build --workspace @openmathmodel/web;对运行中的后端实测 /api/system 返回 200 与预期字段。
尚未验收(需浏览器):三个按钮的交互与状态行视觉、导出的 txt 内容、未登录/后端停机两种场景下的诊断结论展示。
设置中心「自定义 API」此前整页是摆设:表单只落 localStorage、「测试连接」是定时器假动作、已保存接口是写死的演示行、三个开关无消费方。本轮全部接真,对话回复与任务执行统一按该配置在服务端出网调用模型(密钥不下发页面、无浏览器跨域问题):
- 后端新增
users.llm_config(迁移 0010,JSON 可空列兼容 SQLite 补列):已保存接口列表(名称/协议/Base URL/密钥/模型/组织/自定义请求头/路径前缀)、主接口与三个行为开关;GET/PUT /api/account/llm-config仅本人可读写。 - 新增
omm_api/llm.py:接口协议下拉的五个选项全部可用——OpenAI Compatible / Ollama / 自定义 REST 走 Chat Completions 形状(Base URL 为裸域名时自动补/v1/chat/completions,已带路径则原样拼接,显式路径前缀最优先),Anthropic Messages(x-api-key + anthropic-version)、Gemini generateContent(key 走查询串)各自映射;流式 SSE 逐协议解析、用量归一并输出结构化用量日志(「记录接口用量」)。「允许使用第三方中转站」关闭时仅放行官方域名与本机地址(403 PROXY_DISABLED);「失败时自动切换备用接口」仅在超时、网络层失败或 HTTP 429 时按保存顺序切换,模型侧 4xx/5xx 属配置问题不重试;流式一旦开始输出不再回退,避免内容重复。httpx升为运行时依赖;测试经_transport_factory注入 MockTransport,全程不出网。 - 新增
POST /api/chat(需登录):无状态代理,对话历史随请求携带、服务端不落库;按「流式输出」开关走 SSE(meta/delta/done/error 事件)或一次性 JSON。POST /api/llm/test:用表单当前值做最小补全,返回时延、实际模型与回复摘录,验证地址/密钥/模型 ID 全链路。 - 任务执行换脑(engine_glue):按 run → project.owner →
users.llm_config装配节点,配置可用时「问题分析」「建模方案」两个阶段走omm-agent-skills真实 LLM 节点(goal→problem_statement 适配;JSON Schema 校验 + 一次修复重试;方案 A/B 产出后停在审批),未配置或提示词缺失时整链回落 sim-0.1;其余四阶段仍为模拟节点,待后续批次替换。omm-agent-skills的 requires-python 放宽至 3.10(与 core 同理:纯标准库 + 惰性注解,backend venv 当前为 3.10)。 - 前端:对话页发送后的假回复(650ms 定时器固定文案)改为真实流式渲染(
integration/agent-chat.ts),完成后标注实际域名、模型与是否切换备用(第三方中转站按开关文案显示实际域名),未配置/未登录时提供「前往设置中心配置模型接口」入口;设置保存时同步 llm-config(更新主接口或创建首个;example.com 示例占位视为未配置,避免把演示值当真实接口);「测试连接」「保存为新接口」接真;已保存接口列表从服务端渲染,菜单支持设为主接口/删除;面板打开时表单、三个开关与协议下拉均以服务端为准回填(integration/llm-settings.ts)。密钥提示文案改为「密钥仅保存在本机后端」。 - OpenAPI 基线重导出(新增 3 个路径);英文词典删除假动作词条、新增对话与接口管理文案。
当日执行并通过:pytest backend/api/tests -q(126 个用例,含 llm-config 读写、五协议映射、流式/回退/门控、任务换脑与修复重试等 28 个新用例;本机需 PYTHONUTF8=1——alembic.ini 是 UTF-8 而系统 locale 为 GBK,属环境问题与本轮改动无关)、pytest agents/skills/tests(3.10 兼容 26 用例)、export_openapi.py --check、node --test(en-US 词典 5 项)、npm run check 与 npm run build --workspace @openmathmodel/web。
尚未验收(需浏览器 + 真实厂商接口):设置面板的回填/测试连接/接口列表交互、对话页流式渲染与错误提示视觉、真实 OpenAI 兼容网关与 Ollama 的实测(本轮上游全部为 MockTransport 模拟)。
实测反馈驱动的三轮加固与厂商预设落地:
- 错误兜底:
llm.py把网络层异常全部归一为可执行错误信封(超时 504 LLM_TIMEOUT、连接失败 502 LLM_UNREACHABLE、重定向 LLM_REDIRECTED 提示改 Base URL、非 JSON 响应 LLM_BAD_RESPONSE 展示响应开头),不再向页面裸露 500;超时/网络失败与 429 一样触发备用接口切换;「测试连接」不再限制 max_tokens(推理模型思考预算可能远超小额上限),读超时放宽至 45 秒。 - 路径与域名护栏:OpenAI 兼容协议在 Base URL 为裸域名时自动补
/v1/chat/completions(带路径原样拼接、显式前缀最优先);填知名厂商官网/聊天页域名(deepseek.com、chatgpt.com、claude.ai、kimi.com、x.ai、bigmodel.cn 等)时直接提示正确 API 地址(400 LLM_WEBSITE_URL)。前端把我们自己后端的 404 映射为「后端尚未加载新接口,请重启后端服务」。 - 厂商预设(
integration/llm-providers.ts):九家主流厂商官方 API 一键填入——OpenAI(gpt-5.6-sol/terra/luna)、Anthropic(claude-opus-5/sonnet-5/fable-5/haiku-4-5)、Google Gemini(gemini-3.6-flash/3.5-flash/3.5-flash-lite)、DeepSeek(deepseek-v4-pro/flash,旧 deepseek-chat 已于 2026-07-24 停用)、通义千问(qwen3.8-max,DashScope 兼容模式)、Kimi(kimi-k3)、智谱(glm-5.2/5/5-turbo)、xAI(grok-4.6/4.5)、本地 Ollama。模型名与接入地址采集自各厂商官方文档(2026-08-13)。 - 「模型厂商」页:卡片改由预设生成(副标题=当前在售模型),「配置」跳转自定义 API 并自动填入协议/地址/模型(用户只补 Key),「已连接」状态按已保存接口的域名真实判定;智能路由下拉与输入区模型选择器同步为最新型号;无品牌资源的厂商 logo 回落首字母标。官方域名白名单扩充(api.x.ai、火山方舟、百度千帆、腾讯混元、阶跃、硅基流动等),不受「允许第三方中转站」开关影响。
- 新增测试:网络异常信封 5 例、官网域名护栏 1 例、官方域名门控 1 例(累计 41 个 LLM 相关用例)。词典同步增删。
- 思考过程展示:后端按协议提取推理内容(OpenAI 系
reasoning_content/reasoning、Anthropicthinking内容块、Geminithought: true部件),流式经 SSEreasoning事件转发、非流式随响应reasoning字段返回;前端在回答上方渲染思考块——流式期间「思考中…」shimmer 标签 + 180px 封顶自动跟随视口(超高时上下渐隐遮罩),回答开始后折叠为「已思考 N 秒」可点击展开回看;思考内容只展示、不回写对话历史;适配深色主题与「减少动态效果」偏好。无思考内容的模型在等待首个回答增量时同样显示扫光「思考中…」占位;思考头部按钮覆盖全局 button 悬停底色保持裸文本观感;回复完成后右下角提供复制按钮(复制 Markdown 原文,成功后短暂切换对勾图标)。 - 执行步骤时间线:完成态图标由绿钩改为黑色圆形白色对钩(19px,深色主题反白),与单色设计语言一致。
- 细粒度活动流(参考图节奏:思考/工具/叙述交替):真实 LLM 节点的每次模型调用产出过程事件进
run.log(EngineLlmPort.on_event→ 同事务 append_event)——thinking(思考内容截断至 6000 字 + 耗时)与llm_call(模型/接口/耗时/tokens 摘要);工作台回复区在摘要之后渲染.agent-stream:run.log过程行(深度思考=sparkle、调用模型=lightning,右侧真实耗时,点开浅灰 16px 圆角容器看思考全文或调用参数)、artifact.published(写入 X,可展开)、approval.requested/resolved(等待确认行实时走秒、落定显示等待时长)、node_changed/status_changed作为叙述行穿插;按 sequence 去重,SSE 首连回放历史、重连不重复;step.*事件归上方阶段时间线不重复展示。follow-tail 浮标按产品反馈移除。配套后端测试断言 thinking/llm_call 过程事件入流。 - Agentic 执行轨迹(融合改造,参考 Codex 语义时间线/RemCodex 统一纵向流的公开设计):真实运行下(
data-workspace-source="api")把运行页的六个步骤行去卡片化为时间线行——左侧动作图标(分析/数据/方案/实验/写作/交付各一,进行中呼吸脉冲,完成保持既定的黑底白钩,失败红叉)、中间阶段名(重试时加「第 n 次尝试」徽标)、右侧真实耗时(数据来自/task-runs/{id}/steps控制面接口,与快照并行拉取;完成/失败=服务端起止差,进行中每 500ms 走秒);行点击展开的详情区为浅灰 16px 圆角低权重容器(失败时显示失败原因,进行中显示 current_step);细左轨 + 垂直留白建立时间线关系;「回到最新」follow-tail 浮标(上翻 160px 出现,不抢滚动位置);输入框上方运行状态条(spinner + 状态 · 当前阶段 + n/6,等待确认暂停旋转,规划期与终态不显示)。演示页面完全不受影响(样式全部按 api 来源作用域隔离)。 - 任务执行节奏(按 ADR-0007 组件语义实现,不新增容器):任务创建后、首个步骤启动前(QUEUED 或 RUNNING 且全部页面 PENDING)为规划阶段,控制器广播
omm:run-planning,页面层在同一条 Agent 消息内(注入步骤块头部之后,不拆成第二条对话)发起一次真实的开场分析(走 /api/chat:思考块流式 → 折叠「已思考 N 秒」→ Markdown 分析正文,未配置接口/未登录时整块静默消失,sessionStorage 按 run 防重、刷新不重复扣费)。严格顺序出现:开场分析落定前(data-opening-state="pending")计划部分(折叠开关/步骤列表/阶段摘要/CTA)完全不渲染也不占位——包括「任务正在排队」摘要与「等待任务开始」按钮;回复结束(成功/失败/静默移除均算)后置为 done 并广播omm:opening-analysis-done,控制器立即重渲染,六个阶段按 140ms 级联缓速揭示(等待期间刻意不构建时间线,保留 planning 标记,放行时才播放级联;之后的 SSE 刷新与中途进页不重播)。注意hidden属性对带作者级display:flex/inline-flex的元素无效,这些元素需显式[hidden] { display: none !important },否则会出现「已隐藏却仍显示」。 - 对话页不暴露接口信息:回复正文不再附带实际域名、模型名与 Auto 难度判定(流式期间的「正在调用 」行一并移除);「允许使用第三方中转站」的透明化只体现为设置中心的本机用量记录,关闭时连记录也不留。
- 对话回复 Markdown 渲染(
text/markdown.ts,零依赖):转义优先的受限子集——标题/列表/引用/表格/分隔线/粗斜体/行内代码/代码块/http(s) 链接;$…$、$$…$$、\(…\)、\[…\]公式输出为data-tex节点(行内加data-tex-inline),复用方法库的 KaTeX 懒加载器在全文到齐后排版,加载前保留 LaTeX 源码回退;「$5 和 $10」这类金额不会误判为公式,javascript:链接保持纯文本。流式期间逐增量重渲染,未闭合代码块随增量增长。配套markdown.test.mjs10 个用例(XSS 转义、注入路径、表格、流式半截代码块等)。
当日执行并通过:pytest backend/api/tests/test_llm_*.py test_task_runs_llm_nodes.py、node --test(词典 5 项)、npm run check 与 npm run build --workspace @openmathmodel/web。真实接口对 ainb.plus 网关探针验证连通(假密钥返回上游 401 信封);DeepSeek 官方等真实密钥实测留给浏览器验收。
产品反馈驱动:此前「首个步骤一启动」就揭示六个执行步骤,而题意解析几乎在任务创建后立即启动,体感等于“随便发点什么都马上弹出全部步骤”。本次把揭示时机推迟到规划真正完成:
isPlanningPhase(modeling-workspace-controller.ts)判定窗口延长——QUEUED,或RUNNING且问题分析页仍为PENDING/RUNNING且后续页面均未启动,都属于规划阶段;问题分析页落定(成功/失败)或任一后续阶段启动才视为规划完成。运行离开QUEUED/RUNNING(暂停、待审批、失败、终态)时需要展示状态行,同样揭示。- 规划阶段「收起/查看执行步骤」折叠头与步骤列表一起等待揭示(新增
.activity-summary的规划期隐藏,两种布局通用;[hidden]的!important还原规则此前已存在)。此期间用户看到的是:开场分析(若已配置接口)→「正在思考并规划执行步骤…」扫光行 + 活动流的「深度思考 · 题意解析」过程行;规划完成后六个阶段仍按 140ms 级联揭示,SSE 刷新与中途进页不重播。 - 附带修复:此前若首个快照到达时题意解析已在运行(常态时序),
omm:run-planning不会广播、开场分析整体被跳过且步骤立即可见;判定窗口延长后该竞态消除,开场分析可靠触发(sessionStorage 按 run 防重不变)。 - 不动契约、后端与页面结构:判定完全基于既有
ModelingWorkspaceView.pages[].status语义(问题分析页在PROBLEM_ANALYSIS推进到后续节点时由投影算法翻为SUCCEEDED)。
附件托盘(composer-attachments 模块自有槽位,覆盖首页/确认页/对话页所有 .composer)加可伸缩折叠,动效语言参考 aicss.dev To-do List、按产品黑白灰体系以原生 DOM 重写:
- 折叠头:回形针图标 + 「附件」 + 计数徽标;悬停图标交叉淡出为箭头,收起时箭头旋转 -90°,
aria-expanded随动;点击收展,收展为grid-template-rows 1fr ↔ 0fr+ 透明度过渡。 - 计数徽标「已解析/总数」(已解析 = phase 非 parsing/uploading):逐字符槽位滚动更新(旧字上移、新字滚入,350ms),内容为纯数字斜杠,语言中立不进词典;徽标带
aria-label。 - 新附件按加入顺序级联入场(50ms 步进);解析进度等重渲染不重播动画(已入场 id 集合防重)。收起状态下新增附件自动展开;清空后回到展开态。
- 单模态提醒行(ADR-0010)与错误状态行保持在折叠区外,收起时不会藏住需要知情的内容。
- 修正
.composer-attachments[hidden]:托盘是作者级display:flex,会压过 UA 的[hidden],此前托盘为空时留白不可见,现在有常驻折叠头必须显式还原display:none。 - 全部动效尊重
prefers-reduced-motion;暗色主题按html[data-theme="dark"]补齐配色。
按各厂商官方文档重新采集(2026-08-27),只改数据与识别规则,不动页面结构与接口契约:
- 厂商预设(
integration/llm-providers.ts):智谱换成 glm-5.3 / glm-5.3-flash / glm-5.2(GLM-5.3 于 2026-08-14 发布、08-19 开放 API,与 5.2 共用基座同价);通义千问补 qwen3.8-flash(2026-08-26 上线);Kimi 换成 kimi-k3 / kimi-k2.7-code / kimi-k2.6;Anthropic 把能力最强的 claude-fable-5 提到首位(Mythos 5 仅限 Project Glasswing,不入预设)。OpenAI、Gemini、DeepSeek、xAI 经核对仍是当前在售型号,保持不变。 - 预设新增可选
altHosts:同一厂商的多个官方入口(智谱api.z.ai、Kimiapi.moonshot.ai)共用一张卡片的「已连接」判定与品牌标识,新增presetMatchesHost()供卡片状态与模型选择器共用。 - 官方域名白名单(
llm.py)补api.z.ai、api.moonshot.ai;官网→API 提示补 z.ai / chat.z.ai / www.bigmodel.cn。 - Auto 路由的能力推断:新一代档位记号入表——强档补
fable、-sol,轻档补-luna(带连字符避免误伤 solar 等词根)。命中不了的型号仍按中位 5,用户填写的权重永远优先。 - 模态识别(
integration/model-modality.ts,ADR-0010):修正 Kimi K 系列误判——K2.5 起均支持图片输入,此前被当作纯文本会误报「图片不会被看到」,现改判视觉;GLM 视觉线模式放宽到glm-Nv(覆盖 glm-5v-turbo),并单列原生多模态的 GLM-5.3-Flash;GLM-5.3 官方声明仅文本,精确入纯文本表(不做家族级推断,未知型号继续沉默)。 - 「默认模型 ID」补全(新增
POST /api/llm/models):预设表是快照、写下来那天就开始过期,因此让输入框直接问接口本身要模型列表——OpenAI 兼容家族GET {base}/v1/models(裸域名补/v1,不套「路径前缀」,那是对话补全用的)、AnthropicGET {base}/v1/models(x-api-key + anthropic-version)、GeminiGET {base}/v1beta/models?pageSize=200&key=(去掉条目名的models/前缀)。密钥仍只在服务端使用,不产生 token、不记用量,因而也不受预算闸门约束;上限 300 条,网关未实现(404/405)时给LLM_MODELS_UNSUPPORTED并指向「手填模型 ID」。前端在现有输入框上挂原生<datalist>(UA 默认display:none,零布局改动):先按 Base URL 域名秒填预设型号,聚焦时再拉真实清单合并(fetchEndpointModels按「协议+地址+密钥」缓存,失败不留缓存以便重试),拉不到就只留预设,不打断填写。新增 8 个后端用例覆盖三种协议的路径与头、404 指引、中转站门控与登录校验。 - 费用估算表(
usage.py,影响预算硬闸门):按「本地零费用 → 当代型号 → 上一代型号 → 家族兜底」重新分组,补 GPT-5.6 三档、Fable/Opus 5、Gemini 3.6 Flash、DeepSeek V4 Pro/Flash(2026-08-16 起峰谷两价,按峰价保守估)、Qwen3.8-Flash、GLM-5.3/5.2、Grok 4.5/4.6 的实际单价;顺带修掉qwen3:本地标签被 qwen 家族兜底价抢先命中的失效条目。未收录的新型号仍落家族兜底,不会算不出钱。
真实节点的一次模型调用动辄一两分钟,此前 EngineLlmPort.on_event 产出的 thinking/llm_call 过程事件只挂在推进事务里、要等节点结束的下一次 checkpoint 才随 STEP_SUCCEEDED 一起提交——SSE 是轮询已提交行的模式,结果是每个阶段执行期间工作台完全静默,结束时整批过程行同时闪现。本批修复:
- 推进线程(checkpoint 模式)下,每条过程事件在发生的当下独立提交并立即对 SSE 可见;HTTP 动作路径保持整请求一个事务不变。
- 新增
llm_call_started过程事件(kind/prompt_id/repair):每次模型调用开始时发出。前端把它渲染为带实时走秒的「深度思考 · 阶段」行,thinking到达时整体替换为含思考全文的最终行,无思考内容的模型由llm_call就地落定(活动流仍不展示模型与接口名)。 - 用户刷新页面时若某次调用仍在进行,SSE 首连回放会重建这条走秒行,落点仍在首气泡活动流。
带附件的发送此前在接待判定(POST /v1/task-intake)里被启发式直接放行(假设「附件≈题面」),内容与建模无关的文件照常创建任务,最后由问题分析节点的 viability 门以「题意解析执行失败」收场——语义上是输入问题,却被渲染成带重试按钮的工作流故障。本批修复:
- 契约:
TaskIntakeInput新增可选attachments[](name/excerpt/characters,单摘录上限 2000 字符、最多 20 个),OpenAPI 基线已重导出。 - 服务端(
intake.py):有正文摘录的附件必须进模型判定——提示词逐个列出文件名与摘录(每个截 500 字、最多 5 个),附件内容与输入正文同权;解析不出文字的附件(纯图片/扫描件/关闭自动解析/确认页仅元数据)维持放行,由第二层 viability 门兜底。长题面(≥200 字)仍启发式放行,不为附件多烧判定调用;判定异常一律放行的总原则不变。 - 前端(
task-start-controller.ts):发送前先等浏览器解析完成(store.settled()),把每个附件的文件名与前 1200 字摘录随判定请求上送;@ 引用赛题仍视为带着题面来的,直接放行。判定不放行时沿用既有「首页对话」路径原地回应,不建项目、不出现执行计划。 - 测试:接待判定套件 7→10 个用例(附件摘录必进判定且意图透传、无摘录附件启发式放行不出网、长题面优先于附件判定)。
当日执行并通过:pytest backend/api/tests(255 个用例,其中 task-intake 10 个)、npm run check --workspace @openmathmodel/web(typecheck + eslint)、Web 生产构建(139 个模块)、export_openapi.py 基线重导出。
尚未验收:真实浏览器中发送「短正文 + 内容无关的附件」应停留首页并收到对话式回应(不出现执行页与计划条)的视觉走查。
三项用户实测反馈的修复,不动页面骨架、路由与契约结构:
- 任务创建过场遮罩(新增
integration/task-launch-overlay.ts+task-start-controller.ts接线):此前发送后只有状态行一句「任务已创建,正在进入运行工作台…」。现在接待判定放行后全屏遮罩接管:「创建项目 → 上传附件 → 启动 Agent 工作流」随真实阶段逐步打勾(SubmitOptions.onPhase,无附件时不显示上传步骤),成功后主视觉环收拢成对勾、定格一拍再导航;判定不放行(首页对话)、未登录、出错时遮罩不出现或立即淡出,错误仍走状态行。遮罩 aria-hidden,读屏通道保持状态行(role=status);动效尊重prefers-reduced-motion,暗色主题补齐配色;层级 230(压过设置 180 与二级弹框 220,让位 toast/菜单 260)。 - 开场分析拿不到附件的修复(
attachments/task-attachment-context.ts重写):用户实测「上传了 PDF,首条回复却说没收到题面」。原因是任务附件上下文对服务端正文只等 12 秒、超时只留一句「仍在解析中」,且工作台清单拉取失败会被永久缓存成「无附件」。现在:① 任务创建时把浏览器解析摘录按 run 交接到 sessionStorage(persistTaskAttachmentExcerpts,单条 4000 字/合计 24000 字,与草稿摘要上限一致),服务端正文没赶上等待预算时以摘录顶上(标注「浏览器初步解析、完整正文稍后自动并入」),权威全文就绪后仍并入后续轮次(摘录已覆盖全文的短附件不重复注入);② 工作台清单拉取失败或为空不再缓存,下一轮重试,失败当轮直接用交接摘录兜底;③ 正文请求失败立即重试并计入「解析中」;④「解析中」提示列出文件名并明确指示模型「勿断言缺少题面、勿要求用户补充材料」。 - 执行计划第 4 条压缩(
workspace_view.py_plan_texts):实验与验证条目此前两处发胖——初稿把 EXPERIMENTING 与 VALIDATING 两句拼接,方案确认后又细化为「按方案「名称」实施:步骤1;步骤2;步骤3」(上限 240 字),远长于其他条目(12~24 字)。现在多阶段页面只取首个阶段的短句;细化只保留「按方案「名称」实施」(方案名截 18 字,整条截 40 字兜底),步骤全文仍在建模方案页与「实现计划」分页。对应测试断言同步更新(所有 plan_text ≤ 40 字)。
当日执行并通过:pytest backend/api/tests(完整套件)、npm run check --workspace @openmathmodel/web(typecheck + eslint)、Web 生产构建(140 个模块)。
尚未验收:真实浏览器中的三项视觉走查——发送后过场遮罩的完整节奏(含无附件/出错/未登录分支)、带附件任务的开场分析首条回复内容、执行计划面板各条目长度。
产品反馈:设置中心 → 模型厂商 → 智能路由的四个下拉「问题不是写不写死,而是要看用户自己配了什么」,并且要能即时更新;OpenAI 新旗舰 GPT-6 Astra 需要适配。不动页面结构、路由与接口契约(ADR-0014 决策 11):
- 智能路由下拉(
openmathmodel-ui.ts):删掉四组写死的厂商型号,改为routingSelectOptions(config)生成——自动选择+ 每条已保存接口(取值endpoint-<id>,与输入框模型选择器同一套标识;显示「模型 · 接口名」,主接口再标一下)。新增renderLlmConfigViews把服务端接口配置的三处投影(已保存接口列表、厂商卡片状态、智能路由候选)一起刷,hydrateLlmPanel打开面板、「保存为新接口」「保存修改」、菜单里的设为主接口/删除/调整权重之后都走它,用户不必关掉设置再打开。刷新保留当前选择,接口被删回落自动选择;首次打开时选择从本机设置表读取(那时原生 select 只有 Auto 一项,restoreSettings写不进去)。池子为空/未登录时四个下拉只剩自动选择,下方[data-routing-note]一行说明指向「自定义 API」或登录(新增.settings-field-note,revision 37)。 - 自定义下拉增强(
enhanceSettingsSelect)从openSettingsCenter闭包提为可重复调用的模块级函数:重建前先拆掉旧的.settings-custom-select,选项集合动态变化时原生select与自定义下拉不再分叉。 - 边界如实标注:这四项在本轮只随整张设置表落本机
localStorage,服务端不读取它们;服务端链路由同日的下一条记录(ADR-0015)补上。 - 明确保留:设置中心「自定义 API」表单的预填示例(
api.example.com/ 示例密钥 /gpt-5.6-sol),endpointFromForm对example.com直接判为未配置。 - GPT-6 Astra(2026-09-03 分批 GA,API id
gpt-6-astra,1.05M 上下文、文本+图像输入,标准价 $10/$50,>272K 输入整笔 2×/1.5×):厂商预设(llm-providers.ts)OpenAI 首位改为gpt-6-astra,「配置」一键填入即为它;模态表(model-modality.ts)/^gpt-5/放宽为/^gpt-[5-9]/,Astra 判为视觉;Auto 路由能力推断(llm.py)强档记号新增-astra(否则旗舰按中位 5 使用);费用估算表(usage.py)新增gpt-6-astra/gpt-670/350 元(此前会落到 4/12 元的兜底价,预算闸门对旗舰放行十几倍)。 - 词典:删「根据任务类型、速度与费用自动选择模型。」,增智能路由说明与两条空态提示。
当日执行并通过:npm run check --workspace @openmathmodel/web、npm run build --workspace @openmathmodel/web、node --test src/i18n/en-US.test.mjs(5 项)、pytest backend/api/tests/test_llm_chat.py test_usage.py test_budget_guard.py(79 个用例,含新增 test_endpoint_strength_recognizes_current_flagships、test_model_pricing_covers_gpt6_astra)。
尚未验收(需浏览器):登录后在「自定义 API」保存/删除接口时切到「模型厂商」看四个下拉是否即时跟随;未登录与零接口两种空态提示;OpenAI 卡片副标题与「配置」默认模型为 gpt-6-astra。
业主确认要让四个下拉真正生效。契约与语义见 ADR-0015,落点:
- 契约(
schemas.py/routers/account.py):LlmConfigUpdateRequest增smart_routing: bool = True与task_routes: TaskRoutesModel(coding/research/writing/vision四个可空 id);PUT用normalize_task_routes只存指向本次保存里现存接口的项,GET(llm_config_payload)永远回四键齐全、指向已删除接口的项读出为null。OpenAPI 基线已重导出(export_openapi.py --check通过)。 - 解析与取链(
llm.py):LlmConfig增smart_routing/task_routes,route_for(kind)(开关关闭、未定向、接口不存在 → None)与chain_for(kind)(有定向 →chain_from(定向接口),否则chain());预算硬限制把付费接口过滤掉后指向它们的定向自然失效(find找不到)。 - 任务执行(
engine_glue.py/EngineLlmPort):新增_PROMPT_TASK_KINDS(与_PROMPT_NODE_IDS同键:题意解析 / 数据准备方案 / 方案设计四条 →research;清洗沙盒 / 实验代码 / 检验 →coding;论文四条 →writing),端口按prompt_id取chain_for(kind)传给stream_complete_with_fallback/complete_with_fallback;llm_call过程事件附task_kind与pinned,工作台执行轨迹与 evals 可核对「这一步打给了谁」。接待判定与 Auto 难度判定不受影响。 - 对话(
routers/chat.py):携图且未显式endpoint_id时优先走route_for("vision"),metaroute = {mode: "task", kind: "vision", endpoint_id};用户显式钉接口不改。顺手修掉route_meta["difficulty"]的硬取键(非 Auto 路由的 meta 没有该键会 KeyError)。 - 前端:新增
integration/task-routes.ts(下拉值 ↔ 接口 id、taskRoutesFromForm、normalizeTaskRoutes、resolveTaskRoute,纯函数,task-routes.test.mjs5 项);auth/api.tsLlmConfig增可选smart_routing/task_routes;llm-settings.ts新增routingFromForm,「保存更改」「保存为新接口」与三个行为开关同一纪律一并携带(否则整体替换式 PUT 会把服务端已存的定向抹成缺省);model-modality.ts的resolveSelectedModality("auto")先看vision定向再看主接口,并暴露invalidateModalityConfig(),hydrateModelPickers每次接口/路由变更都作废缓存;设置面板renderRoutingSelects(backdrop, config, { fromServer })——打开面板以服务端task_routes回填、面板内刷新保留未保存选择,hydrateLlmPanel同时回填smartRouting开关;开关文案改为如实描述(「开启后按下方任务类型把调用定向到指定接口;关闭则全部走主接口链」),区块说明补一句「保存后任务的对应阶段与携图对话按此定向」。 - 新增
backend/api/tests/test_task_routes.py(10 个用例:规范化与解析、chain_for、提示词全覆盖归类、llm-config 往返与删接口清理、端口按任务类型选链与开关关闭、携图对话的 vision 定向 / 显式接口优先 / 流式 meta)。
当日执行并通过:pytest backend/api/tests(320 通过、2 跳过;4 个失败全部位于 test_stage_outputs / test_task_runs_llm_nodes 的稳健性复跑与 G3 结果闸门,属于工作树里另一路在进行中的 H3 验证节点改动——test_task_runs_llm_nodes.py、stage_outputs.py、validating.*.prompt.md 均处于修改态——与路由无关)、export_openapi.py --check、npm run check、npm run build、node --test src/i18n/en-US.test.mjs src/integration/task-routes.test.mjs(10 项)。
尚未验收(需浏览器):ADR-0015 验收 4(打开面板以服务端为准回填、保存后重开仍是改后值、删除被定向接口后回落自动);验收 2/3 需要两条真实接口(可用两个本地 Ollama 模型)跑一次任务与一次携图对话,看 run.log 的 llm_call.pinned 与对话 meta 的 route.mode。
用户报障:题意解析失败后点「重试当前阶段」,页面停在「进行中 + 正在思考并规划…」再无变化。直接查本地库对账(run_e761cc88):22:03:05 FAILED → 22:03:23 RUN_RETRIED → 22:03:25 attempt 2 → 22:03:53 再次 FAILED,服务端整个过程正常,页面一条都没收到。
- 前端(
modeling-workspace-controller.ts):运行到终态时服务端发stream.end,控制器置streamEnded并关闭 EventSource、不再重连;retry成功后refresh()拿到 RUNNING 却没有重接事件流,attempt 2 的step.started / llm_call_* / 再次 FAILED全部丢失。ADR-0013 修订重开已为同一问题修过一次(omm:run-reopened),retry 这条路漏了。修法:抽出reopenStream(),refresh()在快照为非终态且流已收尾时重连(after=lastSequence只接增量,replayThrough不变所以新事件按实时落位),onRunReopened复用同一函数。§6 SSE 规则补了这一条。顺带修.todo-count[hidden](display:inline-flex压过 UA 的[hidden],思考态下旧的「0/6」一直挂着)。 - 后端(
llm.py/engine_glue.py):这次两轮失败的正文分别是 HTTP 401「鉴权服务请求失败: … context deadline exceeded (Client.Timeout …)」与 HTTP 500「failed to connect touser=postgres …: FATAL: sorry, too many clients already」——中转站自家鉴权微服务超时、数据库连接池打满,与密钥、模型、请求内容无关。旧逻辑把非 402/429 的上游 4xx/5xx 一律归LLM_UPSTREAM_ERROR(确定性):不切备用接口、引擎整次调用不退避重试,题意解析一次就把任务判死,指引还让用户「检查接口配置」。现在_upstream_error按状态码 502/503/504 或正文命中网关故障特征词(_UPSTREAM_TRANSIENT_HINTS:deadline exceeded / client.timeout / timed out / timeout / too many clients|connections / connection refused|reset / temporarily unavailable / service unavailable / overloaded / server is busy / 鉴权服务请求失败 / 请求超时 / 服务繁忙 / 系统繁忙)归为新码LLM_UPSTREAM_UNAVAILABLE(HTTP 502,文案「接口 X 暂时不可用(HTTP 原状态码):正文」),同时进_FALLBACK_CODES(对话与任务都会切备用接口)与_TRANSIENT_CODES(引擎整次调用 2s/5s 退避重试);_API_ERROR_GUIDANCE新增对症指引(与余额、密钥无关,已自动重试仍失败→稍后再试或加备用接口),_FAILURE_CLASS_RULES补「暂时不可用」。「invalid key」式 401、「boom」式 500、404/400 仍是确定性失败,既有测试语义不变;普通 500 只有正文命中特征词才按瞬态处理。 - 新增
backend/api/tests/test_llm_upstream_transient.py(19 项:两组参数化分类、402 独立码、失败归类与指引、非流式/流式链内回退、密钥错不烧备用接口、单接口配置下引擎退避重试恢复)。
当日执行并通过:pytest 新文件 19/19;test_llm_chat.py、test_llm_engine_retry.py、test_llm_config.py、test_task_routes.py、test_budget_guard.py、test_task_intake.py 共 104 通过;test_task_runs_llm_nodes.py -k "failure or failed or retry" 5 通过;npm run check、npm run build(index 723.91 kB)。浏览器复现验收(失败 → 点重试 → 页面出现第二次尝试行与最终状态)待用户;后端进程需重启才会加载新的瞬态判定(npm run dev 启动的 uvicorn 不带 --reload)。
用户报障:在任务页追问后没点暂停就切到别的页面(或从有历史的任务切到另一条记录再回来),那一轮的提问、已生成/已思考的部分全部不见;没完成过一次对话的任务页上只剩阶段失败摘要,看起来像"凭空冒出一句话"。
- 根因:站内除工作台五个阶段面板之间是软切换外,所有跳转(
go()→window.location.href、侧栏「最近任务」的裸<a href>)都是整页导航,浏览器直接掐断在途的POST /api/chat流并销毁内存;而本机对话记录(tasks/conversation-log.ts)只在一轮回复完整收到后才成对写入,agent-chat的在途登记(pendingTurns+omm:chat-turn-committed/failed)又是纯内存的,只救得了软切换。失败的一轮(接口报错 / 后台失败)同样不落盘。 - 修法(不改导航方式):在途轮做成可恢复的持久状态。
conversation-log.ts:新增按归属隔离的在途轮记录openmathmodel.chatPending.v1.<scope>(PendingTurnRecord:owner 标签页 id、用户消息、附件名、半截 reply/reasoning、时间戳),save/load/clearPendingTurn;settleInterruptedTurn()把它落定为正式条目:用户消息 + 一条interrupted: true的回复(保留半截正文与思考,note记原因,≤300 字)。parseConversationLog只对 interrupted 回复放行空正文。clearConversationLog / clearAllConversationLogs一并清在途记录。agent-chat.ts:sendConversationTurn发起即写在途记录(「保存任务历史」开启时),delta/reasoning 节流 500ms 回写;pagehide那一刻把所有在途轮同步落定为「回复在离开页面时中断,以上为离开前已生成的部分…」/「…尚未收到内容;请重新发送。」;成功清除;失败落定(note=「回复生成中断:<错误>」,用户暂停且一字未收 =「已暂停生成。」)。configureConversation绑定归属时settleStalePendingTurn():同标签页留下的或超过 15 分钟无心跳的在途记录落定(软切换回来仍活着的、别的标签页正在生成的不碰)。history 重建:被打断且无正文的一对不进模型上下文(保住 user/assistant 严格交替),有半截正文的照常进入。开场分析被打断只清记录不留条目(规划阶段重进会按既有防重逻辑重发)。- 页面层:任务页
appendRestoredReply与首页restoreHomeChat识别interrupted条目——半截正文照渲染、思考照回看盒、下方一行.muted.reply-interrupted灰字说明(workflow-refresh.css:有正文时虚线隔开),无正文不挂复制按钮;restoreHomeChat读记录前先settleStalePendingTurn。en-US 词典补两句。
- 契约与后端不动:
/api/chat依旧无状态,服务端不知道也不需要知道这轮被掐断。
当日执行并通过:node --test src/tasks/conversation-log.test.mjs(15 项,新增 interrupted 解析、在途记录往返、落定追问/开场、清理联动)与 src/i18n/en-US.test.mjs;npm run check;npm run build(index 728.06 kB)。浏览器复现验收待用户:任务页追问 → 思考/生成中点侧栏另一个任务 → 回来应看到自己的提问 + 半截回复(或空回复)+ 灰字说明;首页对话同理。
用户驳回(同日):「不能光靠一句子来解决。你数据没有存好也没有做到持续性。」——上面这套「留痕」方案已被下一条整体替换;
chatPending在途记录与pagehide落定机制已移除,本机记录降级为只读兜底。
用户要求的是持续性(切走再回来,回复应该完整或仍在生成)与数据存好(不是浏览器内存/localStorage),不是一句中断说明。根因是「浏览器是生成的宿主」:/api/chat 无状态转发,页面一卸载生成就死。重做为:
- 后端
orm.ChatTurnRow/ 迁移0019_chat_turns:chat_turns(id, user_id, scope_id, status, opening, text, attachments, reply, reasoning, meta, error_code, error_message, trace, created_at, updated_at, ended_at),索引(scope_id, created_at);test_migrations的 ORM↔迁移一致性照常约束。omm_api/chat_turns.pyChatTurnHub(app.state.chat_turns,进程内单例):start建行后在守护线程里跑llm.stream_events,事件按seq缓冲、正文/思考每秒批量回写库、终态后缓冲保留 10 分钟;stream_live(after)用条件变量推事件 +: ping心跳;stop立即定格为stopped(工作线程丢弃上游迟到事件并关连接);recover_interrupted()在 lifespan 启动时把遗留running标interrupted(文案「服务重启,本轮生成中断;需要完整回答请重新发送」);persist=False时只在内存托管(「保存任务历史」关闭);delete_scope(s)/delete_user_turns供删除与隐私联动;用量只在正常完成时记一次(partial/stopped 不记)。routers/chat.py:把配置/预算/视觉/端点校验抽成_prepare_call,Auto 路由判定延后到后台线程里(_resolve_chain),用量记账钩子_usage_hooks用独立会话——旧POST /api/chat复用同一套、行为不变(67 项既有测试通过)。新增POST /turns(202)、GET /scopes/{scope}/turns、DELETE /scopes/{scope}、GET /turns/{id}、GET /turns/{id}/events?after=、POST /turns/{id}/stop、PATCH /turns/{id}(trace)。schemas.ChatTurnStartRequest(scope_id形如^(run|chat)_[A-Za-z0-9]{6,40}$)与ChatTurnTraceRequest。SSE 生成器不再触碰请求作用域的 DB 会话(FastAPI 在流式开始前就会关闭依赖会话)。- 联动:
privacy._delete_runs先删chat_turns(项目删除级联);PUT /api/account/privacy-settings由开到关时delete_user_turns。 - 新增
backend/api/tests/test_chat_turns.py(17 项:后台完成落库 + 单条用量、无观众照常完成、after=续播、停止冻结半截并关上游、停止且一字未收 →GENERATION_STOPPED、上游 500 → failed、409 并发、开场轮空 text、trace 往返、非本人运行 404、chat_归属按用户隔离、非法归属 422、历史关闭 → 只在内存、关闭历史删库、删归属停生成、删项目级联、重启标 interrupted)。
- 前端
- 新增
integration/chat-turns-api.ts(类型化客户端 + SSE 解析readChatTurnEvents);agent-chat.ts重写为托管轮路径:sendConversationTurn有归属 →runHostedTurn(建轮 →onTurnStarted→followTurn:附着、AbortSignal→ 服务端stop、断线按after=seq重连 ≤5 次、失败拉视图补正文、settleTurn统一收口),无归属 → 旧无状态路径;新增hydrateConversation(scope)(拉全部轮,已定格的进 history)、attachConversationTurn(turn, handlers, signal)(续接 running 轮)、entryFromTurn(轮 → 页面条目:stopped/failed/interrupted/中途出错 →interrupted + note)。移除pendingTurns/pagehide/ localStorage 写入 /omm:chat-turn-committed|failed。 modeling-workspace-controller.ts:首个快照到手 →configureConversation→await hydrateConversation(run)→ 广播omm:conversation-restore{runId, goal, turns}→ 才renderWorkspace;并发刷新同样等这一步(conversationReadyPromise),规划阶段不会重复发起开场分析。legacy/openmathmodel-ui.ts:omm:conversation-restore先渲染本机旧记录,再按时间重建服务端的轮(开场轮 →restoreOpeningReply;running 开场轮 →resumeOpeningTurn原位续播并落防重标记;追问轮 → 用户气泡 +appendRestoredReply(entryFromTurn);running 追问轮 →resumeFollowUpTurn);抽出createReplyPresenter(思考块 + 流式 Markdown + 域名透明行 + Auto 难度行)、settleGeneratingRow、presentReplyFailure供首发streamAssistantReply与续接attachAssistantReply共用;轨迹行改为回复完成后PATCH /turns/{id}(不再写 localStorage);appendReplyTraceRow支持startedAt(续接的生成计时从服务端建轮时刻起算);omm:run-planning不再读在途记录,只看防重标记与页面上是否已有开场块。home-chat.ts:restoreHomeChat改为 async(本机旧记录 +hydrateConversation(chat),running 轮续接直播),首发与续接共用presentReply;task-start-controller.ts相应改为.then(ok => !ok && resetHomeChat())。recent-tasks.ts删除首页对话时先DELETE /api/chat/scopes/{chat}(文案改为「全部消息将被清除」)。tasks/conversation-log.ts精简为只读兜底(parse/load/clear*,导出sanitizeTrace),删除PendingTurnRecord全家;测试 8 项。en-US 词典:删两句已废文案、补「已暂停生成,以上为暂停前已生成的部分。」「回复在生成中出错,以上为出错前已生成的部分。」「服务重启,本轮生成中断;需要完整回答请重新发送」「回复仍在服务端生成中,正在续接…」。
- 新增
当日执行并通过:pytest backend/api/tests 全量 360 通过 / 2 跳过(含新文件 17/17、test_migrations、test_llm_chat);npm run check;npm run build;node --test "src/**/*.test.mjs" 107/107。受保护的 React 入口未改。后端进程需重启(npm run dev 的 uvicorn 不带 --reload;开发库由 create_all 自动建 chat_turns,部署环境跑 alembic upgrade head)。浏览器验收待用户:任务页追问 → 思考中点侧栏另一个任务 → 点回来应看到完整回复或仍在直播的半截 + 后续增量;刷新同理;暂停键应真正停止服务端生成(GET /turns/{id} 状态 stopped)。
同日实机联调(对本机 uvicorn --reload 的开发后端 + PostgreSQL,用本机模拟 OpenAI 协议 SSE 服务和一次性用户,不入库的临时脚本)23/23 通过:观众 1.6s 后断开 → 无观众继续生成 → after=seq 续接只补尾段、拼起来等于完整回复 → 落库行一致;stop 保留半截且 ~1s 内关掉上游连接;运行中再建轮 409;touch main.py 触发 uvicorn 重载后遗留轮变 interrupted(半截保留、事件流只回 GENERATION_INTERRUPTED);用量只记完成轮;DELETE /scopes/{id} 级联清空。开发库 alembic_version 由 0018 手动 stamp 到 0019(表已由 create_all 建出)。踩坑:Windows 系统代理会让 httpx 默认把 127.0.0.1 也走代理(502 空响应),测试客户端需 trust_env=False;后端 llm._direct_mounts 早已对本机目标绕过代理。
用户看到「模型厂商」卡片仍是 claude-fable-5 / gemini-3.6-flash,而 Claude、Gemini 都已上新:「难道每次都得重新写前端吗?不能实时同步上吗」。此前型号(llm-providers.ts PROVIDER_PRESETS[].models)与单价(usage.py PRICING)都是代码里的快照,上一次手工更新是 09-03。改为服务端从公共目录同步(数据源、裁剪规则与取舍见 ADR-0017,接口与字段见 §5.7):
- 后端
- 新增
omm_api/model_catalog.py:厂商预设PROVIDERS(唯一的型号事实来源,含内置兜底快照)、reduce_catalog(models.devapi.json→ 8 家厂商的可对话型号 + 全目录单价索引)、highlights_of(最新三款正式版,读取时现算)、ModelCatalog(文件缓存data/model-catalog.json+ 后台线程按 TTL 刷新 +view()/pricing_cny()/vision();失败保留旧数据并记error,强制刷新最小间隔 60 s,CACHE_VERSION变化即作废旧缓存)、进程级访问点set_current/current。 config.py新增model_catalog_enabled / model_catalog_url / model_catalog_ttl_seconds / model_catalog_timeout_seconds / model_catalog_cache_path / usd_cny_rate;main.py在create_app建目录并set_current,lifespan 启停后台线程。routers/chat.pyllm_router:GET /catalog、POST /catalog/refresh(要求登录;关闭同步 409MODEL_CATALOG_DISABLED;拉不到 502MODEL_CATALOG_UNREACHABLE)。usage.model_pricing:先按模型 ID 精确命中目录单价 × 汇率,再走手写前缀表,再兜底价;手写表降级为「目录没有时的兜底」。- 新增
tests/test_model_catalog.py(16 项:非对话 / 已下线过滤与新在前排序、Gemini 卡片只收 gemini 家族、亮点正式版优先 + 测试版垫后 + 别名不进亮点、DashScope 只收 qwen 家族、智谱备用键、单价索引官方优先 + 别的平台补缺 + 不过滤已下线、空 / 无关目录拒收、刷新写缓存 + 重启读缓存不出网、旧版本 / 损坏缓存忽略、失败保留旧快照并报错、TTL 与强制刷新间隔、单价换汇与大小写、usage.model_pricing目录优先、接口未登录 401、关闭同步时内置快照 + 409、refresh 返回新视图)。conftest夹具model_catalog_enabled=False且缓存路径指向tmp_path。
- 新增
- 前端
auth/api.ts新增CatalogModel / CatalogProvider / ModelCatalogView与getModelCatalog / refreshModelCatalog;新增integration/model-catalog.ts(会话内缓存、refresh 替换缓存、currentModelCatalog同步读)与integration/model-catalog-view.ts(纯函数:providerHighlights / providerModels / catalogVision / catalogFreshnessText)+model-catalog-view.test.mjs(4 项)。integration/llm-providers.ts去掉全部模型名,只留品牌骨架;legacy/openmathmodel-ui.ts:卡片副标题占位「正在同步型号…」→ 打开设置时loadModelCatalog填亮点;区块标题下[data-catalog-status]新鲜度行;标题右侧「同步型号」按钮(refresh-catalog,withBusyButton,失败 toast 并保留上一份);「配置」默认模型 = 亮点首位;catalogModelsForHost取代seedModelOptions,refreshModelOptions合并接口自报清单;hydrateLlmPanel回填时目录晚到会补一次补全。styles.css新增.settings-heading-actions与状态行换行样式。integration/model-modality.ts:resolveSelectedModality两条分支先查目录vision(classifyModel),未收录再回落命名规则。en-US 词典补 13 条。
- 不动:
App.tsx/screens.tsx/OpenMathModelScreen.tsx;页面结构、路由、DOM 槽位;Auto 路由能力推断endpoint_strength。
当日执行并通过:pytest backend/api/tests 全量 377 通过 / 2 跳过;npm run check;npm run build(index 735.61 kB);node --test "src/**/*.test.mjs" 117/117。实机(本机 uvicorn --reload 开发后端自动重载):启动后约 1 s 完成首次同步并落 data/model-catalog.json(≈156 KB,8 家厂商、3170 条单价);一次性用户 GET /api/llm/catalog 显示 Anthropic claude-fable-5-1 / claude-opus-5 / claude-sonnet-5、Google gemini-3.8-flash / gemini-3.7-flash / gemini-3.5-flash-lite、通义 qwen3.8-flash / qwen3.8-max / qwen3.7-plus、智谱 glm-5.3-flash / glm-5.3 / glm-5.2、xAI grok-4.6 / grok-4.5 / grok-4.3;POST /catalog/refresh 200 并更新 synced_at;未登录两条都 401。踩坑:第一版按 family 取「每家族最新」在真实数据上把 gemma-4-26b / qvq-max / glm-4.7-flash 顶进卡片,改为按时间取最新 + Google 只收 gemini 前缀;亮点改为读取时现算,缓存文件版本升到 2 强制重拉。浏览器验收待用户(本环境无浏览器):卡片副标题与状态行、「同步型号」按钮、「配置」默认模型、模型 ID 补全。
截图现场:「机器人竞技攻击策略优化」实验运行阶段 FAILED,用户在聊天框说「怎么又失败了,快继续想办法」「继续啊」——前端只把消息 POST /notes(终态 409)、标「任务已结束,本条按问答处理」并挂一个对失败运行必死(RUN_NOT_COMPLETED)的「按这条要求继续修改」按钮,模型也不知道运行状态。根因:对话通道只有被动的备注注入、没有执行权,且 §11.3 旧决策明文「重做由人显式操作,绝不由备注文本触发」。用户拍板:分级执行(retry / resume / pause / 审批选项直接做;取消先确认)、已完成运行仍开修订门但点名阶段预选、失败运行的「选起点重做」留下一刀。改动(接口与页面行为见 §5.8,决策见 ADR-0018,设计文档 §11.3 已回写 v3.34):
- 后端
- 新增
omm_api/run_control.py:legal_actions(与状态机 / ADR-0013 同判据)、load_context(待审批门选项、修订轮数、上一轮待确认提案)、decide_locally(词表规则:重试 / 恢复 / 暂停 / 取消 / 退回 / 点名选项match_option/ 点名阶段explicit_stage;否定词、「继续说」类对话延续、问句标记)、judge_intent(池里最弱接口complete_once,10 s / 200 token,只认合法集内结果)、decide(本地 → 出网门槛:≤200 字、带合法动作的气味词、非问句)、execute(走execute_action/accept_revision/record_run_note;ApiError→rejected回执)、prompt_block(状态块 + 「不得声称执行了未列出的操作」)、run_control_step(独立 session、一个事务;任何异常回落为无动作)。 engine_glue.py抽出accept_revision(session, run, text, stage=None)(request_revision可点名推荐起点),routers/task_runs.py的/revisions与/notes改为委托(record_run_note落到run_control)。routers/chat.pystart_chat_turn:run_…归属且非开场的轮,producer 先跑控制步骤、yieldaction事件、把状态块追到 system 提示词后再_resolve_chain+stream_events;上一轮meta.actions作为previous_actions传入。chat_turns.py_run把action事件累进live.meta["actions"]随轮落库。- 新增
tests/test_run_control.py23 项(合法动作集;失败 / 暂停 / 执行中 / 审批门 / 修订门 / 已完成的本地规则与反例;字母 / 序数 / 别名选项点名;提案确认 / 放弃 / 作废;判定回复解析只认合法结果;出网门槛;提示词块;e2e:「怎么又失败了,快继续想办法」→ 运行 RUNNING + 备注落库 + 首事件action+meta.actions持久化 + 系统提示词带执行后状态且未出网判定;问句只聊不动;取消两轮确认 / 放弃 / 提案过期;「同意,按这个方案做」选定 G1;已完成运行「从数据准备重做,…」开门并预选 → 「确认」重跑;弱模型判定生效;判定 500 回落对话;chat_归属不经控制)。e2e 钉OMM_AGENT_NODES=sim让引擎仍走模拟链、mock 只服务对话与判定。
- 新增
- 前端
chat-turns-api.tsRunControlAction/ChatMeta.actions;agent-chat.tsChatHandlers.onAction、applyEvent处理action、续接重放meta.actions、entryFromTurn回执兜底重建轨迹;新增integration/run-control-view.ts(纯函数)+run-control-view.test.mjs(4 项,含「所有标题静态且有英文词条」)。legacy/openmathmodel-ui.ts:presentRunControlAction(回执 → 轨迹行,插在生成行之前;proposed挂「确认执行」= 发一句「确认」;executed广播omm:run-reopened);恢复态最后一轮的待确认提案照挂按钮(按落盘轨迹下标定位,不比文本——语言切换会翻标题);删除发送前postRunNote与offerRevisionCta死路行;en-US 补 13 条。
- 不动:
App.tsx/screens.tsx/OpenMathModelScreen.tsx;页面结构、路由、DOM 槽位;引擎状态机与/actions端点形状(OpenAPI 未变)。
当日执行并通过:pytest backend/api/tests(见下方全量结果);npm run check;npm run build;node --test "src/**/*.test.mjs" 125/125。浏览器验收待用户(本环境无浏览器):失败态说「继续」后轨迹行「已重试阶段 · 实验运行」+ 工作台状态即时变为执行中;「取消任务」→「等待你确认操作」行带按钮 → 点按钮或回「确认」→ 已取消;已完成态「从数据准备重做…」→ 待确认事项里数据准备预选。
用户原话:「你现在只是通过固定的关键词来识别判断吗?agent 自己没有相关的意图识别吗?我希望不管说什么,只要是需要执行的,都会出现真正的修改,而且这个修改显示的进度,也不一定要接着上一次的显示啊」。如实答复的现状:规则先判、弱模型判定被 ≤200 字 / 气味词 / 非问句三道门槛拦住、判定看不到上文、动作被状态机锁死(FAILED 只能 retry,RUNNING 下的修改要求只静默落备注)。用户拍板:规则只做第一层门槛、模型识别必须有;同一运行从选定阶段重做(不另起新运行);重做先提案再确认。改动(接口与行为见 §5.9,决策见 ADR-0019,ADR-0018 §1 / §2 / §5 标注已修订):
- 引擎(
agents/core):新增EventType.RUN_REDO;reducer_on_run_redo(合法源 = 工作态 / FAILED / NEEDS_REVIEW;在途 RUNNING 步骤 → CANCELLEDsuperseded: redo from <target>;清 review / failure / paused / cancel_requested;state = target+force_rerun+_discard_from(target));TaskRunEngine.redo(snapshot, target_state, reason, note_id)(COMPLETED / CREATED 拒绝);can_transition前向矩阵不放宽(states.py注明回退边只在 RUN_REDO reducer 里)。agents/evalsCONTROL_FLOW_FIELDS登记RUN_REDO(target_state, from_state)。测试:test_engine.py+3(FAILED 回退更早阶段、在途步骤被取代且丢弃评审、拒绝边界),test_replay.py+1(两次 redo 后重放 == 实时快照);core 130 / evals 48 通过。 - 后端:
engine_glue.pyRUN_REDO投影 +REDO_STATUSES+redo_run(session, run, stage, text)(409RUN_NOT_REDOABLE/ 422INVALID_STAGE/ 422EMPTY_TEXT;备注先 flush 再发事件;run.logredo_requested);runner.py_superseded_in_flight+WorkflowAdvancer.advance只吞run_domain_eventsseq 唯一约束冲突并 warning,其它 IntegrityError 照抛;run_control.py:ACTION_KINDS+redo、CONFIRM_REQUIRED = {cancel, redo}、legal_actions(..., revision_gate=)(FAILED→{retry, redo};RUNNING / PAUSED / 节点闸门 + redo;修订门不开)、本地规则「点名阶段 + 修改 / 重试词」→ redo(点名失败阶段折 retry)、decide改为「提案回应 → ≤12 字规则直判 → 模型(≤800 字,带最近三轮对话)→ none 回落规则」、_judge_prompt带阶段进度 / 最近对话、_parse_judge_reply(user_text=)阶段缺失按正文推断 / 未开始阶段 → none、redo 提案 / 执行 / 放弃回执;routers/chat.py从hub.list_scope取最近三轮{text, reply}传history。 - 后端测试:
test_run_control.py28 项(重写出网门槛用例为「快路径 + 全覆盖 + none 回落」;新增本地 redo 规则、判定 redo 解析边界、e2e:FAILED 上一句无词表词的「换成随机森林…」经模型判 redo → 提案(状态不变、不落备注)→ 确认 → RUNNING@建模方案、备注正文 = 原话、tick 后 attempt 2 到 G1;G1 门上「从数据准备重做」→ 确认 → 门 CANCELLED、数据准备 attempt 2;未开始阶段的要求静默落备注;「算了」放弃;判定提示词带「最近对话」);新增test_run_redo.py5 项(redo_run回退 + 备注 + run.log;状态 / 输入边界;_superseded_in_flight两种方言;真在途让位:模拟节点执行中途从另一会话redo_run→ 本 tick 返回 None + warning、步骤行 CANCELLED、下一 tick 从目标阶段 attempt 2 起跑、不触发 executor lost;无关 IntegrityError 照抛);test_chat_turns.py的上游 mock 统一把判定请求答 none(判定不再被门槛拦后,阻塞流会被判定调用吃掉前几段)。全量pytest backend/api/tests419 通过 / 2 跳过(PG 专属)。 - 前端:
run-control-view.tsredo标题「已从阶段重做」/ 类别「从阶段重做」,提案与放弃行后缀带阶段名;chat-turns-api.tsRunControlAction.text;run-control-view.test.mjs+1(5 项);en-US +1。npm run check/npm run build/node --test129/129 通过。 - 不动:受保护入口、页面结构与 DOM 槽位、
/actions端点形状(无新 HTTP 端点,重做只经对话确认触发)、契约Resolution形状。
浏览器验收待用户:失败态说一句不带任何固定词的修改要求(如「换成随机森林比较靠谱」)→ 轨迹行「等待你确认操作 · 从阶段重做 · 建模方案」带按钮 → 确认 → 「已从阶段重做 · 建模方案」+ 工作台进度回到建模方案继续、上游两段保留;执行中说「从题意解析重做」→ 确认 → 当前步骤标已取代、下一步从题意解析起。
用户实机走查 ADR-0018 路径的截图:实验运行 FAILED → 回「按典型参数继续」→ 上面一个 Agent 气泡「已重试阶段 · 实验运行 ✓ / 正在生成回复 / 思考中…」,下面又一个 Agent 气泡「收起执行步骤 / 重试失败阶段。/ 已记录补充要求…(JSON 行)/ ws_list / env_probe / 深度思考 · 实验执行」,两个同时在转;第二张截图里上面那个气泡的思考正文在逐项算分。读码根因:
- 渲染:
tailTraceHost只认「尾部是轨迹块才续写,否则另起带署名的新块」。对话即控制面之后,动作由对话轮自己触发,executed回执 →omm:run-reopened→ 控制器重接 SSE,第二次尝试的事件到达时尾部正是还在生成的回复块,于是另起一块。 - 内容:
prompt_block的回复要求「先告知已执行的操作,再回答用户的问题」把「按典型参数继续」当成了问题,模型在对话里替运行去算典型参数下的结果——聊天模型与运行里的实验节点并行解同一道题。 - 顺带:
run.log{kind:"user_note"}没有专门分支,落进「其他 run.log 原样 JSON」兜底(terminal 图标 + 可展开的原始 JSON,含备注全文与 note_id)。
改动(用户拍板:三项一起做):
- 前端
integration/modeling-workspace-controller.ts:抽activityHeader();新增replyRunTraceHost(replyBlock)(回复块内只建一次的折叠头 +.agent-stream.run-trace);tailTraceHost加第二条规则「尾部是.follow-up-reply→ 写进该回复块」;user_note并入「有现成人话 message 的运营事件 → 叙述行」分支。legacy/openmathmodel-ui.ts:appendReplyActions改为紧跟.analysis-copy插入;toggle-activity优先折叠折叠头紧随其后的列表。workflow-refresh.cssrevision 38:折叠头贴正文时 4px 上间距。 - 后端
run_control.py:_HANDOFF_KINDS/reply_rules(actions)三档回复口径(交接 / 提案待确认 / 默认),prompt_block末行改用它;导出reply_rules。tests/test_run_control.py+1(test_reply_rules_hand_off_to_the_run_after_executed_actions:六种交接动作同一口径、提案口径、rejected / dismissed / pause / cancel 与无动作走默认、prompt_block末行一致)。 - 不动:受保护入口、路由、DOM 槽位类名、
/actions与轮视图契约(action事件形状不变)、刷新重进路径(历史事件仍回放到首气泡、对话轮在其后重建)。
当日执行并通过:pytest backend/api/tests/test_run_control.py 29 项;npm run check;npm run build(index 745.58 kB);node --test "src/**/*.test.mjs" 137/137。浏览器验收待用户(本环境无浏览器):失败态回一句让运行继续的话 → 只有一个 Agent 气泡:轨迹行「已重试阶段 · 实验运行」→ 简短交接回复(不再自己算题)→ 复制按钮 → 「收起执行步骤」+ 第二次尝试的步骤在同一气泡里往下走;点折叠头只收起步骤区、回复轨迹行不受影响;「已记录补充要求…」为一行叙述、无 JSON 展开;再发一条消息后新的步骤落到新回复块里。已知但未动:首气泡封口后(题意解析完成)没有对话时,后续阶段的步骤仍按既有设计另起一个「收起执行步骤」轨迹块,与首气泡相邻——属 2026-08-21 起的既有形态,如需一并合并再议。
用户从任务「机器人竞技攻击策略优化」页回到首页,新开对话只问了一句「今天是什么日子」,Agent 却回「您上传的是 B 题:机器人竞技策略的优化问题,我已经读取并梳理了题目内容」并开始分解问题。
根因不在服务端隔离(chat_… 归属按 user 隔离、不注入任何运行上下文;对话历史也已由 configureConversation 换绑清空),而在前端拼上下文:sendConversationTurn 对任何归属都调用任务附件收集器,而 task-attachment-context.ts 判断「当前任务是谁」不看对话绑定的归属,读的是 URL run_id、没有则退回标签页级的 sessionStorage.openmathmodel.activeRunId——这个键在每次打开任务页时写入、只在任务被删 / 404 时清。首页没有 run_id,于是拿着上一个任务的 run 拉 /workspace 的上传附件,把题面 PDF 全文以「用户为本任务上传了附件,内容如下:【任务附件:…】」并进了新对话的第一条消息。连带一个缓存缺陷:附件清单缓存 entriesPromise 模块级、不按 run 分键,resetTaskAttachmentContext() 定义了但无人调用——同一标签页从任务 A 切到任务 B 不刷新时,B 的对话要么带上 A 的附件、要么(A 的都已注入过时)永远拿不到 B 自己的附件。
- 前端
attachments/task-attachment-context.ts:collectTaskAttachmentContext(runId)改为显式接收对话绑定的运行,不再读 URL / sessionStorage 猜身份(删activeRunId()与ACTIVE_RUN_KEY);不是run_…直接返回 null 且不发请求;清单缓存记entriesRunId,run 变了整套状态(清单 / 首轮等待预算 / 摘录标记)自动作废。integration/agent-chat.ts:只在turnScope是run_…时收集任务附件;configureConversation换绑时调用resetTaskAttachmentContext()。新增attachments/task-attachment-context.test.mjs3 项(非运行归属不产生附件也不读残留身份;换运行清单作废、切回重新并入;摘录兜底按运行隔离)。 - 不动:
taskGoal()在无归属(演示态)时读全局openmathmodelPrompt的既有兜底——有归属时本就只认scopeGoal,首页对话不受影响;服务端CHAT_SYSTEM_PROMPT未加当前日期(「今天是什么日子」答成 5 月 7 日是模型不知道日期,用户暂不要求改)。
当日执行并通过:npm run check;npm run build(index 751.02 kB);node --test "src/**/*.test.mjs" 145/146——唯一失败项 paper-figures.test.mjs 读的 packages/contracts/fixtures/v1/valid/document-draft.4.json 正被另一条切片线程改动中,与本修复无关。浏览器验收待用户:从任意任务页回首页新开对话问一句无关的话,回复不应提到该任务的附件 / 题面;任务页对话仍能在开场分析与追问里看到自己的附件;同一标签页从任务 A 切到任务 B(不刷新)追问时,B 的回复引用的是 B 的附件。
用户在首页对话里看不到回复右下角的复制按钮,以为是回归。核对代码与 git log:复制按钮(appendReplyActions)自 2026-08-13 起只挂在任务页的回复块上(legacy/openmathmodel-ui.ts),首页对话的回复由 integration/home-chat.ts 渲染,从未挂过操作区——不是丢了,是首页从来没有。顺带按用户要求加评价按钮;用户拍板:赞 + 踩一对、落服务端对话轮、首页与任务页都加。
- 后端:
chat_turns.feedback(String(8)可空:up/down/ NULL)—— ORM + Alembic0020_chat_turn_feedback(SQLite 开发库由_add_missing_sqlite_columns自动补列);ChatTurnHub.set_feedback与set_trace同一套落点(内存里的轮先改内存再回写,否则直接改库行);PUT /api/chat/turns/{id}/feedback(ChatTurnFeedbackRequest{feedback: up|down|null},非法值 422,别人的轮 404);轮视图(内存快照 / 库行)多出feedback。test_chat_turns.py+3:置值 / 改评 / 撤回三处一致且不碰正文与轨迹;内存缓冲已回收只剩库行的轮也能评;非本人 404。OpenAPI 基线随之重导(+1 路径、+1 schema)。 - 前端:新增
integration/reply-actions.ts——两页共用的操作区(复制 + 赞 + 踩),紧跟.analysis-copy之后插入;纯逻辑(replyActionsMarkup/nextFeedback/createFeedbackController:先按下再落库、失败退回原状并 toast、落库期间的点击忽略、以服务端确认值为准)与 DOM 挂载(mountReplyActions)分开,reply-actions.test.mjs4 项覆盖前者。没有服务端轮 id 的回复(本机旧记录、无状态通道)只显示复制——评价没有落点就不摆出来。legacy/openmathmodel-ui.ts的appendReplyActions改为委托该模块,四处调用(重建追问 / 重建开场 / 续接直播 / 首发)都把轮 id 与已有评价传进去;home-chat.ts的presentReply收尾与appendSettledReply重建同样挂上。chat-turns-api.ts:ChatTurnView.feedback?+setChatTurnFeedback;agent-chat.tsentryFromTurn的条目带turnId/feedback(ConversationLogEntry新增两个可选字段,只由服务端轮映射产生,本机旧记录不带)。styles.css:.reply-action-button[aria-pressed="true"]按下态(实心图标 + 深色,含暗色主题);en-US.ts+3 词条。 - 不动:受保护入口、页面结构与路由;
.reply-actions位置与既有复制交互不变。
当日执行并通过:pytest backend/api/tests 427 passed / 2 skipped;export_openapi.py --check 重导后一致;npm run check;npm run build(index 753.18 kB);node --test "src/**/*.test.mjs" 150/150。浏览器验收待用户:首页对话与任务页的每条已完成回复右下角都有「复制 · 赞 · 踩」三颗按钮;点赞后图标实心、刷新 / 重进仍保持;再点一次撤回、点另一个改评价;后端不可达时按钮退回原状并提示「反馈保存失败」。
用户两条报障:(1)活动流里「深度思考 · 实验执行」等小标题下的盒子展开后高度无限,要和聊天思考块一样限高、内部滚动;(2)明明还在思考中,一退出建模页 / 切别的对话,运行就变成「执行失败」。
(1)是既有设计的有意选择(workflow-refresh.css 原注释「展开详情是用户主动要读全文:内容多长就多高」),按用户要求改掉:.stream-detail pre 统一 max-height: min(260px, 40vh) + overflow: auto(首版取聊天思考块的 380px,用户复验后要求再矮一点,压到约 10 行),细滚动条贴容器底色;生成中的实时区仍是 220px 矮窗吸底。覆盖思考 / 阶段产出 / 沙盒执行 / ws_list·env_probe 原始日志 / 回复轨迹行,前端零逻辑改动。
(2)查库复盘 run_4c9db02321d344c8998ab9a0aa805619,与切页无关——前端切页只关 SSE、中止本页 fetch,服务端 cancel_run / pause_run 只由显式动作触发;真实链路是两件事叠加:
- 开发者以
uvicorn omm_api.asgi:app --reload …在backend/api起 API 却没加--reload-dir(README 早已标注「必带」):watchfiles 默认监视 cwd 下全部*.py,沙盒第一次python_run写出data/workspaces/<run>/steps/<step>/main.py(21:49:45)即触发重载,18 秒后新进程的heal_interrupted把在途步骤判成interrupted: executor lost before completion(TRANSIENT)——页面上就是「深度思考 · 实验执行(本次调用中断)18s」。这种配置下实验阶段每次尝试都会在第一次 python_run 后被打断,永远跑不完。 - 第 2 次尝试里 DeepSeek 实验模型(
deepseek-v4-flash-vision-exp)两次把完整的{"tool": "python_run", …}信封写进reasoning_content、content为空(同一运行五次沙盒调用有三次如此),chat_text原样把空串交给内环 → 「输出未通过结构校验」修复梯 → 仍为空 →experiment sandbox failed … 0 run(s): 尚未用 python_run 运行任何代码,运行 FAILED。
改动:
- 后端
llm.py:新增salvage_answer_from_reasoning——正文为空且思考以}收尾时,从最后一个}往前找第一个能把尾巴整体解析成 JSON 对象的{(容忍末尾 ``` 围栏),_account_and_emit以之为答案返回并在 `llm_call` 事件打 `answer_from_reasoning: true` 审计标记、日志 warning;正文非空时思考一字不掺。`engine_glue.advance_run`:`heal_interrupted` 真修复到步骤时调用 `_note_executor_restart`——落 `run.log{kind:"executor_restarted", message:"后端进程在「X」执行中途重启,进行中的模型调用被打断;已自动作为第 N 次尝试重跑该阶段"}` 并在 omm.engine 日志给出 `--reload-dir` 修正命令。新增 `tests/test_llm_reasoning_salvage.py` 8 项、`tests/test_executor_restart_note.py` 2 项(用 BaseException 穿透推进链复现悬挂 RUNNING 步骤;断言叙述事件落在 `step.failed` 之后、第 2 次 `step.started` 之前,正常推进零叙述)。 - 前端
modeling-workspace-controller.ts:executor_restarted并入「有现成人话 message → 叙述行」分支,不落原始 JSON 兜底。 - 工具链:新增
tools/dev-api.mjs与根npm run dev:api——README「手动双终端」那条命令的封装(--reload-dir backend/api/omm_api --reload-dir agents --timeout-graceful-shutdown 5),附加参数透传 uvicorn;两份 README 的手动命令上方都改为先推荐它。 - 不动:受保护入口、路由、DOM 槽位;
heal_interrupted语义(仍然失败重跑);沙盒工作区位置。
当日执行并通过:pytest backend/api/tests 435 passed / 2 skipped / 1 deselected(唯一剔除项 test_stage_outputs.py::test_references_projection_… 依赖正被另一条切片线程改动中的 stage_outputs.py,与本修复无关);npm run check;npm run build(index 755.67 kB);node --test "src/**/*.test.mjs" 150/150;npm run dev:api -- --port 8011 实机启动日志 Will watch for changes in these directories: [agents, backend/api/omm_api]、/api/health ok。浏览器验收待用户(本环境无浏览器):任务页展开任意「深度思考 / 阶段产出 / 已在沙箱执行实验代码 / ws_list」行,盒子最高约 260px(约 10 行)、内部出现细滚动条、对话不再被推走;用 npm run dev:api 重启 API 后重跑实验阶段,python_run 之后不再出现「本次调用中断」;若仍以旧命令启动,活动流会在中断行下方补一句「后端进程在「实验运行」执行中途重启…」说明原因。
用户截图:在对话里让 Agent 重试(ADR-0018),回复「已重试阶段 · 实验运行」落在底部,但重试后的 ws_list / env_probe / 「深度思考 · 实验执行 1m 8s」全在页面顶端的首气泡里,底部看不到任何动静,得往上翻几屏才找到;另外首气泡末尾的黑色「Agent 正在执行」几乎贴着下一条用户气泡。
根因:实时事件自 2026-08-21 起已经按「首气泡封口后流向对话末尾」排布(tailTraceHost),但重进 / 整页导航(切阶段页、切别的任务再回来)时 hydrateHistory 回放的历史事件一律落回首气泡(resolveStreamHost 的 replay 分支),而对话轮由 omm:conversation-restore 重建在其后——重试是在对话里触发的,用户随后切页几乎是必然的,于是每次回来都是「进度在顶、对话在底」。阶段页左栏(.focused-agent-scroll)更进一步:resolveStreamHost 只认 .chat-scroll,那里连实时事件都一律堆在摘要下方。间距:.user-message 只有 4px 上边距,Agent 块 → 用户气泡之间没有任何规则补齐(反向的用户气泡 → Agent 回复是 24px)。
- 前端
integration/modeling-workspace-controller.ts:AgentStreamState新增replayAtMs(回放中这条事件的服务端时间);新增historyTraceHost(scroll, atMs)——找最后一条data-turn-at不晚于事件时间的用户气泡,事件写进它紧随的回复块内部的运行步骤区(replyRunTraceHost,与实时到达时同一落点),早于全部追问的仍落回首气泡;conversationScroll同时认.chat-scroll与.focused-agent-scroll;liveEventsFlowToTail:总览页沿用「首气泡封口」判定,阶段页左栏在计划揭示且已有对话时才把实时事件排到末尾(没有对话时保持「摘要 → 步骤 → 主操作」,不为一条步骤另立署名块);tailTraceHost的署名改从所在.chat-pane里克隆(阶段页取面板顶部的 Agent 署名)。legacy/openmathmodel-ui.ts:appendRestoredUserBubble多收createdAt,服务端轮重建(含在途轮续接)时写data-turn-at;本机旧记录无服务端时间,不带标记、仍在所有轮之前。 - 样式
workflow-refresh.cssrevision 40:.chat-scroll .assistant-block + .user-message、.focused-agent-scroll .assistant-block + .user-message、.focused-agent-scroll [data-agent-cta] + .user-message补 24px 上间距,与「用户气泡 → Agent 回复」同一节奏;首页对话线程自带.assistant-block下边距,不受影响。 - 不动:受保护入口、路由、DOM 槽位;实时事件在总览页的既有落点;
step.*仍不进活动流。
当日执行并通过:npm run check;npm run build(index 756.49 kB);node --test "src/**/*.test.mjs" 150/150。浏览器验收待用户(本环境无浏览器):任务页对话里说「重试」→ 切到实验页再切回(或刷新)→ 重试后的 ws_list / env_probe / 深度思考行应出现在「已重试阶段 · 实验运行」那条回复的下方(「收起执行步骤」折叠头之后),页面停在底部即可看到,首气泡里只剩重试之前的轨迹;阶段页左栏有对话时新步骤同样排在最后一条回复之下、没有对话时仍在摘要下方;首气泡黑色主操作与下一条用户气泡之间约 24px。
同一条报障的延伸:首气泡末尾那颗黑色主操作([data-agent-cta],总览页是「前往实验与验证」这类跳转、阶段页左栏是重试 / 确认方案)随首气泡一起被对话推到几屏之上。第一版把它镜像成一枚小药丸放进输入框上方「执行计划」面板的头部,用户否决(位置不好看)——要的是和执行进度一样,跟到最新一条对话的最下面。镜像方案已整体撤回,改为搬按钮本体:
- 前端
integration/modeling-workspace-controller.ts:新增followConversationTail(root)——对话区里最后一条.assistant-block.follow-up-reply(回复块或轨迹块)存在时,把审批选项列表(renderApprovalOptions摆在按钮前面的[data-approval-options])与按钮一起append到它的末尾;没有对话时退回原位。搬的是按钮本体:文案、禁用态、onClick处理与幂等 token 全部沿用,不存在两处分叉。原位留一个隐藏占位[data-agent-cta-home](放在按钮之后而不是之前——renderApprovalOptions靠cta.previousElementSibling找已有选项,前面多个占位会让它每次刷新再建一份),块被移除时按钮退回占位、绝不随块消失。触发点三处:renderAgent(每次快照刷新,选项列表刚摆好一起搬)、streamAppend/appendGrouped(replyRunTraceHost在尾部回复块末尾新建步骤区会排到按钮之后,追加后把按钮重新压到最后)、对话区直接子节点的MutationObserver(用户新发一轮由页面层直接写进对话区,控制器没有回调可接;只看直接子节点,按钮搬进块内是孙节点变化不会触发自己;cleanup里disconnect)。演示夹具([data-demo-only])里的 CTA 不动。 - 样式
workflow-refresh.cssrevision 41:.follow-up-reply > .running-live-cta / .focused-stage-cta12px 上间距;紧跟.agent-stream(自带 10px 下边距)时 2px、紧跟.approval-options(自带 12px)时 0,与它在首气泡里的间距一致。revision 40 的「Agent 块 → 用户气泡 24px」补一条[data-agent-cta-home] + .user-message(阶段页左栏按钮搬走后占位顶替它的位置)。 - 撤回:
task-todo-panel.ts的.todo-head-row/.todo-action/renderTaskTodoAction与对应样式、控制器里的镜像同步与[data-todo-action]点击分支——面板恢复原样。 - 不动:按钮的动作语义、审批选项列表的渲染与滚入视野、受保护入口与 DOM 槽位。
当日执行并通过:npm run check;npm run build(index 757.40 kB);node --test "src/**/*.test.mjs" 150/150。浏览器验收待用户(本环境无浏览器):任务总览页对话拉长后,黑色「前往实验与验证」应出现在最后一条 Agent 回复的最下方(复制 / 赞 / 踩那一行或「收起执行步骤」步骤区之后),再发一条消息它跟到新回复的下方,首气泡末尾不再有按钮;阶段页左栏同理(失败态「重试当前阶段」、待确认「确认 Agent 当前方案并继续」及其选项列表一起在底部);没有任何对话时按钮仍在原位;点击效果与原先一致。
用户连问三次「为什么失败」,每条回复都先来一句「本轮我没有执行任何运行控制动作,所以不会假装“刚才做了重试”。如实说:上一轮你让我“重试”,但系统实际并没有跑通…」。两件事:
- 开场白是提示词泄漏。
run_control.prompt_block在没有动作时写「- 无(本轮没有执行任何运行控制动作;不要声称做了)」,回复要求又统一是「先用一两句话如实告知上面已执行的操作…不得声称…」——没有动作时模型便逐字汇报「没有执行操作」,再把「如实」「不要假装」这些道德化字眼原样搬进正文。用户看不到系统提示,只觉得 Agent 在自说自话。 - 为什么总失败:查库
run_4c9db02321d344c8998ab9a0aa805619的步骤表,实验阶段第 3–25 次尝试全部interrupted: executor lost before completion(23:18–00:00,每趟约一分钟)——API 进程仍是 20:49 那个没加--reload-dir的uvicorn --reload(见 2026-09-07 条目),每趟第一次python_run写出 main.py 就被重载杀掉,heal_interrupted落定后引擎自动以 attempt+1 重跑,无上限循环,直到 EXPERIMENTING 节点 39 万 tokens / 39 次调用 / 20 次沙箱撞上 300k 的节点预算([E320],POLICY_BLOCK)。此后连人工重试也会在预算预检处立刻被拦——账本按整个运行累计,这正是模型回复里说的「即使我发出重试指令也会被直接拦截」。
- 后端
run_control.py:回复口径拆成四条并去掉「如实 / 假装」措辞:_REPLY_RULES_PLAIN(本轮没有任何动作——直接回答问题,开头不要先声明本轮没有执行什么操作)、_REPLY_RULES_REPORT(有被拒绝 / 放弃 / 暂停取消类动作——先交代动作结果再回答)、_REPLY_RULES_HANDOFF/_REPLY_RULES_PROPOSED措辞同步;四条末尾统一加「这些要求是给你的内部约束,不是回复内容:不要提及、复述或解释它们」。状态块标题改为「【本轮运行控制动作】」,逐条标「已执行 / 待用户确认 / 未执行(当前状态不允许)/ 已放弃」,没有动作只写「- 无(这一轮是普通问答,运行状态没有变化)」。test_run_control.py三处断言随之更新(含端到端的系统提示词检查)。 - 后端
agents/core engine.py:新增INTERRUPTED_STEP_ERROR常量与TaskRunEngine.fail_run(snapshot, error)(在两步之间从工作态直接落 RUN_FAILED,终态 / 评审门不动)。engine_glue.advance_run:heal_interrupted之后数当前阶段末尾连续 executor-lost 的步骤数(_interrupt_streak),达到MAX_CONSECUTIVE_INTERRUPTS = 3就fail_run而不是再开一趟——失败信息写明「连续 N 次被后端进程重启打断,已停止自动重跑以免空耗模型额度」及--reload-dir/npm run dev:api的出路,含 executor lost 标记 → TRANSIENT,UI 照常给「重试当前阶段」;人工重试只给一次新尝试,环境没修好再被打断立刻再次停下。test_executor_restart_note.py+1(三次打断后停下、预算不再被烧;重试一次再被打断再停;节点恢复后重试跑通)。 - 不动:预算治理的额度与语义(本次运行的 EXPERIMENTING 节点已超 300k,重试仍会被 E320 拦下——需启动 API 前设
OMM_NODE_MAX_TOKENS提高上限,或新建任务);对话控制面的动作判定与执行。
当日执行并通过:pytest backend/api/tests 440 passed / 2 skipped / 1 deselected(同一条被另一会话改动中的 stage_outputs 用例);pytest agents/core backend/worker 169 passed。浏览器验收待用户:在失败的任务里问「为什么失败」,回复应直接从原因说起,不再有「本轮我没有执行任何运行控制动作 / 如实说」开场;让 API 反复重启的环境下,实验阶段最多连续 3 趟被打断即停在 FAILED,失败信息里能看到 --reload-dir 的提示。
上一条的收尾是「不动:预算治理的额度与语义」,用户看到实验阶段又被 [E320] 拦下后拍板推翻——默认不要上限。这次改的是默认值与"无上限"这件事能不能被表达,不是把闸门删掉。
改的理由(一句话:保护弱、副作用重):额度原本挡的失控是「进程反复重启 → 阶段无上限重跑」,那条已由上一条的 MAX_CONSECUTIVE_INTERRUPTS = 3 在源头掐断,不再需要拿钱包当刹车;而额度一旦烧光是整个运行不可逆地卡死——账本按 run 累计、预检在调用之前,人工点「重试当前阶段」也会立刻被拦,用户只能弃掉这个 run 重开(2026-09-07 的 run_4c9db023… 正是如此)。
- harness
budget.py:新增一等值UNLIMITED(=math.inf)与is_unlimited(),四个维度(run tokens / 调用次数 / 沙箱次数 / 墙钟)以及节点 tokens 在无上限时跳过越线检查,账本照记——报告与错误上下文里的用量始终是真数。取inf而不是-1之类的负数哨兵:下游有budgets.max_sandbox_runs < 1(实验节点判断"够不够复跑核对")、min(6, budgets.max_sandbox_runs)、thread.join(max_wall_clock_s)这类裸比较,负数会被读成"一次都不给"而静默跳过复跑,inf则让每处比较天然成立。0保持原义(真实额度"一次都不给",subagent_slice在父预算耗尽时就切出 0,下游靠它跳过工作),没有被并进"无上限"。 - harness 同文件:
BudgetGovernor增default_node_budget。此前check_llm_call对没open_node过的节点回落到NodeBudget()即 §4.7 的 30 万,执行面只 open_PROMPT_NODE_IDS里的节点,任何表外节点都会绕过本地配置在 30 万处被硬停。subagent_slice逐维度传递无上限(四分之一的无穷还是无穷);snapshot()["limits"]与subagents.py的 spawn 审计把非有限值报成null——两处都会进 JSON /jsonb列,json.dumps(inf)出来的裸Infinity会让整条事件被拒收。RunBudget/NodeBudget的字段类型随之改成int | float,§4.7 那张表的数字原样保留(设计契约不动,由执行面决定传什么)。 - 执行面
engine_glue.py:_env_int→_env_limit,默认返回UNLIMITED,只有正整数才恢复硬闸(0/ 负数 / 非数字 / 不填都是关闭——手敲环境变量的人写 0 想表达的是"别限",与治理器里的 0 语义不同,注释已写明);_scaled_limit让修订轮追加配额对无上限维度成为空操作;_build_budget_governor同时把节点预算传给open_node与default_node_budget。_budget_stop_message改口径:默认不设限,出现这条说明本机显式配了额度,指引改为"调高或删除对应环境变量(置 0 即关闭)后重启 API"。 - 文档:
backend/api/README.md环境变量表补四行 + 一段说明(含"用量监控的月度费用预算与硬限制是另一套闸门,仍然生效");.env.example补一组注释掉的示例值。 - 不动:
LoopBudget的max_turns/repairs/no_progress_k/tool_fail_m(E330–E332)与MAX_CONSECUTIVE_INTERRUPTS、MAX_REVISION_ROUNDS、子代理并发上限——这些是进展护栏不是钱包护栏,去掉就真的没有停机条件了;设置中心「用量监控」的月度费用预算与硬限制(usage.py)也不动。
当日执行并通过:pytest backend/api/tests 444 passed / 2 skipped;pytest agents 618 passed。新增用例:harness 5 条(无上限不停但照记账 / 表外节点用调用方给的回落值 / 0 仍是真实额度 / 切片保持无上限且不误触发 <1 / limits 报 null 且 json.dumps(allow_nan=False) 不炸),后端 3 条(不配环境变量四项全 inf、0 即关闭、正整数恢复并按轮追加;默认治理器放行 80 万 tokens / 80 次调用 / 50 次沙箱且含表外节点)。test_run_revisions.py::test_each_round_appends_one_more_quota 补 setenv——默认无上限下 inf * 3 == inf 会让原断言恒真、守卫失效。改动只在 API 进程内生效,用户需重启后端;已卡在 [E320] 的历史运行重启后点「重试当前阶段」即可继续(账本仍在,但不再有线可撞)。
用户带图报障:在失败的任务里发了一句带「重试」的话,模型还在「思考中」,回复块下方的执行步骤区(ws_list / env_probe / 深度思考·实验执行)就已经出现并开始滚动,回复随后才说「已按你的指令重试当前阶段」。拍板两条:「模型回答完了之后,再出现下面这些执行的」;「有关键字了就直接触发重新执行肯定不行,要结合在一起的」。查明是两处设计叠加:routers/chat.py 的 producer 在调用模型之前就跑 run_control_step(判定 + 立刻 execute_action),运行一变 RUNNING 工作台就重接事件流、在尾部回复块里长执行步骤;run_control.decide 又把规范化后 ≤12 字的短句词表命中直判、长句判定模型说 none 时回落词表结果——两条路都是「有关键字就执行」。
- 后端
run_control.py:run_control_step拆成plan_control_step(回复前:读状态 + 判定 + 生成将来时的状态块,不改状态、不写库、不发事件)与execute_control_plan(回复后:重读运行状态复核合法性 → 执行 / 落备注 → 回执)。复核失败(回复期间用户点了按钮、待审批门换了)回action{status:"rejected", code:"RUN_STATE_CHANGED"},不按过期快照硬执行。新增ControlPlan/describe_plan(将来时安排,与execute回执一一对应)/execute_dry(提案与放弃这两条不碰库的分支)。decide:删掉RULE_FAST_PATH_MAX_CHARS短句直判与「模型 none 回落规则」,词表候选作为「参考信号」一行随判定提示词下发(明写「词表匹配不等于用户在下命令」);仍本地直判的只有对上一轮提案的「确认 / 算了」。Judge签名加第四参hint。reply_rules四档口径全改将来时并禁止「已重试」「已选择」;prompt_block加note_planned(不触发动作但会落备注的一轮,说明「只是记录,不是修改」)。 - 后端
chat_turns.py:ChatTurnHub.start增before_finish钩子;_run重构为「所有终态路径(上游 done / error、静默收尾、事件超限、线程异常)汇到_finish」——钩子在锁外跑、只在轮仍 running 时调用,产出的action事件排在终态之前并记进meta.actions;钩子跑完若观众恰好 stop 了,动作已经发生,回执照样进 meta(刷新可见)、只不再改终态。用户 stop 后生成线程按原样 break、terminal为 None、钩子不跑——被打断的轮不执行任何动作。 - 后端
routers/chat.py:producer 只做计划并注入提示词,before_finish里execute_control_plan→action_events。 - 前端
openmathmodel-ui.ts:回复呈现器的onAction不再插到生成行之前,改为追加在末尾,并攒进tailRows;settleGeneratingRow增第五参,在「已生成回复」落定之后再把它们接进traceLog(轨迹日志顺序与页面一致,重建不会把回执排到生成行前面)。agent-chat.ts注释随语义更新。run-control-view.ts纯函数不变。 - 文档:新增 ADR-0020;ADR-0018 / ADR-0019 状态行标注被修订的条目。
- 不动:
/actions、闸门、引擎状态矩阵;判定模型仍是池里最弱的一个、按route记账;受保护入口与 DOM 槽位。
当日执行并通过:pytest backend/api/tests 453 passed / 2 skipped;npm run check;node --test "src/**/*.test.mjs" 155/155。test_run_control.py 重写为 35 条:事件顺序断言从「首条是 action」改为「全部 delta → action → 终态」(_assert_actions_after_reply),mock 判定器按「用户这句话」查表作答(词表不再直判,每句都出网);新增 5 条——对话 SSE 正文两个分块之间探运行状态仍是 FAILED、判定说 none 时「重试」「继续啊」「再跑一次」都只是对话、慢流中 stop 后运行不动 / 不落备注 / 无回执且再说一次即正常执行、回复期间点了按钮则回执 RUN_STATE_CHANGED、回复模型 500 时动作照样执行且回执排在 error 之前。后端需重启。浏览器验收待用户(本环境无浏览器):失败任务里说「重试」→ 回复先逐字说完(内容应是「接下来会重试…」而非「已重试」)→ 回复下方出现「已重试阶段 · 实验运行」一行 → 执行步骤区才开始滚动;回复期间按暂停键 → 运行保持失败、不出现回执;「重试过几次了?」这类问句不触发动作。
用户带图复验三条:(1)建模流程页仍有「字穿过盒子」与「非常拥挤」;(2)论文页右侧正文要一个字一个字出现,不能直接整篇出现;(3)论文页左侧目录栏难看。用真实产出(run_4c9db02321d344c8998ab9a0aa805619 的三案方案、两篇万字终稿)经 Playwright 路由拦截灌进测试运行复现,1280–1920 四档视口逐一探测横向溢出。
- 穿盒的真实原因在论文纸面:
.editor-page是 flex 列容器里margin: 0 auto的项,不被拉伸、宽度按内容 min-content 算——朱诺论文里一条宽公式(.md-math-blockmin-content 753px)把纸面撑到 917px 而容器只有 823px,右侧正文被外层overflow: hidden切掉。修:.paper-only-editor .editor-page { width: 100% }(1060 上限与居中不变),公式在自己的块里横向滚动;多列数字表(min-content 724px)在stage-content.ts里套.md-table-wrap(overflow-x: auto)自行滚动,纸面不再出横向滚动条。 - 拥挤在方案行:
.focused-plan-row原是六列一行(标题 205px 定宽 + 三段摘要按比例分),真实产出的核心方法与主要风险都是长句,摘要列三行截断把行高撑得参差、1366 宽下省略号里夹半个字。改成两行卡片:第一行 radio / 标题 / 核心方法(弹性列、单行省略、悬停全名)/ 步骤数,第二行整行主要风险(首句上限 48→90 字,单行省略、悬停全部风险);卡片高度恒定,1280–1920 全档不再拥挤;≤820px 手机档沿用 revision 32 逐行堆叠(grid-row: auto复位)。方案详情表标签列 128→136px、行内距 15→18px、列表项间距 6→8px;左栏审批选项卡说明字号 12→12.5px / 行距 1.65、长标识符允许折行。顺带修agents/skills nodes.py::_plan_blurb:首句自带的「;」再接「;实现语言」出现「;;」,先去掉首句句读。 - 逐字流式没生效的原因:
renderEditorPanel/appendPaperSection都以editor.offsetParent !== null为动画前提;合并工作台里五个阶段面板同存、论文面板在用户停留于别的阶段时是隐藏的,正文(分章事件与终稿)恰恰都在那时到达,于是整段直落并把版本记成「已播过」,用户切回论文页永远是「直接出现」。旧实现另有 16mssetInterval+max(3, 总字数/500)字/帧,万字论文 8 秒播完,看起来是整段整段蹦。重写为PaperTypewriter(每台编辑器一台,分章直播与终稿共用一条队列):requestAnimationFrame驱动、按时间折算字数预算(帧率无关,后台标签切回单帧最多补 100ms);速度随积压自适应(积压 / 24,夹在 48–320 字/秒:一万五千字终稿约一分半,单章半分钟上下,尾段逐字可辨);编辑器不可见时暂停(200ms 探一次可见性),切回论文页才从停下的位置继续、不追赶不重播;多章按到达顺序排队,SSE 重放的历史章节若排在正在打字的章节之后轮到时整段直落只保顺序;终稿在直播未打完时到达 → 头部(标题 / 摘要 / 关键词)与已看过的字数直接放出、从直播停下的位置接着打(skipChars = headChars + revealed),直播已全部打完 → 静默换成终稿(原行为);beforeinput整段放行、stream-in入场、光标、吸底跟随、reduceMotion跳过、用户草稿主权、每 30 帧刷新字数统计均保留。大纲链接改为使用时现取(分章直播会按需补挂链接)。 - 大纲栏:列宽 190→212px(≤700px 容器 160→172px);首版做成「状态点 + 标题」(已写完绿底勾、当前章节左侧主色粗标 + 加粗),用户复验明确不要——改为一列干净的目录:状态点留在 DOM(直播打勾逻辑不动)但
display: none,不画左标、不加粗,当前章节只用浅底(字重 500),未写完的章节用淡一档字色(a:has(.outline-status:not(.done)))示意进度;字号 12.5 / 行高 1.5、overflow-wrap: anywhere;.focused-stage-pane令牌层与暗色主题同步。 - 不动:受保护入口、页面模板与路由、DOM 槽位(方案行仍是 radio / strong / 三个 span / i 的既有结构,CSS 按序摆位)。
当日执行并通过:agents/skills pytest tests/test_nodes.py 161 passed;npm run check;npm run build(index 755.67 kB);node --test "src/**/*.test.mjs" 150/150;Playwright 实机:方案页 1280 / 1366 / 1440 / 1536 / 1920 五档探针「scrollWidth > clientWidth」元素 0 个;论文页纸面 scrollWidth == clientWidth;打字机脚本——终稿在论文面板隐藏时到达 4.5s 后正文 0 字、data-streaming=true,切到论文页 0.8s 后 356 字 / 3.3s 后 1563 字,切走 2.5s 内字数不增长,切回继续,键入一字后整段放行、data-streaming 撤销、大纲 8 项全部打勾。截图存 audit-current/stage-polish-2026-09-07/(*-before* / *-after* / typewriter-*.png)。浏览器验收待用户:方案页方案卡两行(标题 + 核心方法 + 步骤数 / 主要风险)不再拥挤;论文页右侧正文不再被右边裁掉;停留在别的阶段等论文写完再切到论文页,正文应从头逐字打出,中途切走再切回从原处继续,开始编辑立即全文放出;左侧大纲是一列纯文字目录(无勾、无左标、不加粗),当前章节浅底、未写完的章节淡灰。
用户带图报障两条:(1).running-live-cta(「重试当前阶段」这颗黑色主操作)总出现在内容最底层、一直显示——问一句「重新开始吧」「为什么失败」这类与运行无关的话,回复下面也顶着它;要求「只有该显示的时候才显示,和它没关系的内容下面不该出现」。(2).activity-summary(「收起执行步骤」折叠头)删掉。
根因:2026-09-07 的 followConversationTail 把按钮搬到对话里最后一条 Agent 消息的末尾,判据只看「是不是 .follow-up-reply」——普通问答的回复块也算,于是每发一条消息按钮就跟到新回复下面;renderAgent 又只在规划期隐藏按钮,运行中 kind:"none" 时照样摆一颗禁用的「Agent 正在执行」占位。折叠头则是 2026-09-07 修「两个 Agent 气泡」时随 replyRunTraceHost / tailTraceHost 重新挂回来的(对话层自己的过程区早在 09-05 就按用户要求撤了)。
- 前端
integration/modeling-workspace-controller.ts:新增runTraceBlocks(scroll)——对话里承载过运行事件的 Agent 块(直接子节点带.agent-stream.run-trace),tailTraceHost新建的轨迹块活动流也标上run-trace,成为「这个块与运行有关」的唯一标记。followConversationTail改为只跟这些块里最后一个:「「X」阶段失败。」写在哪个块,「重试当前阶段」就紧挨着出现在那个块的末尾,之后的普通对话不再带走它;没有这样的块时按钮留在原位(首气泡末尾 / 阶段页左栏摘要之后)。renderAgent:kind === "none"(运行中「Agent 正在执行」、排队「等待任务开始」、已取消「任务已结束」)时按钮与选项列表一起hidden,不再摆禁用占位;点击回调的finally同步这一条(动作生效、运行重新转起来时按钮随之收起)。删除activityHeader(),replyRunTraceHost/tailTraceHost不再挂折叠头。 - 前端
legacy/openmathmodel-ui.ts:?demo=1的两处演示模板(总览页首气泡、阶段页左栏时间线)去掉.activity-summary按钮;toggle-activity点击分支(含.steps-count徽标重建)整段删除;注释同步。i18n/en-US.ts删「查看执行步骤 / 收起执行步骤」两条死词条。 - 样式
styles.css/workflow-refresh.css:删除全部.activity-summary规则(基础 / 暗色 /.modeling-chat-pane/.focused-agent-scroll三档响应式 /[hidden]/ hover·active)、.steps-count与omm-count-pop、只由折叠头写入的.agent-stream.collapsed与 api 布局的.activity-list.collapsed;revision 38 注释改述。.follow-up-reply > .agent-stream + .running-live-cta等间距规则不动。 - 不动:按钮的动作语义、审批选项列表的渲染与滚入视野、执行轨迹的落点规则(
resolveStreamHost/historyTraceHost)、受保护入口与 DOM 槽位。
当日执行并通过:npm run check;npm run build(index 795.23 kB);node --test "src/**/*.test.mjs" 173/173;真实 Chromium(CDP)里按控制器同一套 DOM 结构跑按钮落位场景 9/9(无对话留原位 / 普通回复不吸走 / 落了运行事件的回复块接走 / 之后的普通回复不再带走 / 新轨迹块接走 / 选项列表同行 / 对话清空退回占位 / 页面无 .activity-summary)。应用内浏览器验收待用户(本环境没有已登录的运行):失败任务里问「为什么失败」→ 回复下方没有按钮,「重试当前阶段」仍留在「「X」阶段失败。」那条叙述所在的 Agent 块末尾;运行正常执行时页面上没有「Agent 正在执行」的灰色按钮;阶段失败 / 等待确认时按钮(与选项列表)出现在失败 / 等待叙述的下方;全站不再有「收起执行步骤」折叠头,步骤行直接展示、每行仍可点开详情。
用户反馈:每次点重试都要往上滚才能看清发生了什么。读码定位不是滚动条没跟上,而是反馈落点错了——在底部点按钮,重试的进度与失败原因却被写回页面顶端。四个根因:
isPlanningPhase(view)只看快照(RUNNING + 六阶段全 PENDING/RUNNING),分不出「首次规划」与「第 N 次重试题意解析」:重试把planPhase翻回planning,首气泡被「解封」,resolveStreamHost把「重试失败阶段。」「深度思考 · 题意解析」写回首气泡摘要下方的活动流;同时renderCopy清空摘要、执行计划面板退回「正在思考」。- SSE 帧先入活动流、快照 80ms 后才刷新(
onWorkspaceEvent→ingestStreamEvent→scheduleRefresh),落点用的是上一状态的planPhase:step.failed与run.status_changed(FAILED)相隔不到 80ms 就一起落顶,隔开了就一顶一尾,同一次运行里两次失败叙述位置不一。 - 失败原因只存在于顶部:
RUN_FAILED只把error写进run.failure_message,事件 payload(step.failed的{node, attempt, failure_class}、状态迁移的{from, to, reason})都不带文案;时间线上只有一句「「X」阶段失败。」,原因只经快照进首气泡摘要。 - 点击之后没有视口跟随;
streamAppend的吸底只在距底 120px 内生效。
拍板(决策卡):A+B+C 全做;E 合并进首气泡。
- 后端
engine_glue.py:_project_status增可选extra,随 payload 一并下发(v1 契约 payload 为自由对象、消费方容忍未知字段);RUN_FAILED的 FAILED 迁移带message(=failure_message)与failure_class。test_task_runs_and_actions.py::test_injected_failure_then_retry_completes补断言:events/history里的 FAILED 迁移事件reason == "「实验运行」阶段失败"、message与快照failure.message一致、failure_class == CODE_DEFECT。 - 前端 A(规划期单向)
modeling-workspace-controller.ts:AgentStreamState新增firstStageSettled,由事件流维护(markFirstStageSettled:任何step.succeeded / step.failed / approval.requested、运行离开 QUEUED/RUNNING、从 FAILED/PAUSED/WAITING_APPROVAL/COMPLETED 回到 RUNNING),实时与首连回放同一判据,且在解析落点之前更新——失败叙述本身就按封口后的规则落位,不再受 80ms 刷新时序影响。isPlanningPhase(root, view)见到该位直接返回 false;新增planRevealed(root)(planPhase === "revealed" || firstStageSettled)供firstAssistantMessage与阶段页左栏的liveEventsFlowToTail共用。挂载时若壳层被复用给另一个运行(root.dataset.runId不同)丢弃上一个运行的活动流状态。 - 前端 B(原因随失败落位):
run.status_changed{to: FAILED}改为streamFailure——叙述行「「X」阶段失败。」+ 直接可见(不折叠)的原因段落.stream-note.stream-failure(message原文、white-space: pre-wrap),failure_class === TRANSIENT时补一句「多为模型接口或网络的瞬态问题,直接重试通常能过。」(其余类别是保守兜底归类,不下结论)。按钮随即由followConversationTail压在它下面——原因与动作同处。历史回放从事件重建,刷新后原样。 - 前端 C(视口跟随本次动作):新增
armFollowTail / disarmFollowTail / shouldStickToTail。按钮的重试 / 恢复 / 确认被服务端受理后(act成功 →refresh之后):先把对话末尾滚进视野(按钮可能停在页面中段——失败块之后又聊过几句),之后每条新行都吸底、不看 120px 阈值;用户 wheel / touchstart / pointerdown 即放手;本次尝试落定(状态离开 RUNNING/QUEUED)也放手。appendGrouped同步改用shouldStickToTail。暂停不跟随。 - 前端 E(合并相邻块):
tailTraceHost在尾部就是首条 Agent 消息(还没有任何对话)时返回 null,resolveStreamHost回落到首气泡摘要下方的活动流——不再另起紧挨首气泡的「Agent」轨迹块(2026-08-21 起的既有形态退役:两个相邻署名中间什么都没有,且与刷新后回放把这批行放回首气泡的形态对不上)。有对话时规则不变:尾部回复块内部的.run-trace/ 尾部用户消息后的轨迹块。 - 样式
workflow-refresh.css:.stream-failure左侧 2px 淡红竖线 + 浅底,暗色主题配色;en-US.ts补瞬态提示词条。 - 不动:按钮的动作语义、审批选项列表、历史回放的按轮落位(
historyTraceHost)、受保护入口与 DOM 槽位。
当日执行并通过:pytest backend/api/tests 462 passed / 2 skipped;npm run check;npm run build(index 802.93 kB);node --test "src/**/*.test.mjs" 173/173;真实 Chromium(CDP)按控制器同一套函数跑落点 / 原因 / 跟随 / 回放场景 20/20(规划期行落首气泡、刷新前到达的首次失败仍封口并留在首气泡且不另起相邻块、重试快照不再翻回 planning、重试行按时间顺序接在失败之后、有对话时失败与按钮一起落到尾部回复块、瞬态失败带提示 / 非瞬态不带、arm 后从顶部滚到底且新行持续吸底、wheel 放手、落定放手、无对话回放全部落首气泡且从历史推出首阶段已落定、重进中途重试的快照不再是 planning)。后端需重启才会在新失败上带原因;已有的历史失败事件没有 message,时间线上仍只显示一句叙述。应用内浏览器验收待用户:失败任务里点「重试当前阶段」→ 视线不离开点击处:按钮下方出现「重试失败阶段。」→ 深度思考走秒 → 「「X」阶段失败。」+ 原因段落 + 按钮重新出现;顶部摘要与执行计划面板在重试期间不再清空 / 退回思考态;没聊过天的运行不再出现两个相邻的 Agent 署名。
用户带图报障:论文页正文一万六千字、底部状态「已恢复本机草稿」,左侧大纲栏却还是模板空态一行字。
根因:大纲只在 renderEditorPanel 把 Agent 定稿灌进编辑器的那一条路上顺手建(buildDraftBlocks → rebuildPaperOutline),编辑器内容另有来源时没人管它。截图正是其中最常见的一种——本机草稿恢复:bindPaperEditor(全局键 openmathmodelPaperDraft.v1)与 task-autosave.restorePaper(按项目键、user_edited)把上次的现场写回编辑器,随后 renderEditorPanel 因 hasUserPaperDraft() 按「用户主权」早退——正文在(多半就是上次渲染出来的定稿,点过一次撤销 / 加粗就算「用户编辑过」)、目录却停在骨架。用户自己增删改标题、论文阶段未产出而用户先写了,同样没有大纲。
- 前端
integration/stage-content.ts:新增syncPaperOutlineFromEditor(root = document)——以编辑器里实际存在的非空h2为准重建大纲,口径与buildDraftBlocks一致(h1论文标题不进目录;「摘要」与各章是h2;小节按paper_section提示词约定是###→h3,不进目录)。没有id的标题补section-N锚点(已被占用时退到section-N-2…,绝不改动已有锚点,与定稿渲染的#section-N/#section-abstract一致);条目全部标 done(内容都在);spyOutline(从bindOutlineNavigation的滚动跟随里抽出)按当前滚动位置高亮。rebuildPaperOutline记data-outline-signature,标题集合没变时不重建(保住 active 态)。让路规则outlineOwnedByStream:分章直播未写完(livePaperState.rendered.size < total,大纲由它预挂全章、逐章打勾)或打字机还在逐字上屏(pending > 0)时不动大纲。正文里一个h2都没有:模板空态原样保留;只有残留旧链接时换成「正文中还没有章节标题。」。bindOutlineSync:编辑器input300ms 防抖后同步——改章标题、加一章、删一章大纲都跟着变,每台编辑器只挂一次。三处接入:renderEditorPanel的用户草稿早退分支改为先同步大纲再返回;正常渲染路径挂bindOutlineSync;renderStageContent在document_draft为 null 时也同步(论文阶段未产出、用户先写了)。 - 前端
legacy/openmathmodel-ui.ts:render()末尾(mountTaskAutosave之后——两条草稿恢复路径都已跑完)非演示态调一次syncPaperOutlineFromEditor();演示夹具的大纲是固定样张,不动。en-US.ts补一条词条。 - 不动:
buildDraftBlocks/preparePaperOutline/appendPaperSection的建纲逻辑与直播打勾;.outline的 DOM 结构(.outline-heading+a > .outline-status + 文本)与样式;受保护入口。
当日执行并通过:npm run check;npm run build(index 823.11 kB);node --test "src/**/*.test.mjs" 178/178;真实 Chromium(CDP)按同一套函数跑 16/16:只有占位段时模板空态不动;恢复的草稿(h1 + 摘要 h2 + 三章 h2 + 小节 h3)→ 大纲四项、h3 不入、空态移除、全部 done、首项 active;同一批标题不重建;滚动后 active 跟随;用户新加无 id 的 h2 → 防抖后入纲并补锚点;中间插章保持文档顺序、序号被占时退到带后缀的锚点;改标题文字大纲同步;直播进行中 / 打字机未打完 → 让路;删光 h2 → 旧链接换成一句说明、加回来大纲回来;点击大纲滚到对应标题并高亮。应用内浏览器验收待用户:打开截图里那个任务的论文页,左侧应列出摘要与各章目录,点击可跳转、滚动正文时高亮跟随;在正文里新加一个「标题 2」段落约 0.3 秒后出现在大纲里。
2026-08-12 下线主题切换时,html[data-theme="dark"] 规则作为不可达死代码整批保留(见上文同日条目)。业主明确不再需要深色主题,本轮把这些规则连同相关注释和死组件一并删除,界面只剩浅色一套。
- CSS 规则:用 PostCSS 按选择器逐条删除 355 条规则——styles.css 179 条、workflow-refresh.css 132 条、reference-theme.css 20 条、attachments.css 16 条、projects.css 7 条、accessibility.css 1 条(
html[data-contrast="high"][data-theme="dark"])。三处「浅色选择器 + 深色重复项」的选择器列表(.recent、.reference-picker-list、.composer-attachments-head:hover)只摘掉深色那一半,浅色规则原样保留。html[data-theme="dark"] { … color-scheme: dark }变量块随之消失;:root的color-scheme: light保留。 - reference-theme.css 的浅色守卫:63 条
html:not([data-theme="dark"]) …改写为html:root …。两者匹配同一个元素,特异度同为 (0,1,1),这批调色板规则相对 styles.css 组件默认值的层叠关系分毫未变——直接降成html会掉一个类级权重,部分规则会被 styles.css 的双类选择器反超。文件头注释记了这条约束。 - 死组件:styles.css 的
.theme-options / .theme-option / .theme-preview(含.theme-preview.dark)整块删除——主题选择器 2026-08-12 已从设置中心移除,DOM 里不再有这些节点;reference-theme.css 中对应的三条覆盖与两处:is()列表项同步清理。 - TypeScript:
renderCostChart的dataset.theme === "dark"分支按浅色取值压平。i18n/locale.ts里「语言与主题共用」的注释、task-routes.test.mjs里拿theme: "dark"当无关键的夹具(改用interfaceLocale)一并清掉。 - 源码与
dist产物中data-theme命中均已归零;本条取代上文 2026-08-12 条目里「保留而未清理」的结论。
当日执行并通过:npm run build --workspace @openmathmodel/web(index 824.14 kB);node --test 178/178;六张样式表 PostCSS 重新解析全部通过。真实 Chromium(CDP)逐路由验收:首页、我的项目、赛题库、优秀论文、方法库、设置中心渲染与改前一致,论文年份筛选 / 设置下拉 / 项目排序三个液态玻璃弹层正常开合;页面计算样式核对 --ink #101720、--soft #f3f5f8、--line #e4e8ee、body #f3f5f8、.main #fff、.nav-item #465263、color-scheme: light,与改前取值相同。
用户带图报障:实验阶段连续三次「深度思考 · 实验执行(本次调用中断)」后运行落 FAILED,失败框里是整段给开发者看的话(interrupted: executor lost before completion、uvicorn --reload / --reload-dir / npm run dev:api / README)。三条拍板:这段不能显示到前端让用户看到;不要设置限额;以及一个疑问——「为什么总说后端进程重启打断,我明明没重启」。
排查:查库 run_8bc8eacd… / run_f0e1ff8c…(09-18)步骤表,实验阶段各趟都在 python_run 之后 2–12 秒被判 interrupted;Win32_Process 里当前 API 进程的命令行是 python -m uvicorn omm_api.asgi:app --reload --timeout-graceful-shutdown 5 --port 8000——没有 --reload-dir(也没有 --app-dir,靠可编辑安装从 VS Code 终端直接起,cwd 是仓库根或 backend/api,两者都盖住 backend/api/data/);已装 uvicorn 0.52.4 的 Config 在没给 --reload-dir 时监视 Path.cwd() 下全部 *.py,沙盒每次写出 backend/api/data/workspaces/<run>/steps/<step>/main.py 都触发一次热重载。所以「重启」不是用户做的,是 --reload 自动做的;用户看不到这层,文案却把责任说成「后端进程重启」,自然对不上。
- 后端
engine_glue.advance_run:删除MAX_CONSECUTIVE_INTERRUPTS/_interrupt_streak与fail_run分支——被中断的阶段无上限自动重跑(attempt+1),运行不再因中断次数落 FAILED;INTERRUPTED_STEP_ERROR/StepStatus两个只为它服务的导入随之移除(TaskRunEngine.fail_run保留为引擎能力)。_note_executor_restart:给用户的run.log{kind:"executor_restarted"}改为「「X」的上一次执行在中途意外中断,已自动重新开始第 N 次尝试」——不带进程 / 热重载 / 启动参数任何开发细节(前端渲染路径不变);服务端omm.enginewarning 改为「left RUNNING by a lost executor」并附诊断:能确诊时直指监视目录与沙盒目录,否则给出通用的npm run dev:api提示。 - 后端 新增
reload_guard.py:纯函数reload_watch_dirs(argv, cwd)(与 uvicornConfig同口径:--reload-dir/--reload-dir=/ 环境变量UVICORN_RELOAD_DIRS,缺省 cwd)与reload_watch_hazard(argv, cwd, workspaces_dir)(沙盒工作区落在监视目录内即命中;目录形式的--reload-exclude盖住工作区算排除,glob 形式不算——uvicorn 过滤器只拿它匹配文件名末段);warn_if_reload_watches_sandbox在create_app的 lifespan 里、推进线程启动前调用,命中就以 ERROR 打一块!!!包住的日志(uvicorn reload 子进程继承父进程的sys.argv与 cwd,所以能在子进程里看出来)。API 不拒绝启动——生产没有--reload,这层只对开发者说话。 - 测试
test_executor_restart_note.py:叙述断言改为新文案并加「不含开发词」守卫;原「三次打断后停下」用例替换为「连续 5 次打断运行始终 RUNNING、无 failure,环境修好后第 6 趟直接成功、叙述 5 条」。新增test_reload_guard.py8 项(非 reload 不报;裸--reload命中并给出目录与命令;--reload-dir限定源码不报;显式--reload-dir backend/api仍命中;目录排除清除 / glob 排除不清除;环境变量等价;相对workspaces_dir按 cwd 解析;启动函数按真实 argv / cwd 打 ERROR 或不打)。 - 文档:
backend/api/README.md(启动命令下补一段「漏--reload-dir时会看到什么」,预算说明里的MAX_CONSECUTIVE_INTERRUPTS改为指向 reload_guard)、根README.md(同段措辞)、.env.example注释;engine_glue._env_limit的 docstring 同步。 - 不动:
INTERRUPTED_STEP_ERROR常量与 TRANSIENT 归类(前端「重试当前阶段」路径不变);工作区目录位置(backend/api/data/workspaces)——它是跨阶段共享的执行暂存,不为躲开监视范围搬家;tools/dev-api.mjs/npm run dev(本就带正确参数)。
当日执行并通过:pytest backend/api/tests 479 passed / 2 skipped(含新增 11 项)。实机复现:用同款参数(--reload、无 --reload-dir)从仓库根起一个只装了护栏的探针应用,uvicorn 日志 Will watch for changes in these directories: ['E:\\…\\OpenMathModel'],子进程 argv / cwd 与父进程一致,护栏按预期打出 ERROR 块。随后停掉那条没有 --reload-dir 的进程(Win32_Process 确认其 reloader 的 cwd 是 backend/api,监视范围盖住 data/workspaces),以 npm run dev:api 重启,/api/health ok、监视目录只剩 agents 与 backend/api/omm_api。浏览器验收待用户:在失败的任务上点「重试当前阶段」,实验阶段的 python_run 之后不再出现「本次调用中断」;即便出现,活动流只会补一句「上一次执行在中途意外中断,已自动重新开始第 N 次尝试」,运行不再因此落 FAILED。
用户带图报障:项目卡片的「文件」有数字,「实验」「论文」却对所有项目(包括已完成的)一律灰掉,什么都不显示。原因是切片②只在契约 Project.stats 里定义了 latest_run 与 artifact_count,projects-page.ts 的 rowHtml 把后两列硬编码成 —,展示层再把 — 译成灰色「暂无统计」——数据源从未接上,而不是数据为空。
- 契约
project.schema.json:project_stats新增可选字段experiment_count/paper_count(非负整数)。口径 = 该项目全部运行中EXPERIMENTING/PAPER_WRITING节点 现行(stage_outputs.status=current)阶段输出条数:重做 / 重试只保留最新版、模拟链路不落行故计 0,与成果页只列最近一趟产物的口径一致。做成可选而非 required 是因为check_compat.py把新增 required 判为破坏性变更;旧生产者不返回时前端仍显示「暂无统计」。夹具project.1/3.json补字段,新增反例project.bad-experiment-count.json;TS / Pydantic 重新生成,手工维护的src/index.ts同步;OpenAPI 基线重导。 - 后端
routers/projects.py:stage_output_counts_subquery()经task_runs把stage_outputs归到项目、按节点sum(case)一次算出两列,include=stats时与既有产物计数子查询一并outerjoin,仍是单条查询、无 N+1。 - 前端
projects-page.ts:ProjectItem增加experimentCount/paperCount,三列计数统一走count(),—只在旧服务端缺字段时出现。projects-presentation.ts与样式不动。 - 测试
test_projects.py:新增「两次运行、首轮实验被重做、另有 VALIDATING / DATA_PREPARATION 行」用例断言experiment_count=2 / paper_count=1、superseded 与其他阶段不计、q过滤后计数不变;既有空统计断言改为含两个新字段。
当日执行并通过:validate.py(16 schema / 75 fixture)、check_compat.py OK、generate_python.py --check、npm run check --workspace @openmathmodel/contracts、export_openapi.py --check、pytest backend/api/tests 479 passed / 2 skipped、npm run check(web)。只读探针核对本地库:3 个已完成项目均为实验 1 / 论文 1,2 个失败项目为实验 1 / 论文 0。真实 Chromium 验收:注册一次性 smoke 账号,白盒造「首轮实验 superseded + v2 current、论文 v1 current、MODEL_PLANNING current」与一个从未运行的对照项目,网格卡片显示「文件 0 · 实验 1 · 论文 1」与「0 · 0 · 0」,列表视图三列同值且无 project-stat-unavailable 灰化;验收后账号与两个项目已删除。
用户带图反馈任务执行页的执行轨迹:小标题太多,连续做的同类事情应归进可折叠的二级标题;「现在怎么都是写的模拟」;轨迹全是小标题、太单薄,要加 agent 的话。三条拍板:两级折叠;每步一句中文说明(改工具协议提示词);存量误标一起改。
排查:「模拟」来自 engine_glue._ARTIFACT_NAMES 按产物 kind 起展示名——这张表本是模拟链路(SimStageNode 的 baseline-metrics.svg / report-draft.md)用的,却套在所有产物上,真实实验画的每张图都登记成「基线实验结果图(模拟)」(本地库 6 个运行、34 张图,artifact.published 事件同名)。轨迹单薄:ws_read / ws_list / env_probe / subagent:* / 各类 *_review 没有专门分支,落进「标题 = 工具名、详情 = 原始 JSON」兜底,审稿人的结论和总结也被当 JSON 藏起来;既有 appendGrouped 只合并紧挨着的同 key 行,思考行与工具行交替出现,从未触发;沙盒 / 审稿 / 提议人走文本协议,模型在 JSON 前写的那句说明只进 llm_delta 实时框,落定即丢。
- 后端
engine_glue._ARTIFACT_NAMES改按(kind, 文件名)精确匹配,只认模拟链路与论文草稿自己的文件,其余产物按 URI 尾部的真实文件名展示(stage_outputs._artifact_file_name的 docstring 同步)。llm.py新增lead_in_note:取文本协议回复里 JSON(或代码围栏)之前的那句话,超 300 字截断,混有模型原生工具调用标记(DeepSeek 偶尔把 DSML / 尖括号标签漏进正文)时丢弃;EngineLlmPort.chat_text在调用落定后发run.log{kind:"agent_note", prompt_id, text}。 - 提示词
chat_adapter.tool_protocol_note:「只输出一个 JSON 对象」改为「先用一句简短的中文说明这一步要做什么、为什么(原样展示给用户,不要花括号),紧接着一个 JSON 对象」;适配器解析规则不变。 - 迁移
0021_artifact_display_names:把artifacts.name与artifact.published载荷里误标的名字改回 URI 尾部的真实文件名,模拟链路自己的文件不动;纯数据修正、可重复执行,downgrade 不回写错误名字。 - 前端 新增纯函数模块
integration/trace-rows.ts:工具行人话(「读取 experiment.py」「写入 …」「生成数据画像:…」「已在沙箱执行实验代码」等)、子代理组标题(单个「角色:任务」,并行「角色 ×N:各自视角」)与收束状态、审稿结论(加粗一句结论 + 审稿人总结原话)、论文补图 / 成稿 / 定向回改叙述、过程组标题;isQuietTool标出不进轨迹的工具——列目录(ws_list,「查看工作区文件」「查看 data/ 下的文件」)、检索知识库、查阅知识卡片、探测运行环境(env_probe)这类摸底动作对用户没有信息量,成败都不画,事件照常落库(同日复看时用户点名去掉,组行上「思考 3 次 · 运行代码 2 次」这类计数摘要也一并去掉;读文件ws_read与数据画像table_profile经用户确认保留显示);原控制器里的STAGE_BY_PROMPT迁入并补齐方案提议 / 汇总 / 定稿、稳健性检验、各审稿与论文补图,多语言模板后缀同口径。modeling-workspace-controller.ts两级折叠:subagent:<kind>的 spawn / result 不再单独成行,而是开 / 收一个子代理会话组——并行派发共用一组、最后一个 result 收束,组行走秒并带失败后缀,阶段推进 / 失败 / 进程重启时收组,会话期间落点换了就在新落点接「(续)」段;会话之外连续的思考 / 工具 / 旁白行归成过程组(按提示词说在做什么),被叙述、阶段产出、审稿结论等关键节点隔断就另起一组;组行下方显示组里最新一句旁白。auto_redo_exhausted/redo_requested并入叙述行分支。 - CSS
workflow-refresh.css只增量追加:.stream-group-body[hidden]补display:none(折叠体的作者级display:flex压过 UA 的[hidden],既有「写入产物文件 ×N」组此前从来收不起来)、组头旁白单行省略、组内旁白样式。 - 测试 新增
test_llm_agent_note.py6 项;新增test_artifact_display_names_migration.py(在 0020 上造数据再升到 head,断言真实图改名、模拟文件不动、回退再升级结果不变;Config()不带 alembic.ini——env.py 见到配置文件就fileConfig,默认禁用已存在的 logger,本文件按名字排在最前,会让其后靠 caplog 断言的test_db_ready/test_graph_mode收不到日志);test_task_runs_llm_nodes.py端到端断言真实链路产物名不含「(模拟)」;test_chat_adapter.py加 2 例;trace-rows.test.mjs10 项。
当日执行并通过:pytest backend/api/tests 486 passed / 2 skipped;agents 各包测试 761 passed / 1 skipped;npm run check;npm run build(index 843.69 kB);node --test 188/188。开发库 alembic upgrade 到 0021,只读核对真实产物误标 0,剩余 29 个运行的「(模拟)」产物与事件都是模拟链路自己的文件。无头 Chrome(CDP 拦截 /api 回放只读夹具)截改前改后图(audit-current/2026-09-27-trace-grouping/,不入库):截图所用运行的执行轨迹顶层 254 项 → 72 项(24 个组),原始工具名行 85 → 0,「(模拟)」14 → 0;SSE 中途接入与整段回放结构一致。复看后(quiet-* 截图):夹具里 28 条列目录 / 检索知识库调用不再画出,全部行 244 → 213,组 24 → 21(去掉列目录行后只剩一行的不再套组),计数摘要 0;再藏探测运行环境后(quiet-probe-* 截图):10 条 env_probe 不再画出,全部行 213 → 203,组 21 → 20。未验:新提示词下真实模型写出的中文旁白——存量运行没有 agent_note 事件,截图里的旁白演示派生自改提示词之前的英文输出。
用户反馈「意图识别 + 状态判断 + 路由守卫整体有问题」,列出意图、是否要求执行、是否需要建模、事件、会话状态、续接还是新任务、言语行为、题型、缺失信息、把握、路由十一个维度。复现(判定打桩、不出网):首页「美赛和国赛有什么区别」「我想学数模,从哪开始」「这张图是什么意思」+ 截图、「看看」+ PDF、长篇提问都直接建任务;首页对话里「好的,开始吧」孤立判定、放行后 goal 只剩这一句;未配置接口时「你好」也建项目;接待结论被前端丢弃,对话模型不知道路由;首页对话不带附件。运行页「谢谢」「辛苦了」「太慢了吧」「怎么还没好」「这个模型的精度多少」、贴来的新题全部记成补充要求注入后续节点。用户拍板三处策略:拿不准先提议、确认再启动;首页确认 = 对话里回一句;未配置接口也用本地规则拦。
- 接待(
intake.py重写):本地信号(赛题标识 / 任务信号 / 问句 / 知识问句 / 请求与执行措辞 / 寒暄 / 光秃秃的「开始」/ 指代上文 / 修改 / 附件指代 / 题型)先判,拿不准的交最弱模型做一次 JSON 判定,判定只给intent(五值)/execution/continuation/domain/missing/confidence,不写回复;route(start / propose / clarify / reply)由规则定,顺序与取值见 ADR-0024 §1。附件与 @ 引用只是证据;问句判断按长度分档(60 字内任何问句标记都算,更长只认句首句尾);needs_info 的本地否决收窄为「要求动手 → start,没说 → propose」;判定失败只有执行措辞 + 任务信号 / 题面 / 附件才 start。 - 契约:
TaskIntakeInput加history/pending_task/confirmed;TaskIntakeResult加route/understanding/task_goal/context_excerpt/chat_ready,intent旧三值保留并与 route 对齐;ChatRequest.intake(首页对话轮的接待结论)。OpenAPI 基线已重导出。 - 首页对话(
routers/chat.py):带intake的轮换用首页系统提示词(不得自称已开始建模)并注入【接待判定】块(提议时复述题意并问是否开始、缺信息时说清缺什么、问知识时直接答、问文件时按附件内容答);托管轮首个meta事件带intake落meta.intake;任务页的轮忽略它。engine_glue._attachments_summary把params.conversation_context排在最前、单独封顶 1500 字。 - 运行页(
run_control.py):判定 JSON 加utterance(supplement / question / feedback / chat / new_task),只有 supplement 落备注;判定缺席时本地兜底(classify_utterance:新题 / 情绪短句 / 问句 / 寒暄不进备注,其余照旧记下);超过 800 字仍不送动作判定,但做一次只分类的判定(judge_utterance,带本任务题面);new_task 的状态块让模型提示回首页新建。问句标记补「怎么 / 多少 / 什么 / 哪个 / 哪些 / 哪里 / 多久 / 是否 / 有没有」。执行安全(合法集、提案确认、回复后执行)不变。 - 前端:
home-chat.ts记着首页对话原话与上一轮提议(homeIntakeContext/rememberIntake),重进时从最后一轮的meta.intake恢复提议;对话轮带intake上送;托盘里还没注入过的附件正文随这一轮送给模型(collectConversationAttachments新增only过滤,每个附件只注入一次,托盘保留作任务材料);chat_ready=false时直接展示模板回应、不发起对话轮。task-start-controller.ts随接待判定上送history/pending_task(确认页confirmed),start 时 goal 用task_goal、params.conversation_context用context_excerpt,两者写进草稿(TaskDraft.task_goal/conversation_context)供失败重试。受保护入口、页面模板与路由零改动。 - 测试:
test_task_intake.py重写为 23 例;test_run_control.py+6(本地兜底表、判定类别解析、新题状态块、三条端到端);test_chat_turns.py+5(【接待判定】注入与meta.intake落库、无 intake / 任务页不变、无状态通道、校验、问题分析摘要);task-start-state.test.mjs+1。
当日执行并通过:pytest backend/api/tests 532 passed / 2 skipped、npm run check --workspace @openmathmodel/web、npm run build --workspace @openmathmodel/web(index 848.58 kB)、node --test(194/194)、npm run check --workspace @openmathmodel/contracts、export_openapi.py --check(基线已重导出)、check_compat.py、validate.py。未验:真实模型下的判定质量(用例全部打桩),以及首页「提议 → 回一句开始 → 进入执行页」的浏览器走查。
真实运行 run_278ee396(题面被截断的能源调度题)18 秒失败于题意解析:节点判「题目信息不足」却不带错误码,_classify_failure 按文案没命中、兜底成 CODE_DEFECT,E6 看板记「无错误码」;失败文案让用户新建任务,下面却跟着「重试当前阶段」——同输入重试只会再判一次不足(08-28 那条早就点过「语义上是输入问题,却被渲染成带重试按钮的工作流故障」)。用户 1 分钟后新建任务重贴了完整题面。拍板两处:方向 = 任务页对话里就地补题面、补完自动重新解析;错误码 = 新开 E6xx 输入码段,只开 E610。
- 错误码(
agents/core/src/omm_agent_core/errors.py):ErrorCode.INPUT_INSUFFICIENT = "E610"+Disposition.NEEDS_INPUT(用户补了输入才重试)+ CATALOG 一条(ownernode);E6 看板by_code经 CATALOG 带出 owner / 处置。 - 题意解析(
agents/skills/src/omm_agent_skills/nodes.py):判 insufficient →NodeResult.failed(..., error_code=E610);文案改为「可以直接在本任务的对话里补充缺的内容(整段粘贴题面也行),补充后会自动重新解析题意;也可以新建任务、附上完整题面与数据文件重新发起」。 - 失败归类(
engine_glue.py):_classify_failure(error, error_code=None)码优先(E610 → DATA_DEFECT,契约FailureClass八类不改),再走文案规则、最后兜底 CODE_DEFECT;STEP_FAILED 投影传载荷里的码。RUN_FAILED 载荷不带码 →last_step_error_code:按 seq 倒序取最近一条步骤事件(STEP_STARTED / STEP_SUCCEEDED / STEP_FAILED),是 STEP_FAILED 才取它的码——之后又开过步骤,更早那次失败的码就不代表这次失败。 - 运行页(
run_control.py):10-02「运行页只把补充要求记为备注」在这里有一个例外。RunControlContext.awaiting_problem_supplement(FAILED、停在题意解析、码 E610 三者同时成立)时,失败运行上不触发动作的话照样分类(超 800 字的长文走只分类的judge_utterance),判为 supplement 就改判 retry:原话加「【题面补充】」前缀落成scope=PROBLEM_ANALYSIS的备注(只进题意解析的提示词,上限放宽到 2 万字),随后自动重试,回执说「已把你补充的题面交给『题意解析』,重新解析题意」;判定提示词在这个状态下说明补的题目内容(包括整段粘贴同一道题)算 supplement、明显是另一道题才算 new_task。问句 / 新题照旧只回对话;别的原因失败(含题意解析的其它码)不受影响。运行输入goal记在 RUN_CREATED 里、事件溯源不可改,且只有题意解析读原始题面,所以补充走备注而不是改题面。 - 前端:零改动——失败文案本身就是引导。已知局限:「重试当前阶段」按钮照旧跟在失败叙述下面,不补题面直接点 = 同输入再判一次不足(多花一次模型调用);隐藏或改写它属于界面交互改动,本次不做。事件日志不可变,历史失败不回填码。
- 测试:
test_failure_error_codes.py+2(码优先于文案;判不足的运行步骤与运行分型都是 DATA_DEFECT、看板uncoded == 0);test_run_control.py+6(状态判定只在三条件同时成立时为真;计划与判定提示词;端到端:真题意解析节点 + 打桩模型,判不足 → 对话补题面 → 备注 scope 与前缀 → 自动重试 → 第二次解析的提示词同时带原题面与补充 → 通过,last_step_error_code先 E610、通过后为空;长文补充;问句不动作;普通失败运行补要求照旧不重试);coretest_errors.py、skillstest_nodes.py在既有用例里加断言。
当日执行并通过:pytest backend/api/tests 552 passed / 2 skipped;agents 五包 + worker 844 passed / 1 skipped;strict mypy 与 ruff 对照改动前零新增;临时 worktree 检出改动前代码跑新测试,5 条行为用例全红、守卫用例全绿。未验:真实模型下的补题面判定与重新解析(用例全部打桩)。注意 npm run dev 拉起的 API 不带热重载、且会复用 8000 端口上已在运行的 API——要在真实运行里用上这次改动,须把旧 API 进程整个停掉再启动。
复盘 10-01 以来的三个真跑时查出两件事。①run_e898… 里提议人两次 knowledge_search 收到空参数,看上去像 10-02 的扁平信封容错没生效;实际是它跑在 10-02 那次修复之前起的 API 进程上——npm run dev 拉起的 API 不带热重载,还会复用 8000 端口上已在运行的 API,改了代码不整个重启,真跑就一直用旧代码,而事件日志里没有任何字段能说明一步是哪版代码跑的,只能靠旧代码的行为特征倒推。②09-01 以来 322 条文本协议回复里有 10 条夹带 DeepSeek 原生工具调用标记(<||DSML|| invoke name=…> 加 parameter 块):其中 4 条只有原生标记、没有 JSON 信封,适配器把它当终答、解析失败,结构修复提示把本想调工具的模型推去交卷,白烧一轮;另外 6 条「JSON 信封 + 原生标记」里,原生那个调用被静默丢掉,模型不知道它没执行。
- 原生调用兼容(
agents/skills/src/omm_agent_skills/chat_adapter.py):正文里的 DSMLinvoke块按出现顺序还原成信封(竖线全角 / 半角、个数与空白放宽;string="false"的参数值按 JSON 解析、解不开保留原文;只有一个名为arguments的参数时它就是整个参数对象;name为空的块跳过)。没有 JSON 信封时执行第一个原生调用,下一条工具结果末尾提示「已当作信封执行了 X、后面的 Y 没有执行,之后请直接写 JSON 信封」;与 JSON 信封同时出现时只执行信封,并点名原生标记里没执行的调用(与已执行信封同工具同参数的不算)。半截标签、终答里提到 DSML 照旧当终答。 - 执行代码身份(新增
backend/api/omm_api/code_identity.py):API 进程启动时给它执行的源码拍快照——omm_api/omm_contracts/omm_agent_core/omm_agent_harness/omm_agent_skills/omm_agent_tools六个包的*.py与agents/prompts/*.prompt.md(提示词注册表同样是进程内缓存):逐文件 sha256 汇总成 12 位指纹;git HEAD 与分支直接读.git(loose ref → packed-refs → linked worktree 的 commondir);相对 HEAD 有改动的源码文件在拍照时跑一次git --no-optional-locks status(不刷新索引、不抢 index.lock),git 不在或报错记「未知」而不是「没改动」。快照约 130 ms、只拍一次。 - 步骤盖章(
engine_glue._ProjectingSink):STEP_STARTED 领域事件落库时 payload 多一个executor:code(启动时指纹)、git(head/branch/dirty_count/dirty最多 8 个)、pid、started_at;进程启动后磁盘源码又改过时加stale(count/files最多 8 个)。只进run_domain_events:v1step.started投影与页面不变;引擎内存里的事件不带它,重放时 reducer 忽略多出的键;盖章出错只缺章、不拦步骤。 - 旧进程告警:「改过」先比修改时间与大小,对不上再比内容摘要(切分支再切回、原样保存不算改动),每 5 秒最多查一次。步骤照常执行,盖章记下
stale,服务端日志打一块醒目 WARNING(同一组改动只打一次,改动多了再打)说明本进程不热重载、这一步按启动时的旧代码执行、怎么重启。GET /api/system增加code:available/code/git/dirty(改动文件数)/started_at/stale/stale_files——只给计数,不带文件名与路径。tools/dev-local.mjs复用已运行的 API 时打印它的代码版本与启动时间,旧进程就醒目提醒先停掉再启动;自己拉起的 API 在健康检查通过时也打印代码版本。 - E6 看板(
agents/harness/src/omm_agent_harness/metrics.py):报告新增code节——按指纹归并的版本列表(git / 相对 HEAD 改动数 / 步骤数 / 旧进程步骤数 / 阶段,按首次出现排序)、stale_steps、processes、unstamped_steps(本次之前的运行与评测会话没有章,如实计数、不推断版本);Markdown 加「代码版本」一节;批次 totals 加stale_steps/unstamped_steps。端点响应模型RunMetricsReport的顶层键是固定字段,新键不加进去会被静默丢掉,因此补了code,OpenAPI 基线重新导出。 - 测试:
test_chat_adapter.py+4(真库原文还原、原生调用回提示、信封与原生标记并存、半截标签 / 空 name 照旧当终答);新增test_code_identity.py15 项(快照口径、git 身份四种形态与真仓库的改 / 删 / 重命名 / 未跟踪、节流与只告警一次、快照失败不盖章、领域事件盖章而 v1 投影不变、旧进程盖章、/api/system不带路径、E6 端点读回);test_e6_metrics.py+2;test_run_metrics.py/test_metrics_home.py顶层键加code。backend/worker的test_lease.py两个依赖墙钟余量的用例(被抢的租约与抢到的租约共用 50 ms TTL)改为同目录开两个存储——该过期的一方短 TTL、抢 / 续的一方 60 s,负载下失败率 1/100 → 0/100,lease.py未改。
当日执行并通过:pytest backend/api/tests 567 passed / 2 skipped;agents 五包 + worker 850 passed / 1 skipped;strict mypy 与 v3.64 基线逐条相同、ruff 对照改动前零新增;export_openapi.py --check;临时 worktree 检出改动前代码跑新测试,行为用例全红(原生调用 3 条、代码身份 5 条)、守卫用例全绿。未验:真实模型下原生调用还原后的工具执行(用例用真库原文打桩);旧进程告警的实机走查。要在真实运行里用上 10-03 的几次改动,仍须把旧 API 进程整个停掉再启动——之后的运行可以在 E6 看板「代码版本」一节直接核对每一步跑的是哪版代码。
回退重做有三种来由:用户在对话里要求从某阶段重做、在闸门上选了重做选项、图按条件边自动回退。reducer 在回退落地前把被作废的上一轮产出打成反馈包 TaskRunSnapshot.iteration_feedback(回退原因、谁触发的、第几轮、作废了哪几段),重做的节点与三条审稿环一直在读,拍板的人却看不到:重做轮的 G1 / G2 / G4 审批卡与首轮一模一样,只有 G3 在标题里多一句「较上一轮」。契约里工作台视图 pending_approval.description 与审批接口 ApprovalRequest.description 两个字段早就定义了,却一直是 null,前端也从未渲染。用户拍板:卡片里加「上一轮反馈」块——后端把摘要写进 description,前端在选项上方渲染,并重启开发服务做浏览器验收。
- 摘要口径(新增
backend/api/omm_api/approval_feedback.py):feedback_digest(feedback, gate_state)把反馈包摘成几行。第一行是来由:从哪一段起回退重做、谁触发的(按你的要求 / 在某阶段的闸门上选了重做 / 图按条件边自动回退)、第几轮;之后每行一条「- 」开头的事实:你的要求或回退原因(自动回退去掉「图 X 条件边自动回退:」前缀,闸门选项 id 不另列)、作废的上一轮产出,再按闸门所在阶段取材——结果验证(G3)取上一轮未过的稳健性检查(检验脚本真跑通才算,口径同 G3 闸门)与实验审稿、检验脚本审稿未解决的阻断意见,论文撰写(G4)取上一轮终稿审计发现(分类计数 + 前 3 处)与质量告警,建模方案(G1)取上一轮推荐的方案与候选数,数据准备(G2)取上一轮的画像与清洗结论。每类只点名前几条,引用原文与回退原因单独封顶。只转述事实、数字原样,不评判本轮好坏;首轮闸门(没有反馈包)和位于回退起点上游的闸门不出。 - 接线(
engine_glue.py/workspace_view.py/serialize.py):_ProjectingSink绑定引擎正在推进的快照(open_engine重放后sink.follow(snapshot)),REVIEW_REQUESTED 投影成审批行时把摘要存进evidence["feedback"]——开门那一刻算好,之后读视图不再重放事件。工作台视图与审批接口的description都由它生成,按契约上限(2000 / 4000 字)在整行处截断。契约、OpenAPI 基线、引擎与 reducer 不动。 - 前端:新增纯函数模块
integration/approval-feedback.ts(describeApprovalFeedback:description→ 来由 + 逐条事实 + 幂等戳,不碰 DOM);modeling-workspace-controller.ts新增renderApprovalFeedback,在followConversationTail之后调用,把块贴在每个主操作按钮上方「选项列表 / G4 证据块」整组之前(按属性认块,不打乱它们按「前一个兄弟」认位置的约定),门解决或首轮闸门即摘掉;workflow-refresh.css只增量追加.approval-feedback*(与 G4 证据块同一套紧凑排版、左侧色条,紧跟主操作时补 12px 上边距);英文词典加「上一轮反馈」。受保护入口、页面模板与路由零改动。 - 测试:新增
test_approval_feedback.py8 项(无包与上游闸门不出;G3 自动回退逐行全文、检验脚本没跑通不列检查;G4 闸门选项回退的审计分类与质量告警、零发现;G1 用户原话 + 推荐方案,同一个反馈包下游的 G3 只给来由与作废范围;G2 画像与清洗;坏数据不抛;长文整行截断;端到端:模拟链首轮 G1 无description→redo_run→ 重做轮 G1 视图与审批接口同文、过两份契约 schema、审批行存了摘要、被作废的首轮门没有、批准跑完卡片消失);新增approval-feedback.test.mjs4 项。
当日执行并通过:pytest backend/api/tests 575 passed / 2 skipped;npm run check --workspace @openmathmodel/web;node --test 236/236;npm run build --workspace @openmathmodel/web;strict mypy 与 v3.65 基线逐条相同、ruff 对照改动前零新增;临时 worktree 检出改动前代码、只放入新模块跑新测试,接线用例红、7 条纯函数用例绿。浏览器验收(开发服务 + 模拟链测试运行,截图在 audit-current/h4-approval-feedback-20261003/,不入库):首轮 G1 没有块 → 从建模方案重做(附言「换成更简单的贪心调度方案,先保证能跑通」)→ 重做轮 G1 在运行页与建模方案页的主操作上方出现块(来由 + 你的要求 + 作废的上一轮产出)→ 点「确认 Agent 当前方案并继续」后块随门摘掉,运行跑完后终态页也没有残留。未验:G2 / G3 / G4 重做轮的块在真实运行里的样子(模拟链批准 G1 后直接跑完、不再开门,这三道门的摘要口径只由单测覆盖);模拟链的建模方案没有候选方案,验收里看不到「上一轮推荐的方案」一行;测试账号没配模型接口,运行页对话走不到「确认重做」那步,验收里的重做直接调用对话控制面同一个 engine_glue.redo_run。
同日那次只在 API 进程里给步骤盖代码身份:backend/worker 执行面(H6 原型,事件写进 runs/agent-runtime/events/<run_id>/events.jsonl,同一份日志也是重放来源)推进的步骤没有章,E6 看板只能记成未盖章;而代码身份模块放在 omm_api 里,worker 不依赖 omm_api,用不上。用户拍板:通用实现搬进 API 与 worker 都依赖的 harness,API 留适配层;评测会话不盖,只盖真实 worker。
- 通用实现(新增
agents/harness/src/omm_agent_harness/code_identity.py):快照、指纹、git 身份、旧代码检查、盖章与同一组改动只告警一次的逻辑原样搬入;快照哪些包(packages)、提示词模板目录(prompts_dir)、告警里怎么称呼进程(process_label)与怎么重启(restart_hint)改成ProcessCode的参数。harness 不导入 skills,提示词目录由各执行面传入。 - API(
backend/api/omm_api/code_identity.py):收成适配层——API 的包清单(omm_api / omm_contracts / omm_agent_core / harness / skills / tools)、「API 进程 / npm run dev 先 Ctrl+C」的告警口径与进程级单例。engine_glue盖章、main.py启动时拍照与/api/system摘要的调用方式不变,告警原文不变。 - worker(新增
backend/worker/src/omm_worker/code_identity.py):worker 的包清单(omm_worker / core / harness / skills / tools,执行面不导入控制面)与「worker 进程」告警口径;StampingSink包住JsonlEventStore,STEP_STARTED 落盘前复制一份加executor(code/git/pid/started_at,启动后源码又改过时加stale),引擎内存里的事件不动,盖章出错只缺章、照常落盘。WorkerRuntime构造时先拍快照(记推进任何步骤之前的代码),新参数stamp_executor缺省开;评测的build_runtime用固定时钟与顺序 id、事件日志要逐字节可复现,传False关掉。reducer 处理 STEP_STARTED 只读 state / step_id / attempt,带章的日志重放结果不变。 - E6 看板:口径不变;worker 的事件交给
aggregate_run即可读出代码版本,评测会话照旧记未盖章。 - 测试:harness 新增
test_code_identity.py12 项(原 backend/api 的 10 条通用用例随实现搬来,另补按包名解析源码根、不给 roots 时按包快照 2 条);backend/api 的代码身份用例收为 5 条接线用例(旧进程用例改用适配层并断言 API 口径的告警);worker 新增 6 项(日志里步骤开始事件带章、其余事件不带、E6 读回;旧进程盖stale且按 worker 口径只告警一次;盖章出错不拦步骤;关掉开关不盖;带章日志重放与实时快照一致;真进程快照只覆盖执行面的包);评测 +1(评测会话不带章)。
当日执行并通过:pytest backend/api/tests 565 passed / 2 skipped(10 条通用用例移到 harness);agents 五包 + worker 869 passed / 1 skipped;strict mypy 与改动前基线逐条相同(多检查两个新模块)、ruff 对照改动前零新增;临时 worktree 检出改动前代码、只放入两个新模块跑 worker 新测试,3 条接线用例红、3 条守卫用例绿;再放入运行时接线但不关评测,评测「不带章」用例红、金轨迹照绿,补上评测开关后全绿。未验:长驻 worker 进程的实机走查(worker 仍是原型,没有生产调用方)。
10-03 起沙盒有「收尾运行」:波末最后一次运行没过验收、而更早某次运行的结果能全过时,执行体把那次的代码原样复跑一遍收尾(26b113d),同名文件被覆盖回那一版。但后来失败的运行新写出、那一版没写过的文件仍留在工作区——审稿卡的「工作区文件」清单、实验阶段列的数据文件、论文补图可读的数据、之后各沙盒里模型自己 ws_list 看到的都是工作区原样;节点又按「类型 + 文件名」累加每次运行报上来的产物、只增不删,失败运行独有的图和结果表也进了阶段产出。收尾挑来源时用的是当前工作区清单,「至少有一张图」这类断言还可能靠失败版的图过关。用户拍板:这些文件删掉,而不是只打标或完整还原工作区。
- 执行器(
agents/tools):SubprocessRunner.run的结果新增created_files/modified_files/deleted_files——本次运行新建 / 改写 / 删掉的工作区相对路径(排序,空的不列;超时的运行也报,它写下的文件同样留在工作区);产物采集改用同一份运行后快照,跑完只扫一遍工作区。ws_write的结果加created(写之前文件不存在)。TaskWorkspace.discard(paths)只删工作区根内的普通文件,越界或绝对路径、目录、不存在的文件一律跳过、不抛,返回真删掉的。 - 执行体(
agents/harness/src/omm_agent_harness/sandbox_agent.py):按执行顺序记下每次模型运行、收尾复跑与ws_write新建 / 写过 / 删掉的路径和报上来的产物。遗留文件 = 晚于来源运行的执行新建、收尾复跑没有重新写出的路径;来源运行(含)之前写过的不算(那时它就在工作区里,包括模型用ws_write写的配置),来源运行之后被删过的也不算(可能是原本就在、被失败运行删掉又重建的文件,宁可留着)。run_sandbox_task(discard_files=…)给了就开清理:挑来源时按那次运行自己写过的路径预估遗留,复跑后的重评按真实遗留,两处都把遗留当作不存在(断言看到的文件清单与读文件都没有它们)——通过不能靠失败版留下的文件,没有能诚实过关的运行就不收尾。复跑通过才以「路径 → 它们报过的产物 id」交给调用方删,删掉的文件报过的产物不再当指标来源(报告的produced_artifacts仍照列全部,作审计清单);复跑没过就进下一波,模型还要接着改,文件不动。没给discard_files时行为与之前一致。 - 节点与装配:清洗 / 实验 / 检验 / 论文补图四个沙盒调用点经
_finalize_cleanup接上。删除能力由执行侧经services.extras["discard_workspace_files"]注入——API 的open_engine与 worker 的_open_run都给本运行工作区的TaskWorkspace.discard,拿不到就不开清理。删掉的文件报过的产物从本阶段采集里拿掉(不进阶段产出与图件清单,文件内容仍在产物存储里可查),执行轨迹补一句agent_note:「收尾时删掉了失败运行留下的 N 个文件(复跑的那一版没有写它们):…」(最多点名 8 个)。契约、OpenAPI 基线与前端不动。 - 测试:harness
test_sandbox_agent.py+5(来源之后的调试运行与ws_write新建的文件被删,来源之前写过的、复跑重写的、只被改写的与下发的数据都不动,删掉的metrics.json不再当指标来源;来源运行没画图、失败的调试运行画了——不清理时会靠那张图判收尾通过,开了清理就不收尾、什么都不删;原本就在的文件被删掉又重建时留着;复跑没过不删;一次没过的收尾复跑之后再收尾,它新建的文件也算遗留);skillstest_nodes.py+3(实验节点调试运行画的图被删、不进阶段产出、轨迹补旁白,复跑重写的结果表照留;执行侧没给删除能力就不开清理;采集拿掉产物后同名新版仍顶替原位);tools +4(运行报出新建 / 改写 / 删掉的路径、没变化不带这三个键;超时的运行也报;ws_write报是否新建;discard只删工作区内的普通文件);workertest_runtime.py+1、backend/api 新增test_finalize_cleanup_wiring.py1 项(删除能力经 extras 注入节点,只删本运行工作区里的文件)。
当日执行并通过:pytest backend/api/tests 566 passed / 2 skipped;agents 五包 + worker 882 passed / 1 skipped;strict mypy 与改动前基线逐条相同、ruff 对照改动前零新增;临时 worktree 检出改动前代码跑新测试,14 条新用例全红、同文件其余 264 条全绿,放入本次改动后 278 条全绿。未验:真实运行里的清理(需要一次「后面的运行失败、收尾复跑通过」的真跑;要用上这次改动,仍须把旧 API 进程整个停掉再启动)。已知局限:模型终答叙事(approach_summary / figure_notes)可能描述的是后来没过的版本,本次未处理。
上一节留下的已知局限:收尾复跑通过时,模型的终答还是波末那份,写在没过验收的运行之后——实验的 approach_summary / progress_note、清洗 / 检验 / 论文补图的 summary 与 figure_notes 可能描述的是没被采用的版本,而阶段产出、实验审稿材料与执行轨迹正文都照抄它。最常见的收尾恰好是来源与失败的几次运行在同一波(第 1 次运行就出了指标、之后几次调参没过),「取来源那一波的终答」对它无效。10-03 E610 那一节也留了一条:题意解析因信息不足失败时,失败文案让用户去对话里补题面,下面的主操作却还是「重试当前阶段」,不补题面直接点只会同输入再判一次不足。用户拍板两处:收尾通过后让模型按采用的那一版重写叙事;E610 的主按钮换成「去对话里补充题面」(不是隐藏)。
- 叙事重写(
agents/harness/src/omm_agent_harness/sandbox_agent.py):run_sandbox_task(on_narrative_rewrite=…)。收尾复跑通过且没取消时,另开一个不带工具的内环(LoopBudget(max_turns=2):一轮交终答,多一轮兜住误调工具被退回;结构修复照旧一次),按同一套终答键与校验让模型重写。prompt 只给采用的那次运行的代码(保头 6000 字)、复跑 stdout(保尾 2000 字)、复跑的验收结果、工作区现有文件(最多列 80 个,图件说明只能写现有的图)与旧终答,不带任务说明(工具协议、审稿意见)与运行预算。写成就替换终答,on_final_answer回传重写后那份;结构不合格、轮数用尽或调用出错都沿用原终答,任务照样通过;这次调用的用量计入报告。没触发收尾、收尾没过、已取消都不重写。 - 节点(
agents/skills/src/omm_agent_skills/nodes.py):_narrative_note给清洗 / 实验 / 检验 / 论文补图四个沙盒调用点补一句执行轨迹旁白——写成了:「终答叙事已按收尾采用的第 N 次运行重写(原来那份写在没过验收的运行之后)」;没写成:带上原因(最多 120 字),说明沿用原终答、叙事可能描述的是后来没过验收的版本。下游(阶段产出、实验审稿材料、轨迹正文、图件清单)照旧读终答,拿到的就是重写后那份。 - E610 主操作(
backend/api/omm_api/workspace_view.py):_awaiting_problem_supplement(运行 FAILED、停在题意解析、最近一次步骤失败码是 E610,与对话控制面run_control.awaiting_problem_supplement同一口径)成立时,工作台主操作投影为{"kind": "supplement", "label": "去对话里补充题面"};其余失败照旧retry「重试当前阶段」。 - 契约:
modeling-workspace-view.schema.json的agent_action_kind加supplement,agent_action联合加supplement_agent_action(必须有目标路由,审批与 option 字段为空),两处都是 ADDITIVE;Python / TS 生成类型与 OpenAPI 基线用脚本重生成;fixture 新增 validmodeling-workspace-view.2.json(E610 失败运行的视图)与 invalidmodeling-workspace-view.supplement-without-route.json。 - 前端(
integration/modeling-workspace-controller.ts):点supplement走focusConversationInput——把同页.chat-composer textarea滚入视野并聚焦,不调用动作接口;发出去的补充由对话控制面按 10-03 的口径改判为带题面补充的重试。运行页与五个阶段页的对话输入框都在工作台根节点内、与主按钮同侧。受保护入口、页面模板与路由零改动。 - 测试:harness
test_sandbox_agent.py+7(重写生效;prompt 截断与必填键;重写不合格沿用原终答;重写时的工具调用被拒、不执行;重写抛错沿用原终答且任务仍通过;收尾没通过不重写;已取消不重写),既有 6 条收尾用例补一轮重写回复并断言模型调用次数;skillstest_nodes.py两条实验收尾用例补叙事断言(阶段产出用重写后那份、重写 prompt 带采用的代码而不带调试片段、旁白多一句);backend/apitest_failure_error_codes.py的 E610 用例补工作台视图(过契约 schema、主操作逐字段相等),非 E610 的失败仍是retry;test_run_control.py补「补题面前主操作是supplement、补完不再是」。
当日执行并通过:临时 worktree 里改动涉及的 5 个测试文件 309 passed;validate.py(16 schema / 77 fixture)、check_compat.py;另起 worktree 检出改动前代码跑新测试,17 红 / 292 绿(harness 13 = 新 7 条 + 既有收尾 6 条多出的那轮重写,skills 2,backend/api 2),放入本次改动后 309 条全绿。与下一节的并行改动一起合回主工作区后跑全量:pytest backend/api/tests 572 passed / 5 skipped(PostgreSQL 576 passed / 1 skipped),agents 五包 + worker 889 passed / 1 skipped,node --test 237/237,npm run check / npm run build(web),契约 Python / TS 生成 --check / --verify 与 export_openapi.py --check;strict mypy 较改动前只多 1 条(生成的 SupplementAgentAction.label 用 constr(...),与生成器既有的同款错误相同)。浏览器验收(10-07,开发服务 + 测试账号的 E610 种子运行,模型接口是本机打桩,截图在 audit-current/2026-10-05-e610-supplement/,不入库):运行页主按钮由「重试当前阶段」变为「去对话里补充题面」;点击后焦点落在对话输入框且在可视区内,点击期间 0 个网络请求、运行状态与 updated_at 不变。未验:真实模型下的叙事重写质量(用例全部打桩),需要一次「后面的运行失败、收尾复跑通过」的真跑;要在真实运行里用上,仍须把旧 API 进程整个停掉再启动。
用户问「现在是不是不能同时跑多个不同的建模任务……我不要限制」。查明是两层:①建任务闸门——同一用户「排队中 + 执行中」的运行数达到上限就 409 CONCURRENCY_LIMIT,上限取设置中心「高级设置 · 最大并发任务」(下拉默认显示「3 个」,NULL 时用部署默认 3);②执行面单线程——RunnerThread 每个节拍取全部可推进运行、逐个串行 advance,真实节点一步就是一个阶段(实验动辄十几分钟),其它运行全部干等。开发库实证:当天两个真跑的 13 个步骤跨运行重叠 0 次,后建的那个三个阶段实际只干了约 2.5 分钟、却等了 44 分钟,每个阶段都恰好在另一个运行的步骤结束那一秒才开始。设置面板的说明文字自己就写着「调大不等于并行加速」,system-overview §9 也列为已知缺口。用户拍板:执行面改成真并行;「最大并发任务」默认不限、保留可选上限;存量数据只清掉随旧面板默认值存下的 3。
- 执行面(
backend/api/omm_api/runner.py):RunnerThread改成调度线程 + 每运行一个工作线程——每个节拍给每个可推进、且没有步骤在途的运行派一个omm-runner-<run_id>守护线程推进一步(仍是一步一派,模拟节点的节奏不变),运行之间互不等待,并行数不设上限。在途表保证同一运行同一时刻只有一个工作线程:advance_run开头的heal_interrupted会把同一运行在途的 RUNNING 步骤判成 executor lost 重跑,正依赖这一点。单个运行推进抛错只记日志、让出在途位,下个节拍照常再派,不再像串行时那样中断整轮、连累排在后面的运行。新增in_flight()/wait_idle(timeout);线程名omm-mock-runner→omm-runner;WorkflowAdvancer.advance与engine_glue.advance_run注释里「进程内只有一个推进线程」改成「同一运行只派一个工作线程」。 - 跨进程互斥(
RunnerLock,替掉每个节拍拿放一次的runner_tick_mutex):PostgreSQL advisory lock(0x6F6D6D01)拿在一条单独的 AUTOCOMMIT 连接上(不留长事务、不挡 VACUUM),有步骤在途期间一直拿着,空闲的节拍才放——节拍很快返回而步骤还在跑,若照旧每个节拍放锁,另一个 API 进程就能抢到锁、把在途步骤判死重跑(双倍扣费)。停机时还有步骤在途就不放,留给进程退出、连接断开时释放。SQLite 只出现在测试夹具,直接放行。 - 连接池(
config.py/db.py/main.py):PostgreSQL 池放大到database_pool_size = 10、database_max_overflow = 40(OMM_DATABASE_POOL_SIZE/OMM_DATABASE_MAX_OVERFLOW可覆盖):每个在途运行要用连接,推进锁另占一条,再加 HTTP 请求、SSE 与对话轮,默认池(5 + 溢出 10)在几个任务同时跑时会让请求排队等连接。 - 建任务闸门(
routers/task_runs.py/config.py/schemas.py):default_max_concurrent_runs3 →None(不限;部署仍可用OMM_DEFAULT_MAX_CONCURRENT_RUNS设默认上限),max_concurrent_runs_ceiling = 8只约束用户自己选的上限。上限为空时整段闸门跳过;设了上限照旧只数排队中与执行中的运行,409 文案改成「把最大并发任务调高或改成不限」。PUT /api/account/preferences的max_concurrent_runs改为必传、可为null(= 不限,回落部署默认)——漏传字段是 422,不会把已存的上限悄悄清掉;OpenAPI 基线随之重导出。 - 存量数据(迁移
0022_unlimited_concurrent_runs):旧面板每次保存都把下拉当前值一起推上去,而下拉默认显示「3 个」,所以存着的 3 绝大多数不是用户刻意选的(当天两个真跑的属主存的就是 3,不清的话照样被 3 卡住)。迁移把max_concurrent_runs = 3清回 NULL,1 / 5 / 8 这类要动手才选得到的值保留;纯数据修正,downgrade 不回写。本地默认链路启动只create_all、不跑迁移,已有的开发库要手动alembic upgrade head。 - 前端:「最大并发任务」下拉加「不限」放第一个(默认),其余按 1 / 3 / 5 / 8 升序;说明文字改为「同时排队或执行中的任务数上限,默认不限、保存后立即生效;任务之间并行推进,同时跑得越多越抢本机算力和模型接口额度」(
legacy/openmathmodel-ui.ts只改这一行,英文词典同步)。account-preferences.ts的parseMaxConcurrency改成三态:数字 /null(不限)/undefined(认不出、不推送——不能和「不限」混用,否则一个坏值就把上限清掉了);回填支持null。受保护入口、页面模板与路由零改动。 - 并行的代价:并行的沙盒互相抢 CPU,单次运行的 120 秒时限更容易撞上;同一模型接口的并发请求更多,可能被限流变慢;月度费用的硬限制按步检查,并行时最多多花「并行任务数」步。
- 测试:backend/api
test_runner_lock.py改写(runner_tick_mutex→RunnerLock:无竞争放行;PostgreSQL 上对手持锁时拿不到、放了能拿到;持锁连接是 idle 而不是 idle in transaction;锁被占时节拍不派活、放了恢复推进;步骤在途期间对手拿不到锁,步骤结束后也要等到空闲的节拍才放);新增test_runner_parallel.py(两个运行的慢步骤同时在跑——串行必然超时;同一运行上一步没完不派第二个线程;一个运行抛错不连累别的、下个节拍再派;真引擎 + 真调度循环三个模拟运行一起推进到第一个闸门;连接池按配置放大);test_account_preferences.py改写(默认不限且回填 null;漏传字段 422、不清旧上限;默认不限连建 10 个;设成 2 个时第 3 个 409,改回不限即放行);新增test_concurrency_default_migration.py(0021 → 种 3 / 1 / 5 / 8 / NULL → head,只有 3 变成 NULL);前端network-preferences.test.mjs+1(「不限」→null),坏值断言改为undefined。
当日执行并通过:临时 worktree 里 pytest backend/api/tests SQLite 572 passed / 5 skipped、PostgreSQL 576 passed / 1 skipped(advisory lock 与连接池用例只在 PostgreSQL 上跑);另起 worktree 检出改动前代码跑新测试:test_runner_lock.py 收集即 ImportError,test_runner_parallel.py 4 条 SQLite 用例全红(串行节拍卡在 barrier 上等到超时),偏好与迁移的 4 条新断言全红。与上一节的改动一起合回主工作区后的全量结果见上一节;开发库 alembic upgrade head(0021 → 0022)。验收(10-07,开发服务 + 测试账号,截图在 audit-current/2026-10-05-unlimited-concurrency/,不入库):设置中心「最大并发任务」回填「不限」,下拉为不限 / 1 个 / 3 个 / 5 个 / 8 个,与改动前截图同尺寸同取景;测试账号连建 5 个 auto_start=false 的运行全部 201 / QUEUED(第 4、5 个不再 409),核对完全部取消。未验:多个真实运行同时跑实验阶段时的 CPU 争用与模型接口限流;两个 API 进程共库时的锁交接只由 PostgreSQL 单测覆盖。要用上这次改动须重启 API,并对已有开发库跑一次 alembic upgrade head。
10-05 宠物产业真跑(run_d14b2827)停在 FAILED,实验阶段两次都死在同一处。第 5 次实验(G3 推荐重做实验的那一轮)8 个回合 = 列目录、读两个数据文件、ws_write 五次重写 experiment.py(2.0k → 1.4k → 4.0k → 3.6k → 16.9k 字符)——模型以为 ws_write 能追加,把脚本分几段写,第 8 回合才写出完整脚本,回合就用完了;第 2 次实验(检验后自动回退那一轮)8 个回合全花在读文件上(检验结果、两个数据文件、上一轮 experiment.py 按 3000 字符分 4 段读完)。两次都是内环 max_turns 出口(E330),R2 的 6 次运行一次没动、还剩两波,run_sandbox_task 见内环没交出合法终答就收束,整个阶段判失败。ws_write 的实现是整文件覆盖,给模型的工具说明却只写「写工作区 UTF-8 文本文件。」;波次提示词只报 R2 还剩几次运行,不报每波只有 8 个回合。用户拍板:连着修真跑暴露的两个缺陷,先修这一个。
- 执行体(
agents/harness/src/omm_agent_harness/sandbox_agent.py):内环以max_turns收束(回合用完)、断言(含收尾运行)没过、没取消、R2 运行预算还有余额时,不再收束、照常开下一波——回合用完只是这一波的配额尽了,不是任务的预算尽了。下一波的反馈开头写明「上一轮 N 个回合用完也没有交出终答」:这一波一次没运行时再点名它调用了哪些工具(如ws_read ×7),要求先把完整脚本直接交给运行工具(源码作为 code 传入,不必先ws_write落盘;ws_write整文件覆盖、不是追加),不要把回合花在分段重读整份旧文件上;跑过但回合用完时带上运行次数,提醒跑通自查后就交终答。结构违约(E120)、无进展(E331)、工具连败(E332)与取消照旧直接收束(修复梯子不跨级);波数上限max_waves = 3照旧兜底,单波 8 回合、R2 = 6 都不改。 - 回合可见:波次提示词「工作方式与终答要求」补一句「每一轮最多 N 个回合(调一次工具或交一次终答各算 1 个),回合用完还没交终答这一轮就到此结束,脚本写好就直接运行」。运行跟踪器按波计数——内环每个工具回合调一次执行器,调用次数就是本波已用的回合数;本波还没运行过、只剩不超过 2 个回合时,在这一回合最后一条观察前面加「[回合提醒]」(成功的观察加
turn_budget键排在最前,失败的缀在报错后面)。加注只进模型的观察,原始结果照存,断言与节点看到的不变。 - 工具说明(
agents/skills/src/omm_agent_skills/chat_adapter.py):文本协议给模型的ws_write说明补「整文件覆盖写入,不是追加」。 - 契约、OpenAPI 基线、节点与前端不动;报告的
attempts照旧上报波次数(回合用完后开的那一波也算一波)。 - 测试:harness
test_sandbox_agent.py+8(真跑形状:第一波 8 个回合只读写不运行 → 第二波运行通过、attempts为 2,反馈点名调用过的工具;跑过但回合用完的反馈带运行次数与断言差异;回合提醒在成功 / 失败两种观察上的写法;跑过之后不再提醒;波次提示词写明每波回合数;R2 尽或已是最后一波时照样收束;取消之后不再开新波;E331 / E332 照旧收束);skillstest_nodes.py+1(实验节点:第一波 8 个回合只写不跑 → 第二波运行通过 → 节点成功、共两波)。
当日执行并通过(与下一节一起回归):agents 五包 + worker 900 passed / 1 skipped,pytest backend/api/tests 572 passed / 5 skipped(PostgreSQL 576 passed / 1 skipped);CI 口径的 Python 段(compileall、契约 validate.py / check_compat.py、generate_python.py --check / --verify、API import、export_openapi.py --check、六包 import)全过;strict mypy 本节两个文件与改动前逐条相同;ruff 对照改动前 E501 等规则零新增,只多了中文全角标点类提示(RUF001–003,全仓同款)。临时 worktree 检出改动前代码跑新测试:harness 4 条行为用例红、4 条守卫用例绿,节点用例红。未验:真实模型拿到「上一轮回合用完」反馈与回合提醒后的表现(用例全部打桩)——要在真实运行里用上,仍须把旧 API 进程整个停掉再启动。已知局限:单波仍是 8 个回合,读大文件仍花回合;一个任务最多 3 波,三波都只读写不运行仍会判失败。
检验节点在回退重做轮产出跨轮对比 round_comparison(上一轮未过的检查 × 本轮检查,按 id 求交分三桶:转为通过 / 仍未通过 / 本轮未复检;09-18 进契约、09-27 结果页渲染),G3 卡片的「较上一轮」句、论文材料与结果页都据此说话;但「沿用同名 id」只写在任务卡与审稿材料的提示词里。真跑 run_5d6bf571 第 3 轮检验的 15 项检查 id 全换了:forecast_target → forecast_target_consistency、pareto_front → pareto_nondominated、storage_param_sensitivity → storage_cost_robustness,另有两项换了说法,只有 data_provenance 是真的没再复检——上一轮未过的 6 项全落进「本轮未复检」,跨轮对比形同虚设。这是用户拍板连着修的第二个缺陷。修法不做按名字的模糊配对(配错就是编出来的事实,与契约「只计数与点名、不判好坏」冲突),改为验收时强制同 id。
- 验收断言(
agents/skills/src/omm_agent_skills/nodes.py):回退重做轮(上一轮检验真跑通且有未过项,与跨轮对比同一口径)给检验沙盒加一条断言previous_checks_rechecked:上一轮每个未过的检查 id,要么在本轮标记行的checks里同 id 出现(检查实现可以改进,id 与阈值不变),要么在标记行"not_rechecked": [{"id": …, "reason": …}]里写明不复检的原因(缺 id 或缺原因不算声明);缺了就判不过、按 R2 打回修复,反馈点名缺了哪些(id + 名称)、本轮现有哪些 id、出口怎么写。首轮检验不加这条断言,任务卡不变。 - 任务卡与审稿材料:任务卡「上一轮未过检查」段改为逐项沿用原 id 复检(验收会逐个核对)、阈值照旧、确实无法复检的写进
not_rechecked、不要改名或悄悄删掉;检验审稿材料的「上一轮反馈」段附上本轮声明不复检的条目与原因,事实行要求审稿人核查「声明不复检的理由是否成立」;检验审稿提示词模板validating_review.default升到 v4,第 6 条「跨轮核查」同样写明:声明不复检的项须核查理由是否成立,确实无法复检(如所需数据本轮拿不到)才算成立,站不住的按「删掉」记 major 并点名。 - 跨轮对比:仍按 id 求交;落进「本轮未复检」的项若有声明,条目带
reason,G3「较上一轮」句与论文材料写成「名称:原因」(有原因时用「;」分隔,没有原因时逐字照旧)。experiment-summary.v1的round_check_ref只有 id / name、API 投影只取这两键,结果页照旧——契约、OpenAPI 基线与前端不动。 - 测试:skills
test_nodes.py改 2 条(test_validation_redo_compares_checks_across_rounds:本轮改为声明一项不复检,断言任务卡新文案、验收清单多出新断言、对比与 G3 句带原因、审稿卡带声明,对照组首轮的验收清单没有新断言;test_reviewer_redo_notes_carry_last_round_blockers_and_facts:事实行新文案),新 2 条(test_validation_redo_renaming_previous_checks_is_sent_back_for_repair:第一波把上一轮的 id 改了名被打回,第二波改回原 id 后三桶各就各位;test_previous_checks_rechecked_assertion_accepts_same_ids_or_declared_reasons:断言纯函数的四种情形);另给守卫用例test_reviewers_receive_last_round_feedback_in_their_task_cards补一条断言:检验审稿角色卡里有第 6 条的新文案。
当日执行并通过:见上一节(两节一起回归)。临时 worktree 检出改动前代码(只含上一节的改动)跑新测试:改 2 新 2 共 4 条红;守卫用例 test_reviewers_receive_last_round_feedback_in_their_task_cards 原有断言都过,只红在新补的模板断言上。strict mypy 较改动前多 2 条 no-untyped-def(新断言工厂与内层 check 沿用同文件既有断言工厂的无注解写法,同款)。未验:真实模型会不会拿声明出口躲开没过的检查——由检验审稿人按理由是否成立把关(模板第 6 条、事实行与材料附注三处同一口径);run_5d6bf571 那种整组改名的真跑复现。要在真实运行里用上,仍须把旧 API 进程整个停掉再启动。
用户反馈论文页导出的 Word 排版问题,导出器那一侧已修(不插零宽空格、不开单词中间换行、重写表格列宽,见 ADR-0025 修订)。用本机 WPS 实测后仍有几行被两端对齐拉开:摘要里连写着 rolling_domination=0.0021355180250620664、forecast_target_consistency=0.9175215033197788 这类内部指标名和 16~19 位小数,Word / WPS 都断不开。根因有两条。一是实验脚本的 OMM_METRICS_JSON 只有「键 → 值」,冻结清单给实验指标的「含义」只写 实验指标 {key},写手没有别的叫法可用。二是 paper_section 要求冻结值「不四舍五入、不改精度」、paper_finalize 要求「保持原样」,因为数字审计按数字串逐字对账,写成 0.002136 会被记为无出处数值。用户拍板:提示词与审计一起改。
- 数字审计(
agents/skills/src/omm_agent_skills/frozen_numbers.py):unsourced_numbers在允许集里找不到某个 token 时,再看它是否等于某个允许值(冻结值 ∪ 材料数值)按 token 自己的小数位四舍五入(ROUND_HALF_UP/ROUND_HALF_EVEN)的结果。只有至少 3 位有效数字的 token 才这样判:0.5、12这类短数舍入后能对上太多值,仍只认相等。截断不算舍入。章级审计重写、摘要核对与终稿审计都走这个函数,口径一致。清单表头改为「保留 4 位有效数字四舍五入(附例),不换算单位、不改写成别的数;『含义』里的英文键名只用于对账,正文用中文说法」。 - 写作提示词:
paper_section(v7)、paper_finalize(v4)、paper_writing(总编失败时的整篇回退,v12)改为:数值保留 4 位有效数字四舍五入、不换算单位;指标和变量在正文、摘要、表格里都用中文说法(按材料里的定义写),不写rolling_domination、metrics.xxx、data_source_synthetic=1这类代码标识符或「键=值」写法;材料里看不出含义的指标宁可不写。paper_outline(v10):brief 点名指标时写中文说法,括号里附清单编号;编号和键名只供写手对账,不进正文。章级审计重写的反馈改为「改为清单 / 材料中的数值(可保留 4 位有效数字四舍五入,不得换算或另编)」。 - 契约、OpenAPI 基线、前端不动;DocumentDraft 里的冻结清单照旧存全精度值。已生成的论文不受影响。
- 测试:skills
test_nodes.py改 1 条(清单表头新文案)、新增 1 条(test_unsourced_numbers_accepts_values_rounded_to_their_written_precision:舍入到 6 / 5 / 4 / 0 位小数的都认;截断、不足 3 位有效数字、舍错方向的照报)。
当日执行并通过:agents 全量 857 passed / 1 skipped,其中章级审计重写用例里编造的 300、99、1180 照样被报。未验:真实模型按新提示词写出的中文指标名是否贴切(用例全部打桩)。要在真实运行里用上,须把旧 API 进程整个停掉再启动。已知局限:实验脚本仍不输出指标的中文名,写手只能从材料推断,材料里没有定义的指标会被略去。
用户带图报障:「VLSI布图规划优化建模」(run_4b46e80…,10-03 在实验阶段失败,库里没有论文阶段产出)的论文页摆着一整篇「失联潜水器」论文(2 问题分析 / 2.1 约束分析…,底部「16,426 字 · 已恢复本机草稿」)。用户以为是写死的模板,要求论文正式开写前显示已有的加载动画。
根因:代码里没有这篇稿子(全仓零命中),它属于 09-09 的项目 proj_04371d1d…。legacy/openmathmodel-ui.ts 的 bindPaperEditor / savePaperDraftNow 把本机草稿存在不分项目的全局键 openmathmodelPaperDraft.v1:在任一任务的编辑器里动一下(打字、加粗、Ctrl+S)整篇正文就写进去,此后打开任何任务的论文页都会被灌回编辑器,状态栏显示「已恢复本机草稿」。有论文产出的任务里 renderEditorPanel 随后用定稿把它顶掉,所以看不出来;没有产出的任务里它就一直摆着,占位段落被顶掉,workflow-empty 的空态插画也挂不上。2026-09-17 那次「正文一万六千字、大纲是空态」的报障是同一篇串台稿,当时只补了大纲。task-autosave 与 hasUserPaperDraft 早已按项目分键,只有这把全局键在串台。串台期间若在别的任务页里点过编辑器,task-autosave 还会把这篇以 user_edited: true 存进那个项目的按项目键。
- 前端 新增
tasks/paper-draft.ts(零依赖):readUserPaperDraft/writeUserPaperDraft/clearPaperDraft,按 URL 上合法的project_id(否则落到 demo 档)读写openmathmodel.paperDraft.v1.<scope>({html, saved_at, user_edited: true}),作用域推导只剩这一份。编辑器的savePaperDraftNow/bindPaperEditor/resetPaperDraft、task-autosave 的落盘与恢复、stage-content 的hasUserPaperDraft全部改走它。全局键不再读写;「恢复初始正文」照旧连它一起清。 - 串台副本:
demoteLeakedPaperDraft()在本项目记录与全局键内容一字不差时把记录改为user_edited: false(正文留着不删),之后不再恢复、也不挡 Agent 正文。只在renderStageContent的document_draft == null分支调用(dropLeakedPaperDraft,排在syncPaperOutlineFromEditor之前);降级后编辑器换回占位段落,字数归零,状态回到「编辑内容自动保存到本机」,大纲回到「论文大纲将在论文生成后显示。」。有论文产出的项目一概不碰:全局键记的是最后一次编辑的地方,那里一字不差的那份多半就是用户在自己论文上的修改。代价:被污染的任务第一次打开时,旧稿会一直显示到阶段产出返回(夹具实测 46 ms),之后不再出现。 - 不动:页面模板、
workflow-empty.tsx的挂载规则、直播与定稿渲染、.outline结构、受保护入口。 - 测试:新增
tasks/paper-draft.test.mjs7 条:按项目隔离;全局键永不当草稿恢复,写草稿不碰全局键;非用户草稿、空正文、损坏记录一律忽略;清除只清本项目和全局键;一字不差的副本降级,正文与时间戳保留;用户自己的草稿(没有全局键 / 内容不同 / 在副本上改过)绝不降级;存储被禁用时不抛错。
当日执行并通过:npm run check;node --test "src/**/*.test.mjs" 248/248;npm run build(index 942.21 kB)。浏览器验收用本机夹具 .agents/paper-draft-leak-fixture.mjs(API 走夹具、页面转发 vite,localStorage 按夹具源隔离),截图在 audit-current/2026-10-08-paper-draft-leak/,不入库:
- 改前:全局键里放一篇串台稿,打开实验阶段失败、没有论文产出的运行的论文页 → 整篇旧稿 +「已恢复本机草稿」+ 大纲按旧稿建,与用户截图同形。
- 改后:同一页面显示水母空态「把探索写成论文 / 开始撰写」,0 字,大纲空态。按项目键里放一份与全局键相同的副本 → 第 140 ms 旧稿出现、第 186 ms 换回空态,记录降为
user_edited: false。 - 点「开始撰写」输入 → 700 ms 后存进本项目键、全局键原样;刷新后恢复且不降级。换一个
project_id→ 空态。「恢复初始正文」→ 空态,本项目键与全局键都被清掉。 - 有论文产出的运行(
run_e898…的真实投影,.agents/dump-stage-outputs.py只读导出):与全局键相同的用户草稿照常恢复、不降级;没有草稿时直接渲染定稿(19,872 字),全程不闪旧稿。 - 运行页、数据、建模、实验、论文、完成 6 个路由加载均无脚本错误。
未验:用户真实浏览器里各项目键的污染情况(不读取用户浏览器数据)。应用内验收待用户:刷新截图里那个任务的论文页,应看到水母空态,左侧大纲为「论文大纲将在论文生成后显示。」。已知、本次未改:失败的运行论文页右上角仍写「Agent 正在撰写」(continue-paper 按钮只区分是否 COMPLETED)。
- 首页与确认页已使用现有 DOM 创建 Project/TaskRun;
- 草稿携带任务类型、模型选择和附件元数据;
- 返回的
run_id/project_id已进入任务执行页; - 401 已打开现有登录模态并在成功后恢复动作;
- 后续补充真实浏览器登录后的创建断言、重复点击幂等断言和二进制附件上传。
- 增加 StageOutput 版本、哈希、来源 Artifact 与 producer step;
- 保留历史版本;
STEP_SUCCEEDED不再丢弃 outputs/metrics;- SSE 增加轻量 output-updated 指针事件。
按 DatasetProfile → PlanProposal → ExperimentSummary → DocumentDraft → DeliveryManifest 顺序实现。每类都要先发布 Schema、Fixture 和生成类型,再接 API 与 DOM。
- 用节点注册表替换
SIM_NODES; - 补齐 DATA/EXPERIMENT/VALIDATION/PAPER 技能;
- 统一 API 与
backend/worker的队列、租约、事件和 Artifact Store; - 保持本规范的 workspace 接口与页面合同稳定。