@tea-agent/loop-agent 0.38.0 → 0.38.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/dist/application/dag/generate-task-dag.js +9 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +97 -19
- package/dist/executors/dag-pi-executor.js +42 -4
- package/dist/executors/shell-executor.js +269 -15
- package/dist/infrastructure/harness/atomic-write.js +2 -1
- package/dist/shared/openspec-spec.js +76 -0
- package/dist/shared/operator/capabilities.js +149 -27
- package/dist/task/config-types.js +43 -0
- package/dist/task/frontend-project-capability.js +292 -38
- package/dist/worker/console/chat/deferred-turn.js +91 -0
- package/dist/worker/console/chat/mcp-inventory.js +221 -0
- package/dist/worker/console/chat/pi-runtime.js +165 -83
- package/dist/worker/console/chat/repo-walk.js +10 -1
- package/dist/worker/console/chat/routes.js +477 -55
- package/dist/worker/console/chat/semantic-activity.js +20 -8
- package/dist/worker/console/chat/session-store.js +146 -2
- package/dist/worker/console/chat/turn-process.js +17 -30
- package/dist/worker/console/harness-pi-model-matrix.js +137 -0
- package/dist/worker/console/operator-actions.js +128 -4
- package/dist/worker/console/operator-user-error.js +19 -0
- package/dist/worker/console/pi-readiness.js +8 -1
- package/dist/worker/console/recovery-cta.js +4 -4
- package/dist/worker/console/recovery-error-copy.js +4 -4
- package/dist/worker/console/security.js +20 -1
- package/dist/worker/console/server.js +6 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DSvF6RXQ.js → abnfDiagram-N423BO3Z-C4bmo3iY.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DuI4ogtJ.js → arc-SAD7McOS.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DdBbgD4y.js → architectureDiagram-T3A2C74G-DAVLUdK1.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-xQC2HELm.js → blockDiagram-VBNYF7ZC-CPPdv4vj.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DPMSgwIZ.js → c4Diagram-5PPSVZJV-D1GRGZax.js} +1 -1
- package/dist/worker/console/static/assets/channel-R6oQIDkw.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-WTEKTxin.js → chunk-2GRJ4B5K-D_Bq2ZqK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B6vZ0Pv_.js → chunk-2Q5K7J3B-CPLVtamK.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BrerNAix.js → chunk-5RXB4S5H-DnaZWcL3.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D_ch2aaI.js → chunk-5VM5RSS4-BRTmFgnq.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-VvfacNrq.js → chunk-6Q2QTUOP-BRlfYZoH.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B-uGFIg2.js → chunk-GF5L2VYU-CjNybD4u.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BlysCtod.js → chunk-JWPE2WC7-D3e-74fG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BjAUAfcT.js → chunk-KBJHAD2P-Br3ir6f2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BeZnBmb-.js → chunk-RYQCIY6F-1NgVuRbh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Bbfe3UJG.js → chunk-XXDRQBXY-BTYuvuWN.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BPXlmPWI.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BKJQnYVM.js → cose-bilkent-JH36ORCC-BC6Bgosf.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B1wtI3hU.js → cynefin-VYW2F7L2-DV-HypfL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DCF6DBSu.js → cynefinDiagram-MW4NZA55-DhTaddO-.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJyNGRlP.js → dagre-VZM6K2ZE-BnEcOmiI.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B7j1-VsZ.js → diagram-7IWD3JNH-xphGRVKp.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BREJ140a.js → diagram-B4RE2ZJO-CrdD1mRG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CDPHmHGH.js → diagram-LBJQPF4R-D4YdLsaF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtZLrez1.js → diagram-Q27KOJAE-LCKyGZ5U.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-mzybu8oU.js → diagram-UB23O5K3-BeiTe7t0.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Dk3I2uO6.js → ebnfDiagram-BXEA7PRR-BO2cq8GS.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BT97u_tQ.js → erDiagram-JOGREHBK-CmWww3Nt.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CMOtYzui.js → flowDiagram-UKHOOZJN-C2kkUYg5.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DiNydkuY.js → ganttDiagram-PKOTCBZU-DY_JZ2iQ.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Cwt6fLkl.js → gitGraphDiagram-DS77QQ5N-BizhCSw9.js} +1 -1
- package/dist/worker/console/static/assets/index-BXN89VPX.js +325 -0
- package/dist/worker/console/static/assets/index-CuH5CNFu.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-AsGIGKKB.js → infoDiagram-6WML65LV-Dlo2eK_d.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BL8ITZh3.js → ishikawaDiagram-WSZJBQD7-3EIlIIuZ.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-VPKAUX8M.js → journeyDiagram-NVQOT4AX-DiMDe5Yh.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BL56hfGp.js → kanban-definition-27J2QSJJ-sN553oIN.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BmSG3nIZ.js → linear-D9Py31Ld.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DWGK9kwP.js → mermaid.core-D7Occzk_.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS--vvKLjo0.js → mindmap-definition-FAOFIHXS-DjDRboiR.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B41mxb2-.js → pegDiagram-VL7TDLO6-Cgc4qlzp.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DTlu77sw.js → pieDiagram-7S7Q4E2Y-DxZj8pKd.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bk5A7alv.js → quadrantDiagram-CIZ2JOQS-CJ50MngR.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Csf9jzQy.js → railroadDiagram-AXF67PYL-BY-OD8Tr.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CeptoSNx.js → requirementDiagram-LRYGKXZP-CQFButfK.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-A-DLCLer.js → sankeyDiagram-W5VNT64P-BB_QUK_b.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-22VbPdOI.js → sequenceDiagram-SI44F4Z6-CqhXZjyx.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWvAyBC9.js → sizeCapture-X5ZJPWSS-2l1U4rgJ.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-qICZ75yT.js → stateDiagram-OKZ733FA-BX1FbrhJ.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DJKi0Qel.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-xi7lcMyp.js → swimlanes-SLNWSIFB-DOSnT7hb.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-YYe7EEm5.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Bu6Nl6O2.js → timeline-definition-Z64GVDOM-DVJQA7AN.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BzMz4Bn9.js → vennDiagram-T6HMQDX7-74uxl9gx.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CxaJnzc5.js → wardleyDiagram-T6FBY63Y-DsBxlcg_.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-5JK-omQK.js → xychartDiagram-ELKLHX3M-snuW9Mjw.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +39 -0
- package/dist/worker/console/static-src/app/useOperatorActions.js +1 -1
- package/dist/worker/console/static-src/app/useRecoveryActions.js +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +82 -22
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +45 -0
- package/dist/worker/console/static-src/operator-chat/resource-diagnostics.js +204 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +8 -1
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +42 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +18 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +216 -10
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +50 -22
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +26 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +755 -51
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +18 -9
- package/dist/workflows/dag/backend-test-gap-fill.js +205 -0
- package/dist/workflows/dag/backend-test-intake-context.js +32 -0
- package/dist/workflows/dag/backend-test-layout.js +133 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +167 -32
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +368 -33
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +884 -140
- package/dist/workflows/dag/backend-test-scenario-partitions.js +249 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +100 -56
- package/dist/workflows/dag/contract-validator-registrations.js +3 -1
- package/dist/workflows/dag/convergence/controller.js +141 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/dynamic-runtime/shared.js +1 -1
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +386 -0
- package/dist/workflows/dag/frontend-plan-render.js +90 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +479 -28
- package/dist/workflows/dag/init-hybrid.js +920 -110
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +77 -8
- package/dist/workflows/dag/prompt.js +45 -0
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/rerun-feedback.js +212 -0
- package/dist/workflows/dag/rerun-plan.js +93 -27
- package/dist/workflows/dag/rerun-run.js +3 -0
- package/dist/workflows/dag/rerun-task.js +61 -2
- package/dist/workflows/dag/retry-policy.js +33 -0
- package/dist/workflows/dag/types.js +148 -4
- package/dist/workflows/dag/validate.js +7 -2
- package/docs/templates/backend-test-dag.json +60 -35
- package/docs/templates/frontend-design-contract.md +40 -14
- package/docs/templates/frontend-implementation-contract.schema.json +9 -2
- package/docs/templates/frontend-task-constraints.md +40 -13
- package/docs/templates/frontend-task-requirement.md +54 -20
- package/harness.json +3 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/references/review-checklist.md +2 -0
- package/skills/frontend-implementation/references/design-spec.md +16 -19
- package/skills/frontend-implementation/references/node-contracts.md +15 -11
- package/dist/worker/console/static/assets/channel-DLVz9TYW.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeAuGcO9.js +0 -1
- package/dist/worker/console/static/assets/index-DQxiso1w.js +0 -325
- package/dist/worker/console/static/assets/index-_j0Hzo69.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DzKvfHTX.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cxqr_qM1.js +0 -8
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-panel diagnostics presentation.
|
|
3
|
+
*
|
|
4
|
+
* Pi ResourceLoader emits machine English (`warning` / `collision` + full
|
|
5
|
+
* Windows paths). The Skills / Packages / Models panels must not dump that
|
|
6
|
+
* block above the inventory — it pushes the list off-screen. Classify here,
|
|
7
|
+
* then render a header chip + grouped popover with natural Chinese copy.
|
|
8
|
+
*/
|
|
9
|
+
const COLLISION_NAME = /name\s+"([^"]+)"\s+collision/i;
|
|
10
|
+
const INVALID_CHARS = /invalid characters|must be lowercase/i;
|
|
11
|
+
const INVALID_EDGE_HYPHEN = /must not start or end with a hyphen/i;
|
|
12
|
+
const INVALID_DOUBLE_HYPHEN = /consecutive hyphens/i;
|
|
13
|
+
const GROUP_COPY = {
|
|
14
|
+
"load-failed": {
|
|
15
|
+
heading: "需要处理",
|
|
16
|
+
hint: "有些清单没读完整,下面的列表可能不全。",
|
|
17
|
+
},
|
|
18
|
+
"invalid-name": {
|
|
19
|
+
heading: "名称不规范",
|
|
20
|
+
hint: "技能已经加载,还能用。名称请改成小写字母、数字和连字符,例如 sdd-requirement-analysis。",
|
|
21
|
+
},
|
|
22
|
+
collision: {
|
|
23
|
+
heading: "同名未生效",
|
|
24
|
+
hint: "同名技能只保留先扫到的那一份,后面的不会进当前会话。",
|
|
25
|
+
},
|
|
26
|
+
other: {
|
|
27
|
+
heading: "其他提示",
|
|
28
|
+
hint: "不影响你浏览当前列表。",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const GROUP_ORDER = [
|
|
32
|
+
"load-failed",
|
|
33
|
+
"invalid-name",
|
|
34
|
+
"collision",
|
|
35
|
+
"other",
|
|
36
|
+
];
|
|
37
|
+
export function shortResourcePath(path) {
|
|
38
|
+
if (!path)
|
|
39
|
+
return undefined;
|
|
40
|
+
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
41
|
+
if (parts.length === 0)
|
|
42
|
+
return path;
|
|
43
|
+
const file = parts.at(-1);
|
|
44
|
+
if (!file)
|
|
45
|
+
return path;
|
|
46
|
+
const parent = parts.at(-2);
|
|
47
|
+
return parent ? `${parent}/${file}` : file;
|
|
48
|
+
}
|
|
49
|
+
export function skillNameFromPath(path) {
|
|
50
|
+
if (!path)
|
|
51
|
+
return undefined;
|
|
52
|
+
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
|
53
|
+
if (parts.length === 0)
|
|
54
|
+
return undefined;
|
|
55
|
+
const last = parts.at(-1);
|
|
56
|
+
if (!last)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (/^skill\.md$/i.test(last) && parts.length >= 2) {
|
|
59
|
+
return parts.at(-2);
|
|
60
|
+
}
|
|
61
|
+
return last.replace(/\.md$/i, "");
|
|
62
|
+
}
|
|
63
|
+
export function diagnosticMatchesSkill(item, skill) {
|
|
64
|
+
if (item.path) {
|
|
65
|
+
const left = item.path.replace(/\\/g, "/").toLowerCase();
|
|
66
|
+
const right = skill.path.replace(/\\/g, "/").toLowerCase();
|
|
67
|
+
if (left === right || left.endsWith(right) || right.endsWith(left)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Boolean(item.skillName && item.skillName === skill.name);
|
|
72
|
+
}
|
|
73
|
+
function shortenMessage(message) {
|
|
74
|
+
const trimmed = message.trim();
|
|
75
|
+
if (trimmed.length <= 80)
|
|
76
|
+
return trimmed;
|
|
77
|
+
return `${trimmed.slice(0, 79)}…`;
|
|
78
|
+
}
|
|
79
|
+
function titleForLoadFailed(diag) {
|
|
80
|
+
const hay = `${diag.code ?? ""} ${diag.message}`.toLowerCase();
|
|
81
|
+
if (hay.includes("preference"))
|
|
82
|
+
return "自动使用偏好读不出来";
|
|
83
|
+
if (hay.includes("package"))
|
|
84
|
+
return "软件包清单没读完整";
|
|
85
|
+
if (hay.includes("skill"))
|
|
86
|
+
return "技能清单没读完整";
|
|
87
|
+
if (hay.includes("extension"))
|
|
88
|
+
return "扩展没读完整";
|
|
89
|
+
if (hay.includes("prompt"))
|
|
90
|
+
return "提示词没读完整";
|
|
91
|
+
if (hay.includes("theme"))
|
|
92
|
+
return "主题没读完整";
|
|
93
|
+
if (hay.includes("context"))
|
|
94
|
+
return "上下文文件没读完整";
|
|
95
|
+
return "有些内容没读完整";
|
|
96
|
+
}
|
|
97
|
+
function detailForInvalidName(message) {
|
|
98
|
+
if (INVALID_DOUBLE_HYPHEN.test(message)) {
|
|
99
|
+
return "名称里不要连用两个连字符。现在这样也能用。";
|
|
100
|
+
}
|
|
101
|
+
if (INVALID_EDGE_HYPHEN.test(message)) {
|
|
102
|
+
return "名称开头和结尾不要用连字符。现在这样也能用。";
|
|
103
|
+
}
|
|
104
|
+
return "名称请只用小写字母、数字和连字符。现在这样也能用。";
|
|
105
|
+
}
|
|
106
|
+
export function classifyResourceDiagnostic(diag) {
|
|
107
|
+
const type = (diag.type ?? "warning").toLowerCase();
|
|
108
|
+
const message = diag.message ?? "";
|
|
109
|
+
const collisionMatch = message.match(COLLISION_NAME) ??
|
|
110
|
+
(type === "collision" ? message.match(/"([^"]+)"/) : null);
|
|
111
|
+
if (type === "collision" || collisionMatch) {
|
|
112
|
+
const skillName = collisionMatch?.[1] ?? skillNameFromPath(diag.path);
|
|
113
|
+
return {
|
|
114
|
+
kind: "collision",
|
|
115
|
+
tone: "warning",
|
|
116
|
+
title: skillName ? `「${skillName}」有多份` : "有一份同名技能没生效",
|
|
117
|
+
detail: "同名只保留先扫到的那一份,这份被盖掉了。",
|
|
118
|
+
path: diag.path,
|
|
119
|
+
shortPath: shortResourcePath(diag.path),
|
|
120
|
+
skillName,
|
|
121
|
+
retryable: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (INVALID_CHARS.test(message) ||
|
|
125
|
+
INVALID_EDGE_HYPHEN.test(message) ||
|
|
126
|
+
INVALID_DOUBLE_HYPHEN.test(message)) {
|
|
127
|
+
const skillName = skillNameFromPath(diag.path);
|
|
128
|
+
return {
|
|
129
|
+
kind: "invalid-name",
|
|
130
|
+
tone: "warning",
|
|
131
|
+
title: skillName ?? "有个技能名称不合规范",
|
|
132
|
+
detail: detailForInvalidName(message),
|
|
133
|
+
path: diag.path,
|
|
134
|
+
shortPath: shortResourcePath(diag.path),
|
|
135
|
+
skillName,
|
|
136
|
+
retryable: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const isLoadFailed = type === "error" ||
|
|
140
|
+
diag.retryable === true ||
|
|
141
|
+
diag.code === "RESOURCE_LOAD_FAILED" ||
|
|
142
|
+
diag.code === "PACKAGE_INVENTORY_FAILED" ||
|
|
143
|
+
diag.code === "PI_RESOURCE_PREFERENCES_MALFORMED";
|
|
144
|
+
if (isLoadFailed) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "load-failed",
|
|
147
|
+
tone: "error",
|
|
148
|
+
title: titleForLoadFailed(diag),
|
|
149
|
+
detail: shortenMessage(message),
|
|
150
|
+
path: diag.path,
|
|
151
|
+
shortPath: shortResourcePath(diag.path),
|
|
152
|
+
retryable: diag.retryable === true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
kind: "other",
|
|
157
|
+
tone: type === "error" ? "error" : "warning",
|
|
158
|
+
title: shortenMessage(message) || "一条提示",
|
|
159
|
+
path: diag.path,
|
|
160
|
+
shortPath: shortResourcePath(diag.path),
|
|
161
|
+
retryable: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export function summarizeResourceDiagnostics(diagnostics) {
|
|
165
|
+
const items = diagnostics.map(classifyResourceDiagnostic);
|
|
166
|
+
const buckets = new Map();
|
|
167
|
+
for (const item of items) {
|
|
168
|
+
const list = buckets.get(item.kind) ?? [];
|
|
169
|
+
list.push(item);
|
|
170
|
+
buckets.set(item.kind, list);
|
|
171
|
+
}
|
|
172
|
+
const groups = GROUP_ORDER.flatMap((kind) => {
|
|
173
|
+
const groupItems = buckets.get(kind);
|
|
174
|
+
if (!groupItems?.length)
|
|
175
|
+
return [];
|
|
176
|
+
return [{ kind, ...GROUP_COPY[kind], items: groupItems }];
|
|
177
|
+
});
|
|
178
|
+
const errorCount = items.filter((item) => item.tone === "error").length;
|
|
179
|
+
const warningCount = items.length - errorCount;
|
|
180
|
+
return {
|
|
181
|
+
total: items.length,
|
|
182
|
+
errorCount,
|
|
183
|
+
warningCount,
|
|
184
|
+
chipLabel: chipLabel(errorCount, warningCount),
|
|
185
|
+
groups,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function chipLabel(errorCount, warningCount) {
|
|
189
|
+
if (errorCount > 0 && warningCount > 0) {
|
|
190
|
+
return `${errorCount} 个问题 · ${warningCount} 条提示`;
|
|
191
|
+
}
|
|
192
|
+
if (errorCount > 0) {
|
|
193
|
+
return errorCount === 1 ? "1 个问题" : `${errorCount} 个问题`;
|
|
194
|
+
}
|
|
195
|
+
if (warningCount === 1)
|
|
196
|
+
return "1 条提示";
|
|
197
|
+
return `${warningCount} 条提示`;
|
|
198
|
+
}
|
|
199
|
+
export function skillHasNameWarning(skill, items) {
|
|
200
|
+
return items.some((item) => item.kind === "invalid-name" && diagnosticMatchesSkill(item, skill));
|
|
201
|
+
}
|
|
202
|
+
export function noticesForSkill(skill, items) {
|
|
203
|
+
return items.filter((item) => item.kind !== "collision" && diagnosticMatchesSkill(item, skill));
|
|
204
|
+
}
|
|
@@ -31,6 +31,13 @@ export function operatorChatEventsUrl(origin, sessionId) {
|
|
|
31
31
|
export function operatorChatAbortUrl(origin, sessionId, turnId) {
|
|
32
32
|
return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/abort`;
|
|
33
33
|
}
|
|
34
|
+
export function turnPhaseFromOwnership(bag) {
|
|
35
|
+
if (bag.stoppingTurnId || bag.stopRequested)
|
|
36
|
+
return "stopping";
|
|
37
|
+
if (bag.activeTurnId || bag.streaming || bag.submitInFlight || bag.pendingSubmission)
|
|
38
|
+
return "running";
|
|
39
|
+
return "idle";
|
|
40
|
+
}
|
|
34
41
|
export function createTurnOwnershipBag(seed) {
|
|
35
42
|
return {
|
|
36
43
|
submitInFlight: false,
|
|
@@ -53,7 +60,7 @@ export function createTurnOwnershipBag(seed) {
|
|
|
53
60
|
}
|
|
54
61
|
/** Whether a second concurrent send is blocked by ownership / busy. */
|
|
55
62
|
export function isSubmitOwnershipBusy(bag) {
|
|
56
|
-
return bag
|
|
63
|
+
return turnPhaseFromOwnership(bag) !== "idle";
|
|
57
64
|
}
|
|
58
65
|
/**
|
|
59
66
|
* Release submit ownership. Optionally scoped to a clientRequestId so a stale
|
|
@@ -3,6 +3,48 @@
|
|
|
3
3
|
* Extracted so double-submit, TURN_ACTIVE adoption, transport ambiguity, and
|
|
4
4
|
* generation fences can be unit-tested without mounting the full React tree.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* Choose the submission path from the mutable ownership facts, never from a
|
|
8
|
+
* possibly stale React render. A stopping intent without a bound Turn must
|
|
9
|
+
* remain held until reconciliation binds or releases that ownership.
|
|
10
|
+
*/
|
|
11
|
+
export function decideSubmissionRoute(input) {
|
|
12
|
+
if (input.turnPhase === "stopping") {
|
|
13
|
+
return input.blockedByTurnId
|
|
14
|
+
? {
|
|
15
|
+
action: "queue-deferred",
|
|
16
|
+
blockedByTurnId: input.blockedByTurnId,
|
|
17
|
+
}
|
|
18
|
+
: { action: "hold" };
|
|
19
|
+
}
|
|
20
|
+
return input.turnPhase === "idle" ? { action: "send-now" } : { action: "hold" };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reconcile a deferred PUT that raced with Stop completion. The same
|
|
24
|
+
* clientRequestId may move to the normal POST path only after authoritative
|
|
25
|
+
* state proves that no Turn/runtime remains busy.
|
|
26
|
+
*/
|
|
27
|
+
export function reconcileDeferredSubmissionFromState(input) {
|
|
28
|
+
const { clientRequestId, blockedByTurnId, state } = input;
|
|
29
|
+
if (!state)
|
|
30
|
+
return { action: "hold" };
|
|
31
|
+
if (state.deferredTurn?.clientRequestId === clientRequestId) {
|
|
32
|
+
return { action: "deferred", deferredTurn: state.deferredTurn };
|
|
33
|
+
}
|
|
34
|
+
if (state.activeTurn?.clientRequestId === clientRequestId) {
|
|
35
|
+
return { action: "accepted", turn: state.activeTurn };
|
|
36
|
+
}
|
|
37
|
+
if (state.latestTurn?.clientRequestId === clientRequestId) {
|
|
38
|
+
return { action: "accepted", turn: state.latestTurn };
|
|
39
|
+
}
|
|
40
|
+
const activeTurnId = state.activeTurn?.turnId ?? state.activeTurnId ?? null;
|
|
41
|
+
if (state.turnPhase === "stopping" && activeTurnId === blockedByTurnId) {
|
|
42
|
+
return { action: "retry-deferred" };
|
|
43
|
+
}
|
|
44
|
+
if (!isRuntimeBusyUnion(state))
|
|
45
|
+
return { action: "send-now" };
|
|
46
|
+
return { action: "hold" };
|
|
47
|
+
}
|
|
6
48
|
export function createPendingSubmission(input) {
|
|
7
49
|
let resolveOutcome;
|
|
8
50
|
const outcomePromise = new Promise((resolve) => {
|
|
@@ -5,6 +5,7 @@ import { hydrateSessionThread } from "../../chat/turn-process.js";
|
|
|
5
5
|
import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
|
|
6
6
|
import { CHAT_SESSION_LIST_LIMIT } from "../chat-view-types.js";
|
|
7
7
|
import { confirmationToken } from "./format.js";
|
|
8
|
+
import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
|
|
8
9
|
import { SessionTitleWatcher } from "./session-title-watcher.js";
|
|
9
10
|
export async function createSessionAndPrimeTitleWatcher(input) {
|
|
10
11
|
if (input.creatingRef?.current) {
|
|
@@ -69,6 +70,8 @@ export function useChatSessions(params) {
|
|
|
69
70
|
// Shared busy gate for sidebar + Landing create entries (one render feedback).
|
|
70
71
|
const [creatingSession, setCreatingSession] = useState(false);
|
|
71
72
|
const creatingSessionRef = useRef(false);
|
|
73
|
+
/** One-shot guard so a stale-token failure schedules a single page reload. */
|
|
74
|
+
const compactReloadScheduledRef = useRef(false);
|
|
72
75
|
// Only the latest activate/create/restore intent may commit active-session
|
|
73
76
|
// state. This prevents slow responses from an older selection overwriting a
|
|
74
77
|
// newer user choice.
|
|
@@ -215,6 +218,9 @@ export function useChatSessions(params) {
|
|
|
215
218
|
messages: hydrated.messages,
|
|
216
219
|
toolCalls: hydrated.toolCalls,
|
|
217
220
|
streaming: Boolean(body.session.activeTurnId),
|
|
221
|
+
turnPhase: body.session.turnPhase ??
|
|
222
|
+
(body.session.activeTurnId ? "running" : "idle"),
|
|
223
|
+
deferredTurn: body.session.deferredTurn ?? null,
|
|
218
224
|
});
|
|
219
225
|
window.localStorage.setItem(ACTIVE_CHAT_SESSION_STORAGE_KEY, sessionId);
|
|
220
226
|
setActivePreviewPath(null);
|
|
@@ -396,6 +402,18 @@ export function useChatSessions(params) {
|
|
|
396
402
|
});
|
|
397
403
|
const body = (await response.json().catch(() => ({})));
|
|
398
404
|
if (!response.ok) {
|
|
405
|
+
const gateCode = body.error?.code;
|
|
406
|
+
if (isMutationGateFailureCode(gateCode)) {
|
|
407
|
+
// Stale boot tokens: the page predates the current server boot.
|
|
408
|
+
// Auto-reload once so the fresh boot page re-injects tokens; keep
|
|
409
|
+
// an error line up while reloading in case reload is blocked.
|
|
410
|
+
setError(describeMutationGateFailure(gateCode));
|
|
411
|
+
if (isStaleTokenFailure(gateCode) && !compactReloadScheduledRef.current) {
|
|
412
|
+
compactReloadScheduledRef.current = true;
|
|
413
|
+
window.setTimeout(() => window.location.reload(), 1200);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
399
417
|
setError(body.error?.message ?? "上下文压缩失败");
|
|
400
418
|
return;
|
|
401
419
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect } from "react";
|
|
2
2
|
import { formatOperatorUserError } from "../../operator-user-error.js";
|
|
3
3
|
import { confirmationToken, requestId, sleep } from "./format.js";
|
|
4
|
-
import { classifyCreateTurnNetworkFailure, classifyCreateTurnResponse, createPendingSubmission, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, keepAmbiguousAfterRetry, reconcilePendingSubmissionFromState, resolveAmbiguousFromState, } from "./turn-submission.js";
|
|
5
|
-
import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, } from "./turn-stream-controller.js";
|
|
4
|
+
import { classifyCreateTurnNetworkFailure, classifyCreateTurnResponse, createPendingSubmission, decideSubmissionRoute, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, keepAmbiguousAfterRetry, reconcileDeferredSubmissionFromState, reconcilePendingSubmissionFromState, resolveAmbiguousFromState, } from "./turn-submission.js";
|
|
5
|
+
import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, turnPhaseFromOwnership, } from "./turn-stream-controller.js";
|
|
6
6
|
/** Project ChatRefs into the executable ownership bag used by the controller. */
|
|
7
7
|
function ownershipBagFromRefs(refs) {
|
|
8
8
|
return {
|
|
@@ -57,7 +57,7 @@ function clearEmptyAssistant(setMessages, assistantId) {
|
|
|
57
57
|
* detached Turn resource only; it never opens a second inline SSE consumer. */
|
|
58
58
|
export function useChatStream(params) {
|
|
59
59
|
const { origin, refs, session, thread, composer, setError, setAutoScroll, setNotice, recoveryVersion = 0, watchSessionTitle, } = params;
|
|
60
|
-
const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
|
|
60
|
+
const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, setTurnPhase, setDeferredTurn, } = thread;
|
|
61
61
|
const { setInput, setPendingImages } = composer;
|
|
62
62
|
const showNotice = useCallback((message) => {
|
|
63
63
|
if (!setNotice)
|
|
@@ -78,9 +78,10 @@ export function useChatStream(params) {
|
|
|
78
78
|
refs.assistantIdRef.current = null;
|
|
79
79
|
refs.stoppingTurnIdRef.current = null;
|
|
80
80
|
setStreaming(false);
|
|
81
|
+
setTurnPhase("idle");
|
|
81
82
|
setStreamingAssistantId(null);
|
|
82
83
|
clearEmptyAssistant(setMessages, assistantId);
|
|
83
|
-
}, [refs, setStreaming, setStreamingAssistantId, setMessages]);
|
|
84
|
+
}, [refs, setStreaming, setTurnPhase, setStreamingAssistantId, setMessages]);
|
|
84
85
|
/** Follow one session's event stream until it is explicitly aborted. The
|
|
85
86
|
* server's `stream_ready` marker separates replay from live events; this
|
|
86
87
|
* means replay cannot render duplicate messages while live text remains
|
|
@@ -242,9 +243,13 @@ export function useChatStream(params) {
|
|
|
242
243
|
/** Issue real POST /abort for a known turnId (Stop never disposes Session). */
|
|
243
244
|
const postAbortForTurn = useCallback(async (sessionId, generation, turnId) => {
|
|
244
245
|
refs.stoppingTurnIdRef.current = turnId;
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
// Optimistic visual stop: remove the running spinner immediately, but
|
|
247
|
+
// retain ownership as `stopping` until the server proves a terminal state.
|
|
248
|
+
// This keeps the composer responsive without falsely advertising idle.
|
|
249
|
+
refs.streamingRef.current = false;
|
|
250
|
+
setStreaming(false);
|
|
251
|
+
setTurnPhase("stopping");
|
|
252
|
+
setStreamingAssistantId(null);
|
|
248
253
|
try {
|
|
249
254
|
const res = await fetch(operatorChatAbortUrl(origin, sessionId, turnId), {
|
|
250
255
|
method: "POST",
|
|
@@ -278,6 +283,7 @@ export function useChatStream(params) {
|
|
|
278
283
|
markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
|
|
279
284
|
});
|
|
280
285
|
applyOwnershipBagToRefs(refs, bag);
|
|
286
|
+
setTurnPhase(turnPhaseFromOwnership(bag));
|
|
281
287
|
return;
|
|
282
288
|
}
|
|
283
289
|
applyAbortDecision(bag, decision, noticeSink(), {
|
|
@@ -285,6 +291,11 @@ export function useChatStream(params) {
|
|
|
285
291
|
markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
|
|
286
292
|
});
|
|
287
293
|
applyOwnershipBagToRefs(refs, bag);
|
|
294
|
+
setTurnPhase(turnPhaseFromOwnership(bag));
|
|
295
|
+
// Sync the post-decision busy state back to React: optimistic stop
|
|
296
|
+
// flipped it idle up front; pending/keep-busy outcomes restore it.
|
|
297
|
+
if (bag.streaming)
|
|
298
|
+
setStreaming(true);
|
|
288
299
|
}
|
|
289
300
|
catch {
|
|
290
301
|
// Abort network failure: keep busy (UIS-abort-network-fail).
|
|
@@ -297,11 +308,17 @@ export function useChatStream(params) {
|
|
|
297
308
|
networkError: true,
|
|
298
309
|
}), noticeSink());
|
|
299
310
|
applyOwnershipBagToRefs(refs, bag);
|
|
311
|
+
// Network failure: the optimistic idle was premature — restore busy
|
|
312
|
+
// so the turn is not silently dropped; /state reconcile converges.
|
|
313
|
+
refs.streamingRef.current = true;
|
|
314
|
+
setStreaming(true);
|
|
315
|
+
setTurnPhase("stopping");
|
|
300
316
|
}
|
|
301
317
|
}, [
|
|
302
318
|
origin,
|
|
303
319
|
refs,
|
|
304
320
|
setStreaming,
|
|
321
|
+
setTurnPhase,
|
|
305
322
|
markIdleIfCurrent,
|
|
306
323
|
fetchSessionState,
|
|
307
324
|
noticeSink,
|
|
@@ -351,10 +368,12 @@ export function useChatStream(params) {
|
|
|
351
368
|
},
|
|
352
369
|
});
|
|
353
370
|
applyOwnershipBagToRefs(refs, bag);
|
|
371
|
+
setTurnPhase(turnPhaseFromOwnership(bag));
|
|
354
372
|
}, [
|
|
355
373
|
refs,
|
|
356
374
|
noticeSink,
|
|
357
375
|
setStreaming,
|
|
376
|
+
setTurnPhase,
|
|
358
377
|
setStreamingAssistantId,
|
|
359
378
|
setPendingImages,
|
|
360
379
|
watchSessionTitle,
|
|
@@ -363,20 +382,199 @@ export function useChatStream(params) {
|
|
|
363
382
|
setError,
|
|
364
383
|
postAbortForTurn,
|
|
365
384
|
]);
|
|
385
|
+
const queueDeferredTurn = useCallback(async (input) => {
|
|
386
|
+
const activeSession = refs.sessionRef.current;
|
|
387
|
+
if (!activeSession || (!input.text.trim() && input.images.length === 0))
|
|
388
|
+
return { action: "handled" };
|
|
389
|
+
const existing = thread.deferredTurn;
|
|
390
|
+
showNotice("正在排队…");
|
|
391
|
+
const acceptDeferred = (deferredTurn) => {
|
|
392
|
+
setDeferredTurn(deferredTurn);
|
|
393
|
+
setInput("");
|
|
394
|
+
setPendingImages([]);
|
|
395
|
+
setError(null);
|
|
396
|
+
showNotice(existing ? "已更新停止后发送的消息" : "已排队,将在上一轮停止后自动发送");
|
|
397
|
+
};
|
|
398
|
+
const acceptDispatched = () => {
|
|
399
|
+
setDeferredTurn(null);
|
|
400
|
+
setInput("");
|
|
401
|
+
setPendingImages([]);
|
|
402
|
+
setError(null);
|
|
403
|
+
showNotice("消息已进入发送");
|
|
404
|
+
};
|
|
405
|
+
const putOnce = async () => {
|
|
406
|
+
const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn`, {
|
|
407
|
+
method: "PUT",
|
|
408
|
+
headers: {
|
|
409
|
+
"Content-Type": "application/json",
|
|
410
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
411
|
+
},
|
|
412
|
+
credentials: "include",
|
|
413
|
+
body: JSON.stringify({
|
|
414
|
+
clientRequestId: input.clientRequestId,
|
|
415
|
+
blockedByTurnId: input.blockedByTurnId,
|
|
416
|
+
text: input.text,
|
|
417
|
+
images: input.images,
|
|
418
|
+
...(existing ? { expectedRevision: existing.revision } : {}),
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
const body = (await response.json().catch(() => ({})));
|
|
422
|
+
return { response, body };
|
|
423
|
+
};
|
|
424
|
+
try {
|
|
425
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
426
|
+
const { response, body } = await putOnce();
|
|
427
|
+
if (response.ok && body.accepted && body.deferredTurn) {
|
|
428
|
+
acceptDeferred(body.deferredTurn);
|
|
429
|
+
return { action: "handled" };
|
|
430
|
+
}
|
|
431
|
+
if (body.deferredTurn)
|
|
432
|
+
setDeferredTurn(body.deferredTurn);
|
|
433
|
+
if (body.error?.code !== "TURN_NOT_STOPPING") {
|
|
434
|
+
setError(body.error?.message ??
|
|
435
|
+
`待发送消息未被服务端接受(HTTP ${response.status})`);
|
|
436
|
+
return { action: "handled" };
|
|
437
|
+
}
|
|
438
|
+
const canonical = await fetchSessionState(activeSession.sessionId);
|
|
439
|
+
const decision = reconcileDeferredSubmissionFromState({
|
|
440
|
+
clientRequestId: input.clientRequestId,
|
|
441
|
+
blockedByTurnId: input.blockedByTurnId,
|
|
442
|
+
state: canonical ?? undefined,
|
|
443
|
+
});
|
|
444
|
+
if (decision.action === "deferred") {
|
|
445
|
+
acceptDeferred(decision.deferredTurn);
|
|
446
|
+
return { action: "handled" };
|
|
447
|
+
}
|
|
448
|
+
if (decision.action === "accepted") {
|
|
449
|
+
acceptDispatched();
|
|
450
|
+
return { action: "handled" };
|
|
451
|
+
}
|
|
452
|
+
if (decision.action === "retry-deferred" && attempt === 0)
|
|
453
|
+
continue;
|
|
454
|
+
if (decision.action === "send-now" && !existing) {
|
|
455
|
+
showNotice("上一轮已停止,正在发送当前消息…");
|
|
456
|
+
return { action: "send-now" };
|
|
457
|
+
}
|
|
458
|
+
setError(body.error?.message ?? "停止状态已变化,当前消息仍保留在输入框");
|
|
459
|
+
return { action: "handled" };
|
|
460
|
+
}
|
|
461
|
+
return { action: "handled" };
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
const canonical = await fetchSessionState(activeSession.sessionId);
|
|
465
|
+
const decision = reconcileDeferredSubmissionFromState({
|
|
466
|
+
clientRequestId: input.clientRequestId,
|
|
467
|
+
blockedByTurnId: input.blockedByTurnId,
|
|
468
|
+
state: canonical ?? undefined,
|
|
469
|
+
});
|
|
470
|
+
if (decision.action === "deferred") {
|
|
471
|
+
acceptDeferred(decision.deferredTurn);
|
|
472
|
+
return { action: "handled" };
|
|
473
|
+
}
|
|
474
|
+
if (decision.action === "accepted") {
|
|
475
|
+
acceptDispatched();
|
|
476
|
+
return { action: "handled" };
|
|
477
|
+
}
|
|
478
|
+
if (decision.action === "send-now" && !existing) {
|
|
479
|
+
showNotice("上一轮已停止,正在发送当前消息…");
|
|
480
|
+
return { action: "send-now" };
|
|
481
|
+
}
|
|
482
|
+
setError(error instanceof Error
|
|
483
|
+
? `无法确认消息是否已排队:${error.message}`
|
|
484
|
+
: "无法确认消息是否已排队,请刷新后查看服务端状态");
|
|
485
|
+
return { action: "handled" };
|
|
486
|
+
}
|
|
487
|
+
}, [
|
|
488
|
+
origin,
|
|
489
|
+
refs,
|
|
490
|
+
thread.deferredTurn,
|
|
491
|
+
setDeferredTurn,
|
|
492
|
+
setInput,
|
|
493
|
+
setPendingImages,
|
|
494
|
+
setError,
|
|
495
|
+
showNotice,
|
|
496
|
+
fetchSessionState,
|
|
497
|
+
]);
|
|
498
|
+
const cancelDeferredTurn = useCallback(async () => {
|
|
499
|
+
const activeSession = refs.sessionRef.current;
|
|
500
|
+
const deferred = thread.deferredTurn;
|
|
501
|
+
if (!activeSession || !deferred)
|
|
502
|
+
return;
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn`, {
|
|
505
|
+
method: "DELETE",
|
|
506
|
+
headers: {
|
|
507
|
+
"Content-Type": "application/json",
|
|
508
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
509
|
+
},
|
|
510
|
+
credentials: "include",
|
|
511
|
+
body: JSON.stringify({
|
|
512
|
+
clientRequestId: deferred.clientRequestId,
|
|
513
|
+
expectedRevision: deferred.revision,
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
const body = (await response.json().catch(() => ({})));
|
|
517
|
+
if (!response.ok || !body.cancelled) {
|
|
518
|
+
setError(body.error?.message ?? "待发送消息撤回失败");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
setDeferredTurn(null);
|
|
522
|
+
setInput(body.deferredTurn?.text ?? "");
|
|
523
|
+
setPendingImages(body.deferredTurn?.images ?? []);
|
|
524
|
+
setError(null);
|
|
525
|
+
showNotice("待发送消息已移回输入框");
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
529
|
+
}
|
|
530
|
+
}, [
|
|
531
|
+
origin,
|
|
532
|
+
refs,
|
|
533
|
+
thread.deferredTurn,
|
|
534
|
+
setDeferredTurn,
|
|
535
|
+
setInput,
|
|
536
|
+
setPendingImages,
|
|
537
|
+
setError,
|
|
538
|
+
showNotice,
|
|
539
|
+
]);
|
|
366
540
|
const sendPrompt = useCallback(async (text, images) => {
|
|
541
|
+
const initialSession = refs.sessionRef.current;
|
|
542
|
+
if (!initialSession || (!text.trim() && images.length === 0))
|
|
543
|
+
return;
|
|
544
|
+
const ownership = ownershipBagFromRefs(refs);
|
|
545
|
+
const route = decideSubmissionRoute({
|
|
546
|
+
turnPhase: turnPhaseFromOwnership(ownership),
|
|
547
|
+
blockedByTurnId: ownership.stoppingTurnId ?? ownership.activeTurnId,
|
|
548
|
+
});
|
|
549
|
+
const clientRequestId = requestId("chat-submit");
|
|
550
|
+
if (route.action === "hold") {
|
|
551
|
+
if (turnPhaseFromOwnership(ownership) === "stopping")
|
|
552
|
+
showNotice("正在确认上一轮停止状态,当前消息已保留");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (route.action === "queue-deferred") {
|
|
556
|
+
const deferred = await queueDeferredTurn({
|
|
557
|
+
text,
|
|
558
|
+
images,
|
|
559
|
+
clientRequestId,
|
|
560
|
+
blockedByTurnId: route.blockedByTurnId,
|
|
561
|
+
});
|
|
562
|
+
if (deferred.action !== "send-now")
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
367
565
|
const activeSession = refs.sessionRef.current;
|
|
368
566
|
const generation = refs.sessionGenerationRef.current;
|
|
369
567
|
if (!activeSession ||
|
|
568
|
+
activeSession.sessionId !== initialSession.sessionId ||
|
|
370
569
|
(!text.trim() && images.length === 0) ||
|
|
371
570
|
refs.streamingRef.current ||
|
|
372
571
|
refs.submitInFlightRef.current ||
|
|
373
572
|
// AC-R5-002: ownership lease blocks send until terminal release.
|
|
374
|
-
|
|
573
|
+
refs.pendingSubmissionRef.current)
|
|
375
574
|
return;
|
|
376
575
|
// AC-001: acquire synchronous submit ownership before the first await.
|
|
377
576
|
refs.submitInFlightRef.current = true;
|
|
378
577
|
const submitGeneration = ++refs.submitGenerationRef.current;
|
|
379
|
-
const clientRequestId = requestId("chat-submit");
|
|
380
578
|
const userMessageId = requestId("u");
|
|
381
579
|
const assistantId = requestId("a");
|
|
382
580
|
const draftText = text.trim();
|
|
@@ -424,6 +622,7 @@ export function useChatStream(params) {
|
|
|
424
622
|
setAutoScroll(true);
|
|
425
623
|
setError(null);
|
|
426
624
|
refs.streamingRef.current = true;
|
|
625
|
+
setTurnPhase("running");
|
|
427
626
|
refs.assistantIdRef.current = assistantId;
|
|
428
627
|
setStreaming(true);
|
|
429
628
|
setStreamingAssistantId(assistantId);
|
|
@@ -519,7 +718,9 @@ export function useChatStream(params) {
|
|
|
519
718
|
setAutoScroll,
|
|
520
719
|
setError,
|
|
521
720
|
setStreaming,
|
|
721
|
+
setTurnPhase,
|
|
522
722
|
setStreamingAssistantId,
|
|
723
|
+
queueDeferredTurn,
|
|
523
724
|
]);
|
|
524
725
|
const handleStop = useCallback(async () => {
|
|
525
726
|
// AC-R5-001: durable stopRequested is set synchronously before any await so
|
|
@@ -535,6 +736,7 @@ export function useChatStream(params) {
|
|
|
535
736
|
notice: noticeSink(),
|
|
536
737
|
});
|
|
537
738
|
applyOwnershipBagToRefs(refs, bag);
|
|
739
|
+
setTurnPhase("stopping");
|
|
538
740
|
// Immediate abort already scheduled for a known turnId — coalesce via
|
|
539
741
|
// abortPostedTurnIds; stopInFlight only gates concurrent in-flight POSTs.
|
|
540
742
|
if (stopResult.abortedTurnId) {
|
|
@@ -643,6 +845,9 @@ export function useChatStream(params) {
|
|
|
643
845
|
},
|
|
644
846
|
});
|
|
645
847
|
applyOwnershipBagToRefs(refs, bag);
|
|
848
|
+
setTurnPhase(turnPhaseFromOwnership(bag));
|
|
849
|
+
if (state && "deferredTurn" in state)
|
|
850
|
+
setDeferredTurn(state.deferredTurn ?? null);
|
|
646
851
|
if (result === "bound") {
|
|
647
852
|
// US-BUSY-ACTIVE-BOUND: keep streaming/busy with same id.
|
|
648
853
|
setStreaming(true);
|
|
@@ -742,6 +947,7 @@ export function useChatStream(params) {
|
|
|
742
947
|
refs,
|
|
743
948
|
session,
|
|
744
949
|
streaming,
|
|
950
|
+
setTurnPhase,
|
|
745
951
|
markIdleIfCurrent,
|
|
746
952
|
releaseSubmitOwnership,
|
|
747
953
|
postAbortForTurn,
|
|
@@ -763,5 +969,5 @@ export function useChatStream(params) {
|
|
|
763
969
|
}
|
|
764
970
|
};
|
|
765
971
|
}, [session, recoveryVersion, refs, startEventsStream]);
|
|
766
|
-
return { sendPrompt, handleStop, openEventsStream };
|
|
972
|
+
return { sendPrompt, handleStop, cancelDeferredTurn, openEventsStream };
|
|
767
973
|
}
|