From 5ff3e1136d7fae90593a8df991fa3d9d675db920 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 16:49:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E6=95=B4=E8=BD=AE?= =?UTF-8?q?=E6=94=BE=E5=88=B0=E6=9C=8D=E5=8A=A1=E7=AB=AF=E8=B7=91=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9Astore/chat.ts=20=E6=94=B9?= =?UTF-8?q?=E6=88=90=E3=80=8C=E5=8F=91=E8=B5=B7=20+=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E3=80=8D=EF=BC=8C=E6=96=AD=E7=BA=BF=E6=8C=89=20seq=20=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E8=A1=A5=E9=BD=90=EF=BC=8C=E5=88=B7=E6=96=B0=E3=80=81?= =?UTF-8?q?=E5=85=B3=E6=8E=89=E5=86=8D=E6=89=93=E5=BC=80=E6=8E=A5=E7=9D=80?= =?UTF-8?q?=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:一轮问数据搬到服务端之后(上一个提交),页面不该再推这一轮:建图那条流、发起运行、复核、按复核重来、落库都和页面 绑在一起,页面一断这一轮就停。现在页面只发起、只看,要不要运行、要不要重来、什么时候交付都照服务端说的。 做了什么: - 新的 api/chatTurns.ts:发起、查询、停止(挂在 api.chatTurns),streamChatTurn 订阅这一轮的事件日志——先回放、再接实时; 断了按收到的最后一个 seq 自动重连(收到东西马上接着连,连不上按退避),45 秒什么都没收到(服务端 15 秒一拍心跳)当连接死了。 - store/chat.ts:plan / launch / settle / retry 换成发起(launchTurn)+ 播放(play)。收到的操作照原来的渲染逻辑画(画布边收 边长、过程步骤、思考、查看、用量);收到 run 就接上运行自己的实时流画过程(watch 只画过程,不再判结局);stage 说到了 哪一步(停在审批上立刻去拿待审批);turn.retry 照原来自动重来那样重置;end 照服务端落库的那一行摆结局(和刷新之后从库里 恢复的是同一份 restoreTurn)。「重试本轮」「重新运行本轮」「放宽步数」「跑一下」「继续运行」「停止」都走新接口。 - 页面不再写这一轮的结局(不再 PATCH 轮次、不再调 /copilot/review、不再发 POST /api/runs);失败的说法照服务端记的原因 (meta.cause)按 lib/explain 讲,老轮次记的 failure 照旧认。 - 刷新、关掉再打开:服务端还在跑的一轮(ConversationTurn.job)从这一次尝试的第一条事件接着播;库里停在半路、服务端又没在跑 的(老轮次、服务重启后停着的)请服务端接着收尾(mode=resume),不在页面上自己收尾。问数据页的显示不变。 - 页面上那份退路(MAX_ATTEMPTS、fillInput、retryInstruction、等复核的计时)去掉:决定只在服务端一处。后端对照测试改成守 「页面上不再有这几样」。 - scripts/check-chat.mjs:伪造的服务端照 backend/app/chat/job.py 的做法用原来那几份剧本把一轮跑完(生成、发起运行、等运行的剧本 放完、复核、拼那一行),原有各段照过;守落库的几处改成守「页面不 PATCH 这一轮」和照服务端那一行摆;新加「断线重连补齐」 「刷新、关掉再打开接着看」「停止(建图中也停得下)」几段,并核对发起、重试、放宽步数、继续运行、停止的请求体。 - 服务端顺带:turn.attempt 带上到目前为止的用量、从什么时候算起(补交付的到运行结束为止),turn.retry 带上换下来的那次运行 并进之后的用量(页面上的运行壳子里没有用量);接着收尾(resume)不要求运行还在,运行记录删了的照实收成「运行记录不见了」。 --- backend/app/chat/job.py | 20 +- backend/tests/test_chat_turn_policy.py | 57 +- backend/tests/test_chat_turns.py | 23 + frontend/src/api/chatTurns.ts | 153 +++ frontend/src/api/client.ts | 5 + frontend/src/store/chat.ts | 1581 +++++++++--------------- frontend/src/types.ts | 16 + scripts/check-chat.mjs | 1245 +++++++++++++++---- 8 files changed, 1822 insertions(+), 1278 deletions(-) create mode 100644 frontend/src/api/chatTurns.ts diff --git a/backend/app/chat/job.py b/backend/app/chat/job.py index 32bc6f3b..60cd197f 100644 --- a/backend/app/chat/job.py +++ b/backend/app/chat/job.py @@ -310,7 +310,8 @@ class ChatTurnExecution(runtime.JobRun): self.run_id = None await self._job(attempt=attempt, run_id=None) await self.save(run_id=None, meta={"runId": None, "runStatus": None, "usage": dict(self.usage)}) - await self.emit("turn.retry", {"kind": kind, "reason": reason, "base": base, "attempt": attempt}) + await self.emit("turn.retry", {"kind": kind, "reason": reason, "base": base, "attempt": attempt, + "usage": dict(self.usage)}) # ---------------- 执行 ---------------- @@ -751,7 +752,8 @@ async def start(session: AsyncSession, *, conversation: Any, question: str, data raise TurnRefused(422, "放宽到几步要写清楚", TURN_INVALID) request["max_steps"] = int(max_steps) if mode in ("continue", "resume"): - if run is None: + # 接着收尾不要求运行还在:运行记录删了的,服务端照实收成「运行记录不见了」 + if run is None and (mode == "continue" or not run_id): raise TurnRefused(409, "这一轮没有可接着的运行,请重试本轮", TURN_INVALID) if mode == "continue": await _continue_run(run.id, actor) @@ -761,9 +763,10 @@ async def start(session: AsyncSession, *, conversation: Any, question: str, data spent = meta.get("ms") if row.status != "running" and isinstance(spent, (int, float)): request["started_ms"] = now_ms - int(spent) - elif run.started_at is not None: + elif run is not None and run.started_at is not None: request["started_ms"] = int(run.started_at.timestamp() * 1000) - if mode == "resume" and run.status in ("succeeded", "failed", "cancelled") and run.finished_at is not None: + if mode == "resume" and run is not None and run.status in ("succeeded", "failed", "cancelled") \ + and run.finished_at is not None: request["ended_ms"] = int(run.finished_at.timestamp() * 1000) else: # 重试、重新运行、放宽步数、跑一下:服务重启挂起的旧运行没人会再去续它,顺手取消(停在审批上的不算:重试不会从那里发起) @@ -812,8 +815,15 @@ async def start(session: AsyncSession, *, conversation: Any, question: str, data # 这一次尝试从这一条开始:刷新、重开页面时从这里回放 async with SessionLocal() as s: + from app.db.models import ConversationTurn + + fresh = await s.get(ConversationTurn, turn_id) + usage = ((fresh.meta or {}) if fresh is not None and isinstance(fresh.meta, dict) else {}).get("usage") + # 页面据此把这一轮重置成「这一次」的样子:归档、范围、到目前为止的用量、从什么时候算起(补交付的到运行结束为止) seq = await turn_log.append(s, turn_id, "turn.attempt", { - "mode": mode, "attempts": attempts, "scope": request.get("scope") or [], + "mode": mode, "attempts": attempts, "scope": request.get("scope") or [], "usage": usage or {}, + "started_ms": request.get("started_ms"), + **({"ended_ms": request["ended_ms"]} if request.get("ended_ms") else {}), **({"max_steps": request["max_steps"]} if request.get("max_steps") else {})}) await s.execute(update(ChatTurnJob).where(ChatTurnJob.id == turn_id).values(attempt_seq=seq) .execution_options(synchronize_session=False)) diff --git a/backend/tests/test_chat_turn_policy.py b/backend/tests/test_chat_turn_policy.py index f21f7a1c..609c019b 100644 --- a/backend/tests/test_chat_turn_policy.py +++ b/backend/tests/test_chat_turn_policy.py @@ -1,11 +1,10 @@ """问数据一轮的「决定」(app/chat/turn.py;细案 01 做法 1)。 要不要运行、输入怎么填、要不要带着原图重来、重来时说什么,从问数据页下沉到后端:final 带 next,复核带 retry_instruction, -问数据页照它们走;老服务端没给这两个字段时,问数据页退回它自己那份同样的规则。这里守: +问数据的一轮后来整个搬到了服务端(chat/drive.py、chat/job.py),页面只发起、只播放。这里守: - 各条决定的分支(next_step、fill_input、retry_after_review、retry_instruction 的两种措辞); -- 前后端对照:frontend/src/store/chat.ts 里留着的那份常量(MAX_ATTEMPTS、fillInput 认问题字段的正则、两种重来的措辞) - 和这里逐字一致——写法同 test_frontend_type_unions.py,两边一改就对不上; +- 页面上不再留一份同样的决定(一轮整个搬到服务端之后,frontend/src/store/chat.ts 只发起、只播放);用户点重试本轮时的措辞; - /copilot/review 按这一轮试过几次给 retry_instruction:可以重来时是那句话,不重来是 null。 问题、字段名都是虚构的。 @@ -112,7 +111,7 @@ def test_retry_instruction_two_wordings(): # -------------------------------------------------------------------------- -# 前后端对照 +# 页面不再留一份同样的决定 # -------------------------------------------------------------------------- @@ -120,30 +119,23 @@ def _ts() -> str: return CHAT_TS.read_text(encoding="utf-8") -def test_max_attempts_matches_the_frontend(): - found = re.search(r"const MAX_ATTEMPTS = (\d+)", _ts()) - assert found and int(found.group(1)) == turn.MAX_ATTEMPTS +def test_the_page_no_longer_keeps_its_own_copy_of_the_decisions(): + """一轮整个搬到服务端之后(docs/design/orchestration-round §1),问数据页只发起、只播放:要不要运行、要不要重来、重来说 + 什么、入口怎么填都只在这里(和 chat/drive.py),页面上那份退路(MAX_ATTEMPTS、fillInput、retryInstruction)去掉了—— + 两份并存迟早走样,界面层也不放逻辑。以前这里逐字对照两边的常量和措辞,现在守的是页面上没有这几样。""" + ts = _ts() + for name in ("const MAX_ATTEMPTS", "function fillInput", "function retryInstruction", "streamCopilot(", + "api.copilot.review(", "api.runs.start(", "patchTurn("): + assert name not in ts, f"chat.ts 里又出现了 {name}:这一轮的决定、发起、落库应在服务端" -def test_the_question_field_pattern_matches_the_frontend(): - found = re.search(r"/(question\|[^/]+)/i\.test", _ts()) - assert found, "chat.ts 的 fillInput 里找不到认问题字段的正则" - assert found.group(1) == turn.QUESTION_FIELD.pattern and turn.QUESTION_FIELD.flags & re.IGNORECASE - - -def _template(source: str) -> str: - """一段 TS 里由 + 连起来的模板字符串 → 一个 Python format 模板(${x} → {x},\\n → 换行)。""" - pieces = re.findall(r"`([^`]*)`", source) - text = "".join(pieces).replace("\\n", "\n") - return re.sub(r"\$\{(\w+)\}", r"{\1}", text) - - -def test_the_retry_wordings_match_the_frontend(): - body = re.search(r"function retryInstruction\([^)]*\): string \{(.*?)\n\}", _ts(), re.S) - assert body, "chat.ts 里找不到 retryInstruction" - with_base, scratch = re.split(r"\n\s*: ", body.group(1).split("hasBase", 1)[1].split("?", 1)[1], maxsplit=1) - assert _template(with_base) == turn.RETRY_WITH_BASE - assert _template(scratch) == turn.RETRY_FROM_SCRATCH +def test_user_retry_wordings(): + """用户点「重试本轮」时交给助手的话(以前在页面 retryTurn 里拼):有图时说「没有给出可用的结果」,没图的同从零重建。""" + with_base = turn.user_retry_instruction("上周销售额", "已手动停止", True) + assert with_base == ("这个工作流上一次没有给出可用的结果:已手动停止。\n\n请针对这个原因改它(比如调大最大步数、改正工具参数、" + "换个查询方式),改完继续回答原来的问题:上周销售额") + assert turn.user_retry_instruction("上周销售额", "已手动停止", False) == turn.retry_instruction( + "上周销售额", "已手动停止", False) # -------------------------------------------------------------------------- @@ -172,12 +164,13 @@ async def _run(output: dict, graph: dict, code: str) -> str: return run.id -def test_the_frontend_retries_an_error_the_server_says_to_retry(): - """问数据页照服务端给的 next 走:error 操作带 next=retry(只写了思考、一个操作都没给)时带着原因重来一次,不再直接报 - 「助手本轮未完成」。读 chat.ts 里 error 那一支(行为另由 scripts/check-chat.mjs 的 emptyretry 一节在浏览器里守)。""" - branch = re.search(r"case 'error':(.*?)break\n", _ts(), re.S) - assert branch, "chat.ts 里找不到 error 那一支" - assert re.search(r"op\.next === 'retry'", branch.group(1)) and "retry(" in branch.group(1), branch.group(1) +def test_the_page_says_the_server_is_retrying_an_empty_generation(): + """只写了思考、一个操作都没给(error.next=retry)时,重来一次是服务端定的(chat/drive.py 照 next_step 走,问数据的一轮记一条 + turn.retry,kind=empty);页面照它把这一轮重置、写「助手上一次没有给出任何操作,正在重新生成工作流…」。读 chat.ts 里 + turn.retry 那一支(行为另由 tests/test_chat_turns.py 和 scripts/check-chat.mjs 的 emptyretry 一节守)。""" + branch = re.search(r"function retryPatch\((.*?)\n\}", _ts(), re.S) + assert branch, "chat.ts 里找不到 retryPatch" + assert re.search(r"d\.kind === 'empty' \? CHAT_RETRY_TEXT\.empty", branch.group(1)), branch.group(1) async def test_answer_ops_says_to_retry_an_empty_generation(client, monkeypatch): diff --git a/backend/tests/test_chat_turns.py b/backend/tests/test_chat_turns.py index 59e6a226..36f554ff 100644 --- a/backend/tests/test_chat_turns.py +++ b/backend/tests/test_chat_turns.py @@ -834,3 +834,26 @@ def test_attempt_records_say_what_the_page_used_to_say(): legacy = record.attempt_record(row(status="error", error="x"), {"failure": {"title": "操作超时", "reason": "查询超时。"}}, n=1) assert legacy["summary"] == "操作超时:查询超时。" assert record.attempt_record(row(answer="字" * 5000), {}, n=1)["answer"].endswith("…") + + +async def test_an_empty_generation_is_retried_once_on_the_server(client, monkeypatch): + """助手只写了思考、一个操作都没给(error.next=retry):服务端带着原因重来一次(turn.retry kind=empty),不用页面在。""" + import app.api.copilot as copilot + from tests.test_usage_meter import Scripted + + reply = json.dumps({"op": "reply", "text": "上周没有华中区的数据"}, ensure_ascii=False) + model = Scripted(model="claude-opus-5", replies=[[""], [reply]]) + + async def _model(*_a, **_k): + return model, "claude-opus-5" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + conversation_id = await _conversation(client) + turn_id = await _ask(client, conversation_id, "上周华中区销售额") + await _done(turn_id) + events = await _events(turn_id) + [retry] = [e["data"] for e in events if e["type"] == "turn.retry"] + assert retry["kind"] == "empty" and retry["reason"] == "模型没有给出任何修改,也没有说明原因" + row = await _row(turn_id) + assert row.status == "done" and row.answer == "上周没有华中区的数据" and row.meta["noQuery"] is True + assert row.meta["usage"]["copilot"]["calls"] == 2, "两次建图都是这一轮花的" diff --git a/frontend/src/api/chatTurns.ts b/frontend/src/api/chatTurns.ts new file mode 100644 index 00000000..e3990c9b --- /dev/null +++ b/frontend/src/api/chatTurns.ts @@ -0,0 +1,153 @@ +/** + * 问数据的一轮在服务端跑(docs/design/orchestration-round/00-总方案.md §1;服务端 backend/app/api/chat_turns.py),挂在 + * api.chatTurns 上。页面只发起、只看:建图、运行、复核、按复核重来、落库都在服务端,页面断了这一轮也会跑完。 + * + * - start:发起一轮(新问,或者对已有的一轮重试、重新运行、放宽步数、跑一下、继续运行、接着收尾),202 {turn_id, turn}; + * - get:这一轮的任务现在怎样;cancel:停止(取消生成、取消运行); + * - streamChatTurn:这一轮的事件日志(SSE)。先回放 seq > after 的,再推实时;断了按收到的最后一个 seq 自动重连,补齐、 + * 不重复;45 秒什么都没收到(服务端每 15 秒有一拍心跳)就当连接死了,重连。服务端说这一轮结束了(stream.end)才停。 + * + * 请求函数从 ./http 导入,不从 ./client 导入:client.ts 要导入这个文件把它挂到 api 上,反过来导入就成了循环。 + */ +import type { ConversationTurn } from '../types' +import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, failure, get, parseBody, post, report } from './http' + +/** 发起一轮的方式(服务端 app/chat/job.MODES) */ +export type ChatTurnMode = 'ask' | 'retry' | 'rerun' | 'max_steps' | 'run' | 'continue' | 'resume' + +export interface ChatTurnStart { + conversation_id: string + question?: string + /** 只查这几个数据源。新问不给是不限;重试不给沿用这一轮的,空数组是不限 */ + datasource_ids?: string[] + retry_of?: string + mode?: ChatTurnMode + max_steps?: number +} + +/** 事件日志里的一条(服务端 app/chat/job.py 的模块说明列了各种 type) */ +export interface ChatTurnEvent { + seq: number + type: string + data: Record + ts: number +} + +/** 实时流结束时的情况:status 是服务端说的任务状态(stream.end);error 是这一轮读不了(不存在、没权限) */ +export interface ChatTurnStreamEnd { + status?: string + error?: string + httpStatus?: number +} + +const turn = (id: string) => `/chat/turns/${encodeURIComponent(id)}` + +export const chatTurns = { + start: (body: ChatTurnStart) => post<{ turn_id: string; turn: ConversationTurn | null }>('/chat/turns', body), + get: (id: string) => get<{ id: string; conversation_id: string; job: ConversationTurn['job']; turn: ConversationTurn }>(turn(id)), + cancel: (id: string) => post<{ ok: boolean }>(`${turn(id)}/cancel`, {}), +} + +/** 多久什么都没收到就当连接死了(服务端心跳 15 秒一拍) */ +const STALL_MS = 45_000 +/** 连不上时重连的退避上限 */ +const RETRY_MAX_MS = 5000 + +/** + * 订阅这一轮的事件日志。返回取消函数(关掉连接、不再重连)。 + * + * 连接正常结束却没收到 stream.end(服务端重启、代理断开):按最后一个 seq 马上重连;连不上(网络断了、服务端在重启)按 + * 退避重试,直到服务端回来——这一轮在服务端照跑,回来之后补齐中间漏掉的那段 + */ +export function streamChatTurn( + turnId: string, + after: number, + onEvent: (event: ChatTurnEvent) => void, + onEnd: (end: ChatTurnStreamEnd) => void, +): () => void { + let closed = false + let lastSeq = after + let failures = 0 + let controller: AbortController | null = null + let stall: ReturnType | undefined + let again: ReturnType | undefined + + const finish = (end: ChatTurnStreamEnd) => { + if (closed) return + closed = true + clearTimeout(stall) + onEnd(end) + } + const arm = () => { + clearTimeout(stall) + stall = setTimeout(() => controller?.abort(), STALL_MS) + } + + const connect = async () => { + if (closed) return + controller = new AbortController() + /** 这一次连接收到了事件(不只是心跳) */ + let got = false + let alive = false + try { + arm() + const path = `${turn(turnId)}/stream?after=${lastSeq}` + const res = await fetch(BASE + path, { headers: { Accept: 'text/event-stream', ...actorHeader() }, signal: controller.signal }) + if (!res.ok || !res.body) { + const err = failure(res.status, res.statusText, await res.text().catch(() => ''), path) + // 这一轮读不了(删了、不是你的):再连也一样 + if (err.kind !== 'network' && res.status < 500) { + finish({ error: err.message, httpStatus: res.status }) + return + } + throw err + } + report(true) + alive = true + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + arm() + buffer += decoder.decode(value, { stream: true }) + let idx: number + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const frame = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + if (!frame.startsWith('data: ')) continue // 心跳(: ping) + let event: any + try { event = parseBody(frame.slice(6)) } catch { continue } + if (!event || typeof event !== 'object') continue + if (event.type === 'stream.end') { + finish({ status: event.data?.status ?? event.status }) + controller.abort() + return + } + if (typeof event.seq !== 'number' || event.seq <= lastSeq) continue // 重连时重叠的那段只要一份 + lastSeq = event.seq + got = true + if (!closed) onEvent(event as ChatTurnEvent) + } + } + } catch (e: any) { + if (closed) return + if (e instanceof TypeError) report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) })) + } + if (closed) return + clearTimeout(stall) + // 收到了东西就马上接着连;只有心跳的稍等一下;连不上的按退避 + failures = got ? 0 : alive ? 0 : failures + 1 + const delay = got ? 0 : alive ? 300 : Math.min(300 * 2 ** failures, RETRY_MAX_MS) + again = setTimeout(() => void connect(), delay) + } + void connect() + + return () => { + closed = true + clearTimeout(stall) + clearTimeout(again) + controller?.abort() + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 13c722d2..b4fed479 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -39,6 +39,8 @@ import { caliberLedger } from './caliberLedger' import { capabilities, endpoints, notify } from './capabilities' // 普通用户建应用(typesBlueprint.ts 是接口约定) import { blueprints, files, personal, templates } from './blueprints' +// 问数据的一轮在服务端跑(编排完善第 1 波):发起、看、停,实时流在 chatTurns.ts 的 streamChatTurn +import { chatTurns } from './chatTurns' // 请求层搬到了 http.ts(见那边的说明)。原先从这里导出的照旧从这里导出,调用方不用改; // 请求函数也一并转出,给不在 api/ 下、又想直接发请求的地方用。api/ 下的命名空间文件请直接从 http.ts 导入 @@ -986,6 +988,9 @@ export const api = { templates, files, personal, + + // 问数据的一轮(服务端跑整轮:发起、看、停) + chatTurns, } /** diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index c50c3252..88511b76 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -1,17 +1,18 @@ import { create } from 'zustand' -import { ApiError, api, streamCopilot, streamRun } from '../api/client' +import { ApiError, api, streamRun } from '../api/client' +import { streamChatTurn, type ChatTurnEvent, type ChatTurnStart } from '../api/chatTurns' import type { CopilotOp, FixKind, RunFinal } from '../run/decode' -import { copilotContext, copilotLook, decodeRun, looksOf } from '../run/decode' +import { copilotContext, copilotLook, decodeRun } from '../run/decode' import { CHAT_BLUEPRINT_TEXT, CHAT_RETRY_TEXT, LEDGER_TEXT, LOOK_TEXT, REJECTED_TEXT } from '../lib/terms' import type { LookEvent } from '../typesApp' -import { errorMessage, humanizeError, isNetworkError } from '../lib/errors' -import { guardReview, hasEvidence } from '../lib/evidence' +import { errorMessage, humanizeError } from '../lib/errors' +import { hasEvidence } from '../lib/evidence' import { explainRunError, explainStartError } from '../lib/explain' import { parseServerTime } from '../lib/format' import { useCatalog } from './catalog' import { useConversations } from './conversations' import type { - ConversationTurn, GraphSpec, ReviewResult, Run, RunEvent, ValidationIssue, + ChatTurnJob, ConversationTurn, GraphSpec, ReviewResult, Run, RunEvent, } from '../types' /** @@ -24,13 +25,19 @@ import type { * 一轮对话 = 一次「建图 → 跑图 → 出结果」。图对用户是隐藏的(想看可以展开), * 但它仍然是真实存在的工作流,跑完能存成模板、能在画布里继续改。 * + * **一轮在服务端跑,这里只发起、只播放**(docs/design/orchestration-round/00-总方案.md §1)。以前建图、发起运行、复核、 + * 按复核重来、落库都是这一页在推:网络一抖,建图那条流就断了、建了一半的图丢掉;页面一关,这一轮停在 running,没人复核、 + * 不会重来,15 分钟后才被判成断了。现在一轮是服务端的一个后台任务(backend/app/chat/job.py):这里 POST /api/chat/turns 发起, + * 再订阅这一轮的事件日志(api/chatTurns 的 streamChatTurn:先回放、再接实时,断了按 seq 自动补齐),收到什么照原来的渲染 + * 逻辑画——画布边收边长、过程步骤、答案、复核说明、用量。要不要运行、要不要重来、什么时候交付都是服务端定的,这一页不再判; + * conversation_turns 那一行也由服务端写,这里不再写这一轮的结局。刷新、关掉再打开,进行中的轮次从这一次尝试的第一条 + * 接着播(ConversationTurn.job.attempt_seq)。运行自己的事件仍经运行的实时流来(watch),只用来画过程。 + * * 这里**只存原始流**(Copilot 的操作、运行的事件),不在 store 里翻译。 * 翻译归 run/decode.ts,全站一份;store 里再译一遍的结果是同一次运行在 * 对话页和画布上讲出两个不同的故事,而用户没法判断哪个是真的。 * - * 轮次按会话分桶存放。之前是单个 turns 数组,于是"对话"只存在于内存里: - * 刷新页面就全没了,而每轮又各自从零建图、互不知情。现在每一轮同时写进 - * 后端的 conversation_turns,刷新能恢复,下一轮也拿得到上一轮的上下文。 + * 轮次按会话分桶存放,刷新从后端的 conversation_turns 恢复。 * * **忙不忙按会话算,由轮次的相位推出来**,store 上不再有全局的 busy / cancel。 * 全局只有一份的时候,会话 A 在跑、切到 B,B 的按钮就成了「停止」,点下去 @@ -99,7 +106,7 @@ export interface ScopeSource { } /** - * 用户点「重试这一轮」之前的那一次。 + * 用户点「重试这一轮」之前的那一次(服务端归档,backend/app/chat/record.attempt_record)。 * * 用户主动重试不能把上一次抹掉:那一次的答案可能只是有缺口、并非全错, * 新的一次也未必更好,两份要能对照。 @@ -111,13 +118,29 @@ export interface Attempt { summary: string detail?: string runId?: string | null - /** 那一次交出来的答案(截断到 ATTEMPT_ANSWER_CAP) */ + /** 那一次交出来的答案(截断到 4000 字) */ answer?: string at: number + /** 失败的那一次的原因(服务端只记原因,说法在这里按 lib/explain 讲,见 causeFailure) */ + cause?: TurnCause } /** - * 刷新之后还得在的可信度信息,存在 conversation_turns.meta。 + * 这一轮为什么失败(服务端 backend/app/chat/record.py 的 cause):服务端只记发生了什么,说法在界面层按 lib/explain 讲 + * (causeFailure),和记录页、画布右栏同一套。老轮次记的是翻好的 failure,照旧认 + */ +export type TurnCause = + | { kind: 'refused'; message?: string; code?: string | null } + | { kind: 'copilot'; message?: string; hint?: string | null; detail?: string | null } + | { kind: 'blocked'; issues?: string[]; self_checked?: boolean } + | { kind: 'start'; message?: string; code?: string | null; status?: number | null } + | { kind: 'run'; error?: string; detail?: string | null } + | { kind: 'gone' } + | { kind: 'build_interrupted' } + | { kind: 'crash'; message?: string } + +/** + * 刷新之后还得在的可信度信息,存在 conversation_turns.meta(服务端写,backend/app/chat/record.py)。 * * 出具档位、运行类别、「这一条没查库」以前都只活在内存里,刷新就没了——而历史 * 会话恰恰是事后复盘、转发结论时最常看的地方。老轮次把它挂在 review.meta 下, @@ -133,23 +156,28 @@ export interface TurnMeta { issuance?: Record noQuery?: boolean outcome?: 'cancelled' | 'suspended' - /** 运行被人驳回结束(outcome 是 cancelled):副标题那一句,刷新之后照样写「被驳回」,不退回成「你已停止本轮」 */ - rejected?: Rejected + /** + * 运行被人驳回结束(outcome 是 cancelled):服务端记 run.cancelled 带的谁、哪一步、备注(REJECTED_TEXT.sub 拼成一句); + * 老轮次记的是拼好的那一句(sub)。刷新之后照样写「被驳回」,不退回成「你已停止本轮」 + */ + rejected?: Partial & { actor?: string; label?: string; note?: string; decision?: string } + /** 老轮次(页面写的):翻好的失败说明 */ failure?: Failure + /** 服务端写的:失败的原因 */ + cause?: TurnCause attempts?: Attempt[] /** 这一轮从开问到交付的墙钟毫秒 */ ms?: number /** 查了几次库 */ queries?: number /** - * 答案没取全。不记下来的话,刷新之后只能靠「恰好 2000 字」去猜:多个成果键拼起来的 - * 答案比 2000 长,猜不中,「刷新后会自动补全」的承诺就落空了 + * 答案没取全(老轮次:以前页面落库时用的是事件里截断过的那份)。服务端落库读的是完整成果,新轮次不会有 */ clipped?: 'partial' | 'lost' /** 这一问限定了只查哪些数据源 */ scope?: ScopeSource[] /** 这一轮以助手反问收尾(answer 列里是助手问的那句话,不是答案):刷新之后照样标「助手反问」,不当成没查库的回答 */ - asked?: true + asked?: true | { question?: string; options?: string[] } /** 这一轮助手查看过的表(look 事件原样,look §2.5.8):下一轮服务端据此写「上一轮查看过」,追问时直接点名查看 */ looks?: LookEvent[] /** @@ -157,10 +185,7 @@ export interface TurnMeta { * 运行的成果里:有这个记号的轮次恢复时要回运行那里取一次 */ evidence?: boolean - /** - * 这份 meta 写下的时刻(本机时钟)。断在建图阶段的轮次按它和 created_at 里晚的那个算 - * 有多久没动静:后端按 updated_at 算,而重试一轮昨天的问题时 created_at 是昨天的 - */ + /** 这份 meta 写下的时刻 */ at?: number /** 这一轮的花费:助手建图、运行、复核各一份(next-round 细案 01 做法 3),见 TurnUsage */ usage?: TurnUsage @@ -194,7 +219,7 @@ export interface TurnUsage { export interface ChatTurn { id: string - /** 后端 conversation_turns 里的 id。回填靠它 */ + /** 后端 conversation_turns 里的 id(也是服务端这一轮的任务 id)。发起请求还在路上时为 null */ serverId: string | null question: string phase: Phase @@ -224,10 +249,12 @@ export interface ChatTurn { startedAt: number /** 交付、失败、取消或中断的时刻。计时器停在这里 */ endedAt?: number - /** 已收到的最大事件 seq。重新接流时要带上,否则后端把历史整条重推 */ + /** 已收到的最大运行事件 seq。重新接运行的流时要带上,否则后端把历史整条重推 */ lastSeq: number - /** 这一轮自己的取消句柄。放 turn 上而不是 store 上:store 单值会被 - * 下一轮 ask 覆盖,上一轮的 WebSocket 就再也关不掉了 */ + /** 已收到的最大这一轮事件 seq(服务端的事件日志,和运行事件的 seq 是两套) */ + turnSeq?: number + /** 关掉这一轮在这一页上开着的流(这一轮的事件日志、运行的实时流)。放 turn 上而不是 store 上:store 单值会被 + * 下一轮 ask 覆盖,上一轮的流就再也关不掉了。只是不看了,服务端那一轮照跑;要停用 stop */ cancel: (() => void) | null /** 事件之外知道的结局(stream.end、GET 运行查到的状态),交给 decodeRun 收尾 */ final?: RunFinal @@ -245,21 +272,20 @@ export interface ChatTurn { hydrated?: 'pending' | 'done' /** 跑完之后的复核结论。null = 没复核过,和「复核过、没问题」不是一回事 */ review?: ReviewResult | null - /** 复核原样(含 verdict='ok'),落库时和 meta 一起写回 review 字段 */ + /** 复核原样(含 verdict='ok') */ reviewRaw?: ReviewResult | null /** 被复核重写之前的答案。改写是有损的,原件得留着让用户能对照 */ rawOutput?: Record | null - /** 这一轮自动重建过几次图。上限 1——第二次还不行多半是问题本身没说清, - * 该让用户介入,而不是替他一遍遍烧钱 */ + /** 这一轮自动重建过几次图(服务端的 turn.retry)。上限 1——第二次还不行多半是问题本身没说清 */ attempt?: number /** 用户点「重试」之前的各次尝试 */ attempts?: Attempt[] /** - * 答案不全:partial = 这次只拿到了事件里那份截断的,完整版要回源; + * 答案不全(老轮次):partial = 当时只拿到了事件里那份截断的,完整版要回源; * lost = 旧版本保存时就被切在 2000 字,对应的运行也删了,补不回来 */ clipped?: 'partial' | 'lost' - /** 刷新前落库的查库次数和耗时(事件没取回来时头部小标签靠它) */ + /** 落库的查库次数和耗时(事件没取回来时头部小标签靠它) */ meta?: TurnMeta | null /** * 模型此刻在出字:只记写了多少、思考想到哪一句,原文不存也不落库。答案要等 @@ -271,10 +297,11 @@ export interface ChatTurn { /** 这一轮到目前为止的助手建图、复核、被换掉的运行的用量(当前这次运行的在 run.usage),见 TurnUsage */ usage?: TurnUsage /** - * 助手问回来的那句话(N11):这一轮到此结束,问题作为这一轮的内容显示、进历史,输入框提示回答它;用户的回答就是下一轮。 - * 以前这里没有分支:只问不改的一轮流结束时既没有 final 也没有 reply,这一轮一直停在「正在理解需求」 + * 助手问回来的那句话(N11):这一轮到此结束,问题作为这一轮的内容显示、进历史,输入框提示回答它;用户的回答就是下一轮 */ asked?: string + /** 这一轮在服务端的任务(从库里恢复时带来的):进行中的从它的 attempt_seq 接着播 */ + job?: ChatTurnJob | null } export type LoadState = 'loading' | 'ready' | 'error' @@ -306,9 +333,9 @@ interface ChatState { * scope 不给就用这个会话眼下的范围 */ ask: (conversationId: string, question: string, opts?: { scope?: ScopeSource[] }) => boolean - /** 只停这个会话自己正在跑的那一轮 */ + /** 只停这个会话自己正在跑的那一轮(服务端那一轮连同它的运行一起停) */ stop: (conversationId: string) => void - /** 审批完成后重新接上运行 */ + /** 审批完成后重新接上运行的实时流 */ reattach: (conversationId: string, turnId: string) => Promise /** 历史轮次的「执行过程」:第一次点去取事件,之后是展开 / 收起 */ toggleSteps: (conversationId: string, turnId: string) => Promise @@ -317,7 +344,7 @@ interface ChatState { /** 跑挂了或被服务重启打断:从断点接着跑。成功接上返回 true,被拒时抛出后端的原因 */ continueTurn: (conversationId: string, turnId: string) => Promise /** - * 用户主动重试这一轮:上一次折叠留档,新的一次接在同一轮里。 + * 用户主动重试这一轮:上一次由服务端折叠留档,新的一次接在同一轮里。 * maxSteps:把图里 agent 的步数上限调到这个值,原图重跑(「放宽步数重跑」); * rerun:原图原样重跑(取消了、中断了的那种,不需要改); * scope:换一个数据源范围重来(限定的库不在了),不给就沿用这一轮的 @@ -326,7 +353,7 @@ interface ChatState { conversationId: string, turnId: string, opts?: { maxSteps?: number; rerun?: boolean; scope?: ScopeSource[] }, ) => void - /** 向后端核对一轮历史:真实状态、完整答案、档位。只做一次 */ + /** 向后端核对一轮历史:真实状态、完整答案、档位;服务端还在跑的接着播。只做一次 */ hydrate: (conversationId: string, turnId: string) => Promise /** 上次核对撞上后端出错(unknown)的轮次,再核对一次 */ recheck: (conversationId: string, turnId: string) => Promise @@ -352,15 +379,13 @@ const PHASE_TEXT: Record = { unknown: '无法确认本轮的当前状态', } +/** 复核那一步的进度 */ +const REVIEWING = '正在核对结果…' + const EMPTY: ChatTurn[] = [] -/** 落库成果的上限。只是防跑飞,不是显示上限 */ -const ANSWER_CAP = 20_000 - -/** 等复核的上限。超了就先把原答案交出去,说明后补不了就算了(复核这一次的用量晚到了照记,见 settle) */ -const REVIEW_TIMEOUT_MS = 25_000 -/** 实际等多久:开发期检查脚本可以调短它(window.__chatReviewWait),不用真等 25 秒 */ -let reviewWaitMs = REVIEW_TIMEOUT_MS +/** 服务端这一轮的任务还在跑(排队、进行中) */ +const LIVE_JOB = new Set(['queued', 'running']) /** * 旧版本落库时答案被切在这个长度:run.finished 事件里的 output 按字典每个值 @@ -379,16 +404,6 @@ function atClip(s: string): boolean { return n === LEGACY_CLIP } -/** - * 还停在 running 的轮次、又没有运行:断在建图阶段了。建图跟着页面那条流走,页面一关 - * 就停;这么久还没动静就当它断了(和后端 conversations.BUILD_STALE 一致)。「动静」 - * 按最后一次落库算,见 TurnMeta.at - */ -const BUILD_STALE_MS = 15 * 60_000 - -/** 留档的旧答案存多少。它是用来对照的,不是用来读全文的——全文在运行记录里 */ -const ATTEMPT_ANSWER_CAP = 4000 - /** 事件流里的增量:不落库,decodeRun 一开头就丢掉;存进 events 只会让每条都重解码整个会话 */ const EPHEMERAL = new Set(['llm.token', 'llm.thinking.delta']) @@ -400,7 +415,6 @@ const PEN_MS_REDUCED = 1000 const REPLAY_SKIP = new Set(['run.finished', 'run.failed', 'run.cancelled', 'run.interrupted', 'log']) type Patch = (fn: (t: ChatTurn) => Partial) => void -type Save = (body: Record) => Promise type SetState = (fn: (s: ChatState) => Partial | ChatState) => void /** 会话此刻被哪一轮占着。按相位推,不另记一份——另记的那份迟早和相位对不上 */ @@ -425,11 +439,7 @@ export function stepsVisible(t: ChatTurn): boolean { } /** - * 成果 dict 压成一句话,存进 conversation_turns.answer。 - * - * 它有两个去处:刷新页面后重建界面,以及下一轮拼进 Copilot 的上下文。 - * 后者决定了这里不能只存个 "[object Object]"——模型要靠它知道上一轮 - * 答了什么,才接得住"再按月份拆一下"。 + * 成果 dict 压成一句话(同服务端 chat/record.answer_text)。老轮次补全截断的答案时拿它比对 */ function answerText(output: Record | null | undefined): string { if (!output) return '' @@ -438,32 +448,16 @@ function answerText(output: Record | null | undefined): string { if (key.startsWith('_') || value == null || value === '') continue parts.push(typeof value === 'string' ? value : JSON.stringify(value)) } - // 喂给下一轮的那份另有更狠的截断(HISTORY_ANSWER_CHARS),两回事。 - // 这里的上限只是防跑飞的护栏,真切到了必须说出来 - const text = parts.join('\n') - return text.length > ANSWER_CAP - ? text.slice(0, ANSWER_CAP) + `\n\n…(内容过长,已截断,完整内容请查看运行记录)` - : text + return parts.join('\n') } -/** 事件里那份 output 看起来是不是被切过:老后端不带 output_truncated,只能看长度 */ -function looksClipped(output: Record | null | undefined): boolean { - if (!output) return false - return Object.values(output).some((v) => typeof v === 'string' && atClip(v)) -} - -/** 出具信息存进 meta 前瘦一下身:无法回指的数字可能有上百个,横幅只列得下一行 */ +/** 出具信息瘦一下身:无法回指的数字可能有上百个,横幅只列得下一行 */ function slimIssuance(iss: any): Record | undefined { if (!iss || typeof iss !== 'object' || !iss.tier) return undefined const unmatched = Array.isArray(iss.unmatched_numbers) ? iss.unmatched_numbers.slice(0, 20) : undefined return { ...iss, ...(unmatched ? { unmatched_numbers: unmatched } : {}) } } -/** 查库次数:db_query 的每一次调用 */ -function countQueries(events: RunEvent[]): number { - return events.filter((e) => e.type === 'tool.start' && String(e.data?.tool ?? '').startsWith('db_query')).length -} - export function metaOf(t: ConversationTurn): TurnMeta | null { return t.meta && typeof t.meta === 'object' ? (t.meta as TurnMeta) : null } @@ -476,30 +470,6 @@ function reviewOf(t: ConversationTurn): ReviewResult | null { return rest as ReviewResult } -function buildMeta(t: ChatTurn): TurnMeta { - return { - v: 1, - runId: t.run?.id ?? null, - runClass: t.run?.run_class ?? t.meta?.runClass, - runStatus: t.runStatus, - issuance: slimIssuance(t.output?._issuance ?? t.rawOutput?._issuance) ?? t.meta?.issuance, - noQuery: t.noQuery || undefined, - outcome: t.phase === 'cancelled' ? 'cancelled' : t.phase === 'suspended' ? 'suspended' : undefined, - rejected: t.phase === 'cancelled' && t.rejected ? t.rejected : undefined, - failure: t.failure ?? undefined, - attempts: t.attempts?.length ? t.attempts : undefined, - ms: t.endedAt ? t.endedAt - t.startedAt : t.meta?.ms, - queries: t.events.length ? countQueries(t.events) : t.meta?.queries, - clipped: t.clipped, - scope: t.scope?.length ? t.scope : undefined, - asked: t.asked ? true : undefined, - evidence: hasEvidence(t.output) || t.meta?.evidence || undefined, - looks: lookList(t), - usage: usageOfTurn(t), - at: Date.now(), - } -} - const USAGE_KEYS = ['cost_usd', 'unpriced_calls', 'calls', 'unfinished_calls', 'input_tokens', 'output_tokens'] as const /** 两份用量相加(缺的当 0)。b 不是对象时原样交回 a */ @@ -527,7 +497,7 @@ export function usageOfTurn(t: ChatTurn): TurnUsage | undefined { return Object.keys(out).length ? out : undefined } -/** 当前这次运行换下来了(自动重来、用户点重试):它的用量并进 earlier,这一轮的花费不丢这一笔 */ +/** 当前这次运行换下来了(自动重来):它的用量并进 earlier,这一轮的花费不丢这一笔(同服务端 record.retire_run) */ function retireRun(t: ChatTurn): TurnUsage | undefined { const u = usageOfTurn(t) if (!u?.run) return u @@ -547,12 +517,6 @@ export function turnCost(u: TurnUsage | undefined): { usd: number; unpriced: num return { usd: Number(sum.cost_usd ?? 0), unpriced: Number(sum.unpriced_calls ?? 0), calls: Number(sum.calls ?? 0) } } -/** 这一轮的查看记录:操作流里有就取操作流的(重试会重新查看),恢复的历史轮次没有操作流,沿用 meta 里的 */ -function lookList(t: ChatTurn): LookEvent[] | undefined { - const looks = looksOf(t.ops) - return looks.length ? looks : t.meta?.looks?.length ? t.meta.looks : undefined -} - /** * 一行错误文案 → 结构化。后端的 detail 按约定已是人话,humanizeError 只做拆句和去类名。 * 运行本身的失败不走这里,走 runFailure @@ -598,10 +562,9 @@ function runFailure(error: string | null | undefined, detail?: string | null): F const lineOf = (f: Failure) => (f.reason ? `${f.title}:${f.reason}` : f.title) /** - * 发起运行那一下被拒(POST /runs 的 ApiError)的说明。绑定的工具在本机不存在(422 run_tool_missing) - * 按 lib/explain 讲,带上直达入口(数据源工具去数据页,自定义 / MCP 工具去工具页),和画布上 - * 发起被拒(run/RunControl)同一套;这时还没有运行 id,流里的报错块不会再拿原话认一遍, - * 所以入口得在这里就记进 failure。别的照旧按一句报错拆 + * 发起运行那一下被拒(服务端记的 start 原因:状态码、说法、机读码)的说明。绑定的工具在本机不存在(422 run_tool_missing) + * 按 lib/explain 讲,带上直达入口(数据源工具去数据页,自定义 / MCP 工具去工具页),和画布上发起被拒(run/RunControl) + * 同一套;这时还没有运行 id,流里的报错块不会再拿原话认一遍,所以入口得在这里就记进 failure。别的照旧按一句报错拆 */ function startFailure(e: unknown): Failure { const x = explainStartError(e) @@ -631,92 +594,96 @@ const SUSPENDED: Failure = { hint: '点击「继续运行」从断点继续;如需整轮重来,请点击「重新运行本轮」。', } -// ------------------------------------------------------------------------- -// 落库:同一轮的写入排队发,免得「运行中」比「完成」晚到、把它盖回去 -// ------------------------------------------------------------------------- - -/** 还没拿到 serverId 的新轮次:开轮请求在路上,写入要等它 */ -const serverIds = new Map>() -const saveChains = new Map>() - -function saver(conversationId: string, turnId: string): Save { - return (body) => { - const prev = saveChains.get(turnId) ?? Promise.resolve() - const next = prev.then(async () => { - const turn = findTurn(conversationId, turnId) - const serverId = turn?.serverId ?? (await serverIds.get(turnId)) ?? null - if (!serverId) return - try { - await api.conversations.patchTurn(conversationId, serverId, body) - } catch { /* 存不下不该影响正在进行的对话 */ } - }) - saveChains.set(turnId, next) - return next - } +/** 建图时服务重启(或者老轮次:页面被关掉):没有可恢复的断点 */ +const BUILD_INTERRUPTED: Failure = { + title: '上次未完成', + reason: '生成工作流时页面被关闭或服务重启,本轮没有可恢复的断点。', + hint: '点击「重试本轮」重新开始。', } -function findTurn(conversationId: string, turnId: string): ChatTurn | undefined { - return useChat.getState().byConversation[conversationId]?.find((t) => t.id === turnId) -} - -/** - * 在回收站里的会话只能翻看。从这里发起的运行左栏找不到、没人看着,却照样计费; - * 跑着的时候又不能彻底删除。按钮已经按这个关掉了,这里再守一道 - */ -const inTrash = (conversationId: string) => - useConversations.getState().trash.some((c) => c.id === conversationId) - -/** - * 连同 meta 和复核一起写。两者在后端都是整块替换。review 写 {} 而不是 null:null 等于 - * 「这次不改」,重试之后上一次的复核就留在库里,刷新后挂到新答案头上 - */ -function persist(conversationId: string, turnId: string, body: Record = {}) { - const turn = findTurn(conversationId, turnId) - if (!turn) return Promise.resolve() - return saver(conversationId, turnId)({ ...body, meta: buildMeta(turn), review: turn.reviewRaw ?? {} }) -} - -/** - * 一轮里「当前这一次」的异步流程:建图的 SSE、启动运行、事件流、复核。 - * - * 停止、重试、重新接流都会换一个 epoch,旧流程之后到的东西一律作废。不这样的话, - * 被停掉的 SSE 缓冲里还没派发完的 final 会把这一轮又启动起来;重试之后,上一次 - * 那条流晚到的事件会混进新的一次里。 - */ -interface Flow { - conversationId: string - turnId: string - patch: Patch - save: Save - persist: (body?: Record) => Promise - alive: () => boolean -} - -const epochs = new Map() - -function flowOf(conversationId: string, turnId: string): Flow { - const epoch = (epochs.get(turnId) ?? 0) + 1 - epochs.set(turnId, epoch) - const alive = () => epochs.get(turnId) === epoch - const patch = patcher(useChat.setState as SetState, conversationId, turnId) - const save = saver(conversationId, turnId) +/** 限定的库一个都不在了:补救是「不限数据源重试」,带着原范围重试只会再被拒一次 */ +function scopeGoneFailure(message: string): Failure { + const at = message.indexOf(':') return { - conversationId, - turnId, - alive, - patch: (fn) => { if (alive()) patch(fn) }, - save: (body) => (alive() ? save(body) : Promise.resolve()), - persist: (body) => (alive() ? persist(conversationId, turnId, body) : Promise.resolve()), + title: at > 0 ? message.slice(0, at) : message, + reason: at > 0 ? `${message.slice(at + 1).split('。')[0]}。` : undefined, + hint: '点击「不限数据源重试」;如仍需限定该数据源,请先前往「数据」页确认它存在且已启用。', + detail: message, + scopeGone: true, } } -/** 让这一轮手上所有的旧流程作废 */ -const retire = (turnId: string) => epochs.set(turnId, (epochs.get(turnId) ?? 0) + 1) +/** + * 服务端记的失败原因 → 界面上的说明(说法都在这里和 lib/explain,服务端不写人话标题)。scope 是这一问限定的数据源: + * 开流前被拒时认得出是不是「限定的库都不在了」(认码不认话;不带码的老说法退回按开头认) + */ +function causeFailure(cause: TurnCause, scope?: ScopeSource[]): Failure { + switch (cause.kind) { + case 'refused': { + const message = String(cause.message ?? '') + if (cause.code === 'datasource_scope_empty' || (scope?.length && message.startsWith('限定的数据源'))) { + return scopeGoneFailure(message) + } + return failureOf(message || '助手没有开始') + } + case 'copilot': + // 后端给的是三段:message 人话、hint 怎么办、detail 原始异常。原文收进可展开、可复制的技术细节 + return { + ...failureOf(cause.message || '生成失败'), + ...(cause.hint ? { hint: String(cause.hint) } : {}), + ...(cause.detail ? { detail: String(cause.detail) } : {}), + } + case 'blocked': + // 自查修正过(或重来一次)还是没过。校验消息是写给画布用户的("先拖一个节点进来"),直接甩给一个在问数据页 + // 打字的人毫无意义——先给一句他能照做的,原文留在技术细节里作线索 + return { + title: '未能生成可运行的工作流', + reason: cause.self_checked === false ? '已自动调整一次,生成的工作流仍未通过检查。' : '助手已按检查结果修正过,生成的工作流仍未通过检查。', + hint: '请补充描述后重新提问,写明要查询的数据库、时间范围和指标。', + detail: (cause.issues ?? []).join('\n') || undefined, + } + case 'start': + return startFailure(new ApiError(Number(cause.status ?? 0), String(cause.message ?? ''), { code: cause.code ?? undefined })) + case 'run': + return runFailure(cause.error ?? '', cause.detail ?? undefined) + case 'gone': + return GONE + case 'build_interrupted': + return BUILD_INTERRUPTED + case 'crash': + return failureOf(cause.message || '发生错误') + } + return failureOf('发生错误') +} + +/** 归档的那一次:失败的照 lib/explain 再讲一遍(服务端只记原话),别的照服务端写的那一句 */ +function withSummary(a: Attempt): Attempt { + if (a.outcome !== 'failed' || !a.cause) return a + const f = causeFailure(a.cause) + return { ...a, summary: lineOf(f), ...(f.detail && !a.detail ? { detail: f.detail } : {}) } +} + +const attemptsOf = (list: unknown): Attempt[] | undefined => + Array.isArray(list) && list.length ? (list as Attempt[]).map(withSummary) : undefined + +/** 被驳回的那一句:服务端记的是谁、哪一步、备注,按 REJECTED_TEXT.sub 拼;老轮次记的是拼好的那一句 */ +function rejectedOf(r: TurnMeta['rejected']): Rejected | null { + if (!r || typeof r !== 'object') return null + return { sub: typeof r.sub === 'string' && r.sub ? r.sub : REJECTED_TEXT.sub(r) } +} // ------------------------------------------------------------------------- // 从库里恢复 // ------------------------------------------------------------------------- +/** 服务端还在跑的这一轮,它在哪一步 → 这一页的相位和进度 */ +function livePhase(job: ChatTurnJob): { phase: Phase; status: string } { + if (job.stage === 'waiting') return { phase: 'waiting', status: PHASE_TEXT.waiting } + if (job.stage === 'run') return { phase: 'running', status: PHASE_TEXT.running } + if (job.stage === 'review') return { phase: 'running', status: REVIEWING } + return { phase: 'planning', status: PHASE_TEXT.planning } +} + function restoreTurn(t: ConversationTurn): ChatTurn { const meta = metaOf(t) const review = reviewOf(t) @@ -724,15 +691,20 @@ function restoreTurn(t: ConversationTurn): ChatTurn { // 老数据没有 meta:停止写的是 status=error + 「已取消」 const outcome = meta?.outcome ?? (t.status === 'error' && t.error === '已取消' ? 'cancelled' : undefined) const answer = t.answer ?? '' - // 老数据没有 meta.failure,error 列里是原始异常:有运行的按运行失败解释 + // 老轮次记的是翻好的 failure;服务端写的记原因(cause);再老的数据两样都没有,error 列里是原始异常:有运行的按运行失败解释 const failure = meta?.failure + ?? (meta?.cause ? causeFailure(meta.cause, meta.scope) : null) ?? (t.error && t.error !== '已取消' ? (runId ? runFailure(t.error) : failureOf(t.error)) : null) + const live = t.status === 'running' && t.job && LIVE_JOB.has(t.job.status) ? livePhase(t.job) : null let phase: Phase let status: string - if (t.status === 'running') { - // 上次停在半路:页面被关掉、服务重启、或者还在等审批。有运行就去问后端它 - // 现在怎样了;没有运行说明断在建图阶段,没有断点可续 + if (live) { + // 服务端还在跑:hydrate 从这一次尝试的第一条事件接着播 + ;({ phase, status } = live) + } else if (t.status === 'running') { + // 上次停在半路(服务重启后没接着收尾、或者这一波之前页面推的老轮次):有运行就请服务端接着收尾;没有运行说明断在 + // 建图阶段,没有断点可续 phase = runId ? 'checking' : 'error' status = runId ? PHASE_TEXT.checking : PHASE_TEXT.error } else if (t.status === 'error') { @@ -746,22 +718,18 @@ function restoreTurn(t: ConversationTurn): ChatTurn { status = PHASE_TEXT.done } - const buildInterrupted: Failure = { - title: '上次未完成', - reason: '生成工作流时页面被关闭或服务重启,本轮没有可恢复的断点。', - hint: '点击「重试本轮」重新开始。', - } const output: Record | null = answer ? { answer, ...(meta?.issuance ? { _issuance: meta.issuance } : {}) } : null const graphful = !!t.graph && ((t.graph as GraphSpec).nodes?.length ?? 0) > 0 + const rejected = phase === 'cancelled' ? rejectedOf(meta?.rejected) : null return { id: t.id, serverId: t.id, question: t.question, phase, - status, + status: phase === 'cancelled' && rejected ? REJECTED_TEXT.title : status, thinking: '', ops: [], events: [], @@ -770,9 +738,9 @@ function restoreTurn(t: ConversationTurn): ChatTurn { // 先给个壳:run_class 从 meta 来;usage、完整成果由 hydrate 回源补 run: runId ? ({ id: runId, run_class: meta?.runClass } as Run) : null, output, - error: phase === 'error' ? (failure ? lineOf(failure) : t.status === 'running' ? buildInterrupted.title : '') : '', - failure: phase === 'error' ? (t.status === 'running' ? buildInterrupted : failure) : phase === 'suspended' ? SUSPENDED : null, - rejected: phase === 'cancelled' ? meta?.rejected ?? null : null, + error: phase === 'error' ? (failure ? lineOf(failure) : t.status === 'running' ? BUILD_INTERRUPTED.title : '') : '', + failure: phase === 'error' ? (t.status === 'running' ? BUILD_INTERRUPTED : failure) : phase === 'suspended' ? SUSPENDED : null, + rejected, startedAt: Date.parse(t.created_at ?? '') || Date.now(), endedAt: meta?.ms != null ? (Date.parse(t.created_at ?? '') || 0) + meta.ms : undefined, lastSeq: 0, @@ -790,7 +758,7 @@ function restoreTurn(t: ConversationTurn): ChatTurn { review: review && review.verdict !== 'ok' ? review : null, reviewRaw: review, rawOutput: review?.original ? { answer: review.original } : null, - attempts: meta?.attempts, + attempts: attemptsOf(meta?.attempts), // 上次记下没取全的:运行还在就等 hydrate 补,运行没了就补不回来了。 // 老数据没记过,只能看是不是恰好切在 2000 字——运行也删了的,至少别装作完整 clipped: meta?.clipped === 'partial' ? (runId ? 'partial' : 'lost') @@ -799,11 +767,16 @@ function restoreTurn(t: ConversationTurn): ChatTurn { meta, scope: meta?.scope, asked: meta?.asked && answer ? answer : undefined, + usage: meta?.usage, + job: t.job ?? null, } } /** 恢复出来的这一轮要不要马上向后端核对(而不是等它滚进视口) */ function needsEagerHydrate(t: ChatTurn): boolean { + // 服务端还在跑:马上接着播 + if (t.job && LIVE_JOB.has(t.job.status) && BUSY_PHASES.has(t.phase)) return true + if (t.phase === 'waiting' && t.job && LIVE_JOB.has(t.job.status)) return true if (!t.run?.id) return false if (t.phase === 'checking' || t.phase === 'suspended') return true // 「接着跑」给不给取决于运行现在的状态;老数据没存过 @@ -815,6 +788,53 @@ function needsEagerHydrate(t: ChatTurn): boolean { && (t.clipped === 'partial' || (typeof t.output?.answer === 'string' && atClip(t.output.answer))) } +function findTurn(conversationId: string, turnId: string): ChatTurn | undefined { + return useChat.getState().byConversation[conversationId]?.find((t) => t.id === turnId) +} + +/** + * 在回收站里的会话只能翻看。从这里发起的运行左栏找不到、没人看着,却照样计费; + * 跑着的时候又不能彻底删除。按钮已经按这个关掉了,这里再守一道 + */ +const inTrash = (conversationId: string) => + useConversations.getState().trash.some((c) => c.id === conversationId) + +// ------------------------------------------------------------------------- +// 这一页上的「当前这一次」:播放这一轮的事件日志、接运行的实时流 +// ------------------------------------------------------------------------- + +/** + * 一轮里「当前这一次」在这一页上开着的流。 + * + * 停止、重试、重新接流都会换一个 epoch,旧的流之后到的东西一律作废:重试之后,上一次那条流晚到的事件不能混进新的一次里 + */ +interface Flow { + conversationId: string + turnId: string + patch: Patch + alive: () => boolean +} + +const epochs = new Map() + +function flowOf(conversationId: string, turnId: string): Flow { + const epoch = (epochs.get(turnId) ?? 0) + 1 + epochs.set(turnId, epoch) + const alive = () => epochs.get(turnId) === epoch + const patch = patcher(useChat.setState as SetState, conversationId, turnId) + return { conversationId, turnId, alive, patch: (fn) => { if (alive()) patch(fn) } } +} + +/** 让这一轮手上所有的旧流程作废 */ +const retire = (turnId: string) => epochs.set(turnId, (epochs.get(turnId) ?? 0) + 1) + +/** 正在播放的轮次:能从外面让它重新接上运行的实时流(审批过了) */ +interface Player { + flow: Flow + openRun: (runId: string, from?: number) => void + closeRun: () => void +} +const players = new Map() export const useChat = create((set, get) => ({ byConversation: {}, @@ -829,7 +849,7 @@ export const useChat = create((set, get) => ({ forget: (conversationId) => set((s) => { - s.byConversation[conversationId]?.forEach((t) => { retire(t.id); t.cancel?.() }) + s.byConversation[conversationId]?.forEach((t) => { retire(t.id); t.cancel?.(); players.delete(t.id) }) const { [conversationId]: _gone, ...rest } = s.byConversation const { [conversationId]: _state, ...states } = s.loadState return { byConversation: rest, loadState: states } @@ -860,22 +880,10 @@ export const useChat = create((set, get) => ({ ...(lastScope && !(conversationId in s.scopes) ? { scopes: { ...s.scopes, [conversationId]: lastScope } } : {}), } }) - // 内存里已有的那几轮没有并进来,下面对它们的 hydrate / persist 按 id 找不到,自然跳过 - const now = Date.now() - detail.turns.forEach((row, i) => { - const t = restored[i] + // 内存里已有的那几轮没有并进来,下面对它们的 hydrate 按 id 找不到,自然跳过 + for (const t of restored) { if (needsEagerHydrate(t)) void get().hydrate(conversationId, t.id) - // 断在建图阶段、早就没动静的:库里那行一直是 running,每次进来都当它还在跑, - // Copilot 的上下文也把它当成没答完的一轮漏掉。认定断了就写回去。 - // 动静按最后一次落库算:重试昨天那一轮时 created_at 是昨天,而它正在别的页面里建图 - // 后端判「断了」按 updated_at(conversations.BUILD_STALE),有就用同一只钟;老后端 - // 没有这一列,退到 created_at 和本机记下的 meta.at 里晚的那个 - const updated = parseServerTime(row.updated_at ?? null)?.getTime() - const at = updated ?? Math.max(parseServerTime(row.created_at ?? null)?.getTime() ?? 0, t.meta?.at ?? 0) - if (row.status === 'running' && !t.run && t.phase === 'error' && at && now - at > BUILD_STALE_MS) { - void persist(conversationId, t.id, { status: 'error', error: lineOf(t.failure ?? GONE) }) - } - }) + } } catch (e) { set((s) => ({ loadState: { ...s.loadState, [conversationId]: 'error' }, @@ -929,21 +937,11 @@ export const useChat = create((set, get) => ({ // 没取过的会话能走到这里,只可能是刚建的空会话:它没有历史可丢 loadState: state ? s.loadState : { ...s.loadState, [conversationId]: 'ready' }, })) - const flow = flowOf(conversationId, id) - useConversations.getState().touch(conversationId, question) - - // 先开一轮,拿到 serverId 再开始建图。顺序不能反:Copilot 那边要读 - // 这次会话的历史,而"这一轮"必须已经是 running 状态才不会被算进自己的上下文 - const started = api.conversations.startTurn(conversationId, question) - .then((turn) => { - patcher(set, conversationId, id)(() => ({ serverId: turn.id })) - return turn.id - }) - .catch(() => null) - serverIds.set(id, started) - - plan(flow, { question, instruction: question, attempt: 0, scope }) + void launchTurn(flowOf(conversationId, id), { + conversation_id: conversationId, question, + ...(scope.length ? { datasource_ids: scope.map((d) => d.id) } : {}), + }) return true }, @@ -954,41 +952,41 @@ export const useChat = create((set, get) => ({ if (!target) return retire(target.id) target.cancel?.() - // 上面只是把自己这头的事件流关了。运行在后端,断开订阅不会让它停: - // 以前点了「停止」,模型和 SQL 其实还在跑、还在计费,界面上却已经显示"已取消" - if (target.run?.id) { - void api.runs.cancel(target.run.id).catch(() => undefined) // 已经跑完会回 409,无妨 - } + players.delete(target.id) + // 上面只是把自己这头的流关了。这一轮在服务端跑,断开订阅不会让它停:停止要告诉服务端,它连同运行一起停。 + // 发起请求还在路上的(没有 serverId),launchTurn 拿到 id 后看到这一轮已作废,会补一个停止过去 + if (target.serverId) void api.chatTurns.cancel(target.serverId).catch(() => undefined) // 已经结束会回 409,无妨 patcher(set, conversationId, target.id)(() => ({ phase: 'cancelled', status: PHASE_TEXT.cancelled, cancel: null, error: '', failure: null, endedAt: Date.now(), writing: null, ...(target.run ? { runStatus: 'cancelled', final: { status: 'cancelled' } } : {}), })) - void persist(conversationId, target.id, { status: 'error', error: '已取消' }) }, runNow: (conversationId, turnId) => { const turn = findTurn(conversationId, turnId) - if (!turn?.graph || isBusy(get().byConversation[conversationId]) || inTrash(conversationId)) return + if (!turn?.graph || !turn.serverId || isBusy(get().byConversation[conversationId]) || inTrash(conversationId)) return const flow = flowOf(conversationId, turnId) flow.patch(() => ({ phase: 'running', status: PHASE_TEXT.running, pendingRun: false, startedAt: Date.now(), endedAt: undefined, })) - void launch(flow, turn.graph, turn.question) + void launchTurn(flow, { conversation_id: conversationId, retry_of: turn.serverId, mode: 'run' }) }, continueTurn: async (conversationId, turnId) => { // 从断点接着跑。**不带图**——问数据页的用户改不了图,这里就是"用原来的 // 配置重试失败的那一步",对超时、限流、网络抖、服务重启这类瞬时失败有用。 // 配置本身错了(模型 id 写错、缺必填输入)要到画布上改,那边的「接着跑」 - // 会把改过的图一起带上。 - // - // 接上之后 run.finished 照常触发 settle,复核层自动覆盖这一次 + // 会把改过的图一起带上。接上之后服务端照常等它跑完、复核、收尾 const turn = findTurn(conversationId, turnId) - if (!turn?.run || isBusy(get().byConversation[conversationId]) || inTrash(conversationId)) return false + if (!turn?.run || !turn.serverId || isBusy(get().byConversation[conversationId]) || inTrash(conversationId)) return false try { - await api.runs.continue(turn.run.id) + const res = await api.chatTurns.start({ conversation_id: conversationId, retry_of: turn.serverId, mode: 'continue' }) + const flow = flowOf(conversationId, turnId) + flow.patch(() => ({ phase: 'running', status: PHASE_TEXT.running, error: '', failure: null, job: res.turn?.job ?? null })) + play(flow, turn.serverId, Math.max(0, (res.turn?.job?.attempt_seq ?? 1) - 1)) + return true } catch (e) { // 409 的 detail 是人话(「这次运行已取消,不能接着跑…」),交给调用方弹出来。 // 顺手再核对一次:按钮是照着过时的状态给的,别让它留在那里被点第二次 @@ -996,8 +994,6 @@ export const useChat = create((set, get) => ({ void get().hydrate(conversationId, turnId) throw e } - await get().reattach(conversationId, turnId) - return true }, retryTurn: (conversationId, turnId, opts) => { @@ -1005,69 +1001,54 @@ export const useChat = create((set, get) => ({ const turn = turns?.find((t) => t.id === turnId) if (!turn || isBusy(turns) || inTrash(conversationId)) return turn.cancel?.() - // 服务重启挂起的旧运行没人会再去续它:不取消的话,它在记录里一直是「已挂起 · 可续跑」。 - // 停在审批上的不算——重试不会从那里发起 - if (turn.run?.id && turn.phase !== 'waiting' && (turn.phase === 'suspended' || turn.runStatus === 'interrupted')) { - void api.runs.cancel(turn.run.id).catch(() => undefined) - } const flow = flowOf(conversationId, turnId) - const base = turn.graph && (turn.graph.nodes?.length ?? 0) > 0 ? turn.graph : null - const record = attemptOf(turn) - const reason = record.detail ? `${record.summary}(${record.detail.slice(0, 300)})` : record.summary - const direct = !!base && !!(opts?.maxSteps || opts?.rerun) + const base = turn.graph && (turn.graph.nodes?.length ?? 0) > 0 + const mode = base && opts?.maxSteps ? 'max_steps' as const : base && opts?.rerun ? 'rerun' as const : 'retry' as const const scope = opts?.scope ?? turn.scope - - // 上一次原样留档,这一次从头记:旧的事件、答案、复核留在这里只会让人分不清 - // 哪段是最终那次 - flow.patch((t) => ({ - phase: direct ? 'running' : 'planning', - status: opts?.maxSteps ? `正在将步数上限放宽到 ${opts.maxSteps} 并重新运行…` - : direct ? '正在重新运行本轮…' : '正在按原问题重新生成…', - attempts: [...(t.attempts ?? []), record], - ops: [], events: [], run: null, output: null, review: null, reviewRaw: null, - rawOutput: null, failure: null, error: '', lastSeq: 0, final: undefined, - runStatus: undefined, clipped: undefined, pendingRun: false, steps: undefined, - hydrated: 'done', startedAt: Date.now(), endedAt: undefined, attempt: 0, cancel: null, - noQuery: false, thinking: '', writing: null, scope: scope?.length ? scope : undefined, - // 上一次的运行换下来了,它花的钱还算在这一轮里 - usage: retireRun(t), + // 先把相位摆出来;上一次怎么归档、这一次从哪开始,等服务端的 turn.attempt 来了照它重置 + flow.patch(() => ({ + phase: mode === 'retry' ? 'planning' : 'running', status: retryStatus(mode, opts?.maxSteps), + failure: null, error: '', pendingRun: false, hydrated: 'done', endedAt: undefined, cancel: null, + startedAt: Date.now(), writing: null, scope: scope?.length ? scope : undefined, })) - void flow.persist({ status: 'running', error: '', answer: '' }) - - if (direct && base) { - const graph = opts?.maxSteps ? withMaxSteps(base, opts.maxSteps) : base - flow.patch(() => ({ graph })) - void launch(flow, graph, turn.question) + if (!turn.serverId) { + // 发起那一下就没到服务端(这一轮在库里不存在):原样再问一次 + void launchTurn(flow, { + conversation_id: conversationId, question: turn.question, + ...(scope?.length ? { datasource_ids: scope.map((d) => d.id) } : {}), + }) return } - plan(flow, { - question: turn.question, attempt: 0, baseGraph: base, scope, - instruction: base - ? `这个工作流上一次没有给出可用的结果:${reason}。\n\n` - + `请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),` - + `改完继续回答原来的问题:${turn.question}` - : `${turn.question}\n\n(上一次没有成功:${reason}。请针对这个原因调整,重新给出完整的工作流。)`, + void launchTurn(flow, { + conversation_id: conversationId, retry_of: turn.serverId, mode, + ...(mode === 'max_steps' ? { max_steps: opts!.maxSteps } : {}), + ...(opts?.scope ? { datasource_ids: opts.scope.map((d) => d.id) } : {}), }) }, hydrate: async (conversationId, turnId) => { const turn = findTurn(conversationId, turnId) - const runId = turn?.run?.id - if (!turn || !runId || turn.hydrated) return + if (!turn || turn.hydrated) return + const patch = patcher(set, conversationId, turnId) + // 服务端还在跑这一轮(刷新、关掉再打开):从这一次尝试的第一条事件接着播 + if (turn.serverId && turn.job && LIVE_JOB.has(turn.job.status)) { + patch(() => ({ hydrated: 'done' })) + play(flowOf(conversationId, turnId), turn.serverId, Math.max(0, turn.job.attempt_seq - 1)) + return + } + // 库里那一行停在半路、服务端又没在跑它(服务重启后没接着收尾、这一波之前页面推的老轮次):请服务端接着等那次运行、 + // 照常复核收尾,不在这一页上自己收尾 + if (turn.phase === 'checking' && turn.serverId && turn.run?.id) { + patch(() => ({ hydrated: 'pending' })) + await resumeTurn(conversationId, turnId) + return + } + const runId = turn.run?.id + if (!runId) return // 核对期间用户点了重试或停止:拿回来的是上一次的状态,作废 const epoch = epochs.get(turnId) ?? 0 const stale = () => (epochs.get(turnId) ?? 0) !== epoch - const patch = patcher(set, conversationId, turnId) patch(() => ({ hydrated: 'pending' })) - /** - * 查到了库里那行还不知道的结局:内存里改,库里也改。只改内存的话那行永远是 - * running——每次进来都要再核对一遍,Copilot 的上下文把它当没答完的一轮漏掉, - * 运行一删它就只能退成「上次没有跑完」,连「已取消」都说不出来了 - */ - const conclude = (fields: Partial, error: string) => { - patch(() => fields) - void persist(conversationId, turnId, { status: 'error', error }) - } let run: Run try { run = await api.runs.get(runId) @@ -1075,33 +1056,10 @@ export const useChat = create((set, get) => ({ const cur = findTurn(conversationId, turnId) if (stale() || !cur) return if (e instanceof ApiError && e.status === 404) { - if (cur.phase === 'checking') { - conclude({ - hydrated: 'done', runStatus: 'gone', phase: 'error', status: PHASE_TEXT.error, - error: GONE.title, failure: GONE, - }, GONE.title) - return - } - // 没取全的答案,运行又删了:补不回来,但得说出来,也记下来 + // 没取全的答案,运行又删了:补不回来,但得说出来 const lost = cur.phase === 'done' && !cur.review?.answer && (cur.clipped === 'partial' || atClip(String(cur.output?.answer ?? ''))) patch((t) => ({ hydrated: 'done', runStatus: 'gone', clipped: lost ? 'lost' : t.clipped })) - if (lost) void persist(conversationId, turnId) - } else if (cur.phase === 'checking') { - // 后端出错、超时:不知道它现在怎样,但不能一直停在「正在核对」转圈——那之后 - // 再也没有人来核对(重连只在断开过之后才触发)。说清楚,给「重新核对」 - const said = errorMessage(e) - const raw = humanizeError(e).raw - const failure: Failure = { - title: PHASE_TEXT.unknown, - reason: `查询本轮运行状态时出错(${said})。运行可能仍在进行,也可能已结束。`, - hint: '请稍后点击「重新核对」;服务端重新连接后会自动再核对一次。', - // 原话已经在括号里的,技术细节不再重复一遍 - detail: raw && !said.includes(raw) ? raw : undefined, - } - patch(() => ({ - hydrated: undefined, phase: 'unknown', status: PHASE_TEXT.unknown, failure, error: lineOf(failure), - })) } else { patch(() => ({ hydrated: undefined })) // 已经有个结局摆着,下次再补 } @@ -1111,63 +1069,16 @@ export const useChat = create((set, get) => ({ const cur = findTurn(conversationId, turnId) if (!cur || stale()) return patch(() => ({ run, runStatus: run.status, final: { status: run.status, pending }, hydrated: 'done' })) - const unsettled = cur.phase === 'checking' || cur.phase === 'suspended' || cur.phase === 'error' - - // 这一轮其实还活着:接回去,别让它在历史里装死 - if (run.status === 'running' || run.status === 'queued') { - if (unsettled) { - const flow = flowOf(conversationId, turnId) - // 计时不从提问那天算:中断过的接着上次停下的地方走,停在半路的从运行开始算 - const spent = cur.endedAt ? Math.max(0, cur.endedAt - cur.startedAt) : null - const startedAt = spent != null ? Date.now() - spent - : parseServerTime(run.started_at ?? null)?.getTime() ?? cur.startedAt - flow.patch(() => ({ - phase: 'running', status: PHASE_TEXT.running, failure: null, error: '', startedAt, endedAt: undefined, - })) - watch(flow, run.id, cur.lastSeq, cur.question) - } + const settled = cur.phase === 'suspended' || cur.phase === 'error' + // 已经收过尾的这一轮其实又活了(别处接着跑了、通过了审批、跑完了):请服务端接着等它、照常复核收尾 + const revived = run.status === 'running' || run.status === 'queued' || (run.status === 'interrupted' && pending) + || run.status === 'succeeded' + if (settled && revived && cur.serverId) { + await resumeTurn(conversationId, turnId) return } - if (run.status === 'interrupted') { - if (pending) { - // 还在等人:库里那行保持 running 就是对的,它确实没结束 - patch(() => ({ phase: 'waiting', status: PHASE_TEXT.waiting, failure: null, error: '' })) - void useCatalog.getState().refreshApprovals() - } else if (cur.phase !== 'done' && cur.phase !== 'suspended') { - conclude({ phase: 'suspended', status: PHASE_TEXT.suspended, failure: SUSPENDED, error: '' }, PHASE_TEXT.suspended) - } - return - } - if (run.status === 'cancelled') { - if (cur.phase !== 'done' && cur.phase !== 'cancelled') { - // 被驳回只看服务端的 end_reason:核对时手上只有运行记录,副标题用报错原文(它就是给人看的那句) - const rejected = run.end_reason === 'rejected' ? { sub: run.error || REJECTED_TEXT.title } : null - conclude({ phase: 'cancelled', status: rejected ? REJECTED_TEXT.title : PHASE_TEXT.cancelled, failure: null, - error: '', rejected }, '已取消') - } - return - } - if (run.status === 'failed') { - if (cur.phase === 'error' && cur.failure) return - if (cur.phase !== 'done') { - const failure = runFailure(run.error || '') - conclude({ phase: 'error', status: PHASE_TEXT.error, failure, error: lineOf(failure) }, lineOf(failure)) - } - return - } - if (run.status !== 'succeeded') return - if (unsettled) { - // 跑完了,但交付没赶上(复核期间关了页面、服务重启):把这一轮补完, - // 走一遍和实时一样的复核与落库。相位停在「正在核对」而不是「运行中」:运行 - // 早就结束了,按实时算的计时会从提问那天一路数到现在(「72:00:01」) - const flow = flowOf(conversationId, turnId) - flow.patch(() => ({ phase: 'checking', status: '正在核对结果…', failure: null, error: '' })) - // 耗时按运行真正结束的时刻算:补交付可能发生在几天之后 - const endedAt = parseServerTime(run.finished_at ?? null)?.getTime() - void settle(flow, run.id, cur.question, run.output ?? null, { run, endedAt }) - return - } - // 已经交付过的:答案没取全就换成完整的,顺手把档位补回来 + if (run.status !== 'succeeded' || cur.phase !== 'done') return + // 已经交付过的:答案没取全就换成完整的(老轮次),顺手把档位补回来 const full = answerText(run.output) const stored = String(cur.output?.answer ?? '') const repaired = !cur.review?.answer && stored.length >= 200 @@ -1175,7 +1086,6 @@ export const useChat = create((set, get) => ({ const issuance = slimIssuance(run.output?._issuance) if (repaired) { patch(() => ({ output: run.output, clipped: undefined })) - void persist(conversationId, turnId, { answer: full }) } else if (hasEvidence(run.output) && !hasEvidence(cur.output) && !cur.review?.answer) { // 库里只存了答案的文字,逐段证据的标注(_evidence)和字段名都在运行的成果里: // 换回运行那份,刷新之后报告里的数字照样能点开。复核改写过的不换(有证据的不会被改写) @@ -1197,27 +1107,81 @@ export const useChat = create((set, get) => ({ reattach: async (conversationId, turnId) => { const turn = findTurn(conversationId, turnId) if (!turn?.run) return - // 等审批时的那条流后端不关;不先关掉它,恢复后的事件会从两条流各来一遍 - turn.cancel?.() - const flow = flowOf(conversationId, turnId) - // 计时接着上次停下的地方走,不从提问那一刻算:中断、失败之后隔了一夜才点 - // 「接着跑」,头部不该写「已运行 14 小时」。同一次会话里等审批的那段照算, - // 它是这一轮过程的一部分 - const spent = turn.endedAt ? Math.max(0, turn.endedAt - turn.startedAt) : 0 - const startedAt = turn.endedAt || turn.restored ? Date.now() - spent : turn.startedAt - // 上一轮的错要清掉:接着跑之后它还挂在那里,就成了一条已经不成立的红字 - flow.patch(() => ({ - phase: 'running', status: PHASE_TEXT.running, error: '', failure: null, startedAt, - final: undefined, runStatus: 'running', endedAt: undefined, cancel: null, writing: null, - })) - watch(flow, turn.run.id, turn.lastSeq, turn.question) + // 这一页正在播这一轮:审批过了,服务端接着等那次运行,这里把运行的实时流重新接上 + const player = players.get(turnId) + if (player?.flow.alive()) { + player.openRun(turn.run.id, turn.lastSeq) + return + } + // 没在播(老轮次、刷新前停在审批上的):请服务端接着等这次运行、照常收尾 + if (turn.serverId) await resumeTurn(conversationId, turnId) }, })) if (import.meta.env.DEV) { ;(window as any).__chat = useChat - // 检查脚本用:把等复核的上限调短(毫秒),量「复核晚于上限时用量照记」 - ;(window as any).__chatReviewWait = (ms: number) => { reviewWaitMs = ms } +} + +/** 用户点重试时先摆出来的那一句(服务端的 turn.attempt 来了照它再说一遍) */ +function retryStatus(mode: 'retry' | 'rerun' | 'max_steps' | string, maxSteps?: number): string { + return mode === 'max_steps' ? `正在将步数上限放宽到 ${maxSteps} 并重新运行…` + : mode === 'rerun' ? '正在重新运行本轮…' + : mode === 'retry' ? '正在按原问题重新生成…' + : mode === 'run' || mode === 'continue' || mode === 'resume' ? PHASE_TEXT.running + : PHASE_TEXT.planning +} + +/** + * 发起一轮(新问、重试本轮、重新运行、跑一下),拿到 id 就开始播。发起那一下就被拒(409 这段对话正忙、网络断了……): + * 说清楚,给「重试本轮」——服务端没有这一轮的话就原样再问一次 + */ +async function launchTurn(flow: Flow, body: ChatTurnStart) { + try { + const res = await api.chatTurns.start(body) + // 发起请求在路上时用户点了停止或重试:服务端那一轮已经起来了,得追一个停止过去 + if (!flow.alive()) { + void api.chatTurns.cancel(res.turn_id).catch(() => undefined) + return + } + flow.patch(() => ({ serverId: res.turn_id, job: res.turn?.job ?? null })) + play(flow, res.turn_id, Math.max(0, (res.turn?.job?.attempt_seq ?? 1) - 1)) + } catch (e) { + if (!flow.alive()) return + const failure = failureOf(e) + flow.patch(() => ({ + phase: 'error', status: PHASE_TEXT.error, error: lineOf(failure), failure, cancel: null, endedAt: Date.now(), + })) + } +} + +/** + * 请服务端接着等这一轮的那次运行、照常复核收尾(mode=resume),再接着播。核对撞上后端出错、超时:不知道它现在怎样, + * 但不能一直停在「正在核对」转圈——说清楚,给「重新核对」 + */ +async function resumeTurn(conversationId: string, turnId: string) { + const turn = findTurn(conversationId, turnId) + if (!turn?.serverId) return + const flow = flowOf(conversationId, turnId) + try { + const res = await api.chatTurns.start({ conversation_id: conversationId, retry_of: turn.serverId, mode: 'resume' }) + if (!flow.alive()) return + flow.patch(() => ({ hydrated: 'done', job: res.turn?.job ?? null })) + play(flow, turn.serverId, Math.max(0, (res.turn?.job?.attempt_seq ?? 1) - 1)) + } catch (e) { + if (!flow.alive()) return + const said = errorMessage(e) + const raw = humanizeError(e).raw + const failure: Failure = { + title: PHASE_TEXT.unknown, + reason: `查询本轮运行状态时出错(${said})。运行可能仍在进行,也可能已结束。`, + hint: '请稍后点击「重新核对」;服务端重新连接后会自动再核对一次。', + // 原话已经在括号里的,技术细节不再重复一遍 + detail: raw && !said.includes(raw) ? raw : undefined, + } + flow.patch(() => ({ + hydrated: undefined, phase: 'unknown', status: PHASE_TEXT.unknown, failure, error: lineOf(failure), + })) + } } /** 定点更新某个会话里的某一轮。认的是 turn id,所以切走看别的会话也不受影响 */ @@ -1257,35 +1221,6 @@ async function pendingApproval(runId: string): Promise { } } -/** 这一轮现在的样子,折成一条留档 */ -function attemptOf(t: ChatTurn): Attempt { - const n = (t.attempts?.length ?? 0) + 1 - const answer = answerText(t.output) || undefined - const base = { - n, at: t.endedAt ?? Date.now(), runId: t.run?.id ?? null, - answer: answer && answer.length > ATTEMPT_ANSWER_CAP ? answer.slice(0, ATTEMPT_ANSWER_CAP) + '…' : answer, - } - const signals = (t.review?.signals ?? []).map((s) => s.detail).join('\n') || undefined - // 被驳回的那次照实写谁在哪一步驳回(以前写「已手动停止」,用户其实没有停过它) - if (t.phase === 'cancelled' && t.rejected) { - const sub = t.rejected.sub && t.rejected.sub !== REJECTED_TEXT.title ? `:${t.rejected.sub}` : '' - return { ...base, outcome: 'rejected', summary: `${REJECTED_TEXT.title}${sub}` } - } - if (t.phase === 'cancelled') return { ...base, outcome: 'cancelled', summary: '已手动停止' } - if (t.phase === 'suspended') return { ...base, outcome: 'suspended', summary: '服务重启,运行中断' } - if (t.phase === 'error' || t.failure) { - const f = t.failure ?? failureOf(t.error || '发生错误') - return { ...base, outcome: 'failed', summary: lineOf(f), detail: f.detail } - } - if (t.review?.severity === 'broken') { - return { ...base, outcome: 'unusable', summary: `结论不可用:${t.review.note}`, detail: signals } - } - if (t.review?.severity === 'degraded') { - return { ...base, outcome: 'partial', summary: `存在缺口:${t.review.note}`, detail: signals } - } - return { ...base, outcome: 'done', summary: '已给出答案' } -} - /** * 图里 agent 节点的步数上限(取最大的那个)。没配的跟随设置里的默认值 fallback; * 以前画布自动写进去的 12 后端按「没配」处理(engine/guards.py),这里口径一致 @@ -1299,381 +1234,255 @@ export function agentSteps(graph: GraphSpec | null | undefined, fallback: number })) } -function withMaxSteps(graph: GraphSpec, steps: number): GraphSpec { +// ------------------------------------------------------------------------- +// 播放:服务端这一轮的事件日志 → 这一轮的样子 +// ------------------------------------------------------------------------- + +/** 这一次尝试开始了(turn.attempt):上一次的过程、答案、复核都清掉,归档照服务端给的 */ +function attemptReset(t: ChatTurn, d: Record, ts: number): Partial { + const mode = String(d.mode ?? 'ask') + const attempts = attemptsOf(d.attempts) ?? (mode === 'run' || mode === 'continue' || mode === 'resume' ? t.attempts : undefined) + const scope = Array.isArray(d.scope) && d.scope.length ? d.scope as ScopeSource[] : t.scope + const usage = d.usage && typeof d.usage === 'object' ? d.usage as TurnUsage : t.usage + const startedAt = typeof d.started_ms === 'number' ? d.started_ms : ts ? ts * 1000 : Date.now() + if (mode === 'continue' || mode === 'resume') { + // 接着等同一次运行:过程照旧留着,运行的流接着上次收到的地方。运行早就跑完了的(补交付)停在「正在核对」、计时停在 + // 运行结束的那一刻:按实时算会从提问那天一路数到现在(「72:00:01」) + const ended = typeof d.ended_ms === 'number' ? d.ended_ms : undefined + return { + phase: ended ? 'checking' : mode === 'resume' && t.phase === 'waiting' ? 'waiting' : 'running', + status: ended ? REVIEWING : PHASE_TEXT.running, + error: '', failure: null, final: undefined, endedAt: ended, writing: null, startedAt, attempts, scope, usage, + hydrated: 'done', + } + } + if (mode === 'run') { + // 只建了图、用户点「运行」:建图的过程照旧留着,只换上这一次的运行 + return { + phase: 'running', status: PHASE_TEXT.running, attempts, scope, usage, + events: [], run: null, output: null, review: null, reviewRaw: null, rawOutput: null, failure: null, error: '', + lastSeq: 0, final: undefined, rejected: null, runStatus: undefined, pendingRun: false, hydrated: 'done', + startedAt, endedAt: undefined, writing: null, noQuery: false, + } + } return { - ...graph, - nodes: graph.nodes.map((n: any) => (n.type === 'agent' - ? { ...n, data: { ...n.data, config: { ...(n.data?.config ?? {}), max_steps: steps } } } - : n)), + phase: mode === 'ask' || mode === 'retry' ? 'planning' : 'running', + status: mode === 'ask' ? PHASE_TEXT.planning : retryStatus(mode, Number(d.max_steps) || undefined), + attempts, scope, usage, + ops: [], events: [], run: null, output: null, review: null, reviewRaw: null, + rawOutput: null, failure: null, error: '', lastSeq: 0, final: undefined, rejected: null, + runStatus: undefined, clipped: undefined, pendingRun: false, steps: undefined, + hydrated: 'done', startedAt, endedAt: undefined, attempt: 0, + noQuery: false, thinking: '', writing: null, asked: undefined, + } +} + +/** 助手的一条操作:原样收下(思考增量并进上一条),照原来的样子更新进度。要不要运行、要不要重来都等服务端说 */ +function opPatch(t: ChatTurn, op: CopilotOp): Partial { + const last = t.ops[t.ops.length - 1] + const ops = op.op === 'thinking' && last?.op === 'thinking' + ? [...t.ops.slice(0, -1), { ...last, delta: String(last.delta ?? '') + String(op.delta ?? '') }] + : [...t.ops, op] + switch (op.op) { + case 'thinking': + return { ops, phase: 'planning', thinking: (t.thinking + (op.delta ?? '')).slice(-2000) } + case 'heartbeat': + // 查看进行中(looking)不是这一页的阶段:浮条直说「正在查看表结构…」 + return { ops, status: op.phase === 'looking' ? LOOK_TEXT.running + : PHASE_TEXT[(op.phase as Phase) ?? 'planning'] ?? '正在处理…' } + case 'look': + case 'context': { + // 查看结束:浮条换成查看了什么 + const look = op.op === 'look' ? copilotLook(op) : copilotContext(op) + return look ? { ops, status: look.title } : { ops } + } + case 'plan': + return { ops, phase: 'building', status: op.summary || '正在生成工作流…' } + case 'add_node': + return { ops, phase: 'building', status: `正在生成工作流…(${ops.filter((o) => o.op === 'add_node').length} 步)` } + case 'use_caliber': + // 助手选用了一份已发布口径(取数节点和口径卡由服务端生成,过程里那一行由 decodeCopilot 写) + return { ops, phase: 'building', status: LEDGER_TEXT.use.status } + case 'done': + return { ops, explanation: op.explanation ?? '' } + case 'ask': { + // 助手要先问清楚才能动手(N11):记下问题。这一轮怎么收尾(反问、摆着等运行)等服务端的 end + const asked = String(op.question ?? '').trim() + return asked ? { ops, status: CHAT_BLUEPRINT_TEXT.asked, asked } : { ops } + } + case 'final': + // 图建好了:先摆出来(画布边收边长到这里收尾),跑不跑由服务端定(stage run / end ready / end error) + return { ops, ...(op.graph ? { graph: op.graph as GraphSpec } : {}), explanation: op.explanation ?? t.explanation } + case 'usage': { + // 这一轮助手建图花了多少(服务端 answer_ops 最后一条):累加进这一轮的用量 + const base = t.usage ?? t.meta?.usage + return { ops, usage: { ...base, copilot: addUsage(base?.copilot, op as UsageTotal) } } + } + } + return { ops } +} + +/** 自动重来一次(turn.retry):这一次的过程和答案清掉(它是自动补救,用户还没拿到过答案),换下来的运行的用量并进这一轮 */ +function retryPatch(t: ChatTurn, d: Record): Partial { + const status = d.kind === 'empty' ? CHAT_RETRY_TEXT.empty + : d.base ? '上次运行未成功,正在调整工作流…' : '上次运行未成功,正在重新生成工作流…' + return { + attempt: Number(d.attempt ?? (t.attempt ?? 0) + 1), phase: 'planning', status, + ops: [], events: [], run: null, output: null, final: undefined, runStatus: undefined, + review: null, reviewRaw: null, rawOutput: null, lastSeq: 0, failure: null, error: '', + // 服务端带着换下来的那次运行的用量(页面上的运行壳子里没有用量);老服务端没带就按手上的算 + usage: d.usage && typeof d.usage === 'object' ? d.usage as TurnUsage : retireRun(t), } } /** - * 用户的问题填进这张图的入口字段。 - * - * 在此之前这里是硬编码的 `input: {}`——问题根本没进图。Copilot 只要给入口 - * 声明了一个必填字段(它有时候会,比如 `topic`),这次运行就在第一个节点 - * 上立刻挂掉:"缺少必填输入:topic"。用户看到的是自己刚问完就报错,而且 - * 错误里提的那个字段名他从来没见过——那是生成的图里的东西。 - * - * 之前"能跑"纯属运气:Copilot 那一次恰好没声明字段。 - * - * history 是另一件事:图跑起来的时候,模型得知道"它们"指的是上一轮那批数据。 - * Copilot 侧的上下文只影响图长什么样,管不到这个。 + * 这一轮到此为止(end):照服务端落库的那一行摆结局(和刷新之后从库里恢复的是同一份),过程留着。跑完了的成果用 end 带来的 + * 完整那份(报告的逐段证据在里面);复核改写了的照那一行 */ -export function fillInput( - graph: GraphSpec, question: string, history = '', -): Record { - const entry = (graph.nodes ?? []).find((n: any) => n.type === 'input') - const fields: any[] = (entry?.data?.config?.fields ?? []) as any[] - if (!fields.length) return { question, history } // 没声明字段也带上,下游可能用 input.question - - const payload: Record = { history } - for (const f of fields) { - if (f?.name) payload[f.name] = f.default ?? '' +function endPatch(t: ChatTurn, d: Record): Partial { + const row = d.turn as ConversationTurn | null + if (!row) return { cancel: null, writing: null, endedAt: Date.now() } + const r = restoreTurn(row) + const run = d.run && typeof d.run === 'object' ? d.run as Partial & { id: string } : null + const output = d.output && typeof d.output === 'object' && r.phase === 'done' && !r.review?.answer && !r.noQuery && !r.asked + ? d.output as Record : r.output + return { + phase: r.phase === 'checking' ? 'unknown' : r.phase, + status: r.status, error: r.error, failure: r.failure, rejected: r.rejected, + review: r.review, reviewRaw: r.reviewRaw, rawOutput: r.rawOutput, output, + meta: r.meta, usage: r.meta?.usage ?? t.usage, noQuery: r.noQuery, asked: r.asked, pendingRun: r.pendingRun, + graph: r.graph ?? t.graph, explanation: r.explanation || t.explanation, attempts: r.attempts, + run: run ? ({ ...(t.run ?? {}), ...run } as Run) : r.run ? (t.run?.id === r.run.id ? t.run : r.run) : null, + runStatus: run?.status ?? r.runStatus, + final: run?.status ? { status: run.status } : r.final ?? t.final, + // 计时停在服务端记的那一刻(补交付的停在运行结束时) + clipped: undefined, writing: null, endedAt: r.meta?.ms != null ? t.startedAt + r.meta.ms : Date.now(), + job: row.job ?? null, } - // 问题往哪个字段放:名字像"问题"的优先,其次第一个必填的,再不然第一个。 - // 只有一个字段时不用挑——那必然就是它 - const named = fields.find((f) => - /question|query|topic|input|问题|需求|主题/i.test(String(f?.name ?? ''))) - const target = named ?? fields.find((f) => f?.required) ?? fields[0] - if (target?.name) payload[target.name] = question - return payload -} - -/** 把这次会话之前几轮压成纯文本,塞进 input.history */ -function historyOf(conversationId: string, upToTurnId: string): string { - const turns = useChat.getState().byConversation[conversationId] ?? [] - const lines: string[] = [] - for (const t of turns) { - if (t.id === upToTurnId) break - const answer = answerText(t.output) || t.explanation - if (t.question && answer) lines.push(`问:${t.question}\n答:${answer.slice(0, 600)}`) - } - return lines.slice(-6).join('\n\n') } /** - * 一轮最多自动再试一次。第二次还不行多半是问题本身没说清,该让用户介入。 - * 这几个决定(要不要运行、要不要重来、重来说什么)已经下沉到服务端 backend/app/chat/turn.py:final 带 next,复核带 - * retry_instruction,这里照它们走;下面这份常量和两种重来的措辞只在老服务端(没给这两个字段)时用,和服务端那份逐字 - * 对照(backend/tests/test_chat_turn_policy.py) + * 播放这一轮在服务端的事件日志(发起 + 播放)。after 是从哪一条之后开始(这一次尝试的第一条之前);断线时 streamChatTurn + * 按收到的最后一个 seq 自动重连、补齐。收到发起了运行(run)就接上运行自己的实时流画过程;运行停在审批上、审批过了接着跑 + * 时(stage waiting → run)重新接上 */ -const MAX_ATTEMPTS = 2 - -/** 重来时交给助手的话(同服务端 chat/turn.py 的 RETRY_WITH_BASE / RETRY_FROM_SCRATCH),只在老服务端时自己拼 */ -function retryInstruction(question: string, reason: string, hasBase: boolean): string { - return hasBase - ? `这个工作流上一次运行没有成功:${reason}。\n\n` - + `请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),` - + `改完继续回答原来的问题:${question}` - : `${question}\n\n(上一次没有成功:${reason}。请针对这个原因调整,重新给出完整的工作流。)` -} - -interface PlanArgs { - /** 用户原本问的是什么。跑图取数、复核都要用它,不能被重试的补充说明污染 */ - question: string - /** 真正发给 Copilot 的那句话。重试时会在后面缀上失败原因 */ - instruction: string - /** 重试时把上一次那张图交回去,让它改而不是重建。null = 从零建 */ - baseGraph?: GraphSpec | null - attempt: number - /** 这一问限定只查的数据源。空或不给 = 不限 */ - scope?: ScopeSource[] -} - -/** - * 建图 → 跑图。从 ask 里拆出来,是为了能带着失败原因原地重来一次。 - * - * 重试必须回到**同一轮**:用户问了一个问题,中间重建了一次图是过程,不是 - * 两次对话。之前这件事全靠用户自己发现不对、再打一句「重来」——他那句话 - * 本来就是在手动做这件事。 - */ -function plan(flow: Flow, args: PlanArgs) { - const { question, instruction, baseGraph, attempt, scope } = args +function play(flow: Flow, serverId: string, after: number) { const { patch } = flow - - /** 这一轮建出来的图。retry 要把它交回去,所以得在闭包里留一份 */ - let built: GraphSpec | null = baseGraph ?? null - /** 助手问回来的那句话:流结束时没有别的结局(final / reply / error)就按反问收尾 */ - let asked = '' - /** 这一轮已经有了结局(final、reply、error):流结束时不再按反问收尾 */ - let settled = false - /** - * 服务端这一轮已经按运行时同一套规则自查过(收到了 check 的结论):没过的问题已经交回模型在同一轮里改过了(N11、G12)。 - * 这时前端不再整轮重来——那是再烧一次完整生成,改的还是同一批问题;只有老服务端(不自查)才由前端重来一次 - */ - let selfChecked = false - - /** - * 带着原因自动重来一次。超过上限就把原因如实留在这一轮上,不再烧钱。 - * - * 关键是**把失败的那张图一起交回去**。只给一句"上次没成功:工具参数错了" - * 而不给图,模型只能照着原问题从零再建一遍——很可能建出一模一样的那张, - * 白跑一个来回。而且它连自己上次建了什么都看不到:会话历史按 - * `status != running` 过滤,正在重试的这一轮恰好被排除在外。 - * - * 交回去之后走的是后端的"改图"分支:图预置进累加器,模型只输出改动操作, - * 于是调大最大步数、改正工具参数这种事就是改一个字段,不是推倒重来。 - * - * 这里会清空这一次的过程和答案——它是自动补救,用户还没拿到过答案。用户主动 - * 点的重试走 retryTurn,上一次要留档。 - */ - const retry = (reason: string, instruction?: string, status?: string) => { - // 服务端给了那句话(复核的 retry_instruction)就是它已经按次数判过可以重来;老服务端由这里按次数判 - if ((!instruction && attempt + 1 >= MAX_ATTEMPTS) || !flow.alive()) return false - const base = built && (built.nodes?.length ?? 0) > 0 ? built : null - patch((t) => ({ - attempt: attempt + 1, - phase: 'planning', - status: status ?? (base ? '上次运行未成功,正在调整工作流…' : '上次运行未成功,正在重新生成工作流…'), - ops: [], events: [], run: null, output: null, final: undefined, runStatus: undefined, - review: null, reviewRaw: null, rawOutput: null, lastSeq: 0, failure: null, error: '', - // 换下来的那次运行花的钱还是这一轮的 - usage: retireRun(t), - })) - plan(flow, { - question, - attempt: attempt + 1, - baseGraph: base, - scope, - instruction: instruction ?? retryInstruction(question, reason, !!base), - }) - return true + players.get(flow.turnId)?.closeRun() + let watching: { runId: string; stop: () => void } | null = null + const openRun = (runId: string, from = 0) => { + if (!flow.alive()) return + if (watching?.runId === runId) return + watching?.stop() + watching = { runId, stop: watch(flow, runId, from) } + } + const closeRun = () => { + watching?.stop() + watching = null + } + /** 运行停在审批上:运行的实时流会自己结束(stream.end interrupted),审批过了要重新接 */ + const parked = () => { + const w = watching + watching = null + w?.stop() } - /** 建图阶段就停下了:没有运行,没有断点,只能重试或换个说法 */ - const fail = (failure: Failure, graph?: GraphSpec) => { - patch(() => ({ - ...(graph ? { graph } : {}), - phase: 'error', status: PHASE_TEXT.error, error: lineOf(failure), failure, - cancel: null, endedAt: Date.now(), - })) - void flow.persist({ status: 'error', error: lineOf(failure) }) - } - - const cancelCopilot = streamCopilot( - { - instruction, base_graph: baseGraph ?? null, intent: 'answer', conversation_id: flow.conversationId, - ...(scope?.length ? { datasource_ids: scope.map((d) => d.id) } : {}), - }, - (op) => { - // 停止之后缓冲里还没派发完的操作:一条 final 就能把这一轮又跑起来 - if (!flow.alive()) return - // 先原样收下,翻译交给 decodeCopilot。thinking 合并在写入时做: - // 一次生成几百上千条 delta,逐条存下来光数组就比图大一个量级 - patch((t) => { - const last = t.ops[t.ops.length - 1] - if (op.op === 'thinking' && last?.op === 'thinking') { - const merged = { ...last, delta: String(last.delta ?? '') + String(op.delta ?? '') } - return { ops: [...t.ops.slice(0, -1), merged] } + const apply = (e: ChatTurnEvent) => { + const d = e.data ?? {} + switch (e.type) { + case 'turn.attempt': + closeRun() + patch((t) => ({ ...attemptReset(t, d, e.ts), turnSeq: e.seq })) + if ((d.mode === 'continue' || d.mode === 'resume')) { + const runId = findTurn(flow.conversationId, flow.turnId)?.run?.id + if (runId) openRun(runId, findTurn(flow.conversationId, flow.turnId)?.lastSeq ?? 0) } - return { ops: [...t.ops, op] } - }) - switch (op.op) { - case 'thinking': - patch((t) => ({ - phase: 'planning', - thinking: (t.thinking + (op.delta ?? '')).slice(-2000), - })) - break - case 'heartbeat': - // 查看进行中(looking)不是这一页的阶段:浮条直说「正在查看表结构…」 - patch(() => ({ status: op.phase === 'looking' ? LOOK_TEXT.running - : PHASE_TEXT[(op.phase as Phase) ?? 'planning'] ?? '正在处理…' })) - break - case 'look': - case 'context': { - // 查看结束:浮条换成查看了什么。记录随这一轮的 meta 落库(buildMeta 从操作流取) - const look = op.op === 'look' ? copilotLook(op) : copilotContext(op) - if (look) patch(() => ({ status: look.title })) - break - } - case 'plan': - patch(() => ({ phase: 'building', status: op.summary || '正在生成工作流…' })) - break - case 'add_node': - patch((t) => ({ - phase: 'building', - status: `正在生成工作流…(${t.ops.filter((o) => o.op === 'add_node').length} 步)`, - })) - break - case 'use_caliber': - // 助手选用了一份已发布口径(取数节点和口径卡由服务端生成,过程里那一行由 decodeCopilot 写) - patch(() => ({ phase: 'building', status: LEDGER_TEXT.use.status })) - break - case 'done': - patch(() => ({ explanation: op.explanation ?? '' })) - break - case 'check': - // 自查的结论(通过、没修好):同一轮里已经交回模型改过了 - if (op.status === 'passed' || op.status === 'failed') selfChecked = true - break - case 'ask': - // 助手要先问清楚才能动手(N11):记下问题,这一轮到此为止。后面要是还有 final(问之前已经改了图),按 final 收尾、 - // 问题一并带上;流就此结束的话,结束时按反问收尾 - asked = String(op.question ?? '').trim() - if (asked) patch(() => ({ status: CHAT_BLUEPRINT_TEXT.asked, asked })) - break - case 'reply': { - // 这句话不需要工作流——问的是前几轮已经查出来的东西,或者是 - // 对过程说的话(「重试」「换个说法」)。以前这些也各自重建一整张 - // 图、跑一遍 153 张表的库,纯属白花钱 - settled = true - const text = String(op.text ?? '') - patch(() => ({ - phase: 'done', status: PHASE_TEXT.done, cancel: null, endedAt: Date.now(), - output: { answer: text }, noQuery: true, - })) - void flow.persist({ status: 'done', answer: text }) - break - } - case 'final': { - settled = true - const graph = op.graph as GraphSpec - built = graph // 要重试的话,交回去的就是它 - // 连 meta 一起写:它的 at 是「建图还有动静」的凭据,后端这时也会刷新 updated_at - void flow.persist({ graph, explanation: op.explanation ?? '' }) - - // 没过校验的图不能拿去跑:引擎会照实抛出它自己的措辞("图是空的, - // 先拖一个节点进来"),而用户根本不在画布上,这句话对他毫无意义。 - // 这条路真的把它当答案交到用户面前过 - const blockers = ((op.issues ?? []) as ValidationIssue[]) - .filter((i) => i.level === 'error') - .map((i) => i.message) - // 建完之后怎么走由服务端定(final.next,backend/app/chat/turn.py 的 next_step):run / ready / fail。 - // 老服务端没给 next:照原来的规则——有 error 级问题时自查过就判失败、没自查过重来一次;autorun=false 只建不跑 - const next: string = op.next === 'run' || op.next === 'ready' || op.next === 'fail' ? op.next - : blockers.length ? (selfChecked ? 'fail' : 'retry') - : op.autorun === false ? 'ready' : 'run' - if (next === 'retry' && retry(`生成的工作流没有通过校验:${blockers.join(';')}`)) break - if (next === 'fail' || next === 'retry') { - // 自查修正过(或重来一次)还是没过。校验消息是写给画布用户的("先拖一个节点进来"), - // 直接甩给一个在问数据页打字的人毫无意义——先给一句他能照做的, - // 原文留在技术细节里作线索 - fail({ - title: '未能生成可运行的工作流', - reason: selfChecked ? '助手已按检查结果修正过,生成的工作流仍未通过检查。' : '已自动调整一次,生成的工作流仍未通过检查。', - hint: '请补充描述后重新提问,写明要查询的数据库、时间范围和指标。', - detail: blockers.join('\n'), - }, graph) - break + return + case 'op': + patch((t) => ({ ...opPatch(t, d as CopilotOp), turnSeq: e.seq })) + return + case 'stage': { + const stage = String(d.stage ?? '') + patch((t) => { + // 补交付(运行早就跑完了)停在「正在核对」,不按实时运行画 + if (t.phase === 'checking' && t.endedAt) return { turnSeq: e.seq, ...(stage === 'review' ? { runStatus: 'succeeded' } : {}) } + switch (stage) { + case 'build': + return { turnSeq: e.seq, ...(t.phase === 'planning' || t.phase === 'building' ? {} : { phase: 'planning' as Phase }) } + case 'run': + return { turnSeq: e.seq, phase: 'running' as Phase, final: undefined, + ...(t.phase !== 'running' ? { status: PHASE_TEXT.running } : {}) } + case 'waiting': + return { turnSeq: e.seq, phase: 'waiting' as Phase, status: PHASE_TEXT.waiting, + final: { status: 'interrupted', pending: true }, runStatus: 'interrupted' } + case 'review': + return { turnSeq: e.seq, phase: 'running' as Phase, status: REVIEWING, runStatus: 'succeeded' } } - - // 用户要的是流程本身时(「设计一个每天跑的工作流」),建完就跑 - // 等于替他多花一次钱,而他要的是那张图 - if (next === 'ready') { - // 问了用户的不自动运行(服务端 autorun 为假):图摆着等「运行」,问题照样作为这一轮的内容、进历史 - patch(() => ({ - graph, phase: 'ready', status: asked ? CHAT_BLUEPRINT_TEXT.asked : PHASE_TEXT.ready, cancel: null, - explanation: op.explanation ?? '', pendingRun: true, endedAt: Date.now(), - ...(asked ? { output: { answer: asked } } : {}), - })) - void flow.persist({ status: 'done', ...(asked ? { answer: asked } : {}) }) - break - } - patch(() => ({ - graph, phase: 'running', status: PHASE_TEXT.running, - explanation: op.explanation ?? '', - })) - void launch(flow, graph, question, retry, attempt) - break + return { turnSeq: e.seq } + }) + if (stage === 'waiting') { + parked() + // 审批列表平时 4 秒才轮询一次,头部已经写着「等待审批」,通过/驳回的按钮却要晚几秒才出现——这一下立刻去拿 + void useCatalog.getState().refreshApprovals() } - case 'usage': - // 这一轮助手建图花了多少(服务端 answer_ops 最后一条):累加进这一轮的用量、随 meta 落库。结局(final、reply) - // 那一下已经落过库了,这里再写一次 meta - patch((t) => ({ usage: { ...(t.usage ?? t.meta?.usage), copilot: addUsage((t.usage ?? t.meta?.usage)?.copilot, op) } })) - void flow.persist() - break - case 'error': - settled = true - // 生成过程一个操作都没给(模型只写了思考):服务端说重来一次(error.next = retry,backend/app/chat/turn.py 的 - // next_step),照它带着原因重来,次数照 MAX_ATTEMPTS;试满了才照实报。这一次建图的用量随后那条 usage 照样记进这一轮 - if (op.next === 'retry' && retry(String(op.reason ?? op.message ?? ''), undefined, CHAT_RETRY_TEXT.empty)) break - // 后端给的是三段:message 人话、hint 怎么办、detail 原始异常。 - // 原文不再截成一行甩给用户,收进可展开、可复制的技术细节 - fail({ - ...failureOf(op.message ?? '生成失败'), - ...(op.hint ? { hint: String(op.hint) } : {}), - ...(op.detail ? { detail: String(op.detail) } : {}), - }) - break - } - }, - (error, info?: { code?: string }) => { - if (!error) { - // 流正常结束、只问了一句(ask 之后没有 final / reply):这一轮记成「助手反问」,问题作为内容显示、落库进历史 - if (asked && !settled && flow.alive()) { - patch(() => ({ - phase: 'done', status: CHAT_BLUEPRINT_TEXT.asked, cancel: null, endedAt: Date.now(), - output: { answer: asked }, asked, - })) - void flow.persist({ status: 'done', answer: asked }) + if (stage === 'run') { + const cur = findTurn(flow.conversationId, flow.turnId) + if (cur?.run?.id) openRun(cur.run.id, cur.lastSeq) } return } - if (!flow.alive()) return - const turn = findTurn(flow.conversationId, flow.turnId) - if (!turn || !BUSY_PHASES.has(turn.phase) || turn.run) return - // 限定的库一个都不在了:后端在开流之前就 400,带机读码 datasource_scope_empty。 - // 认出来了,补救得是「不限数据源重试」,带着原范围重试只会再被拒一次。 - // 认码不认话;不带码的老后端退回按 detail 的开头认 - if (scope?.length && (info?.code === 'datasource_scope_empty' || error.startsWith('限定的数据源'))) { - const at = error.indexOf(':') - fail({ - title: at > 0 ? error.slice(0, at) : error, - reason: at > 0 ? `${error.slice(at + 1).split('。')[0]}。` : undefined, - hint: '点击「不限数据源重试」;如仍需限定该数据源,请先前往「数据」页确认它存在且已启用。', - detail: error, - scopeGone: true, + case 'run': { + const runId = String(d.run_id ?? '') + if (!runId) return + patch((t) => ({ + turnSeq: e.seq, phase: 'running', status: PHASE_TEXT.running, runStatus: String(d.status ?? 'queued'), + run: t.run?.id === runId ? t.run : ({ id: runId, status: d.status, run_class: d.run_class, output: {}, usage: {} } as unknown as Run), + ...(t.run?.id === runId ? {} : { events: [], lastSeq: 0 }), + })) + openRun(runId, 0) + return + } + case 'review': + // 复核这一次的用量也是这一轮的花费;结论跟着 end 一起摆出来(复核说明和结论同时出现,而不是结论先读完) + patch((t) => { + const base = t.usage ?? t.meta?.usage + return { turnSeq: e.seq, usage: { ...base, review: addUsage(base?.review, d.result?.usage) } } }) return - } - fail(failureOf(error)) - }, - ) - patch(() => ({ cancel: cancelCopilot })) -} - -async function launch( - flow: Flow, graph: GraphSpec, question: string, retry?: (reason: string, instruction?: string) => boolean, - attempt = 0, -) { - const { patch } = flow - try { - // 这一轮在服务端的 id(开轮请求可能还在路上,等它):运行记下属于哪段对话、哪一轮(服务端核对后封存进运行来源)。 - // 开轮没成的(serverId 为空)不带,运行照常发起 - const turnId = findTurn(flow.conversationId, flow.turnId)?.serverId ?? (await serverIds.get(flow.turnId)) ?? null - const body = { graph, input: fillInput(graph, question, historyOf(flow.conversationId, flow.turnId)), origin: 'chat' as const } - // 来源核对不过(对话刚被删、这一轮已不在):来源只是留痕,不挡这一轮的回答,不带来源照常发起 - const run = await (turnId - ? api.runs.start({ ...body, origin_ref: { conversation_id: flow.conversationId, turn_id: turnId } }) - .catch((e) => (e instanceof ApiError && e.code === 'origin_ref_invalid' ? api.runs.start(body) : Promise.reject(e))) - : api.runs.start(body)) - // 启动请求在路上时用户点了停止或重试:运行已经起来了,得追一个取消过去 - if (!flow.alive()) { - void api.runs.cancel(run.id).catch(() => undefined) - return + case 'review.late': + // 等不及的那次复核晚到了:答案已经照原样交了,它的用量照样记进这一轮 + patch((t) => { + const base = t.usage ?? t.meta?.usage + return { turnSeq: e.seq, usage: { ...base, review: addUsage(base?.review, d.usage) }, + meta: t.meta ? { ...t.meta, usage: { ...base, review: addUsage(base?.review, d.usage) } } : t.meta } + }) + return + case 'turn.retry': + closeRun() + patch((t) => ({ ...retryPatch(t, d), turnSeq: e.seq })) + return + case 'end': + patch((t) => ({ ...endPatch(t, d), turnSeq: e.seq })) + if (d.outcome === 'cancelled' || d.outcome === 'suspended' || d.outcome === 'error') { + // 运行已经停了:它的实时流会把最后几条补完再结束;被取消的不再看 + if (d.outcome === 'cancelled') closeRun() + } + return } - patch(() => ({ run, runStatus: run.status })) - // meta 里的 runId 优先于 run_id 列(重试建图失败时那一列清不掉),所以得一起改: - // 只写列的话,重试出来的这次运行在库里一直挂着 runId=null,跑着的时候刷新或者 - // 另开一页,它就成了「断在建图阶段」,放久了还会被当成断了写回 error。 - // 「跑一下」发起时库里那行还是 done,一并改成 running - void flow.persist({ run_id: run.id, status: 'running' }) - watch(flow, run.id, 0, question, retry, attempt) - } catch (e) { - // 发起那一下没到后端:流程是好的,原样再发起就行。通用的「稍等几秒会自动重试」 - // 在这里不成立——这一页没有谁会替它重发 - const failure: Failure = isNetworkError(e) - ? { ...failureOf(e, { hint: '服务端恢复连接后,点击「重新运行本轮」,已生成的工作流无需重新构建。' }), rerun: true } - : startFailure(e) - patch(() => ({ - phase: 'error', status: PHASE_TEXT.error, error: lineOf(failure), failure, - cancel: null, endedAt: Date.now(), - })) - void flow.persist({ status: 'error', error: lineOf(failure) }) } + + const stopTurn = streamChatTurn(serverId, after, (e) => { + if (!flow.alive()) return + apply(e) + }, () => { + // 服务端说这一轮的事件都发完了(或者这一轮读不了了)。运行的实时流还开着的让它自己收尾 + }) + const close = () => { + stopTurn() + closeRun() + } + players.set(flow.turnId, { flow, openRun, closeRun }) + patch(() => ({ cancel: close })) } /** 一帧之内到的事件并成一次写入。后台标签页不跑 rAF,另挂一个定时器兜底 */ @@ -1732,48 +1541,27 @@ function penOf(patch: Patch): { add: (e: RunEvent) => void; clear: () => void } } /** - * 订阅运行事件。 - * - * 只做两件事:把事件原样存进这一轮,以及更新那几个驱动 UI 状态机的相位。 + * 订阅运行事件,只为画过程:把事件原样存进这一轮,顺带更新「此刻在做什么」那一句和出字计数。 * "查了哪张表、跑了什么 SQL"不在这里翻译——decodeRun 会从同一批事件里读出来。 * - * 结局有两个来源:事件本身(run.finished / failed / cancelled、服务关停的 log), - * 和流结束时后端带来的 stream.end {status}。后者兜住"事件没来齐就断了"的情况—— - * 服务重启后重连,后端回放完就发 stream.end status=interrupted;以前前端不认它, - * 这一轮就一直转圈。 + * 这一轮的相位和结局不看这里:跑完了要复核、要不要重来、什么时候交付都是服务端定的,经这一轮的事件日志来(play)。 + * 只有停在审批上那一下例外:run.interrupted 一到就去拿待审批(头部写着「等待审批」,通过 / 驳回的按钮不该晚几秒才出现) */ -function watch( - flow: Flow, runId: string, after = 0, - question = '', retry?: (reason: string, instruction?: string) => boolean, attempt = 0, -) { - const { patch, conversationId, turnId } = flow - /** 这条流上已经见到结局事件了,stream.end 不用再判一次 */ - let ended = false +function watch(flow: Flow, runId: string, after = 0): () => void { + const { patch } = flow let buffer: RunEvent[] = [] const batch = batcher(() => { if (!buffer.length) return const got = buffer buffer = [] - patch((t) => ({ + patch((t) => (t.run?.id !== runId ? {} : { events: mergeEvents(t.events, got), // 记下进度:审批恢复要重新接流,带上它才不会把历史再收一遍 lastSeq: Math.max(t.lastSeq ?? 0, ...got.map((e) => e.seq ?? 0)), })) }) - const current = () => findTurn(conversationId, turnId) const pen = penOf(patch) - const suspend = () => { - batch.now() - pen.clear() - patch(() => ({ - phase: 'suspended', status: PHASE_TEXT.suspended, failure: SUSPENDED, error: '', - final: { status: 'interrupted', pending: false }, runStatus: 'interrupted', - endedAt: Date.now(), - })) - void flow.persist({ status: 'error', error: PHASE_TEXT.suspended }) - } - const stop = streamRun(runId, (event: RunEvent) => { if (!flow.alive()) return // token 和思考增量:后端不落库,decodeRun 也一开头就丢掉。存进 events 的唯一 @@ -1787,14 +1575,12 @@ function watch( buffer.push(event) batch.push() const d: any = event.data ?? {} - // 补发的历史里的结局是"当时"的结局:一条失败后又接着跑的运行,回放里先有 - // run.failed、后有 run.resumed。照着它改相位、落库,这一轮会先闪成失败再 - // 变回来。回放完后端会用 stream.end 说出现在的状态,结局以那个为准 + // 补发的历史里的结局是"当时"的结局,不据此改什么 if (event.replay && REPLAY_SKIP.has(event.type)) return switch (event.type) { case 'tool.start': { const tool = String(d.tool ?? '') - patch(() => ({ + patch((t) => (t.phase !== 'running' ? {} : { status: tool.startsWith('db_query') ? '正在查询数据…' : tool.startsWith('db_schema') ? '正在读取数据结构…' : tool ? `正在调用 ${tool}…` : '正在运行…', @@ -1802,203 +1588,28 @@ function watch( break } case 'llm.start': - patch(() => ({ status: '正在分析…' })) + patch((t) => (t.phase !== 'running' ? {} : { status: '正在分析…' })) break case 'run.resumed': - patch(() => ({ phase: 'running', status: PHASE_TEXT.running, final: undefined })) + patch(() => ({ final: undefined })) break case 'run.interrupted': - patch(() => ({ phase: 'waiting', status: PHASE_TEXT.waiting })) - // 审批列表平时 4 秒才轮询一次,头部已经写着「等待审批」,通过/驳回的 - // 按钮却要晚几秒才出现——这一下立刻去拿 + patch((t) => (t.phase !== 'running' ? {} : { phase: 'waiting', status: PHASE_TEXT.waiting })) void useCatalog.getState().refreshApprovals() break - case 'log': - // 服务在关停:这一轮会停在断点上。停在审批上的仍然是在等人 - if (d.code === 'server_shutdown' && current()?.phase !== 'waiting') suspend() - break - case 'run.cancelled': { - ended = true - batch.now() - // 人的驳回(reason=rejected)也收成 cancelled:不是故障、不给继续运行,说法写「被驳回」和谁、哪一步、备注 - const rejected = d.reason === 'rejected' ? { sub: REJECTED_TEXT.sub(d) } : null - patch(() => ({ - phase: 'cancelled', status: rejected ? REJECTED_TEXT.title : PHASE_TEXT.cancelled, cancel: null, - endedAt: Date.now(), runStatus: 'cancelled', final: { status: 'cancelled' }, rejected, - })) - void flow.persist({ status: 'error', error: '已取消' }) - break - } - case 'run.failed': { - ended = true - batch.now() - const failure = runFailure(d.error ? String(d.error) : '', d.detail ? String(d.detail) : undefined) - patch(() => ({ - phase: 'error', status: PHASE_TEXT.error, error: lineOf(failure), failure, - cancel: null, endedAt: Date.now(), runStatus: 'failed', final: { status: 'failed' }, - })) - void flow.persist({ status: 'error', error: lineOf(failure) }) - break - } case 'run.finished': - ended = true - batch.now() - patch(() => ({ status: '正在核对结果…', runStatus: 'succeeded' })) - // 跑完不等于答对:先接住这次编排,确认没问题再交付 - void settle(flow, runId, question, d.output ?? null, { - truncated: d.output_truncated === true, retry, attempt, - }) + patch((t) => (t.phase !== 'running' ? {} : { status: REVIEWING })) break } - }, (end) => { + }, () => { batch.now() pen.clear() - if (ended || !flow.alive()) return - const status = String(end?.status ?? '') - const turn = current() - if (!turn) return - if (status === 'interrupted' || status === 'suspended') { - // 连到一条已经停下的运行:分清是停在审批上,还是服务重启挂起的 - void (async () => { - const pending = status === 'suspended' ? false : await pendingApproval(runId) - if (!flow.alive()) return - if (pending) { - patch(() => ({ phase: 'waiting', status: PHASE_TEXT.waiting, final: { status, pending } })) - void useCatalog.getState().refreshApprovals() - } else { - suspend() - } - })() - return - } - if (status === 'succeeded') { - // 结局事件在断线时丢了:成果以库里为准 - ended = true - void settle(flow, runId, question, null, { retry, attempt }) - return - } - // 失败、取消、或者运行不见了:状态和原因让 hydrate 去读 - patch(() => ({ hydrated: undefined, phase: 'checking', status: PHASE_TEXT.checking, cancel: null })) - void useChat.getState().hydrate(conversationId, turnId) }, after) - patch(() => ({ cancel: stop })) -} - - -/** - * 接住一次编排的产出,确认能不能就这么交给用户。 - * - * 「跑完了」和「答得对」是两回事。库里能查到大量 status=succeeded 的运行, - * 过程中检索降级了、工具报错了、agent 步数用满了,而交到用户手上的答案对此 - * 只字不提——最糟的一次交出去的干脆是一句内部管道文案。 - * - * 后端先用规则扫一遍事件流,干净的运行直接返回 ok,一次模型都不调;扫出异常 - * 才叫模型重组答案并把话说破(backend/app/engine/review.py)。 - * - * 这个函数必须**在任何情况下都把答案交出去**:复核是补一层说明,它自己失败了 - * 不能把已经到手的结果一起带走,更不能让这一轮永远停在"正在核对"。 - */ -async function settle( - flow: Flow, runId: string, question: string, - eventOutput: Record | null, - opts: { - truncated?: boolean; retry?: (reason: string, instruction?: string) => boolean; run?: Run; endedAt?: number - /** 这一轮已经自动试了几次(从 0 数):交给复核,服务端据此决定给不给 retry_instruction */ - attempt?: number - }, -) { - const { patch } = flow - - // 成果以库里那份为准。run.finished 事件里的 output 为了控制推送体积被切过 - // (字典每个值 2000 字),以前落库、显示的都是它:几千字的报告停在半句话上, - // 看起来却是完整的,刷新之后还是那半截 - let run: Run | null = opts.run ?? null - if (!run) { - try { run = await api.runs.get(runId) } catch { /* 取不到就用事件里那份,下面会标出来 */ } + return () => { + batch.now() + pen.clear() + stop() } - const full = run?.output && Object.keys(run.output).length ? run.output : null - const output = full ?? eventOutput - const clipped = !full && (opts.truncated || looksClipped(eventOutput)) ? 'partial' as const : undefined - patch((t) => ({ run: run ?? t.run, runStatus: 'succeeded', final: { status: 'succeeded' } })) - - // 成果**先落库**再显示。两件事顺序不能并: - // - 落库要马上,否则复核期间关掉标签页,这次跑出来的东西就没了; - // - 显示要等复核,否则用户已经把结论读完、信了,说明才姗姗来迟—— - // 实测复核要十秒上下,这十秒里他看到的是一个没人核对过的答案。 - void flow.save({ status: 'done', answer: answerText(output) }) - - let result: ReviewResult | null = null - const reviewing = api.copilot.review({ run_id: runId, question, attempt: opts.attempt ?? 0 }) - let late = false - try { - // 复核慢到一定程度就不等了。它是补一层说明,不该反过来把答案扣住不放—— - // 干净的运行后端 15ms 就返回,会走到这个上限的都是真叫了模型的那些 - result = await Promise.race([ - reviewing, - new Promise((r) => setTimeout(() => { late = true; r(null) }, reviewWaitMs)), - ]) - } catch { /* 复核用不了就按原样交付,下面照常走 */ } - if (late && !result) { - // 没等到复核:答案照原样交付(下面照常收尾)。复核那一次照样调了模型、花了钱:它晚到时把用量记进这一轮、再落一次库, - // 这一轮的花费才照实是助手 + 运行 + 复核(不再改答案:说明晚到了就不补) - void reviewing.then((r) => { - if (!r?.usage || !flow.alive()) return - const spent = r.usage - patch((t) => ({ usage: { ...(t.usage ?? t.meta?.usage), review: addUsage((t.usage ?? t.meta?.usage)?.review, spent) } })) - void flow.persist() - }, () => { /* 复核失败了就没有用量可记 */ }) - } - // 复核期间用户点了停止或者重试:这一轮已经不归这次运行管了 - if (!flow.alive()) return - - // 成果带逐段证据时复核只能加说明:改写会把成果整个换成 {answer},报告文档的每个片段 - // 就都对不上了。后端也拦,这里再拦一道,老后端回来的改写同样不落到界面和库里 - result = guardReview(result, output) - // 库里存完整结论(包括 verdict='ok'),store 里只留值得摆到界面上的那部分。 - // 两者分开,"这一轮没复核过"和"复核过、没发现问题"才是两件可区分的事—— - // 事后排查"它当时为什么没警告我",靠的就是这个区别 - const review = result && result.verdict !== 'ok' ? result : null - - // 复核这一次的用量也是这一轮的花费(服务端 review_of_run 的 usage;老服务端没有) - if (result?.usage) { - const spent = result.usage - patch((t) => ({ usage: { ...(t.usage ?? t.meta?.usage), review: addUsage((t.usage ?? t.meta?.usage)?.review, spent) } })) - } - - // 答案根本不可信,而且原因说得清楚——重建一次图再跑,别让用户自己发现。要不要重来、重来说什么由服务端定(复核的 - // retry_instruction,backend/app/chat/turn.py):给了那句话就照它重来,给的是 null 就不重来。老服务端没有这个字段时 - // 照原来的规则自己判 - if (opts.retry && result && 'retry_instruction' in result) { - if (result.retry_instruction) { - const reason = review ? review.signals.map((s) => s.detail).join(';') || review.note : '' - if (opts.retry(reason, result.retry_instruction)) return - } - } else if (review?.retry && review.severity === 'broken' && opts.retry) { - const reason = review.signals.map((s) => s.detail).join(';') || review.note - if (opts.retry(reason)) return - } - - const rewritten = review?.answer ?? null - const issuance = output?._issuance - patch(() => ({ - phase: 'done', - status: PHASE_TEXT.done, - review, - reviewRaw: result, - cancel: null, - endedAt: opts.endedAt ?? Date.now(), - // 复核改写时读的是库里那份完整成果,改写出来的答案不缺尾巴 - clipped: rewritten ? undefined : clipped, - // 到这一步才把成果摆出来:复核说明和结论同时出现,而不是结论先读完 - output, - // 改写是有损的,原件得留着——界面上可以展开对照。它跟着 review 一起 - // 落库,所以刷新之后还在 - ...(rewritten ? { - rawOutput: { answer: review?.original ?? answerText(output) }, - output: { answer: rewritten, ...(issuance ? { _issuance: issuance } : {}) }, - } : {}), - })) - void flow.persist({ status: 'done', answer: rewritten || answerText(output) }) } export { decodeRun } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cce85f52..3bdc7109 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1646,6 +1646,22 @@ export interface ConversationTurn { * (conversations.BUILD_STALE),前端判断同一件事时用同一只钟。老后端没有 */ updated_at?: string + /** 这一轮在服务端的任务(问数据整轮在服务端跑,backend/app/chat/job.job_view)。这一波之前的老轮次没有,为 null */ + job?: ChatTurnJob | null +} + +/** 问数据一轮在服务端的任务现在怎样。status 用后台任务的状态词;stage 是在哪一步;attempt_seq 是这一次尝试从哪一条事件开始 */ +export interface ChatTurnJob { + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'interrupted' + stage: 'build' | 'run' | 'waiting' | 'review' | null + mode: string + run_id: string | null + attempt: number + last_seq: number + attempt_seq: number + error: string | null + /** 等不及的那次复核还在后台跑 */ + late_review?: boolean } /** diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index a09dd895..de7582c6 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -1,6 +1,12 @@ // 问数据页的检查:会话、轮次、运行全部在浏览器这一侧伪造,页面走的是真的 // chat store / ChatPage / AssistantStream,只有后端换成了脚本。 // +// 一轮问数据在服务端跑(docs/design/orchestration-round/00-总方案.md §1):页面只发起(POST /api/chat/turns)、只播放 +// (GET /api/chat/turns/{id}/stream)。这里伪造的服务端(startTurn / driveTurn,照 backend/app/chat/job.py 的做法)用原来那几份 +// 剧本把一轮跑完:生成(copilotScript)→ 发起运行(launchScript)→ 等运行的剧本放完(fakeStream)→ 复核(reviewScript)→ +// 照服务端的写法拼那一行(end)。log.gens / runStarts / reviews 照旧记下服务端这一侧做了什么,log.patches 用来守「页面不再写 +// 这一轮的结局」。 +// // 为什么不真跑:沙箱没有模型密钥,而且要撞上的恰恰是真跑很难稳定复现的那几种 // 时刻——两个会话同时在跑、答案超过 2000 字被事件截断、跑到一半服务重启、 // 历史取得慢或者取失败。这里每一种都由脚本精确地摆出来。 @@ -8,7 +14,7 @@ // 守的事: // 1. 忙不忙按会话算:A 在跑时 B 的按钮是「发送」,停止只停 A 自己,删除保护 // 对准正在跑的那个; -// 2. run.finished 的 output 被截断时,显示和落库都用 GET 运行拿到的完整版; +// 2. run.finished 的 output 被截断时,显示用服务端按库里完整成果写的那一份;页面不 PATCH 这一轮; // llm.token 不进 events; // 3. 服务重启:stream.end status=interrupted 且没有待审批,这一轮收成 // 「服务重启,本轮已中断」,给「继续运行」,不再转圈; @@ -16,10 +22,11 @@ // 5. 已取消的、人驳回这类原样续上还会失败的轮次没有「继续运行」,续跑被拒时说人话; // 6. 1440×900 下长会话的输入框和发送键完整可见,整页不多滚; // 7. 首屏例句不拿系统表造句;可信度信息刷新后还在,且写进单独的 meta,不夹在 review 里; -// 8. 从库里恢复的半路轮次:核对撞上 500 有出路、补交付不走实时计时、查到的终态落库; -// 9. 长答案没取全要记进 meta,刷新后补全;2000 字按码点数,不按 UTF-16; -// 10. 发起运行失败:报错不糊成一整行粗体,不许诺不存在的自动重试;重跑挂起的轮次 -// 顺手取消旧运行;断在建图阶段按最后一次落库算有没有动静,发起运行时 meta 跟上新运行; +// 8. 从库里恢复的半路轮次(老轮次、服务重启后停着的):交给服务端接着收尾(mode=resume),核对撞上 500 有出路、 +// 补交付不走实时计时,结局照服务端写的那一行摆; +// 9. 老轮次的长答案没取全:刷新后补全;2000 字按码点数,不按 UTF-16;新轮次按库里的完整成果交付; +// 10. 发起那一下没到服务端:报错不糊成一整行粗体,不许诺不存在的自动重试;重跑挂起的轮次 +// 顺手取消旧运行;断在建图阶段的老轮次页面不替它写库,重试本轮交给服务端; // 11. 左栏:没打开过的会话也标得出状态;筛选框在有筛选词时不消失;删掉的会话进回收站, // 能预览、恢复、彻底删除(要确认);回收站里只能看,不发起任何运行,正在跑的不给 // 彻底删除(清空也跳过它); @@ -38,6 +45,8 @@ // 19. 答案身上的召回标记(账本接口的 recall)写在轮次底部:「已按口径「X」vN 核对,不影响」、依据有变(目录 / 表结构,几项、 // 按新目录新出现几条错误级问题),看的人手上有说的是这个答案的召回待办时链到待办;没比对的答案照样写依据有变;做完的 // 对账卡上写「差在哪」、问说明有没有帮助(说明有用 / 看不懂)。 +// 20. 一轮在服务端跑:实时流断了按收到的最后一个 seq 重连,补齐、不重复;刷新、关掉再打开,服务端那一轮照跑,页面从 +// 这一次尝试的第一条接着播;停止发给服务端的这一轮(建图中也停得下);发起、重试、继续运行的请求体照实。 // // 所有写请求都被拦在浏览器里,不碰任何库。 // 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时 @@ -63,6 +72,8 @@ const opened = new Set() async function section(name, title, fn) { if (ONLY.length && !ONLY.includes(name)) return console.log(`\n=== ${title} ===`) + // 伪造的服务端:上一段里还在跑的几轮就此放下,不再往下写(各段互不牵连) + srv.gen += 1 try { await fn() } catch (e) { @@ -217,6 +228,10 @@ const REJECT_CONVS = { rejS: conv('c0rejs', '会话:没打开过的那一问', 1, 60 * 24 * 36, { last_status: 'rejected' }), } /** 只在「助手反问」那段出现:库里存着一轮以反问收尾的(answer 列里是助手问的那句话,meta.asked 标着) */ +/** 只在「刷新后接着看」那段出现:一开始是空的,问的那一轮在伪造的服务端里跑,刷新、关掉再打开时从那里取回来 */ +const REPLAY_CONVS = { + replay: conv('c0replay', '会话:刷新后接着看', 0, 60 * 24 * 41), +} const ASKBACK_CONVS = { askBack: conv('c0askbk', '助手反问过的会话', 1, 60 * 24 * 34), } @@ -229,6 +244,7 @@ const ASK_CONVS = { askZ: conv('c0askz', '问数据:各区域销售额和最大的一单', 1, 60 * 24 * 33), } const DETAIL = { + c0replay: [], c0askbk: [turn('tab1', '上周销售额是多少', { answer: '销售额按下单日还是支付日算?', meta: { v: 1, asked: true } })], c0ledgr: [turn('tl1', '上周的周报', { answer: '(周报原文)', graph: GRAPH, run_id: 'run-led', @@ -412,6 +428,8 @@ const TABLES = { const log = { patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [], deleted: [], gens: [], sourceGets: 0, catalogPatches: [], reviews: [], + /** 问数据的一轮:发起(POST /api/chat/turns 的请求体)、停止、实时流的每一次连接(从哪个 seq 之后) */ + turnStarts: [], turnCancels: [], streams: [], } /** 新服务端复核给的重来那句话:前端得原样交回去,不自己拼 */ const DECIDE_RETRY = '(服务端拼好的重来说法)照服务端的决定重新回答:有几家门店' @@ -439,6 +457,20 @@ const ctl = { rejectConvs: false, /** 列表里多摆「口径账本」那段的会话(LEDGER_CONVS) */ ledgerConvs: false, + /** 伪造的服务端:运行的剧本放完了(`${runId}@${after}`)、继续运行过的运行、等复核最多多久(毫秒) */ + wsDone: {}, continued: {}, reviewWaitMs: 25_000, + /** POST /api/chat/turns 根本没到后端(网断了) */ + startFail: false, + /** 接下来几次实时流连接直接断掉(测断线重连补齐) */ + dropStreams: 0, + /** 对话详情里带上服务端写的那几行(测刷新后接着看) */ + liveDetail: false, + /** 助手每条操作之间隔多久(毫秒) */ + opGapMs: 10, + /** 列表里多摆「刷新后接着看」那段的会话(REPLAY_CONVS);那段里建图、运行各停在一道闸上 */ + replayConvs: false, replayBuildGo: false, replayRunGo: false, + /** 「停止」那段:建图停在一道闸上 */ + stopBuildGo: false, } /** 会话的归档 / 删除状态。各段各自 resetDb,免得上一段删掉的会话影响下一段 */ const db = { archived: new Set(), purged: new Set() } @@ -449,7 +481,7 @@ const resetDb = () => { resetDb() const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []), ...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : []), - ...(ctl.askBack ? Object.values(ASKBACK_CONVS) : []), + ...(ctl.askBack ? Object.values(ASKBACK_CONVS) : []), ...(ctl.replayConvs ? Object.values(REPLAY_CONVS) : []), ...(ctl.fixConvs ? Object.values(FIX_CONVS) : []), ...(ctl.rejectConvs ? Object.values(REJECT_CONVS) : []), ...(ctl.ledgerConvs ? Object.values(LEDGER_CONVS) : [])] .filter((c) => !db.purged.has(c.id)) @@ -479,8 +511,13 @@ async function fakeApi(route) { if (id === 'c0faile' && ctl.failE) return json({ detail: '数据库暂时不可用' }, 500) const known = allConvs().find((c) => c.id === id) if (!known && id !== 'c0new') return json({ detail: '这个对话不存在,可能已经被删了' }, 404) - const turns = typeof DETAIL[id] === 'function' ? DETAIL[id]() : DETAIL[id] - return json({ ...(known ?? conv('c0new', '新对话', 0, 0)), turns: turns ?? [] }) + let turns = (typeof DETAIL[id] === 'function' ? DETAIL[id]() : DETAIL[id]) ?? [] + if (ctl.liveDetail) { + // 服务端写的那几行(刷新、关掉再打开时取回来的就是它们):夹具里有的换成服务端现在的,新问的接在后面 + const live = [...srv.turns.values()].filter((T) => T.conversation_id === id && T.gen === srv.gen).map(rowOut) + turns = [...turns.map((t) => live.find((x) => x.id === t.id) ?? t), ...live.filter((x) => !turns.some((t) => t.id === x.id))] + } + return json({ ...(known ?? conv('c0new', '新对话', 0, 0)), turns }) } if (m && method === 'PATCH') { const b = body() @@ -502,109 +539,35 @@ async function fakeApi(route) { m = path.match(/^\/conversations\/([^/]+)\/turns\/([^/]+)$/) if (m && method === 'PATCH') { log.patches.push({ conv: m[1], turn: m[2], body: body() }); return json(turn(m[2], '', body())) } + // 问数据的一轮(伪造的服务端,见 startTurn / driveTurn) + if (path === '/chat/turns' && method === 'POST') { + const b = body() + if (ctl.startFail) { + log.turnStarts.push({ ...b, failed: true }) + return route.abort('connectionrefused') + } + const got = await startTurn(b) + return json(got.json, got.status) + } + m = path.match(/^\/chat\/turns\/([^/]+)(\/stream|\/cancel)?$/) + if (m) { + const T = srv.turns.get(decodeURIComponent(m[1])) + if (!T) return json({ detail: '该轮对话不存在,可能已被删除' }, 404) + if (m[2] === '/stream' && method === 'GET') return streamTurn(route, T, Number(url.searchParams.get('after') ?? 0)) + if (m[2] === '/cancel' && method === 'POST') { + log.turnCancels.push(T.id) + return cancelTurn(T) ? json({ ok: true }) : json({ detail: '这一轮已经结束,无需停止', code: 'chat_turn_finished' }, 409) + } + if (!m[2] && method === 'GET') return json({ id: T.id, conversation_id: T.conversation_id, job: { ...T.job }, turn: rowOut(T) }) + } if (path === '/copilot/generate-stream' && method === 'POST') { + // 画布助手照旧走这条流(问数据的一轮在伪造的服务端里走同一份剧本,见 copilotScript) const b = body() log.gens.push(b) - if (ctl.scope400 && b.datasource_ids?.length) { - // 和后端 copilot._sources 同一个形状:detail 是一句话,code 是稳定的机读码 - // reworded:后端换了说法,只剩机读码认得出来 - return json({ detail: ctl.scope400 === 'reworded' ? '这一轮圈定的库一个也找不到了:可能已经被删掉或停用' - : '限定的数据源都已不可用,可能已被删除或停用。请取消限定后重试,或到「数据」页确认数据源存在且已启用', code: 'datasource_scope_empty' }, 400) - } - if (String(b.instruction ?? '').includes('建流程就失败')) { - // 后端的 error 操作是三段:人话、怎么办、原始异常 - const ops = [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 400 }, - { op: 'error', message: '模型返回的工作流结构有误:第 2 个节点的类型「sql」不存在', - hint: '请重试,或把需求描述得更具体', - detail: "2 validation errors for GraphSpec\nnodes.1.type\n Input should be 'input', 'output', 'llm' [type=literal_error]" }, - ] - return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) - } - if (String(b.instruction ?? '').includes('自查没修好')) { - // 服务端自查交回模型改过、还是没过(check failed),final 带着同一批问题:前端不该再整轮重来 - const ops = [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, - ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), - { op: 'check', status: 'failed', issues: ['「查数」节点还没有选择工具'] }, - { op: 'final', graph: GRAPH, explanation: '', issues: [{ level: 'error', node_id: 'ag', message: '「查数」节点还没有选择工具' }] }, - ] - return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) - } - if (String(b.instruction ?? '').includes('只写了思考')) { - // 生成过程一个操作都没给(deepseek 偶发只出思考):服务端的 error 带 next=retry(backend/app/chat/turn.py 的 next_step)。 - // 重来那一次(指令里缀了「上一次没有成功」)照常直接回答;「两次都」那一问每次都只写思考 - const again = String(b.instruction).includes('上一次没有成功') && !String(b.instruction).includes('两次都') - const spent = { by_model: { m: { input_tokens: 500, output_tokens: 40, calls: 1, cost_usd: 0.001, unpriced_calls: 0 } }, - input_tokens: 500, output_tokens: 40, calls: 1, cost_usd: 0.001, unpriced_calls: 0 } - const ops = again ? [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 200 }, - { op: 'reply', text: '已经查过了:一共 3 家门店。' }, - { op: 'usage', ...spent }, - ] : [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 200 }, - { op: 'thinking', delta: '先想一想要查哪张表……' }, - { op: 'error', message: '助手本轮未完成:模型没有给出任何修改,也没有说明原因', hint: '画布未修改。可以重试,或把需求描述得更具体', - detail: '', reason: '模型没有给出任何修改,也没有说明原因', code: 'copilot_empty', next: 'retry' }, - { op: 'usage', ...spent }, - ] - return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) - } - if (String(b.instruction ?? '').includes('照服务端的决定') || String(b.instruction ?? '').includes(DECIDE_RETRY)) { - // 新服务端:final 带 next(backend/app/chat/turn.py),最后一条是这一轮建图的用量(answer_ops 的 usage)。 - // 「只建不跑」那一问:autorun 没写 false,next 却是 ready——前端得照 next 走 - const ready = String(b.instruction ?? '').includes('只建不跑') - const ops = [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, - ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), - { op: 'done', explanation: '一张三步的图' }, - { op: 'check', status: 'passed', repaired: 0, warnings: [] }, - { op: 'final', graph: GRAPH, issues: [], explanation: '一张三步的图', autorun: true, next: ready ? 'ready' : 'run' }, - { op: 'usage', by_model: { m: { input_tokens: 4000, output_tokens: 300, calls: 1, cost_usd: 0.012, unpriced_calls: 0 } }, - input_tokens: 4000, output_tokens: 300, calls: 1, cost_usd: 0.012, unpriced_calls: 0 }, - ] - return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) - } - if (String(b.instruction ?? '').includes('先问清楚')) { - // 助手要先问清楚才能动手(服务端 copilot_gen 的 ask):问题转出去,这一轮到此为止——没有 final,也没有 reply - const ops = [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, - { op: 'ask', question: '销售额按下单日还是支付日算?' }, - ] - return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) - } - if (String(b.instruction ?? '').includes('表示作废')) { - // 用户说了一条数据事实:服务端核对后转出目录修改提案,只回答、不建图 - const ops = [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, - { op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [ - { path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' }, - value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] }, - { op: 'reply', text: '已整理成数据目录的修改建议,确认后保存即可。' }, - ] - return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) - } - const ops = [ - { op: 'heartbeat', phase: 'planning', elapsed_ms: 800 }, - { op: 'heartbeat', phase: 'looking', elapsed_ms: 1000 }, - // 助手按需查看了表(look 事件):过程里多一行「查看了 N 张表」,查看记录存进这一轮的 meta.looks。 - // 这个库还没有数据目录(catalog_tables: 0):那一行下面提示先去起草目录 - { op: 'look', id: 'L1', by: 'model', need: '查订单和明细', status: 'ok', elapsed_ms: 1200, objects: [ - { kind: 'table', source: 'shop', source_id: 'src-shop', named: [], picked: ['orders', 'order_items'], joined: [], - missing: [], total: 48, catalog_tables: 0 }] }, - { op: 'plan', summary: '查一下再回答' }, - ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), - { op: 'done', explanation: '一张三步的图' }, - { op: 'final', graph: GRAPH, issues: [], explanation: '一张三步的图' }, - ] + const got = copilotScript(b) + if (got.status) return json(got.json, got.status) return route.fulfill({ status: 200, contentType: 'text/event-stream', - body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) + body: got.ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) } if (path === '/datasources/src-shop/catalog/orders/patch' && method === 'POST') { log.catalogPatches.push(body()) @@ -624,60 +587,12 @@ async function fakeApi(route) { if (path === '/datasources/src-shop/catalog/orders/impact' && method === 'GET') { return json({ table: 'orders', templates: [] }) } - if (path === '/copilot/review' && method === 'POST') { - const wait = ctl.reviewDelay[body().run_id] - if (wait) await new Promise((r) => setTimeout(r, wait)) - if (ctl.lateReview && String(body().run_id).startsWith('run-decide')) { - // 复核慢过问数据页等的上限(检查里调短成几百毫秒):答案先交出去,这一次复核的用量晚到了照样要记进这一轮 - await new Promise((r) => setTimeout(r, ctl.lateReview)) - log.reviews.push(body()) - return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [], retry_instruction: null, - usage: { cost_usd: 0.004, unpriced_calls: 0, calls: 1 } }) - } - if (String(body().run_id).startsWith('run-decide')) { - // 新服务端:第一次判不可用、给出重来的那句话;重来之后的那次没问题、不再重来。各带复核的用量 - log.reviews.push(body()) - const first = body().attempt === 0 - return json({ verdict: first ? 'annotated' : 'ok', note: first ? '查到一半就停了' : '', answer: null, retry: first, - severity: first ? 'broken' : '', signals: first ? [{ kind: 'step_limit', detail: '用满了步数', severity: 'broken' }] : [], - retry_instruction: first ? DECIDE_RETRY : null, - usage: { cost_usd: first ? 0.003 : 0, unpriced_calls: 0, calls: first ? 1 : 0 } }) - } - // 老后端的复核不认 _evidence,照样回一次改写 - if (body().run_id === 'run-evid' || body().run_id === 'run-evq') { - return json({ verdict: 'rewritten', note: '检索降级过,结论请对照原始数据', answer: '改写后的答案:销售额大约四万多', - original: body().run_id === 'run-evq' ? EVQ.output.answer : EVIDENCE.output['周报'], retry: false, severity: 'degraded', - signals: [{ kind: 'retrieval_degraded', detail: '检索退回关键词', severity: 'degraded' }] }) - } - return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] }) - } + if (path === '/copilot/review' && method === 'POST') return json(await reviewScript(body())) if (path === '/runs' && method === 'POST') { - const b = body() - const conversation = String(b.input?.question ?? '') - // 发起请求根本没到后端(网断了、后端正在重启) - if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused') - // 发起就被拒:绑定的工具不存在(后端 runs.TOOL_MISSING,{detail, code} 的形状) - if (conversation.includes('工具不在')) { - return json({ detail: '绑定的工具不存在:「查数」(调用工具)绑定的 db_query__nope。请到「数据」页接入,或在节点中重新选择', - code: 'run_tool_missing' }, 422) - } - const id = conversation.includes('整形解析失败') ? (conversation.includes('现在的说法') - ? (conversation.includes('上游') ? 'run-jsonup-now' : 'run-jsonerr-now') - : conversation.includes('节点名带超时') ? 'run-jsonslow' - : conversation.includes('上游') ? 'run-jsonup' - : conversation.includes('改写') ? 'run-jsonauth' : 'run-jsonerr') - : conversation.includes('问数据带证据') ? 'run-evq' - : conversation.includes('带证据') ? 'run-evid' - : conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long') - : conversation.includes('审批') ? 'run-wait' - : conversation.includes('重启') ? 'run-restart' - : conversation.includes('正在写') ? 'run-writing' - : conversation.includes('强杀') ? 'run-killed' - : conversation.includes('步数用满') ? `run-steps-${++runSeq}` - : conversation.includes('照服务端的决定') ? `run-decide-${++runSeq}` - : `run-busy-${++runSeq}` - log.runStarts.push({ id, graph: b.graph, origin_ref: b.origin_ref }) - return json({ id, status: 'queued', run_class: 'exploratory', output: {}, usage: {} }) + const got = launchScript(body()) + if (got.abort) return route.abort('connectionrefused') + if (got.status) return json(got.json, got.status) + return json({ id: got.id, status: 'queued', run_class: 'exploratory', output: {}, usage: {} }) } m = path.match(/^\/runs\/([^/]+)\/(cancel|continue)$/) if (m && method === 'POST') { @@ -688,19 +603,9 @@ async function fakeApi(route) { } m = path.match(/^\/runs\/([^/]+)$/) if (m && method === 'GET') { - const id = m[1] - log.runGets.push(id) - if (id === 'run-long') return json({ id, status: 'succeeded', run_class: 'exploratory', output: { answer: FULL }, usage: {} }) - if (id === 'run-noget' || (id === 'run-unk' && ctl.run500)) return json({ detail: '服务端错误(500),详情请查看服务日志' }, 500) - if (id === 'run-unk') return json({ id, status: 'cancelled', run_class: 'exploratory', output: {}, usage: {} }) - if (id.startsWith('run-404')) return json({ detail: '运行不存在' }, 404) - if (RUNS[id]) return json({ id, run_class: 'exploratory', usage: {}, ...RUNS[id] }) - if (id.startsWith('run-c') || id === 'run-b1') return json({ id, status: 'succeeded', run_class: 'exploratory', output: {}, usage: {} }) - if (id.startsWith('run-decide')) { - return json({ id, status: 'succeeded', run_class: 'exploratory', output: { answer: '三家门店。' }, - usage: { cost_usd: 0.02, calls: 2, input_tokens: 6000, output_tokens: 400 } }) - } - return json({ id, status: 'interrupted', run_class: 'exploratory', output: {}, usage: {} }) + log.runGets.push(m[1]) + const got = runRecord(m[1]) + return json(got.json, got.status) } if (path.startsWith('/approvals')) { log.approvalGets.push(Date.now()) @@ -732,6 +637,197 @@ async function fakeApi(route) { return route.abort() // 其余写操作一律不放 } +/** + * 复核的剧本(服务端 api/copilot.review_of_run 的结果),按运行 id 挑;有的故意慢(reviewDelay、lateReview)。问数据的一轮由伪造的 + * 服务端(fakeTurns)调它,/copilot/review 照旧答它 + */ +async function reviewScript(b) { + const wait = ctl.reviewDelay[b.run_id] + if (wait) await new Promise((r) => setTimeout(r, wait)) + if (ctl.lateReview && String(b.run_id).startsWith('run-decide')) { + // 复核慢过问数据页等的上限(检查里调短成几百毫秒):答案先交出去,这一次复核的用量晚到了照样要记进这一轮 + await new Promise((r) => setTimeout(r, ctl.lateReview)) + log.reviews.push(b) + return { verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [], retry_instruction: null, + usage: { cost_usd: 0.004, unpriced_calls: 0, calls: 1 } } + } + if (String(b.run_id).startsWith('run-decide')) { + // 新服务端:第一次判不可用、给出重来的那句话;重来之后的那次没问题、不再重来。各带复核的用量 + log.reviews.push(b) + const first = b.attempt === 0 + return { verdict: first ? 'annotated' : 'ok', note: first ? '查到一半就停了' : '', answer: null, retry: first, + severity: first ? 'broken' : '', signals: first ? [{ kind: 'step_limit', detail: '用满了步数', severity: 'broken' }] : [], + retry_instruction: first ? DECIDE_RETRY : null, + usage: { cost_usd: first ? 0.003 : 0, unpriced_calls: 0, calls: first ? 1 : 0 } } + } + // 老后端的复核不认 _evidence,照样回一次改写 + if (b.run_id === 'run-evid' || b.run_id === 'run-evq') { + return { verdict: 'rewritten', note: '检索降级过,结论请对照原始数据', answer: '改写后的答案:销售额大约四万多', + original: b.run_id === 'run-evq' ? EVQ.output.answer : EVIDENCE.output['周报'], retry: false, severity: 'degraded', + signals: [{ kind: 'retrieval_degraded', detail: '检索退回关键词', severity: 'degraded' }] } + } + return { verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] } +} + +/** 发起一次运行(服务端 engine/launch.launch_run):按问题挑这次运行的 id,记下发起时的图和来源。被拒的给 {status, json} */ +function launchScript(b) { + const conversation = String(b.input?.question ?? '') + // 发起请求根本没到后端(网断了、后端正在重启) + if (conversation.includes('启动失败') && ctl.launchFail) return { abort: true } + // 发起就被拒:绑定的工具不存在(后端 runs.TOOL_MISSING,{detail, code} 的形状) + if (conversation.includes('工具不在')) { + return { status: 422, json: { detail: '绑定的工具不存在:「查数」(调用工具)绑定的 db_query__nope。请到「数据」页接入,或在节点中重新选择', + code: 'run_tool_missing' } } + } + const id = conversation.includes('整形解析失败') ? (conversation.includes('现在的说法') + ? (conversation.includes('上游') ? 'run-jsonup-now' : 'run-jsonerr-now') + : conversation.includes('节点名带超时') ? 'run-jsonslow' + : conversation.includes('上游') ? 'run-jsonup' + : conversation.includes('改写') ? 'run-jsonauth' : 'run-jsonerr') + : conversation.includes('问数据带证据') ? 'run-evq' + : conversation.includes('带证据') ? 'run-evid' + : conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long') + : conversation.includes('刷新后接着看') ? 'run-replay' + : conversation.includes('审批') ? 'run-wait' + : conversation.includes('重启') ? 'run-restart' + : conversation.includes('正在写') ? 'run-writing' + : conversation.includes('强杀') ? 'run-killed' + : conversation.includes('步数用满') ? `run-steps-${++runSeq}` + : conversation.includes('照服务端的决定') ? `run-decide-${++runSeq}` + : `run-busy-${++runSeq}` + log.runStarts.push({ id, graph: b.graph, origin_ref: b.origin_ref }) + return { id } +} + +/** 库里的运行(GET /api/runs/{id}):{status, json} */ +function runRecord(id) { + if (id === 'run-long') return { status: 200, json: { id, status: 'succeeded', run_class: 'exploratory', output: { answer: FULL }, usage: {} } } + if (id === 'run-noget' || (id === 'run-unk' && ctl.run500)) return { status: 500, json: { detail: '服务端错误(500),详情请查看服务日志' } } + if (id === 'run-unk') return { status: 200, json: { id, status: 'cancelled', run_class: 'exploratory', output: {}, usage: {} } } + if (id.startsWith('run-404')) return { status: 404, json: { detail: '运行不存在' } } + if (RUNS[id]) return { status: 200, json: { id, run_class: 'exploratory', usage: {}, ...RUNS[id] } } + if (id.startsWith('run-c') || id === 'run-b1') return { status: 200, json: { id, status: 'succeeded', run_class: 'exploratory', output: {}, usage: {} } } + if (id.startsWith('run-decide')) { + return { status: 200, json: { id, status: 'succeeded', run_class: 'exploratory', output: { answer: '三家门店。' }, + usage: { cost_usd: 0.02, calls: 2, input_tokens: 6000, output_tokens: 400 } } } + } + return { status: 200, json: { id, status: 'interrupted', run_class: 'exploratory', output: {}, usage: {} } } +} + +/** + * 助手生成过程的剧本(服务端 api/copilot.answer_ops 交出的那一串操作),按问法挑。开流之前就被拒的给 {status, json}。 + * 画布助手经 /copilot/generate-stream 收它;问数据的一轮由伪造的服务端(fakeTurns)照它跑 + */ +function copilotScript(b) { + if (ctl.scope400 && b.datasource_ids?.length) { + // 和后端 copilot._sources 同一个形状:detail 是一句话,code 是稳定的机读码 + // reworded:后端换了说法,只剩机读码认得出来 + return { status: 400, json: { detail: ctl.scope400 === 'reworded' ? '这一轮圈定的库一个也找不到了:可能已经被删掉或停用' + : '限定的数据源都已不可用,可能已被删除或停用。请取消限定后重试,或到「数据」页确认数据源存在且已启用', code: 'datasource_scope_empty' } } + } + if (String(b.instruction ?? '').includes('建流程就失败')) { + // 后端的 error 操作是三段:人话、怎么办、原始异常 + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 400 }, + { op: 'error', message: '模型返回的工作流结构有误:第 2 个节点的类型「sql」不存在', + hint: '请重试,或把需求描述得更具体', + detail: "2 validation errors for GraphSpec\nnodes.1.type\n Input should be 'input', 'output', 'llm' [type=literal_error]" }, + ] + return { ops } + } + if (String(b.instruction ?? '').includes('自查没修好')) { + // 服务端自查交回模型改过、还是没过(check failed),final 带着同一批问题:前端不该再整轮重来 + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, + ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), + { op: 'check', status: 'failed', issues: ['「查数」节点还没有选择工具'] }, + { op: 'final', graph: GRAPH, explanation: '', issues: [{ level: 'error', node_id: 'ag', message: '「查数」节点还没有选择工具' }] }, + ] + return { ops } + } + if (String(b.instruction ?? '').includes('只写了思考')) { + // 生成过程一个操作都没给(deepseek 偶发只出思考):服务端的 error 带 next=retry(backend/app/chat/turn.py 的 next_step)。 + // 重来那一次(指令里缀了「上一次没有成功」)照常直接回答;「两次都」那一问每次都只写思考 + const again = String(b.instruction).includes('上一次没有成功') && !String(b.instruction).includes('两次都') + const spent = { by_model: { m: { input_tokens: 500, output_tokens: 40, calls: 1, cost_usd: 0.001, unpriced_calls: 0 } }, + input_tokens: 500, output_tokens: 40, calls: 1, cost_usd: 0.001, unpriced_calls: 0 } + const ops = again ? [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 200 }, + { op: 'reply', text: '已经查过了:一共 3 家门店。' }, + { op: 'usage', ...spent }, + ] : [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 200 }, + { op: 'thinking', delta: '先想一想要查哪张表……' }, + { op: 'error', message: '助手本轮未完成:模型没有给出任何修改,也没有说明原因', hint: '画布未修改。可以重试,或把需求描述得更具体', + detail: '', reason: '模型没有给出任何修改,也没有说明原因', code: 'copilot_empty', next: 'retry' }, + { op: 'usage', ...spent }, + ] + return { ops } + } + if (String(b.instruction ?? '').includes('照服务端的决定') || String(b.instruction ?? '').includes(DECIDE_RETRY)) { + // 新服务端:final 带 next(backend/app/chat/turn.py),最后一条是这一轮建图的用量(answer_ops 的 usage)。 + // 「只建不跑」那一问:autorun 没写 false,next 却是 ready——前端得照 next 走 + const ready = String(b.instruction ?? '').includes('只建不跑') + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, + ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), + { op: 'done', explanation: '一张三步的图' }, + { op: 'check', status: 'passed', repaired: 0, warnings: [] }, + { op: 'final', graph: GRAPH, issues: [], explanation: '一张三步的图', autorun: true, next: ready ? 'ready' : 'run' }, + { op: 'usage', by_model: { m: { input_tokens: 4000, output_tokens: 300, calls: 1, cost_usd: 0.012, unpriced_calls: 0 } }, + input_tokens: 4000, output_tokens: 300, calls: 1, cost_usd: 0.012, unpriced_calls: 0 }, + ] + return { ops } + } + if (/刷新后接着看|停止测试/.test(String(b.instruction ?? ''))) { + // 建到一半停在闸上:刷新、关掉再打开、停止都要在建图中途发生 + const gate = String(b.instruction).includes('停止测试') ? 'stopBuildGo' : 'replayBuildGo' + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, + { op: 'plan', summary: '查一下再回答' }, + ...GRAPH.nodes.slice(0, 2).map((n) => ({ op: 'add_node', node: n })), + { gate }, + { op: 'add_node', node: GRAPH.nodes[2] }, + { op: 'done', explanation: '一张三步的图' }, + { op: 'final', graph: GRAPH, issues: [], explanation: '一张三步的图', autorun: true, next: 'run' }, + ] + return { ops } + } + if (String(b.instruction ?? '').includes('先问清楚')) { + // 助手要先问清楚才能动手(服务端 copilot_gen 的 ask):问题转出去,这一轮到此为止——没有 final,也没有 reply + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, + { op: 'ask', question: '销售额按下单日还是支付日算?' }, + ] + return { ops } + } + if (String(b.instruction ?? '').includes('表示作废')) { + // 用户说了一条数据事实:服务端核对后转出目录修改提案,只回答、不建图 + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 300 }, + { op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [ + { path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' }, + value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] }, + { op: 'reply', text: '已整理成数据目录的修改建议,确认后保存即可。' }, + ] + return { ops } + } + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 800 }, + { op: 'heartbeat', phase: 'looking', elapsed_ms: 1000 }, + // 助手按需查看了表(look 事件):过程里多一行「查看了 N 张表」,查看记录存进这一轮的 meta.looks。 + // 这个库还没有数据目录(catalog_tables: 0):那一行下面提示先去起草目录 + { op: 'look', id: 'L1', by: 'model', need: '查订单和明细', status: 'ok', elapsed_ms: 1200, objects: [ + { kind: 'table', source: 'shop', source_id: 'src-shop', named: [], picked: ['orders', 'order_items'], joined: [], + missing: [], total: 48, catalog_tables: 0 }] }, + { op: 'plan', summary: '查一下再回答' }, + ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), + { op: 'done', explanation: '一张三步的图' }, + { op: 'final', graph: GRAPH, issues: [], explanation: '一张三步的图' }, + ] + return { ops } +} + /** 每条运行的事件剧本。done=true 时剧本放完就发 stream.end */ const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, data, ts: Date.now() / 1000 }) function script(runId, after = 0) { @@ -814,6 +910,19 @@ function script(runId, after = 0) { end: 'succeeded', } } + if (runId === 'run-replay') { + // 跑到一半停在闸上:关掉再打开时运行还在跑 + return { + events: [ + ev(1, 'run.started', null, { nodes: 3 }), + ev(2, 'node.started', 'ag', { node_type: 'agent', label: '查数' }), + { gate: 'replayRunGo' }, + ev(3, 'node.finished', 'ag', { duration_ms: 900 }), + ev(4, 'run.finished', null, { output: { answer: '刷新之后接着跑完了:两家门店。' } }), + ], + end: 'succeeded', + } + } if (runId === 'run-restart' && after >= 4) { // 接着跑:从断点往下,这次跑完 return { @@ -905,7 +1014,8 @@ function script(runId, after = 0) { async function fakeStream(ws) { const runId = new URL(ws.url()).pathname.split('/')[3] ctl.wsClosed[runId] = false - ws.onClose(() => { ctl.wsClosed[runId] = true }) + let closed = false + ws.onClose(() => { closed = true; ctl.wsClosed[runId] = true }) const after = Number(new URL(ws.url()).searchParams.get('after') ?? 0) const { events, end } = script(runId, after) for (const e of events) { @@ -914,6 +1024,7 @@ async function fakeStream(ws) { continue } if (e.seq <= after) continue + if (closed) return if (runId === 'run-writing' && e.type === 'llm.token' && !ctl.writingAt) ctl.writingAt = Date.now() // 审批和中断同时产生:后端在停下的那一刻才建出这条待审批 if (runId === 'run-wait' && e.type === 'run.interrupted') { @@ -923,8 +1034,474 @@ async function fakeStream(ws) { ws.send(JSON.stringify(e)) await new Promise((r) => setTimeout(r, 15)) } + if (closed) return if (end === 'close') ws.close() else if (end) ws.send(JSON.stringify({ type: 'stream.end', status: end, data: { status: end } })) + // 剧本放完了:伪造的服务端据此认这次运行到了终态(真服务端按库里的状态,事件先落库) + if (end) ctl.wsDone[`${runId}@${after}`] = true +} + +// ---------------------------------------------------------------- 伪造的服务端一轮 + +/** + * 问数据的一轮在服务端跑(backend/app/chat/job.py;接口 backend/app/api/chat_turns.py)。页面只发起(POST /api/chat/turns)、 + * 只看(GET /api/chat/turns/{id}/stream 先回放、再推实时,断了按 seq 重连),这里照服务端的做法把一轮跑完: + * 生成(copilotScript 的剧本,原样记进 log.gens)→ final.next → 发起运行(launchScript,记进 log.runStarts)→ 等运行的剧本放完 + * (fakeStream)→ 复核(reviewScript,最多等 ctl.reviewWaitMs,晚到的照记用量)→ 按复核重来一次 → 照服务端的写法拼那一行(end)。 + * 实时流是长轮询:没有新事件就等一会儿再答,答完连接就关;页面按收到的最后一个 seq 马上接着连(和服务端断线时一样)。 + */ +const srv = { turns: new Map(), seq: 0, launched: new Set(), gen: 0 } +const ACTIVE_JOB = new Set(['queued', 'running']) +const turnActive = (T) => ACTIVE_JOB.has(T.job.status) || !!T.late +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const BUSY_TEXT = '这段对话正有一轮在进行:等它结束或先停止,再提问' + +function emit(T, type, data = {}) { + const e = { seq: T.events.length + 1, type, data, ts: Date.now() / 1000 } + T.events.push(e) + T.job.last_seq = e.seq + return e.seq +} + +/** 那一行(GET /api/conversations/{id} 里的一轮、end 里带的 turn) */ +const rowOut = (T) => ({ ...T.row, meta: T.row.meta ? { ...T.row.meta } : T.row.meta, job: { ...T.job } }) + +const addTotal = (a, b) => { + const out = { ...(a ?? {}) } + for (const k of ['cost_usd', 'unpriced_calls', 'calls', 'unfinished_calls', 'input_tokens', 'output_tokens']) { + const v = Number(b?.[k] ?? 0) + if (Number.isFinite(v) && v) out[k] = Math.round((Number(out[k] ?? 0) + v) * 1e6) / 1e6 + } + return out +} +const retireRun = (usage) => { + const { run, ...rest } = usage ?? {} + return run && Object.keys(run).length ? { ...rest, earlier: addTotal(rest.earlier, run) } : rest +} +const answerText = (output) => Object.entries(output ?? {}) + .filter(([k, v]) => !k.startsWith('_') && v != null && v !== '').map(([, v]) => (typeof v === 'string' ? v : JSON.stringify(v))).join('\n') + +/** 用户点重试:上一次的结局归档(同服务端 chat/record.attempt_record) */ +function attemptRecord(row, n) { + const meta = row.meta ?? {} + const review = row.review ?? {} + const base = { n, at: Date.now(), runId: meta.runId !== undefined ? meta.runId : row.run_id ?? null, + ...(row.answer ? { answer: row.answer.length > 4000 ? `${row.answer.slice(0, 4000)}…` : row.answer } : {}) } + const signals = (review.signals ?? []).map((x) => x.detail).join('\n') || undefined + if (meta.outcome === 'cancelled' && meta.rejected) { + const sub = meta.rejected.sub ?? '' + return { ...base, outcome: 'rejected', summary: `被驳回${sub ? `:${sub}` : ''}` } + } + if (meta.outcome === 'cancelled' || (row.status === 'error' && row.error === '已取消')) return { ...base, outcome: 'cancelled', summary: '已手动停止' } + if (meta.outcome === 'suspended') return { ...base, outcome: 'suspended', summary: '服务重启,运行中断' } + if (row.status === 'error') { + const f = meta.failure + return { ...base, outcome: 'failed', summary: f ? (f.reason ? `${f.title}:${f.reason}` : f.title) : row.error || '发生错误', + ...(meta.cause ? { cause: meta.cause } : {}) } + } + if (review.severity === 'broken') return { ...base, outcome: 'unusable', summary: `结论不可用:${review.note ?? ''}`, detail: signals } + if (review.severity === 'degraded') return { ...base, outcome: 'partial', summary: `存在缺口:${review.note ?? ''}`, detail: signals } + return { ...base, outcome: 'done', summary: '已给出答案' } +} +const attemptReason = (a) => (a.detail ? `${a.summary}(${String(a.detail).slice(0, 300)})` : a.summary) + +/** 种一轮:库里已经有的(夹具里的那一行),或者新问的 */ +function seedTurn(conversationId, id, row) { + const T = { + id, conversation_id: conversationId, row: { ...row, meta: row.meta ? { ...row.meta } : null }, + job: { status: 'succeeded', stage: null, mode: 'ask', run_id: null, attempt: 0, last_seq: 0, attempt_seq: 0, error: null }, + events: [], cancelled: false, late: null, runId: null, usage: {}, looks: [], + } + srv.turns.set(id, T) + return T +} +function knownRow(conversationId, turnId) { + const rows = typeof DETAIL[conversationId] === 'function' ? DETAIL[conversationId]() : DETAIL[conversationId] ?? [] + return rows.find((t) => t.id === turnId) ?? null +} +const scopeOf = (ids) => (ids ?? []).map((id) => { + const hit = [...SOURCES, EXTRA_SOURCE].find((x) => x.id === id || x.name === id) + return hit ? { id: hit.id, name: hit.name } : { id, name: id } +}) + +/** 这一轮还在跑时每一步之间看一眼:停了、这一段检查结束了,就不再往下写 */ +const gone = (T, epoch) => T.cancelled || T.epoch !== epoch || T.gen !== srv.gen + +async function startTurn(b) { + const mode = b.mode ?? (b.retry_of ? 'retry' : 'ask') + let T + if (mode === 'ask') { + log.posts.push(b.conversation_id) + const id = `srv-${b.conversation_id}-${++srv.seq}` + const scope = scopeOf(b.datasource_ids) + T = seedTurn(b.conversation_id, id, turn(id, b.question, { status: 'running', meta: { v: 1, ...(scope.length ? { scope } : {}) } })) + } else { + T = srv.turns.get(b.retry_of) + // 夹具里的那一行:每一段检查从库里原样的那一行起(上一段、上一套主题对它做过什么都不算) + const row = !T || T.gen !== srv.gen ? knownRow(b.conversation_id, b.retry_of) : null + if (row) T = seedTurn(b.conversation_id, b.retry_of, row) + if (!T) return { status: 404, json: { detail: '该轮对话不存在,可能已被删除' } } + } + const others = [...srv.turns.values()].filter((x) => x !== T && x.conversation_id === b.conversation_id + && ACTIVE_JOB.has(x.job.status) && x.job.stage !== 'waiting' && x.gen === srv.gen) + if (mode !== 'resume' && others.length) return { status: 409, json: { detail: BUSY_TEXT, code: 'chat_turn_busy' } } + if (mode !== 'ask' && ACTIVE_JOB.has(T.job.status)) return { status: 409, json: { detail: BUSY_TEXT, code: 'chat_turn_busy' } } + const meta = { ...(T.row.meta ?? {}) } + const runId = meta.runId !== undefined ? meta.runId : T.row.run_id ?? null + const now = Date.now() + const request = { mode, started_ms: now } + let attempts = [...(meta.attempts ?? [])] + const scope = b.datasource_ids ? scopeOf(b.datasource_ids) : meta.scope ?? [] + if (mode === 'continue') { + log.continues.push(runId) + if (runId !== 'run-restart' && runId !== 'run-susp') { + return { status: 409, json: { detail: '这次运行已完成,无需继续运行:只有失败的运行或被服务重启打断的运行可以继续运行。如需再次运行,请重新发起。' } } + } + ctl.continued[runId] = true + delete ctl.wsDone[`${runId}@4`] + } + if (mode === 'resume' && ctl.run500 && runId === 'run-unk') { + // 服务端核对那一下出错(查库超时之类):页面说没核对上,给「重新核对」 + return { status: 500, json: { detail: '服务端错误(500),详情请查看服务日志' } } + } + if (mode === 'continue' || mode === 'resume') { + request.follow = runId + if (T.row.status !== 'running' && typeof meta.ms === 'number') request.started_ms = now - meta.ms + const rec = runRecord(String(runId ?? '')) + if (T.row.status === 'running' && rec.json?.started_at) request.started_ms = Date.parse(rec.json.started_at) + if (mode === 'resume' && ['succeeded', 'failed', 'cancelled'].includes(rec.json?.status) && rec.json?.finished_at) { + request.ended_ms = Date.parse(rec.json.finished_at) + } + T.row = { ...T.row, status: 'running', error: '', meta: { ...meta, scope: scope.length ? scope : undefined } } + } else { + if (['rerun', 'max_steps', 'run'].includes(mode) && !(T.row.graph?.nodes?.length)) { + return { status: 409, json: { detail: '这一轮没有可运行的工作流,请重试本轮', code: 'chat_turn_invalid' } } + } + if (runId && mode !== 'run') { + // 服务重启挂起的旧运行没人会再去续它:顺手取消(停在审批上的不算) + const rec = runRecord(runId) + const pending = ctl.approvals.some((a) => a.run_id === runId && a.status === 'pending') + if (rec.json?.status === 'interrupted' && !pending) log.cancels.push(runId) + } + if (mode !== 'run') { + const archived = attemptRecord(T.row, attempts.length + 1) + attempts.push(archived) + if (mode === 'retry') { + const reason = attemptReason(archived) + request.base = !!T.row.graph?.nodes?.length + request.instruction = request.base + ? `这个工作流上一次没有给出可用的结果:${reason}。\n\n请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),改完继续回答原来的问题:${T.row.question}` + : `${T.row.question}\n\n(上一次没有成功:${reason}。请针对这个原因调整,重新给出完整的工作流。)` + } + if (mode === 'max_steps') request.max_steps = b.max_steps + } + const usage = mode === 'run' ? meta.usage : retireRun(meta.usage) + T.row = { ...T.row, status: 'running', error: '', answer: mode === 'run' ? T.row.answer : '', review: {}, run_id: null, + meta: mode === 'run' ? { ...meta, runId: null } : { v: 1, ...(attempts.length ? { attempts } : {}), + ...(scope.length ? { scope } : {}), ...(usage && Object.keys(usage).length ? { usage } : {}), runId: null } } + } + T.epoch = (T.epoch ?? 0) + 1 + T.gen = srv.gen + T.cancelled = false + T.late = null + T.request = request + T.scope = scope + T.job = { status: 'running', stage: null, mode, run_id: request.follow ?? null, attempt: 0, last_seq: T.events.length, + attempt_seq: 0, error: null } + T.job.attempt_seq = emit(T, 'turn.attempt', { + mode, attempts, scope, usage: T.row.meta?.usage ?? {}, started_ms: request.started_ms, + ...(request.ended_ms ? { ended_ms: request.ended_ms } : {}), ...(request.max_steps ? { max_steps: request.max_steps } : {}), + }) + log.turnStarts.push({ ...b, _turn: T.id }) + void driveTurn(T, T.epoch).catch((e) => console.log(` · 伪造的服务端出错:${e?.stack ?? e}`)) + return { status: 202, json: { turn_id: T.id, turn: rowOut(T) } } +} + +function stage(T, name) { + T.job.stage = name + emit(T, 'stage', { stage: name }) +} + +function saveRow(T, fields, meta) { + const merged = { ...(T.row.meta ?? {}), v: 1 } + for (const [k, v] of Object.entries(meta ?? {})) { + if (v === undefined || v === null) delete merged[k] + else merged[k] = v + } + merged.at = Date.now() + T.row = { ...T.row, ...fields, meta: merged } +} + +/** 这一轮到此为止:写那一行、发 end(同服务端 ChatTurnExecution._conclude) */ +function settle(T, outcome, fields, meta, output = null, run = null) { + const ms = Math.max(0, (T.request.ended_ms ?? Date.now()) - T.request.started_ms) + saveRow(T, fields, { looks: T.looks.length ? T.looks : undefined, ms, ...meta }) + emit(T, 'end', { outcome, turn: rowOut(T), output, run }) + T.job = { ...T.job, status: 'succeeded', stage: null } +} + +async function driveTurn(T, epoch) { + const req = T.request + const mode = req.mode + const autoRetry = mode === 'ask' || mode === 'retry' + let attempts = 0 + let instruction = req.instruction ?? T.row.question + let base = req.base ? T.row.graph : null + let direct = ['rerun', 'max_steps', 'run'].includes(mode) ? T.row.graph : null + if (mode === 'max_steps') { + direct = { ...direct, nodes: direct.nodes.map((n) => (n.type === 'agent' + ? { ...n, data: { ...n.data, config: { ...(n.data?.config ?? {}), max_steps: req.max_steps } } } : n)) } + T.row = { ...T.row, graph: direct } + } + let follow = req.follow ?? null + T.usage = { ...(T.row.meta?.usage ?? {}) } + T.looks = [] + while (true) { + if (!direct && !follow) { + stage(T, 'build') + const gen = { instruction, base_graph: base, intent: 'answer', conversation_id: T.conversation_id, + ...(T.scope?.length ? { datasource_ids: T.scope.map((x) => x.id) } : {}) } + log.gens.push(gen) + const got = copilotScript(gen) + if (got.status) { + const message = got.json.detail + return settle(T, 'error', { status: 'error', error: message, review: {} }, + { cause: { kind: 'refused', message, code: got.json.code ?? null }, runId: null, usage: T.usage }) + } + const ops = [] + for (const op of got.ops) { + if (op.gate) { await until(() => ctl[op.gate] || gone(T, epoch), 15000); continue } + if (gone(T, epoch)) return + emit(T, 'op', op) + if (op.op !== 'thinking' && op.op !== 'heartbeat') ops.push(op) + if (op.op === 'look') T.looks.push(op) + if (op.op === 'usage') T.usage.copilot = addTotal(T.usage.copilot, op) + if (op.op === 'final' && !op.asked) T.row = { ...T.row, graph: op.graph, explanation: op.explanation ?? '' } + await sleep(ctl.opGapMs ?? 10) + } + if (gone(T, epoch)) return + const terminal = ops.filter((o) => ['final', 'reply', 'error'].includes(o.op)).at(-1) + if (!terminal) { + const ask = ops.filter((o) => o.op === 'ask').at(-1) + const q = String(ask?.question ?? '') + return settle(T, 'asked', { status: 'done', answer: q, review: {} }, + { asked: { question: q, options: ask?.options ?? [] }, runId: null, usage: T.usage }, q ? { answer: q } : null) + } + if (terminal.op === 'error') { + if (terminal.next === 'retry' && attempts + 1 < 2) { + attempts += 1 + const reason = terminal.reason ?? String(terminal.message ?? '').replace(/^助手本轮未完成:/, '') + instruction = base ? `这个工作流上一次运行没有成功:${reason}。\n\n请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),改完继续回答原来的问题:${T.row.question}` + : `${T.row.question}\n\n(上一次没有成功:${reason}。请针对这个原因调整,重新给出完整的工作流。)` + emit(T, 'turn.retry', { kind: 'empty', reason, base: !!base, attempt: attempts, usage: T.usage }) + continue + } + const message = String(terminal.message ?? '助手本轮未完成') + return settle(T, 'error', { status: 'error', error: message, review: {} }, + { cause: { kind: 'copilot', message, hint: terminal.hint, detail: terminal.detail }, runId: null, usage: T.usage }) + } + if (terminal.op === 'reply') { + const text = String(terminal.text ?? '') + return settle(T, 'reply', { status: 'done', answer: text, review: {} }, { noQuery: true, runId: null, usage: T.usage }, + { answer: text }) + } + const blockers = (terminal.issues ?? []).filter((i) => i.level === 'error').map((i) => i.message) + const selfChecked = ops.some((o) => o.op === 'check') + const asked = ops.filter((o) => o.op === 'ask').at(-1) + const next = terminal.next ?? (blockers.length ? (selfChecked ? 'fail' : 'retry') : terminal.autorun === false ? 'ready' : 'run') + if (next === 'fail' || next === 'retry') { + return settle(T, 'error', { status: 'error', error: `生成的工作流没有通过校验:${blockers.join(';')}`, graph: terminal.graph, + explanation: terminal.explanation ?? '', review: {} }, + { cause: { kind: 'blocked', issues: blockers, self_checked: selfChecked }, runId: null, usage: T.usage }) + } + if (next === 'ready') { + const q = String(asked?.question ?? '').trim() + return settle(T, 'ready', { status: 'done', graph: terminal.graph, explanation: terminal.explanation ?? '', review: {}, + ...(q ? { answer: q } : {}) }, + { runId: null, usage: T.usage, ...(q ? { asked: { question: q, options: [] } } : {}) }, q ? { answer: q } : null) + } + direct = terminal.graph + } + + stage(T, 'run') + let runId = follow + if (!runId) { + const launched = launchScript({ graph: direct, input: { question: T.row.question, history: '' }, origin: 'chat', + origin_ref: { conversation_id: T.conversation_id, turn_id: T.id } }) + if (launched.abort || launched.status) { + const message = launched.json?.detail ?? '运行没有发起' + return settle(T, 'error', { status: 'error', error: message, review: {} }, + { cause: { kind: 'start', message, code: launched.json?.code ?? null, status: launched.status ?? 0 }, runId: null, usage: T.usage }) + } + runId = launched.id + srv.launched.add(runId) + // 同一个运行 id 各段、各套主题会再用:这一次从头算(剧本还没放、没有接着跑过) + delete ctl.wsDone[`${runId}@0`] + delete ctl.continued[runId] + delete T.usage.run + T.runId = runId + T.job.run_id = runId + saveRow(T, { run_id: runId, status: 'running' }, { runId, runClass: 'exploratory', runStatus: 'queued' }) + emit(T, 'run', { run_id: runId, status: 'queued', run_class: 'exploratory' }) + } else { + T.runId = runId + } + const graphRun = direct + direct = follow = null + const result = await followRun(T, epoch, runId) + if (gone(T, epoch)) return + const rec = runRecord(runId) + const run = { id: runId, status: result.status, run_class: 'exploratory', usage: rec.json?.usage ?? {}, + error: result.error ?? null, end_reason: result.endReason ?? null } + T.usage.run = addTotal({}, rec.json?.usage) + const meta = { runId, runClass: 'exploratory', runStatus: result.status, usage: T.usage } + if (result.status === 'gone') { + return settle(T, 'error', { status: 'error', error: '运行记录不见了', review: {} }, { ...meta, cause: { kind: 'gone' }, runStatus: 'gone' }) + } + if (result.status === 'rejected') { + return settle(T, 'cancelled', { status: 'error', error: '已取消', review: {} }, + { ...meta, runStatus: 'cancelled', outcome: 'cancelled', rejected: result.rejected }, null, { ...run, status: 'cancelled' }) + } + if (result.status === 'cancelled') { + return settle(T, 'cancelled', { status: 'error', error: '已取消', review: {} }, { ...meta, outcome: 'cancelled' }, null, run) + } + if (result.status === 'suspended') { + return settle(T, 'suspended', { status: 'error', error: '服务重启,本轮已中断', review: {} }, + { ...meta, outcome: 'suspended', runStatus: 'interrupted' }, null, { ...run, status: 'interrupted' }) + } + if (result.status === 'failed') { + return settle(T, 'error', { status: 'error', error: result.error ?? '运行失败', review: {} }, + { ...meta, cause: { kind: 'run', error: result.error ?? '', ...(result.detail ? { detail: result.detail } : {}) } }, null, run) + } + + stage(T, 'review') + // 成果带逐段证据时复核只能加说明(服务端 review_of_run 按 locked 不改写):剧本里老后端那种改写在这里收成「加说明」 + const locked = !!(result.output ?? {})._evidence + const reviewing = reviewScript({ run_id: runId, question: T.row.question, attempt: attempts }).then((r) => (locked && r?.answer + ? { ...r, answer: null, original: null, verdict: r.verdict === 'rewritten' ? 'annotated' : r.verdict } : r)) + const review = await Promise.race([reviewing, sleep(ctl.reviewWaitMs ?? 25_000).then(() => null)]) + if (gone(T, epoch)) return + if (review) { + emit(T, 'review', { result: review }) + T.usage.review = addTotal(T.usage.review, review.usage) + if (autoRetry && review.retry_instruction) { + attempts += 1 + T.usage = retireRun(T.usage) + T.runId = null + saveRow(T, { run_id: null }, { runId: null, runStatus: null, usage: T.usage }) + const reason = (review.signals ?? []).map((x) => x.detail).join(';') || review.note + emit(T, 'turn.retry', { kind: 'review', reason, base: !!graphRun?.nodes?.length, attempt: attempts, usage: T.usage }) + instruction = review.retry_instruction + base = graphRun?.nodes?.length ? graphRun : null + continue + } + } else { + // 等不及:先交原答案;那一次复核晚到了照样把用量记进这一轮 + T.late = reviewing.then((r) => { + if (T.epoch !== epoch) return + if (r?.usage) { + const usage = { ...(T.row.meta?.usage ?? {}), review: addTotal(T.row.meta?.usage?.review, r.usage) } + saveRow(T, {}, { usage }) + emit(T, 'review.late', { usage: r.usage }) + } + }).finally(() => { if (T.epoch === epoch) T.late = null }) + } + const output = result.output ?? {} + const rewritten = review && review.verdict !== 'ok' && !output._evidence ? review.answer : null + const issuance = output._issuance + return settle(T, 'done', { status: 'done', answer: rewritten || answerText(output), review: review ?? {} }, + { ...meta, runStatus: 'succeeded', queries: result.queries, + ...(issuance ? { issuance } : {}), ...(output._evidence ? { evidence: true } : {}) }, output, { ...run, status: 'succeeded' }) + } +} + +/** + * 等这次运行到终态(同服务端 _follow:停在审批上照旧等人,没有待审批的断点收成中断)。这一段检查里发起的运行按它的剧本: + * 剧本有结局的等实时流放完再收尾,没结局的(在跑、等审批、出字中)一直等到被停止;库里早就有的运行(老轮次接着收尾)按 + * GET 运行的状态 + */ +async function followRun(T, epoch, runId) { + const after = ctl.continued[runId] ? 4 : 0 + const sc = script(runId, after) + const rec = runRecord(runId) + if (rec.status === 404) return { status: 'gone' } + const pending = () => ctl.approvals.some((a) => a.run_id === runId && a.status === 'pending') + const terminal = (status) => { + const fin = sc.events.filter((e) => e.type === 'run.finished').at(-1)?.data?.output + const failed = sc.events.filter((e) => e.type === 'run.failed').at(-1)?.data + const queries = sc.events.filter((e) => e.type === 'tool.start' && String(e.data?.tool ?? '').startsWith('db_query')).length + const db = rec.status === 200 && rec.json.status === 'succeeded' && Object.keys(rec.json.output ?? {}).length ? rec.json.output : null + if (status === 'succeeded') { + // 成果以库里那份为准(事件里那份为了推送体积被切过) + return { status, queries, output: db ?? (runId === 'run-noget' ? { answer: FULL, note: '附注' } : fin ?? {}) } + } + if (status === 'failed') return { status, error: failed?.error ?? rec.json?.error ?? '', detail: failed?.detail } + if (status === 'cancelled' && rec.json?.end_reason === 'rejected') { + return { status: 'rejected', endReason: 'rejected', rejected: { sub: rec.json?.error || '被驳回' } } + } + return { status } + } + /** 停在审批上:等人(stage waiting),审批过了接着等 */ + const waitApproval = async () => { + if (T.job.stage !== 'waiting') stage(T, 'waiting') + while (!gone(T, epoch) && pending()) await sleep(80) + if (!gone(T, epoch)) stage(T, 'run') + } + const deadline = Date.now() + 120_000 + if (srv.launched.has(runId) || ctl.continued[runId]) { + const ended = sc.end === 'close' ? 'interrupted' : sc.end + if (ended) { + await until(() => ctl.wsDone[`${runId}@${after}`] || gone(T, epoch), 3000) + if (ended !== 'interrupted') return terminal(ended) + if (!pending()) return { status: 'suspended' } + } + // 剧本里不结束的:一直在跑,停在审批上的等人 + while (!gone(T, epoch) && Date.now() < deadline) { + if (pending()) await waitApproval() + await sleep(80) + } + return { status: 'running' } + } + // 库里早就有的运行 + while (!gone(T, epoch) && Date.now() < deadline) { + const status = runRecord(runId).json?.status + if (['succeeded', 'failed', 'cancelled'].includes(status)) return terminal(status) + if (status === 'interrupted') { + if (!pending()) return { status: 'suspended' } + await waitApproval() + } + await sleep(80) + } + return { status: 'running' } +} + +/** 停止(服务端 chat/job.cancel):取消生成、取消运行,那一行收成已取消 */ +function cancelTurn(T) { + if (!ACTIVE_JOB.has(T.job.status)) return false + T.cancelled = true + if (T.runId) log.cancels.push(T.runId) + saveRow(T, { status: 'error', error: '已取消', review: {} }, { outcome: 'cancelled', runStatus: T.runId ? 'cancelled' : undefined }) + emit(T, 'end', { outcome: 'cancelled', turn: rowOut(T), output: null, + run: T.runId ? { id: T.runId, status: 'cancelled', run_class: 'exploratory', usage: {} } : null }) + T.job = { ...T.job, status: 'cancelled', stage: null } + return true +} + +/** 实时流(长轮询):没有新事件就等一会儿;这一轮不在进行中、也都给完了,补一条 stream.end */ +async function streamTurn(route, T, after) { + log.streams.push({ turn: T.id, after }) + if (ctl.dropStreams > 0) { + // 断线:这一次连接什么都没给就断了(网络抖一下)。页面按收到的最后一个 seq 退避重连 + ctl.dropStreams -= 1 + return route.abort('connectionreset') + } + await until(() => T.events.length > after || !turnActive(T) || T.gen !== srv.gen, 6000) + const fresh = T.events.filter((e) => e.seq > after) + let body = fresh.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('') + if (!turnActive(T) && after + fresh.length >= T.events.length) { + body += `data: ${JSON.stringify({ type: 'stream.end', status: T.job.status, data: { status: T.job.status } })}\n\n` + } + return route.fulfill({ status: 200, contentType: 'text/event-stream', body: body || ': ping\n\n' }) } // ---------------------------------------------------------------- 浏览器 @@ -945,7 +1522,7 @@ async function open(theme = 'light', { width = 1440, height = 900, reducedMotion return route.fulfill({ json: { ui: { theme }, run: {}, limits: { max_agent_steps: 25 }, copilot: {}, embedding: {}, ...(settings ?? {}) } }) }) - await page.route(/\/api\/(conversations|copilot\/(generate-stream|review)|runs|approvals|datasources)(\/|\?|$)/, fakeApi) + await page.route(/\/api\/(conversations|chat\/turns|copilot\/(generate-stream|review)|runs|approvals|datasources)(\/|\?|$)/, fakeApi) await page.routeWebSocket(/\/api\/runs\/[^/]+\/stream/, fakeStream) return { page, ctx, errors } } @@ -1192,25 +1769,20 @@ for (const theme of THEMES) { const t = (await chatState(page)).byConversation.c0trunc.at(-1) check('显示的是完整答案', String(t.output?.answer ?? '').length === FULL.length, `${String(t.output?.answer ?? '').length} / ${FULL.length}`) check('llm.token 没进 events', !t.events.some((e) => e.type === 'llm.token'), `${t.events.length} 条`) - const saved = log.patches.slice(patchesBefore).filter((p) => typeof p.body.answer === 'string' && p.body.answer) - check('落库的也是完整答案', saved.length > 0 && saved.every((p) => p.body.answer.length === FULL.length), - saved.map((p) => p.body.answer.length).join(',')) - // 落库是排队异步发的,交付之后再等它一下 - await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta)) - const metas = log.patches.slice(patchesBefore).map((p) => p.body.meta).filter(Boolean) - const meta = metas.at(-1) - check('可信度信息跟着落库(运行、类别、查库次数)', meta?.runId === 'run-long' && meta?.runClass === 'exploratory', - JSON.stringify(metas)) + // 一轮在服务端跑(docs/design/orchestration-round §1):那一行由服务端写,页面不再 PATCH 这一轮的结局 + check('页面不写这一轮的结局(不 PATCH 轮次)', log.patches.slice(patchesBefore).length === 0, + JSON.stringify(log.patches.slice(patchesBefore).map((p) => Object.keys(p.body)))) + const meta = t.meta + check('服务端写的那一行带着可信度信息(运行、类别),页面照它摆', meta?.runId === 'run-long' && meta?.runClass === 'exploratory', + JSON.stringify(meta)) // 后端有了单独的 meta 列:review 只放复核结论,不再夹带 - check('meta 单独写,不再塞进 review', log.patches.slice(patchesBefore) - .every((p) => !p.body.review || !('meta' in p.body.review)), - JSON.stringify(log.patches.slice(patchesBefore).map((p) => Object.keys(p.body.review ?? {})))) + check('meta 单独存,不夹在 review 里', !t.reviewRaw || !('meta' in t.reviewRaw), JSON.stringify(Object.keys(t.reviewRaw ?? {}))) check('完整答案取到了,meta 里没有「没取全」', !meta?.clipped, JSON.stringify(meta?.clipped)) await page.getByText('展开全部').first().click().catch(() => {}) check('完整结尾在页面上', await shows(page, '【完整结尾】')) // 建图时助手查看了哪些表:收在「规划」里,点开是一行,再点开看表名 check('建图的 look 事件原样记进这一轮', t.ops.some((o) => o.op === 'look')) - check('查看记录存进这一轮的 meta.looks(look §2.5.8)', Array.isArray(meta?.looks) && meta.looks.length === 1 + check('查看记录随那一行存进 meta.looks(look §2.5.8)', Array.isArray(meta?.looks) && meta.looks.length === 1 && meta.looks[0].objects?.[0]?.source === 'shop', JSON.stringify(meta?.looks)) const turnCard = page.locator('[data-turn]').last() await turnCard.locator('button', { hasText: '规划' }).first().click() @@ -1252,12 +1824,8 @@ for (const theme of THEMES) { check('页面上是逐段可点的报告,不是改写后的文字', await page.locator('[data-evidence-doc] [data-seg]').count() > 0 && !(await shows(page, '改写后的答案'))) check('没有「复核改写过此答案」的对照', await page.getByText('复核改写过此答案').count() === 0) - await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta)) - const saved = log.patches.slice(patchesBefore) - check('落库的是报告原文,不是改写', saved.filter((p) => typeof p.body.answer === 'string' && p.body.answer) - .every((p) => p.body.answer === EVIDENCE.output['周报']), saved.map((p) => String(p.body.answer ?? '').slice(0, 12)).join(' | ')) - check('meta 记下这一轮有证据(刷新后回运行那里取标注)', saved.map((p) => p.body.meta).filter(Boolean).at(-1)?.evidence === true) - check('落库的复核也不带改写', saved.filter((p) => p.body.review?.verdict).every((p) => !p.body.review.answer)) + check('页面不写这一轮的结局(不 PATCH 轮次)', log.patches.slice(patchesBefore).length === 0) + check('meta 记下这一轮有证据(刷新后回运行那里取标注)', t.meta?.evidence === true, JSON.stringify(t.meta)) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() }) @@ -1304,10 +1872,8 @@ for (const theme of THEMES) { await page.waitForTimeout(300) await page.screenshot({ path: `${SHOTS}/askdata-evidence-${theme}.png` }) await page.keyboard.press('Escape') - await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta)) - const saved = log.patches.slice(patchesBefore) - check('落库的是报告原文,meta 记下这一轮有证据', saved.filter((p) => typeof p.body.answer === 'string' && p.body.answer) - .every((p) => p.body.answer === EVQ.output.answer) && saved.map((p) => p.body.meta).filter(Boolean).at(-1)?.evidence === true) + check('页面不写这一轮的结局;服务端写的 meta 记下这一轮有证据', log.patches.slice(patchesBefore).length === 0 + && t.meta?.evidence === true, JSON.stringify(t.meta)) check('没有运行时报错(实时)', errors.length === 0, errors[0] ?? '') await ctx.close() } @@ -1429,6 +1995,9 @@ for (const theme of THEMES) { () => window.__chat.getState().byConversation.c0resti?.at(-1)?.phase === 'done', null, { timeout: 8000 }) .then(() => true, () => false), (await chatState(page)).byConversation.c0resti.at(-1).phase) check('续跑发给的是这一轮自己的运行', log.continues.at(-1) === 'run-restart', log.continues.join(',')) + const cont = log.turnStarts.filter((b) => b.mode === 'continue').at(-1) + check('继续运行的请求:带着这一轮(retry_of、mode=continue),不带图', cont?.conversation_id === 'c0resti' + && cont?.retry_of === t.serverId && !cont?.graph, JSON.stringify(cont)) check('续上之后答案出来了', await shows(page, '接着跑完了:三条记录。')) check('续上之后补救按钮收起', await page.getByRole('button', { name: '继续运行' }).count() === 0) await page.waitForTimeout(300) @@ -1508,6 +2077,9 @@ for (const theme of THEMES) { (n) => window.__chat.getState().byConversation.c0histf.at(-1).phase === 'done' && window.__chat.getState().byConversation.c0histf.at(-1).attempts?.length === 1, startsBefore, { timeout: 8000 }) .then(() => true, () => false)) + const asked = log.turnStarts.filter((b) => b.retry_of === 'tf4').at(-1) + check('放宽步数的请求:带着这一轮、方式和目标步数(mode=max_steps、max_steps=24),图由服务端改', + asked?.conversation_id === 'c0histf' && asked?.mode === 'max_steps' && asked?.max_steps === 24 && !asked?.graph, JSON.stringify(asked)) const started = log.runStarts.slice(startsBefore).at(-1) check('发起的图里 agent 步数是 24', started?.graph?.nodes?.find((n) => n.type === 'agent')?.data?.config?.max_steps === 24, JSON.stringify(started?.graph?.nodes?.map((n) => n.data?.config))) @@ -1844,9 +2416,9 @@ for (const theme of THEMES) { check('不标「本条未查询数据库」(这不是回答)', !(await card.innerText()).includes('本条未查询数据库')) const box = page.getByRole('textbox', { name: '向数据提问' }) check('输入框提示回答助手的问题', (await box.getAttribute('placeholder')).includes('回答助手的问题')) - const saved = await until(() => log.patches.slice(patches).some((x) => x.body.answer === '销售额按下单日还是支付日算?')) - const body = log.patches.slice(patches).find((x) => x.body.answer)?.body - check('问题落库进历史(answer 列),meta 标着反问', saved && body.status === 'done' && body.meta?.asked === true, JSON.stringify(body)) + // 那一行由服务端写(答案列是助手问的那句话,meta 标着反问):页面不 PATCH,照 end 带来的那一行摆 + check('页面不写这一轮的结局;服务端写的那一行 meta 标着反问', log.patches.slice(patches).length === 0 + && !!turn.meta?.asked, JSON.stringify(turn.meta?.asked)) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() @@ -1924,7 +2496,8 @@ for (const theme of THEMES) { check('重来时交回去的就是服务端给的那句话', log.gens.at(-1)?.instruction === DECIDE_RETRY, log.gens.at(-1)?.instruction) check('第二次复核带着这一轮试过的次数', log.reviews.slice(reviews).map((r) => r.attempt).join(',') === '0,1', JSON.stringify(log.reviews.slice(reviews))) - const last = log.patches.filter((x) => x.body?.meta?.usage).at(-1)?.body?.meta?.usage + // 服务端写的那一行(end 带来的) + const last = turn.meta?.usage check('meta.usage 记下助手、复核、换下来的运行和这一次运行', !!last && Math.abs(last.copilot?.cost_usd - 0.024) < 1e-9 && Math.abs(last.review?.cost_usd - 0.003) < 1e-9 && Math.abs(last.earlier?.cost_usd - 0.02) < 1e-9 && Math.abs(last.run?.cost_usd - 0.02) < 1e-9, JSON.stringify(last)) @@ -1936,7 +2509,8 @@ for (const theme of THEMES) { // 复核慢过等待上限:答案先交出去,复核这一次的用量晚到了照记(这一轮的花费照实是助手 + 运行 + 复核,复核不落下) ctl.lateReview = 900 - await page.evaluate(() => window.__chatReviewWait(200)) + // 服务端最多等复核这么久(检查里调短) + ctl.reviewWaitMs = 200 const patchesBefore = log.patches.length await send(page, '照服务端的决定:复核晚到的那一轮') const delivered = await page.waitForFunction(() => { @@ -1947,12 +2521,13 @@ for (const theme of THEMES) { check('复核没等到:答案先交出去,这时还没有复核的用量', delivered && !early.usage?.review, JSON.stringify(early.usage)) await page.waitForTimeout(1200) const lateTurn = (await chatState(page)).byConversation.c0emptg.at(-1) - const lateMeta = log.patches.slice(patchesBefore).filter((x) => x.body?.meta?.usage).at(-1)?.body?.meta?.usage - check('复核晚到:它的用量照记进这一轮并落库', Math.abs((lateTurn.usage?.review?.cost_usd ?? 0) - 0.004) < 1e-9 + const lateMeta = lateTurn.meta?.usage + check('页面不写这一轮的结局', log.patches.slice(patchesBefore).length === 0) + check('复核晚到:它的用量照记进这一轮(服务端并进那一行的 meta.usage)', Math.abs((lateTurn.usage?.review?.cost_usd ?? 0) - 0.004) < 1e-9 && Math.abs((lateMeta?.review?.cost_usd ?? 0) - 0.004) < 1e-9 && Math.abs((lateMeta?.copilot?.cost_usd ?? 0) - 0.012) < 1e-9, JSON.stringify(lateMeta)) ctl.lateReview = 0 - await page.evaluate(() => window.__chatReviewWait(25_000)) + ctl.reviewWaitMs = 25_000 // 只建不跑:autorun 没写 false,next 是 ready,前端照 next 走、不发起运行 const before = log.runStarts.length @@ -2028,6 +2603,144 @@ for (const theme of THEMES) { await ctx.close() }) + await section('reconnect', '实时流断了:按收到的最后一个 seq 重连,补齐、不重复(一轮在服务端照跑)', async () => { + const { page, ctx, errors } = await open(theme) + await goto(page, 'c0emptg') + ctl.opGapMs = 120 + const streamsBefore = log.streams.length + const patchesBefore = log.patches.length + await send(page, '断线重连:查一下订单和明细') + await page.waitForFunction(() => (window.__chat.getState().byConversation.c0emptg?.at(-1)?.ops.length ?? 0) >= 2, + null, { timeout: 6000 }).catch(() => {}) + // 接下来两次连接都直接断掉(网络抖了两下):这一轮在服务端照跑,页面退避着重连 + ctl.dropStreams = 2 + const ran = await page.waitForFunction(() => { + const t = window.__chat.getState().byConversation.c0emptg?.at(-1) + return !!t?.run?.id && t.phase === 'running' + }, null, { timeout: 12000 }).then(() => true, () => false) + ctl.opGapMs = 10 + await page.waitForTimeout(300) + const t = (await chatState(page)).byConversation.c0emptg.at(-1) + const T = srv.turns.get(t.serverId) + const afters = log.streams.slice(streamsBefore).filter((x) => x.turn === t.serverId).map((x) => x.after) + const resumedAt = afters.findIndex((a, i) => i >= 2 && a > 0 && afters[i - 1] === a && afters[i - 2] === a) + check('断了之后接着连:每次都带上收到的最后一个 seq(断掉的那两次和接上的那一次是同一个)', ran && resumedAt > 0, + afters.join(',')) + check('从不往回要:重连的 seq 只增不减', afters.every((a, i) => i === 0 || a >= afters[i - 1]), afters.join(',')) + check('补齐:服务端这一轮记下的事件,页面一条不少地收到了', !!T && t.turnSeq === T.events.length, + `${t.turnSeq} / ${T?.events.length}`) + const ops = T?.events.filter((e) => e.type === 'op').length ?? -1 + check('不重复:助手的操作一条只收一份', t.ops.length === ops && t.ops.filter((o) => o.op === 'add_node').length === 3, + `${t.ops.length} / ${ops}`) + check('画布边收边长到收尾:图有 3 个节点,运行接上了', t.graph?.nodes?.length === 3 && !!t.run?.id) + check('页面不写这一轮的结局(不 PATCH)', log.patches.slice(patchesBefore).length === 0) + await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + }) + + await section('replay', '刷新、关掉再打开:服务端那一轮照跑,页面从这一次尝试的第一条接着播', async () => { + resetDb() + ctl.replayConvs = true + ctl.liveDetail = true + ctl.replayBuildGo = false + ctl.replayRunGo = false + try { + const patchesBefore = log.patches.length + let { page, ctx, errors } = await open(theme) + await goto(page, 'c0replay') + const startsBefore = log.turnStarts.length + await send(page, '刷新后接着看:各门店的订单数') + await page.waitForFunction(() => (window.__chat.getState().byConversation.c0replay?.at(-1)?.ops + .filter((o) => o.op === 'add_node').length ?? 0) === 2, null, { timeout: 6000 }).catch(() => {}) + const serverId = (await chatState(page)).byConversation.c0replay.at(-1).serverId + + // 刷新:库里那一行是 running、带着服务端的任务(建图中) + await page.reload() + await ready(page, () => !!window.__chat, '问数据页的 store(window.__chat)') + const live = await page.waitForFunction((id) => { + const t = window.__chat.getState().byConversation.c0replay?.find((x) => x.serverId === id) + return !!t && (t.phase === 'planning' || t.phase === 'building') && t.ops.filter((o) => o.op === 'add_node').length === 2 + }, serverId, { timeout: 6000 }).then(() => true, () => false) + const t1 = (await chatState(page)).byConversation.c0replay?.find((x) => x.serverId === serverId) + check('刷新之后:这一轮还在服务端建图,照那个样子摆(不当成断了)', live, `${t1?.phase} · ${t1?.ops?.length} 条操作`) + check('……从这一次尝试的第一条回放,已经建出的两步一步不少、一步不重', + t1?.ops.filter((o) => o.op === 'add_node').length === 2 && t1?.ops.filter((o) => o.op === 'plan').length === 1) + check('……刷新没有再发起一次(只发起过一次)', log.turnStarts.length - startsBefore === 1, + `${log.turnStarts.length - startsBefore} 次`) + check('……回放从这一次尝试开始(after 是 attempt_seq 之前)', log.streams.some((x) => x.turn === serverId && x.after === 0)) + + ctl.replayBuildGo = true + const running = await page.waitForFunction((id) => { + const t = window.__chat.getState().byConversation.c0replay?.find((x) => x.serverId === id) + return t?.phase === 'running' && t.run?.id === 'run-replay' && t.graph?.nodes?.length === 3 + }, serverId, { timeout: 6000 }).then(() => true, () => false) + check('接着播:建完、服务端发起了运行,画布长全', running) + await page.screenshot({ path: `${SHOTS}/replay-running-${theme}.png` }) + check('没有运行时报错(刷新后)', errors.length === 0, errors[0] ?? '') + await ctx.close() + + // 关掉再打开:运行还在跑(停在闸上) + ;({ page, ctx, errors } = await open(theme)) + await goto(page, 'c0replay') + const reopened = await page.waitForFunction((id) => { + const t = window.__chat.getState().byConversation.c0replay?.find((x) => x.serverId === id) + return t?.phase === 'running' && t.run?.id === 'run-replay' && t.ops.filter((o) => o.op === 'add_node').length === 3 + }, serverId, { timeout: 6000 }).then(() => true, () => false) + check('关掉再打开:照样接着看(运行中,建图的过程还在)', reopened, + JSON.stringify((await chatState(page)).byConversation.c0replay?.map((x) => x.phase))) + check('……运行的过程也接回来了', await page.waitForFunction((id) => + (window.__chat.getState().byConversation.c0replay?.find((x) => x.serverId === id)?.events.length ?? 0) >= 2, + serverId, { timeout: 4000 }).then(() => true, () => false)) + ctl.replayRunGo = true + const done = await page.waitForFunction((id) => + window.__chat.getState().byConversation.c0replay?.find((x) => x.serverId === id)?.phase === 'done', + serverId, { timeout: 8000 }).then(() => true, () => false) + check('跑完、复核完:答案出来了', done && await shows(page, '刷新之后接着跑完了:两家门店。')) + check('页面一个结局都没写(不 PATCH)', log.patches.slice(patchesBefore).length === 0) + await page.screenshot({ path: `${SHOTS}/replay-done-${theme}.png` }) + check('没有运行时报错(重新打开后)', errors.length === 0, errors[0] ?? '') + await ctx.close() + } finally { + ctl.replayConvs = false + ctl.liveDetail = false + ctl.replayBuildGo = true + ctl.replayRunGo = true + } + }) + + await section('stopreq', '停止:建图中也停得下,停止请求发给服务端的这一轮', async () => { + const { page, ctx, errors } = await open(theme) + await goto(page, 'c0emptg') + ctl.stopBuildGo = false + const patchesBefore = log.patches.length + const startsBefore = log.turnStarts.length + const runsBefore = log.runStarts.length + await send(page, '停止测试:各门店的订单数') + await page.waitForFunction(() => (window.__chat.getState().byConversation.c0emptg?.at(-1)?.ops.length ?? 0) >= 2, + null, { timeout: 6000 }).catch(() => {}) + const asked = log.turnStarts.slice(startsBefore).at(-1) + check('发起的请求:这段对话、这个问题,不限数据源时不带 datasource_ids', asked?.conversation_id === 'c0emptg' + && asked?.question === '停止测试:各门店的订单数' && !asked?.retry_of && !('datasource_ids' in (asked ?? {})), JSON.stringify(asked)) + const t0 = (await chatState(page)).byConversation.c0emptg.at(-1) + await page.getByRole('button', { name: '停止这一轮' }).click() + await until(() => log.turnCancels.includes(t0.serverId), 3000) + check('停止请求发给服务端的这一轮(POST /api/chat/turns/{id}/cancel)', log.turnCancels.at(-1) === t0.serverId, + `${log.turnCancels.at(-1)} / ${t0.serverId}`) + await page.waitForTimeout(300) + const t = (await chatState(page)).byConversation.c0emptg.at(-1) + check('这一轮收成已取消,不画红', t.phase === 'cancelled' && !t.failure, t.phase) + ctl.stopBuildGo = true + await page.waitForTimeout(400) + const T = srv.turns.get(t0.serverId) + check('服务端那一轮也停了:不再往下建、不发起运行', T?.job.status === 'cancelled' && log.runStarts.length === runsBefore + && T.events.filter((e) => e.type === 'op' && e.data.op === 'add_node').length === 2, `${T?.job.status} · ${log.runStarts.length - runsBefore} 次运行`) + check('建图中停止:补救区说「你在搭建工作流时停止了本轮」', await shows(page, '你在搭建工作流时停止了本轮')) + check('页面不写这一轮的结局(不 PATCH)', log.patches.slice(patchesBefore).length === 0) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + }) + await section('steps', '历史轮次的执行过程:点开、收起', async () => { const { page, ctx, errors } = await open(theme) await goto(page, 'c0idleb') @@ -2396,26 +3109,35 @@ for (const theme of THEMES) { check('补交付完成', await shows(page, '补交付的答案:三条记录。', 5000)) delete ctl.reviewDelay['run-late'] - // 停在半路的几轮:各自的终态查到之后要落库,下次进来不再核对 + // 停在半路的几轮(这一波之前页面推的老轮次,服务端没有它们的任务):页面不再自己收尾、不写库,请服务端接着等各自的 + // 那次运行、照常收尾(POST /api/chat/turns mode=resume),结局照服务端写的那一行摆 ctl.approvals = [{ id: 'ap-t', run_id: 'run-t-wait', node_id: 'ag', mode: 'approve', title: '确认一下', payload: {}, status: 'pending', response: {}, created_at: new Date().toISOString() }] const patchesBefore = log.patches.length + const resumesBefore = log.turnStarts.length await goto(page, 'c0termr') - await until(() => ['tr1', 'tr2', 'tr3', 'tr4', 'tr6'].every((id) => - log.patches.slice(patchesBefore).some((p) => p.turn === id && p.body.status === 'error')), 6000) - await page.waitForTimeout(300) - const saved = (id) => log.patches.slice(patchesBefore).filter((p) => p.turn === id).at(-1)?.body - check('在别处取消的:落库成已取消', saved('tr1')?.status === 'error' && saved('tr1')?.error === '已取消' - && saved('tr1')?.meta?.outcome === 'cancelled', JSON.stringify(saved('tr1'))) - check('在别处失败的:落库成失败,原因跟着写', saved('tr2')?.status === 'error' && /鉴权/.test(saved('tr2')?.error ?? ''), - JSON.stringify(saved('tr2'))?.slice(0, 160)) - check('服务重启挂起的:落库成中断', saved('tr3')?.status === 'error' && saved('tr3')?.meta?.outcome === 'suspended', - JSON.stringify(saved('tr3'))?.slice(0, 160)) - check('运行被删掉的:落库成「上次未完成」', saved('tr4')?.status === 'error' && saved('tr4')?.error === '上次未完成', - JSON.stringify(saved('tr4'))?.slice(0, 160)) - check('……后端现在的说法(鉴权失败(401)):同样认成鉴权失败落库', saved('tr6')?.status === 'error' - && (saved('tr6')?.error ?? '').startsWith('模型鉴权失败'), JSON.stringify(saved('tr6'))?.slice(0, 160)) - check('还在等审批的不落库(它还没结束)', !saved('tr5'), JSON.stringify(saved('tr5'))) + const phaseOf = async (id) => (await chatState(page)).byConversation.c0termr?.find((t) => t.id === id) + for (let i = 0; i < 80; i++) { + const turns = (await chatState(page)).byConversation.c0termr ?? [] + if (['tr1', 'tr2', 'tr3', 'tr4', 'tr5', 'tr6'].every((id) => ['cancelled', 'error', 'suspended', 'waiting'] + .includes(turns.find((t) => t.id === id)?.phase))) break + await page.waitForTimeout(100) + } + const resumed = log.turnStarts.slice(resumesBefore).filter((b) => b.mode === 'resume').map((b) => b.retry_of) + check('停在半路的几轮都交给服务端接着收尾(mode=resume,带着各自这一轮)', + ['tr1', 'tr2', 'tr3', 'tr4', 'tr5', 'tr6'].every((id) => resumed.includes(id)) + && log.turnStarts.slice(resumesBefore).every((b) => b.conversation_id === 'c0termr'), resumed.join(',')) + check('页面不写这几轮的结局(不 PATCH 轮次)', log.patches.slice(patchesBefore).length === 0, + JSON.stringify(log.patches.slice(patchesBefore).map((p) => p.turn))) + const tr = Object.fromEntries(await Promise.all(['tr1', 'tr2', 'tr3', 'tr4', 'tr5', 'tr6'].map(async (id) => [id, await phaseOf(id)]))) + check('在别处取消的:收成已取消', tr.tr1?.phase === 'cancelled', tr.tr1?.phase) + check('在别处失败的:收成失败,原因说人话', tr.tr2?.phase === 'error' && /鉴权/.test(tr.tr2?.error ?? ''), tr.tr2?.error) + check('服务重启挂起的:收成中断', tr.tr3?.phase === 'suspended', tr.tr3?.phase) + check('运行被删掉的:收成「上次未完成」', tr.tr4?.phase === 'error' && tr.tr4?.failure?.title === '上次未完成', + JSON.stringify(tr.tr4?.failure)) + check('……后端现在的说法(鉴权失败(401)):同样认成鉴权失败', tr.tr6?.phase === 'error' + && (tr.tr6?.error ?? '').startsWith('模型鉴权失败'), tr.tr6?.error) + check('还在等审批的:照旧等人(它还没结束)', tr.tr5?.phase === 'waiting', tr.tr5?.phase) ctl.approvals = [] check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() @@ -2428,10 +3150,10 @@ for (const theme of THEMES) { await send(page, '一份取不到运行的长答案') await page.waitForFunction(() => window.__chat.getState().byConversation.c0clips?.at(-1)?.phase === 'done', null, { timeout: 10000 }).catch(() => {}) const t = (await chatState(page)).byConversation.c0clips.at(-1) - check('GET 运行失败:先交截断的那份并标出来', t.clipped === 'partial', t.clipped) - await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta?.clipped === 'partial')) - check('「没取全」写进 meta,刷新后还认得出来', log.patches.slice(patchesBefore).some((p) => p.body.meta?.clipped === 'partial'), - JSON.stringify(log.patches.slice(patchesBefore).map((p) => p.body.meta?.clipped))) + // 一轮在服务端跑:交付读的是库里的完整成果,页面 GET 运行失败也不影响,新轮次不会再有「没取全」 + check('服务端按库里的完整成果交付:答案完整、不标「没取全」', !t.clipped && String(t.output?.answer ?? '').length === FULL.length, + `${t.clipped} · ${String(t.output?.answer ?? '').length} / ${FULL.length}`) + check('页面不写这一轮的结局(不 PATCH 轮次)', log.patches.slice(patchesBefore).length === 0) const runGetsBefore = log.runGets.length await goto(page, 'c0clipt') @@ -2453,9 +3175,12 @@ for (const theme of THEMES) { await ctx.close() }) - await section('launch', '发起运行失败:报错分得清标题和原因,补救说真话', async () => { + await section('launch', '发起那一下没到服务端:报错分得清标题和原因,补救说真话', async () => { + // 一轮在服务端跑之后,发起运行是服务端的事,「发起运行的请求没到后端」不会再发生;会断的是发起这一轮的那一下 + //(POST /api/chat/turns)。这时服务端那边还没有这一轮,补救就是原样再问一次 const { page, ctx, errors } = await open(theme) - ctl.launchFail = true + ctl.startFail = true + ctl.launchFail = false await goto(page, 'c0launu') await send(page, '一个启动失败的问题') const alert = page.locator('[data-turn] [role="alert"]').first() @@ -2464,17 +3189,19 @@ for (const theme of THEMES) { check('标题只是「无法连接服务」,原因不糊进粗体', title === '无法连接服务', title) const text = await alert.innerText().catch(() => '') check('不许诺页面上不存在的自动重试', !text.includes('自动重试'), text.replace(/\n/g, ' / ')) - const rerun = page.locator('[data-remedy="failed"]').getByRole('button', { name: '重新运行本轮' }) - check('流程已经搭好:给「重新运行本轮」,不让它重新搭', await rerun.count() === 1) - check('说出怎么办:连上之后点「重新运行本轮」', text.includes('重新运行本轮'), text.replace(/\n/g, ' / ')) + const retry = page.locator('[data-remedy="failed"]').getByRole('button', { name: '重试本轮' }) + check('给「重试本轮」', await retry.count() === 1) + check('说出怎么办:连上之后点「重试本轮」', text.includes('重试本轮'), text.replace(/\n/g, ' / ')) await page.screenshot({ path: `${SHOTS}/launch-failed-${theme}.png` }) - ctl.launchFail = false + ctl.startFail = false const gensBefore = log.gens.length - const startsBefore = log.runStarts.length - await rerun.click().catch(() => {}) - await until(() => log.runStarts.length > startsBefore, 5000) - check('重跑:原图直接发起,不再去问助手', log.runStarts.length > startsBefore && log.gens.length === gensBefore, - `${log.runStarts.length - startsBefore} 次发起 · ${log.gens.length - gensBefore} 次建图`) + const startsBefore = log.turnStarts.length + await retry.click().catch(() => {}) + await until(() => log.gens.length > gensBefore, 5000) + const again = log.turnStarts.slice(startsBefore).at(-1) + check('重试:服务端还没有这一轮,原样再问一次(同一个问题,不带 retry_of)', again?.question === '一个启动失败的问题' + && !again?.retry_of && again?.conversation_id === 'c0launu', JSON.stringify(again)) + check('这一次照常建图', log.gens.length > gensBefore, `${log.gens.length - gensBefore} 次建图`) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) ctl.launchFail = true check('没有运行时报错', errors.length === 0, errors[0] ?? '') @@ -2542,39 +3269,45 @@ for (const theme of THEMES) { await until(() => log.runStarts.length > startsBefore, 5000) await until(() => log.cancels.slice(before).includes('run-susp'), 2000) check('挂起的旧运行被取消,不会一直挂在记录里', log.cancels.slice(before).includes('run-susp'), log.cancels.slice(before).join(',')) + const rerun = log.turnStarts.filter((b) => b.retry_of === 'to1').at(-1) + check('重新运行本轮的请求:带着这一轮、mode=rerun', rerun?.mode === 'rerun' && rerun?.conversation_id === 'c0suspo', + JSON.stringify(rerun)) check('新的一次照常发起', log.runStarts.length > startsBefore) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() }) - await section('stale', '断在建图阶段:按最后一次落库算有没有动静;发起运行时 meta 跟上新运行', async () => { + await section('stale', '断在建图阶段的老轮次:页面不替它写库;重试本轮交给服务端', async () => { + // 这一波之前页面推的老轮次停在 running、又没有运行:建图那条流随页面、随上次的进程一起没了。以前页面按「多久没动静」 + // 替它写回 error;现在服务端启动时整理它们(chat/job.startup),页面只照实摆「上次未完成」,不写库。 + // 服务端还在建图的一轮带着任务(ConversationTurn.job),照它在跑的样子接着播(见「刷新后接着看」那一段) const { page, ctx, errors } = await open(theme) const patchesBefore = log.patches.length await goto(page, 'c0buildx') await shows(page, '重试之后二十分钟没动静的问题') - await until(() => log.patches.slice(patchesBefore).some((p) => p.turn === 'tx8'), 3000) - await page.waitForTimeout(400) - const mine = () => log.patches.slice(patchesBefore) - check('两天前问的、两分钟前刚重试过:不当成断了写回 error', - !mine().some((p) => p.turn === 'tx7' && p.body.status === 'error'), JSON.stringify(mine().filter((p) => p.turn === 'tx7'))) - check('重试之后二十分钟没动静的:认定断了,写回 error', - mine().some((p) => p.turn === 'tx8' && p.body.status === 'error'), JSON.stringify(mine().filter((p) => p.turn === 'tx8')).slice(0, 200)) + await page.waitForTimeout(600) + const turns = (await chatState(page)).byConversation.c0buildx ?? [] + check('页面不替老轮次写库(不 PATCH)', log.patches.slice(patchesBefore).length === 0, + JSON.stringify(log.patches.slice(patchesBefore).map((p) => p.turn))) + check('断在建图阶段的老轮次:照实说上次未完成', turns.every((t) => t.phase === 'error' && t.failure?.title === '上次未完成'), + turns.map((t) => `${t.id}:${t.phase}`).join(',')) - // 重试一次:搭好图时连 meta 一起写(有动静的凭据),发起运行时 meta.runId 换成新运行 + // 重试一次:交给服务端(带着这一轮、mode=retry),这一次的运行挂在这一轮上 const startsBefore = log.runStarts.length - const retryAt = log.patches.length - await page.locator('[data-remedy="failed"]').getByRole('button', { name: '重试本轮' }).click() + const turnStarts = log.turnStarts.length + await page.locator('[data-remedy="failed"]').last().getByRole('button', { name: '重试本轮' }).click() await until(() => log.runStarts.length > startsBefore, 6000) + const body = log.turnStarts.slice(turnStarts).at(-1) + check('重试本轮:请求带着这一轮和方式(retry_of、mode=retry)', body?.retry_of === 'tx8' && body?.mode === 'retry' + && body?.conversation_id === 'c0buildx', JSON.stringify(body)) const newRun = log.runStarts.at(-1)?.id - await until(() => log.patches.slice(retryAt).some((p) => p.turn === 'tx8' && p.body.run_id === newRun), 3000) - const ps = log.patches.slice(retryAt).filter((p) => p.turn === 'tx8').map((p) => p.body) - const graphSave = ps.find((b) => b.graph) - check('搭好图时连 meta 一起写,带上此刻', !!graphSave?.meta && Math.abs(Date.now() - (graphSave.meta.at ?? 0)) < 30_000, - JSON.stringify(graphSave?.meta ?? null)?.slice(0, 120)) - const started = ps.find((b) => b.run_id === newRun) - check('发起运行:run_id 列和 meta.runId 一起换成新运行,状态是 running', - !!newRun && started?.meta?.runId === newRun && started?.status === 'running', JSON.stringify(started ?? null)?.slice(0, 160)) + await page.waitForFunction((id) => window.__chat.getState().byConversation.c0buildx?.find((t) => t.id === 'tx8')?.run?.id === id, + newRun, { timeout: 4000 }).catch(() => {}) + const tx8 = (await chatState(page)).byConversation.c0buildx.find((t) => t.id === 'tx8') + check('服务端发起的这次运行挂在这一轮上,照常在跑', !!newRun && tx8?.run?.id === newRun && tx8?.phase === 'running', + `${tx8?.run?.id} · ${tx8?.phase}`) + check('页面照旧不写库', log.patches.slice(patchesBefore).length === 0) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close()