mingdao-harness 0.6.39 → 0.6.41
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/docs/ARCHITECTURE.md +3 -3
- package/package.json +5 -2
- package/src/agent.js +50 -2
- package/src/session-events.js +255 -2
- package/src/session.js +46 -3
- package/src/web/app.js +207 -7
- package/src/web/index.html +14 -4
- package/src/web/routes/domains/misc.js +6 -0
- package/src/web/routes/domains/sessions.js +43 -2
- package/src/web/server.js +118 -11
- package/src/web/viewmodel.js +158 -0
- package/src/web/web-io.js +8 -0
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -96,7 +96,7 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
96
96
|
### 2.4 会话 / 记忆 / 任务(11)
|
|
97
97
|
|
|
98
98
|
- `session.js` —— 会话持久化:`<home>/sessions/<时间戳>-<随机>.jsonl` 每轮追加,`--continue` 载入最近一次,`--resume` 打开选择器 —— 会话文件是消息结构的唯一权威,检查点等旁挂状态另存侧车文件。
|
|
99
|
-
- `session-events.js` —— 会话事件日志(v0.7.0 §1.1 切片 A):`<session>.events.ndjson` 追加 12 类事件 `{seq,time,type,data}`(session/turn/step/tool/message/usage/compact/interrupt/checkpoint)—— **seq 是唯一关联键**:跨进程短临界区锁内「读尾行 last+1」、单次 append 后回读校验;读侧跳过 torn 行/未知 type 并返回 `skipped`;`replayToMessages()` 是纯函数(`message.append` 提供消息本体、`compact` 重置基线、`interrupt` 延迟补 tool
|
|
99
|
+
- `session-events.js` —— 会话事件日志(v0.7.0 §1.1 切片 A):`<session>.events.ndjson` 追加 12 类事件 `{seq,time,type,data}`(session/turn/step/tool/message/usage/compact/interrupt/checkpoint)—— **seq 是唯一关联键**:跨进程短临界区锁内「读尾行 last+1」、单次 append 后回读校验;读侧跳过 torn 行/未知 type 并返回 `skipped`;`replayToMessages()` 是纯函数(`message.append` 提供消息本体、`compact` 重置基线、`interrupt` 延迟补 tool 占位),由事件流重放的消息数组必须与原消息数组逐字节等价;事件文件是**旁路**:`loadSession`/`rewriteSession` 不读它,`cfg.eventLog=false` 或 `MINGDAO_EVENT_LOG=0` 可整体回滚;切片 B1 起新增两个纯函数供传输层使用——`readAfter(eventFile, afterSeq)` 只读游标读取(`seq > after`,返回 `{ok,events,lastSeq,skipped}`,文件不存在按空窗口)与 `continuityOf(seqs)` 连续性自检(`{ok,gaps:[{after,before}]}`,seq 必须从 1 起逐条 +1)——仅 `GET /api/events?file=&after=` 与 `GET /api/session` 的 `lastEventSeq` 读事件文件,SSE 帧/UI 仍不读。
|
|
100
100
|
- `session-index.js` —— 会话检索索引:词表倒排 + 按 mtime/size 的增量同步,分片存 `<home>/sessions-index/<2位sha1前缀>.json`(256 片)—— 未变化直接用缓存词表,>8MB 文件跳过并移除条目;中文按 bigram + 单字、查询串同口径分词后 AND 匹配。
|
|
101
101
|
- `memory.js` —— 长记忆与自主进化:用户记忆 `~/.mingdao/AGENTS.md`(手动 + 会话结束自动提取用户偏好并去重追加)、会话日志 `journal.jsonl`(默认不注入系统提示,`--journal` / WebUI 勾选才取最近 3 条)—— 自动提取用 executor 模型(约几十 token),`autoMemory: false` 可关。
|
|
102
102
|
- `tasks.js` —— 多会话后台任务面板:`mingdao run` 拉起独立 worker 子进程,状态落 `<home>/tasks/<id>.json`,命令族 `run` / `tasks` / `tasks watch` / `tasks kill` —— taskId 用不可枚举随机值;kill 前必须过 PID 归属校验(见 `proc.js`)。
|
|
@@ -121,12 +121,12 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
121
121
|
|
|
122
122
|
### 2.7 Web 层(16)
|
|
123
123
|
|
|
124
|
-
- `web/server.js` —— WebUI 服务器:零依赖 `node:http` + SSE,复用 `createAgent` + `createPermission` + `createWebIO` —— 非回环绑定强制访问令牌(参数/配置/本次随机生成,`timingSafeEqual` 比对);Host 白名单防 DNS rebinding;同一会话并发回合由进程级 `busySessions`
|
|
124
|
+
- `web/server.js` —— WebUI 服务器:零依赖 `node:http` + SSE,复用 `createAgent` + `createPermission` + `createWebIO` —— 非回环绑定强制访问令牌(参数/配置/本次随机生成,`timingSafeEqual` 比对);Host 白名单防 DNS rebinding;同一会话并发回合由进程级 `busySessions` 串行化;SSE 每帧带**按 task 从 1 起**的单调 `streamSeq` 与 `id:` 行(心跳 progress 同样带),供客户端跳号检测/断线续订;**双游标**:`streamSeq`(每 task,漏帧检测)与事件日志 seq(每会话跨轮,`lastEventSeq` / `/api/events?after=`)互不推导;帧上既有的 `seq` 是工具卡片配对号(toolStart↔tool 同号),不属于这两套游标。
|
|
125
125
|
- `web/auth.js`(v0.7.0 §1.5)—— WebUI 访问控制的**纯函数层**(从 `server.js` 逐字切出):`resolveWebToken`(参数 > 配置 > 非回环随机生成)、`extractRequestToken`(`?token=` > `X-MingDao-Token` > `Authorization: Bearer`)、`resolveAuthDecision`(放行/拒绝 + `via` 通道)、`tokenEquals`(`timingSafeEqual`,长度不等直接 false)、`isTrustedHost`(Host 白名单防 DNS rebinding)、`authStartupNotice`(启动文案)—— **不读全局、不写 `res`**;401/403 仍由 `routes/api.js` 唯一写入。
|
|
126
126
|
- `web/provider-cache.js`(v0.7.0 §1.5)—— Provider 缓存 / 预检运行时(从 `server.js` 逐字切出,依赖注入工厂):`createProviderCache(deps)` 提供 `providerFingerprint`(代际号 + baseUrl + apiKey 的 sha256 摘要 + timeout 配置)、`getProviderFor`、`ensureProbe`(单次 15s 上限 / TTL 备忘 / 失败只降级)、`invalidateProviderCache`(实例与探测备忘**一起清**)与 `start`(缺 Key 告警 + 本地端点观察者 + 初始 provider)—— 缓存键含出网指纹,配置一变新任务绝不拿旧实例(H-04)。
|
|
127
127
|
- `web/routes/api.js` —— API 编排器:访问控制(token / Host / CSRF)→ 静态壳资源 → 按域分发 —— 跨站浏览器请求按 `Sec-Fetch-Site` 一律 403。
|
|
128
128
|
- `web/routes/domains/config.js` —— 配置域:`/api/state` `/api/config` `/api/models-config`,服务商 Key 管理、自定义模型增删改、API 地址覆盖、系统状态快照 —— `endpointChangeGuard` 防「改地址把 Key 送到别处」。
|
|
129
|
-
- `web/routes/domains/sessions.js` —— 会话域:`/api/sessions` `/api/session` `/api/draft
|
|
129
|
+
- `web/routes/domains/sessions.js` —— 会话域:`/api/sessions` `/api/session` `/api/events` `/api/draft`,列表/搜索、载入(聚焦工作空间,含事件日志位置 `lastEventSeq`)、重命名/删除、按**事件 seq** 游标只读事件日志(`/api/events?file=&after=`,只接受 basename,响应带 `seqKind:'event'`)、按会话隔离的草稿槽。
|
|
130
130
|
- `web/routes/domains/skills.js` —— 技能域:`/api/skills` `/api/skill-library` `/api/mcp-presets`,技能列表/安装/卸载、内置库与线上注册表搜索、MCP 预设接入。
|
|
131
131
|
- `web/routes/domains/schedule.js` —— 调度域:`/api/schedule` `/api/tasks`,定时任务增删暂停恢复/链式编排,聊天 SSE 任务 + 后台 worker + 调度任务合并面板。
|
|
132
132
|
- `web/routes/domains/sync.js` —— 同步域:`/api/sync` `/api/sync-conflicts`,登录/登出/推拉/改密/分享与冲突处理。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mingdao-harness",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.41",
|
|
4
4
|
"description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -73,7 +73,10 @@
|
|
|
73
73
|
"typecheck:strict": "node scripts/strict-ratchet.mjs",
|
|
74
74
|
"preversion": "node test/smoke.js && node test/e2e-local.js && npm run bench",
|
|
75
75
|
"version": "node scripts/bump-changelog.mjs",
|
|
76
|
-
"postversion": "git add package.json package-lock.json CHANGELOG.md 2>/dev/null; git commit -m \"chore: release v$npm_package_version\" --no-verify || true"
|
|
76
|
+
"postversion": "git add package.json package-lock.json CHANGELOG.md 2>/dev/null; git commit -m \"chore: release v$npm_package_version\" --no-verify || true",
|
|
77
|
+
"preflight": "node scripts/preflight.mjs",
|
|
78
|
+
"mutate:shard": "node test/mutate/shard.mjs",
|
|
79
|
+
"mutate:changed": "node test/mutate/run.mjs --changed HEAD~1"
|
|
77
80
|
},
|
|
78
81
|
"devDependencies": {
|
|
79
82
|
"@types/node": "^26.2.0",
|
package/src/agent.js
CHANGED
|
@@ -46,6 +46,8 @@ import { appendEvent, eventFileFor, eventLogEnabled, missingToolCallIds, setEven
|
|
|
46
46
|
// ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断);
|
|
47
47
|
// ③ 工具参数怎么解析(v0.6.17 第四刀:JSON + 容错 + 归一化 + 错误文案)。
|
|
48
48
|
import { READONLY_TIER_SET, visibleToolsFor, serializeToolResult, toolResultMessage, parseToolArgs } from './tools-flow.js';
|
|
49
|
+
// v0.6.40:插话"有→继续、无→收尾"的判据与前端共用同一份纯函数(viewmodel 不读 DOM/全局)。
|
|
50
|
+
import { shouldContinueAfterModel } from './web/viewmodel.js';
|
|
49
51
|
|
|
50
52
|
const MAX_STEPS = 24;
|
|
51
53
|
// 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
|
|
@@ -138,14 +140,55 @@ function resolveAgentEventFile(sessionRef, cfg) {
|
|
|
138
140
|
return null;
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* v0.6.40:把插话缓冲里的**全部**条目注入当前回合的消息数组。
|
|
145
|
+
*
|
|
146
|
+
* 车道语义(与 next-turn 排队严格区分):
|
|
147
|
+
* · 每个 step 边界调用一次,`drain()` 必须**全量取走**(不是一条一步);
|
|
148
|
+
* · 每条插话以 `{role:'user', content:text}` 追加在末尾——模型下一步请求就能看到;
|
|
149
|
+
* · 通过既有 io 事件通道推 `{type:'steer', text}`(Web 端渲染"↳ 已插入本轮")。
|
|
150
|
+
*
|
|
151
|
+
* 纯判据("有插话→继续")在 web/viewmodel.js 的 shouldContinueAfterModel;这里只做副作用。
|
|
152
|
+
* drain 抛异常的边界:视为本轮没有插话(插话是旁路,绝不能让一个坏 buffer 打断回合)。
|
|
153
|
+
*
|
|
154
|
+
* @param {{ drain?: () => any } | undefined} steering
|
|
155
|
+
* @param {any[]} messages
|
|
156
|
+
* @param {any} io
|
|
157
|
+
* @returns {any[]} 本次实际注入的条目(供收尾分支判断是否继续)
|
|
158
|
+
*/
|
|
159
|
+
function drainSteeringInto(steering, messages, io) {
|
|
160
|
+
/** @type {any} */
|
|
161
|
+
let raw;
|
|
162
|
+
try {
|
|
163
|
+
raw = steering?.drain?.() || [];
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.taken) ? raw.taken : [];
|
|
168
|
+
/** @type {any[]} */
|
|
169
|
+
const injected = [];
|
|
170
|
+
for (const it of list) {
|
|
171
|
+
const text = String(it && typeof it === 'object' ? it.text ?? '' : it ?? '');
|
|
172
|
+
if (!text.trim()) continue;
|
|
173
|
+
messages.push({ role: 'user', content: text });
|
|
174
|
+
injected.push(it);
|
|
175
|
+
try {
|
|
176
|
+
if (typeof io?.steer === 'function') io.steer(text);
|
|
177
|
+
else if (typeof io?.push === 'function') io.push({ type: 'steer', text });
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
return injected;
|
|
181
|
+
}
|
|
182
|
+
|
|
141
183
|
/**
|
|
142
184
|
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
143
185
|
* @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
|
|
144
186
|
* cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any,
|
|
145
187
|
* onUsage?: (modelName: string, usage: any) => void,
|
|
146
|
-
* constraints?: any[]
|
|
188
|
+
* constraints?: any[],
|
|
189
|
+
* steering?: { drain?: () => any } }} params
|
|
147
190
|
*/
|
|
148
|
-
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage, constraints: rawConstraints }) {
|
|
191
|
+
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef, onUsage, constraints: rawConstraints, steering }) {
|
|
149
192
|
const preset = modelPreset(modelName) || {};
|
|
150
193
|
// v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
|
|
151
194
|
// 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
|
|
@@ -1039,6 +1082,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1039
1082
|
durationMs: Date.now() - startedAt, perf: perf(),
|
|
1040
1083
|
};
|
|
1041
1084
|
}
|
|
1085
|
+
// 接线点①(v0.6.40):每个 step 边界全量取走插话;放在中止检查之后 = 停止优先。
|
|
1086
|
+
drainSteeringInto(steering, messages, io);
|
|
1042
1087
|
// 本轮的 AbortController 提到压缩之前创建(审计 BUG-037):压缩是一次**大开销的真实请求**
|
|
1043
1088
|
// (30K 输入 × executor 模型),此前它在 `ac` 创建之前发生、拿不到任何 signal,
|
|
1044
1089
|
// 于是压缩期间 Ctrl+C 只能等它返回(远程 600s / 本地 1800s,且还有一次重试)。
|
|
@@ -2063,6 +2108,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
2063
2108
|
});
|
|
2064
2109
|
continue;
|
|
2065
2110
|
}
|
|
2111
|
+
// 接线点②(v0.6.40):没有工具调用、本要收尾——刚插话就继续下一步(本轮不结束)。
|
|
2112
|
+
const steeredNow = drainSteeringInto(steering, messages, io);
|
|
2113
|
+
if (shouldContinueAfterModel({ steer: steeredNow, aborted, stalled: noProgressFired })) continue;
|
|
2066
2114
|
markOutcome(finish === 'length' ? 'capped' : 'done', { capHit: false, truncated: finish === 'length' });
|
|
2067
2115
|
return {
|
|
2068
2116
|
text: finalText,
|
package/src/session-events.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// **为什么不是 .jsonl**:全仓(以及用户脚本/测试)有大量 `readdirSync(sessions).filter(f => f.endsWith('.jsonl'))`
|
|
11
11
|
// 的写法,侧车若也叫 `.jsonl` 就会被当成会话——本版实测已挂掉 2 个 e2e(分享时把侧车当会话推给服务端,
|
|
12
12
|
// 服务端回「你还没有这个会话」)。改成 `.ndjson`(同格式、不同扩展名)让这类碰撞在命名层就不存在。
|
|
13
|
-
// · SSE 帧、UI
|
|
13
|
+
// · SSE 帧、UI、账本、同步都不读事件文件(切片 B1 起仅 `GET /api/events` 用 `readAfter` 只读游标读取)。
|
|
14
14
|
// 因此事件日志坏掉/写不进去,最多是「这条时间线不完整」,绝不影响会话本身。
|
|
15
15
|
//
|
|
16
16
|
// 可靠性约定:
|
|
@@ -42,7 +42,27 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
42
42
|
'checkpoint',
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 审批事件(v0.6.40 悬空闭合切片):`approval.asked` / `approval.decided` 由审批轨道写入。
|
|
47
|
+
*
|
|
48
|
+
* 为什么不并进 `EVENT_TYPES`:那份清单是**对外公开**的 12 类事件,条数被既有回归套件
|
|
49
|
+
* (`test/session-events.js` ①)逐字钉死;本切片不允许改那套断言。但读取与追加又必须认它们——
|
|
50
|
+
* 否则修补器看不见「永远在等审批」的悬空 ask,也没法把合成的 decided 追加进日志。
|
|
51
|
+
* 因此:`EVENT_TYPES` 仍是 12 类;内部可读写集合 = 12 类 + 审批 2 类。
|
|
52
|
+
*/
|
|
53
|
+
export const APPROVAL_EVENT_TYPES = Object.freeze(['approval.asked', 'approval.decided']);
|
|
54
|
+
|
|
55
|
+
/** 内部接受的事件类型:公共 12 类 + 审批 2 类(变量名保持 `EVENT_TYPE_SET`,既有变异锚点不变)。 */
|
|
56
|
+
const EVENT_TYPE_SET = new Set([...EVENT_TYPES, ...APPROVAL_EVENT_TYPES]);
|
|
57
|
+
|
|
58
|
+
/** 审批/工具的关联键字段(按优先级;没有键时按「最近一条」兜底配对)。 */
|
|
59
|
+
const APPROVAL_ID_FIELDS = Object.freeze(['requestId', 'approvalId', 'approvalRequestId', 'id', 'callId']);
|
|
60
|
+
const TOOL_ID_FIELDS = Object.freeze(['callId', 'toolCallId', 'id']);
|
|
61
|
+
|
|
62
|
+
/** 合成闭合事件的固定文案:工具结果未知(不是失败,是进程中断后无从得知)。 */
|
|
63
|
+
export const SYNTHETIC_TOOL_ERROR = '进程中断,工具结果未知';
|
|
64
|
+
/** 合成闭合事件的默认原因(`repairEventLog` 调用方也可覆盖)。 */
|
|
65
|
+
export const SYNTHETIC_REASON = '进程中断(启动时合成闭合事件)';
|
|
46
66
|
|
|
47
67
|
/** 回滚开关:`MINGDAO_EVENT_LOG=0`(或 false/off/no)时全局关闭双写。 */
|
|
48
68
|
export const EVENT_LOG_ENV = 'MINGDAO_EVENT_LOG';
|
|
@@ -383,6 +403,224 @@ export function readEvents(eventFile) {
|
|
|
383
403
|
}
|
|
384
404
|
}
|
|
385
405
|
|
|
406
|
+
/**
|
|
407
|
+
* 从游标处读取事件(v0.7.0 §1.1 切片 B1):`GET /api/events?after=` 的数据源。
|
|
408
|
+
*
|
|
409
|
+
* **两套编号域(别混)**:本文件所有的 seq 都是**事件日志游标**——每个会话跨轮单调
|
|
410
|
+
* (appendEvent 从 1 开始只增)。SSE 帧上的 `streamSeq` 是**每个 task 从 1 起**的流游标
|
|
411
|
+
* (见 `web/server.js#createSseWriter`),用于客户端漏帧检测;两者互不推导,`after` 只收事件 seq。
|
|
412
|
+
*
|
|
413
|
+
* 语义(纯函数:只读文件、不改入参):
|
|
414
|
+
* · 只返回 `seq > afterSeq` 的事件——**严格大于**:客户端持有的 `afterSeq` 那一帧已经收到过,
|
|
415
|
+
* 重复下发会让「跳号检测」把重帧误判成跳号(DSH 的 cursor 语义同此);
|
|
416
|
+
* · `lastSeq` 是**整条日志**的最大 seq(不是过滤后的最后一条):客户端拿它建基线、判断是否追平;
|
|
417
|
+
* · `eventFile` 不存在(旧会话 / `eventLog=false`)是**正常形态**:`{ok:true, events:[], lastSeq:0}`,
|
|
418
|
+
* 不是错误(事件日志是旁路,绝不能让旧会话读不了);
|
|
419
|
+
* · `afterSeq` 非法(非数字 / 负数 / 非安全整数)按 0 处理,与 HTTP 层「after 缺省 = 0」同口径。
|
|
420
|
+
* · 复用 `readEvents`:torn 行 / 未知 type / 缺 seq 的行照旧跳过并计入 `skipped`(可见,不静默丢)。
|
|
421
|
+
*
|
|
422
|
+
* @param {string} eventFile
|
|
423
|
+
* @param {number} [afterSeq]
|
|
424
|
+
* @returns {{ok: boolean, error: string|null, events: SessionEvent[], lastSeq: number, skipped: number}}
|
|
425
|
+
*/
|
|
426
|
+
export function readAfter(eventFile, afterSeq = 0) {
|
|
427
|
+
const read = readEvents(eventFile);
|
|
428
|
+
if (!read.ok) return { ok: false, error: read.error, events: [], lastSeq: 0, skipped: read.skipped };
|
|
429
|
+
const n = Number(afterSeq);
|
|
430
|
+
const after = Number.isSafeInteger(n) && n > 0 ? n : 0;
|
|
431
|
+
let lastSeq = 0;
|
|
432
|
+
for (const ev of read.events) if (ev.seq > lastSeq) lastSeq = ev.seq;
|
|
433
|
+
return { ok: true, error: null, events: read.events.filter((ev) => ev.seq > after), lastSeq, skipped: read.skipped };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 序号连续性自检(v0.7.0 §1.1 切片 B1):给测试与服务端自检用,回答「这条流/这份日志有没有漏帧」。
|
|
438
|
+
*
|
|
439
|
+
* 判定:序号必须从 **1** 起、逐条 +1;`[]` 视为连续(没有事件就没有缺口)。任何不合法的序号
|
|
440
|
+
* (非整数 / ≤0)同样算作缺口——「这一帧的编号都读不出来」与丢帧在客户端是同一件事。
|
|
441
|
+
* 发现缺口后以**实际值**继续比较(`[1,3,4]` 只报一个缺口,而不是把 4 也连带报错)。
|
|
442
|
+
*
|
|
443
|
+
* @param {number[]} seqs 按到达顺序(应为升序)的 seq 列表
|
|
444
|
+
* @returns {{ok: boolean, gaps: {after: number, before: number}[]}}
|
|
445
|
+
* `after` = 缺口前一条的 seq(首条缺 1 时为 0),`before` = 缺口后一条的 seq
|
|
446
|
+
*/
|
|
447
|
+
export function continuityOf(seqs) {
|
|
448
|
+
const list = Array.isArray(seqs) ? seqs : [];
|
|
449
|
+
/** @type {{after: number, before: number}[]} */
|
|
450
|
+
const gaps = [];
|
|
451
|
+
let expected = 1;
|
|
452
|
+
for (const raw of list) {
|
|
453
|
+
const seq = Number(raw);
|
|
454
|
+
if (!Number.isSafeInteger(seq) || seq < 1) {
|
|
455
|
+
gaps.push({ after: expected - 1, before: Number.isFinite(seq) && seq > 0 ? seq : 0 });
|
|
456
|
+
continue; // 坏序号不推进 expected:下一条合法序号仍按原期望判定
|
|
457
|
+
}
|
|
458
|
+
if (seq !== expected) gaps.push({ after: expected - 1, before: seq });
|
|
459
|
+
expected = seq + 1;
|
|
460
|
+
}
|
|
461
|
+
return { ok: gaps.length === 0, gaps };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* v0.6.40:中断 / 崩溃 / 等审批时关应用会留下**没有配对**的事件。这里把它们找出来。
|
|
466
|
+
*
|
|
467
|
+
* 纯函数:不读写文件、不改入参。配对规则(全部按 `seq` 升序处理):
|
|
468
|
+
* · `approval.asked` ↔ `approval.decided`:按 requestId/approvalId/id/callId 配对;
|
|
469
|
+
* 没有 id 时 decided 关闭最近一条无 id 的 ask(审批不嵌套);
|
|
470
|
+
* · `tool.start` ↔ `tool.end`:按 callId/toolCallId/id 配对;没有 id 时同样关闭最近一条;
|
|
471
|
+
* · `turn.start` ↔ `turn.end`:turn 没有 id,用栈配对(end 关闭最近一次 start);
|
|
472
|
+
* · **同 id 多次出现以最后一次为准**:后一次 start/asked 覆盖前一次,不会把已覆盖的旧事件报成悬空。
|
|
473
|
+
*
|
|
474
|
+
* @param {any[]} events
|
|
475
|
+
* @returns {{asks: SessionEvent[], tools: SessionEvent[], turns: SessionEvent[]}}
|
|
476
|
+
*/
|
|
477
|
+
export function findDangling(events) {
|
|
478
|
+
/** @type {Map<string, any>} */
|
|
479
|
+
const asks = new Map();
|
|
480
|
+
/** @type {Map<string, any>} */
|
|
481
|
+
const tools = new Map();
|
|
482
|
+
/** @type {any[]} */
|
|
483
|
+
const turns = [];
|
|
484
|
+
/** @param {any} ev @param {readonly string[]} fields @returns {string} */
|
|
485
|
+
const idOf = (ev, fields) => {
|
|
486
|
+
for (const f of fields) {
|
|
487
|
+
const v = ev?.data?.[f];
|
|
488
|
+
if (v !== null && v !== undefined && String(v) !== '') return String(v);
|
|
489
|
+
}
|
|
490
|
+
return '';
|
|
491
|
+
};
|
|
492
|
+
/**
|
|
493
|
+
* 关闭 map 里最近一条(无 id 的 end/decided 兜底)。
|
|
494
|
+
* @param {Map<string, any>} map
|
|
495
|
+
*/
|
|
496
|
+
const closeLatest = (map) => {
|
|
497
|
+
const keys = [...map.keys()];
|
|
498
|
+
const last = keys.length ? keys[keys.length - 1] : '';
|
|
499
|
+
if (last) map.delete(last);
|
|
500
|
+
};
|
|
501
|
+
for (const ev of ordered(events)) {
|
|
502
|
+
if (ev.type === 'approval.asked') {
|
|
503
|
+
const key = idOf(ev, APPROVAL_ID_FIELDS) || `#${ev.seq}`;
|
|
504
|
+
asks.delete(key); // 先删再插:Map 的插入顺序也要反映「最后一次出现」
|
|
505
|
+
asks.set(key, ev);
|
|
506
|
+
} else if (ev.type === 'approval.decided') {
|
|
507
|
+
const key = idOf(ev, APPROVAL_ID_FIELDS);
|
|
508
|
+
if (key) asks.delete(key);
|
|
509
|
+
else closeLatest(asks);
|
|
510
|
+
} else if (ev.type === 'tool.start') {
|
|
511
|
+
const key = idOf(ev, TOOL_ID_FIELDS) || `#${ev.seq}`;
|
|
512
|
+
tools.delete(key); // 同上:覆盖时刷新插入顺序
|
|
513
|
+
tools.set(key, ev);
|
|
514
|
+
} else if (ev.type === 'tool.end') {
|
|
515
|
+
const key = idOf(ev, TOOL_ID_FIELDS);
|
|
516
|
+
if (key) tools.delete(key);
|
|
517
|
+
else closeLatest(tools);
|
|
518
|
+
} else if (ev.type === 'turn.start') {
|
|
519
|
+
turns.push(ev);
|
|
520
|
+
} else if (ev.type === 'turn.end') {
|
|
521
|
+
turns.pop();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** @param {any} a @param {any} b @returns {number} */
|
|
525
|
+
const bySeq = (a, b) => (Number(a?.seq) || 0) - (Number(b?.seq) || 0);
|
|
526
|
+
return {
|
|
527
|
+
asks: [...asks.values()].sort(bySeq).map(cloneSessionEvent),
|
|
528
|
+
tools: [...tools.values()].sort(bySeq).map(cloneSessionEvent),
|
|
529
|
+
turns: turns.sort(bySeq).map(cloneSessionEvent),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* v0.6.40:为悬空事件合成**要追加**的闭合事件(纯函数,不写文件)。
|
|
535
|
+
*
|
|
536
|
+
* 顺序是嵌套的自然顺序:先 `approval.decided`(内层)→ 再 `tool.end` → 最后 `turn.end`。
|
|
537
|
+
* 每条都带 `data.synthetic=true` 与 `data.reason`——重放/审计能一眼看出这是修补出来的事实,
|
|
538
|
+
* 而不是真实发生过的结束。合成事件不带 `seq`:由 `appendEvent` 在锁内分配,保证接在尾部不重号。
|
|
539
|
+
*
|
|
540
|
+
* @param {{asks?: any[], tools?: any[], turns?: any[]}} dangling
|
|
541
|
+
* @param {{at?: number|Date|null, reason?: string}} [opts]
|
|
542
|
+
* @returns {{type: string, time: number, data: any}[]}
|
|
543
|
+
*/
|
|
544
|
+
export function synthClosers(dangling, { at = null, reason = '' } = {}) {
|
|
545
|
+
const rawTime = Number(at instanceof Date ? at.getTime() : at);
|
|
546
|
+
const time = Number.isFinite(rawTime) && rawTime > 0 ? rawTime : Date.now();
|
|
547
|
+
const why = String(reason || SYNTHETIC_REASON);
|
|
548
|
+
/** @type {{type: string, time: number, data: any}[]} */
|
|
549
|
+
const closers = [];
|
|
550
|
+
const asks = Array.isArray(dangling?.asks) ? dangling.asks : [];
|
|
551
|
+
const tools = Array.isArray(dangling?.tools) ? dangling.tools : [];
|
|
552
|
+
const turns = Array.isArray(dangling?.turns) ? dangling.turns : [];
|
|
553
|
+
/** @param {any} ev @param {readonly string[]} fields @returns {{field: string, value: any}|null} */
|
|
554
|
+
const keyOf = (ev, fields) => {
|
|
555
|
+
for (const f of fields) {
|
|
556
|
+
const v = ev?.data?.[f];
|
|
557
|
+
if (v !== null && v !== undefined && String(v) !== '') return { field: f, value: v };
|
|
558
|
+
}
|
|
559
|
+
return null;
|
|
560
|
+
};
|
|
561
|
+
for (const ask of asks) {
|
|
562
|
+
/** @type {any} */
|
|
563
|
+
const data = { decision: 'aborted', synthetic: true, reason: why };
|
|
564
|
+
const key = keyOf(ask, APPROVAL_ID_FIELDS);
|
|
565
|
+
if (key) data[key.field] = key.value; // 带上原 ask 的关联键,否则第二次 findDangling 仍配不上
|
|
566
|
+
closers.push({ type: 'approval.decided', time, data });
|
|
567
|
+
}
|
|
568
|
+
for (const tool of tools) {
|
|
569
|
+
/** @type {any} */
|
|
570
|
+
const data = { ok: false, error: SYNTHETIC_TOOL_ERROR, synthetic: true, reason: why };
|
|
571
|
+
if (tool?.data?.name !== undefined) data.name = String(tool.data.name);
|
|
572
|
+
const key = keyOf(tool, TOOL_ID_FIELDS);
|
|
573
|
+
if (key) data[key.field] = key.value;
|
|
574
|
+
closers.push({ type: 'tool.end', time, data });
|
|
575
|
+
}
|
|
576
|
+
for (let i = 0; i < turns.length; i += 1) {
|
|
577
|
+
// 每条悬空 turn 各补一条;事件形状不需要 turn 的任何字段。
|
|
578
|
+
closers.push({ type: 'turn.end', time, data: { status: 'aborted', synthetic: true, reason: why } });
|
|
579
|
+
}
|
|
580
|
+
return closers;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* v0.6.40:闭合一个事件日志里的悬空事件。**只追加,不删除/不改写既有行**。
|
|
585
|
+
*
|
|
586
|
+
* 流程:读全量事件 → `findDangling` → 有悬空才 `synthClosers` → 逐条走 `appendEvent`
|
|
587
|
+
* (从而拿到锁内分配的连续 seq)。没有悬空时**连文件都不碰**(返回 repaired:0)。
|
|
588
|
+
*
|
|
589
|
+
* 损坏日志:`readEvents` 会把半截 JSON / 未知 type / 缺 seq 的行计入 `skipped`。只要
|
|
590
|
+
* `skipped > 0` 就视为日志损坏,返回 `{ok:false}` 且**一个字节都不写**——修补器的前提是
|
|
591
|
+
* 「日志是完整的、只是缺收尾」,在坏日志上追加只会把损坏固化进事实源。
|
|
592
|
+
*
|
|
593
|
+
* @param {string} eventFile 事件文件(`.events.ndjson`,不是会话 `.jsonl`)
|
|
594
|
+
* @param {{reason?: string}} [opts]
|
|
595
|
+
* @returns {{ok: boolean, error: string|null, repaired: number, dangling: {asks:any[],tools:any[],turns:any[]}, disabled?: boolean}}
|
|
596
|
+
*/
|
|
597
|
+
export function repairEventLog(eventFile, { reason = '' } = {}) {
|
|
598
|
+
/** @type {{asks:any[],tools:any[],turns:any[]}} */
|
|
599
|
+
const empty = { asks: [], tools: [], turns: [] };
|
|
600
|
+
try {
|
|
601
|
+
const file = path.resolve(String(eventFile || ''));
|
|
602
|
+
if (!String(eventFile || '')) return { ok: false, error: '事件文件路径为空', repaired: 0, dangling: empty };
|
|
603
|
+
if (!eventLogEnabled()) return { ok: true, error: null, repaired: 0, dangling: empty, disabled: true };
|
|
604
|
+
const read = readEvents(file);
|
|
605
|
+
if (!read.ok) return { ok: false, error: read.error ?? '读取事件日志失败', repaired: 0, dangling: empty };
|
|
606
|
+
if (read.skipped > 0) {
|
|
607
|
+
return { ok: false, error: `事件日志损坏(跳过 ${read.skipped} 行),拒绝修补`, repaired: 0, dangling: empty };
|
|
608
|
+
}
|
|
609
|
+
const dangling = findDangling(read.events);
|
|
610
|
+
const closers = synthClosers(dangling, { reason });
|
|
611
|
+
if (!closers.length) return { ok: true, error: null, repaired: 0, dangling };
|
|
612
|
+
let repaired = 0;
|
|
613
|
+
for (const closer of closers) {
|
|
614
|
+
const r = appendEvent(file, { type: closer.type, data: closer.data, at: closer.time });
|
|
615
|
+
if (!r.ok) return { ok: false, error: r.error ?? '追加闭合事件失败', repaired, dangling };
|
|
616
|
+
repaired += 1;
|
|
617
|
+
}
|
|
618
|
+
return { ok: true, error: null, repaired, dangling };
|
|
619
|
+
} catch (err) {
|
|
620
|
+
return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err), repaired: 0, dangling: empty };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
386
624
|
/** @param {any} value @returns {any} 深拷贝(事件里只会有 JSON 值)。 */
|
|
387
625
|
function cloneJson(value) {
|
|
388
626
|
try {
|
|
@@ -392,6 +630,21 @@ function cloneJson(value) {
|
|
|
392
630
|
}
|
|
393
631
|
}
|
|
394
632
|
|
|
633
|
+
/**
|
|
634
|
+
* 悬空事件的对外快照:只保留 `seq/time/type/data`,data 深拷贝。
|
|
635
|
+
* 找悬空是纯函数,调用方拿到的对象不能反过来改到入参上。
|
|
636
|
+
* @param {any} ev
|
|
637
|
+
* @returns {SessionEvent}
|
|
638
|
+
*/
|
|
639
|
+
function cloneSessionEvent(ev) {
|
|
640
|
+
return {
|
|
641
|
+
seq: Number.isInteger(Number(ev?.seq)) ? Number(ev.seq) : 0,
|
|
642
|
+
time: Number.isFinite(Number(ev?.time)) ? Number(ev.time) : 0,
|
|
643
|
+
type: String(ev?.type || ''),
|
|
644
|
+
data: cloneJson(ev?.data && typeof ev.data === 'object' ? ev.data : {}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
395
648
|
/**
|
|
396
649
|
* 事件按 seq 升序(seq 是唯一关联键);缺 seq 的保持原序在后。
|
|
397
650
|
* @param {any[]} events
|
package/src/session.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, warnEventFailure } from './session-events.js';
|
|
6
|
+
import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, repairEventLog, warnEventFailure } from './session-events.js';
|
|
7
7
|
|
|
8
8
|
// —— 冲突备份名(单一事实来源,v0.4.6)——
|
|
9
9
|
// 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
|
|
@@ -168,8 +168,48 @@ export function rewriteSession(file, messages) {
|
|
|
168
168
|
atomicWriteFileSync(file, lines + (lines ? '\n' : ''), { mode: 0o600 }); // 质检 H4:会话文件原子写
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
/**
|
|
172
|
-
|
|
171
|
+
/** 每个会话文件每进程只尝试修补一次;失败也不重试(避免每次读取都整份读事件日志)。 */
|
|
172
|
+
const repairAttempted = new Set();
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* v0.6.40:首次读取会话时闭合「悬空事件」——`approval.asked` 没有 decided、`tool.start`
|
|
176
|
+
* 没有 tool.end、`turn.start` 没有 turn.end(进程被 kill / 崩溃 / 用户等审批时关应用)。
|
|
177
|
+
*
|
|
178
|
+
* 纪律:
|
|
179
|
+
* · **只追加**闭合事件,不改写/删除既有行;修补失败只 warn,绝不影响会话读取;
|
|
180
|
+
* · 同一会话文件在进程内只跑一次(`repairAttempted`),后续读取直接跳过;
|
|
181
|
+
* · `cfg.eventLog === false`(或环境变量关闭)时完全不碰事件文件。
|
|
182
|
+
* @param {any} file 会话文件(不是事件文件;内部换算成 `<session>.events.ndjson`)
|
|
183
|
+
* @param {any} [cfg] 传入时尊重 `cfg.eventLog === false`
|
|
184
|
+
* @returns {{ok:boolean,error:string|null,repaired:number,dangling:{asks:any[],tools:any[],turns:any[]},skipped?:boolean,deduped?:boolean}}
|
|
185
|
+
*/
|
|
186
|
+
export function repairSessionEvents(file, cfg = null) {
|
|
187
|
+
/** @type {{asks:any[],tools:any[],turns:any[]}} */
|
|
188
|
+
const empty = { asks: [], tools: [], turns: [] };
|
|
189
|
+
try {
|
|
190
|
+
if (cfg && cfg.eventLog === false) return { ok: true, error: null, repaired: 0, dangling: empty, skipped: true };
|
|
191
|
+
if (!eventLogEnabled(cfg)) return { ok: true, error: null, repaired: 0, dangling: empty, skipped: true };
|
|
192
|
+
const sessionFile = String(file || '');
|
|
193
|
+
if (!sessionFile) return { ok: false, error: '会话文件路径为空', repaired: 0, dangling: empty };
|
|
194
|
+
const key = path.resolve(sessionFile);
|
|
195
|
+
if (repairAttempted.has(key)) return { ok: true, error: null, repaired: 0, dangling: empty, deduped: true };
|
|
196
|
+
// 先登记再修补:即使修补失败也不再重试(同一会话每进程只跑一次是硬约束)。
|
|
197
|
+
repairAttempted.add(key);
|
|
198
|
+
const eventFile = eventFileFor(key);
|
|
199
|
+
const result = repairEventLog(eventFile, { reason: '进程中断:启动/首次读取会话时合成闭合事件' });
|
|
200
|
+
if (!result.ok) warnEventFailure(eventFile, result.error ?? null);
|
|
201
|
+
return result;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const error = String(/** @type {any} */ (err)?.message ?? err);
|
|
204
|
+
try {
|
|
205
|
+
warnEventFailure(eventFileFor(String(file || '')), error);
|
|
206
|
+
} catch {}
|
|
207
|
+
return { ok: false, error, repaired: 0, dangling: empty };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** @param {any} file @param {any} [cfg] */
|
|
212
|
+
export function loadSession(file, cfg = null) {
|
|
173
213
|
const raw = fs.readFileSync(file, 'utf8');
|
|
174
214
|
const messages = [];
|
|
175
215
|
for (const line of raw.split('\n')) {
|
|
@@ -181,6 +221,9 @@ export function loadSession(file) {
|
|
|
181
221
|
}
|
|
182
222
|
} catch {}
|
|
183
223
|
}
|
|
224
|
+
// v0.6.40:消息读完后顺带闭合悬空事件。失败只 warn(repairSessionEvents 内部吞掉),
|
|
225
|
+
// 返回值与消息数组一字不动——事件侧车坏了/修不了,会话照样可读。
|
|
226
|
+
repairSessionEvents(file, cfg);
|
|
184
227
|
return { file, messages };
|
|
185
228
|
}
|
|
186
229
|
|
package/src/web/app.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, textBody, fmtDur, fmtTok, fmtT } from './util.js';
|
|
2
2
|
import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
|
|
3
3
|
import { sideNavBreakpoint, sideNavLayout, errCardParts, runStripView, permissionSourceLabel, toolStepView, toolTimelineSummary, toolTimelineVisibility, traceRoundView } from './viewmodel.js';
|
|
4
|
-
import { enqueueMessage, dequeueMessage, shouldDrainQueue, queueView, QUEUE_MAX } from './viewmodel.js';
|
|
4
|
+
import { enqueueMessage, dequeueMessage, shouldDrainQueue, queueView, QUEUE_MAX, enqueueSteer, steerView, STEER_MAX } from './viewmodel.js';
|
|
5
|
+
// v0.6.41(§1.1 B1):SSE 序号跳号 → 整窗重取(resync),不逐帧续接
|
|
6
|
+
import { seqContinuity, resyncPlan, seqBanner } from './viewmodel.js';
|
|
5
7
|
|
|
6
8
|
// 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
|
|
7
9
|
// 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
|
|
@@ -773,6 +775,95 @@ function drainQueue(){
|
|
|
773
775
|
setTimeout(()=>{ try{ send(); }catch(e){ console.error('[MingDao] 队列续发失败',e); } },120);
|
|
774
776
|
}
|
|
775
777
|
|
|
778
|
+
// —— 插话队列(v0.6.40,next-step 车道)——
|
|
779
|
+
// 语义:生成中 **Cmd/Ctrl+Enter** → POST /api/steer,服务端在每个 step 边界全量取走,
|
|
780
|
+
// 作为新的 user 消息插进**当前轮**——当前任务不会因此结束;与 Enter 的"排队到下一轮"是两条车道。
|
|
781
|
+
// 失败(网络/404 已结束/429 满/413 超长)一律回落到排队并明确提示,输入不丢。
|
|
782
|
+
let steering=[]; // [{id,text,at}]
|
|
783
|
+
let steerSeq=0;
|
|
784
|
+
|
|
785
|
+
/** 插话 chips:橙色 + ⚡,与蓝色排队 chips 视觉区分。 */
|
|
786
|
+
function renderSteer(){
|
|
787
|
+
const strip=$('#steerStrip'); if(!strip) return;
|
|
788
|
+
const items=steerView(steering);
|
|
789
|
+
if(!items.length){ strip.style.display='none'; strip.innerHTML=''; return; }
|
|
790
|
+
strip.style.display='flex';
|
|
791
|
+
strip.innerHTML='';
|
|
792
|
+
const cap=document.createElement('span'); cap.className='scap';
|
|
793
|
+
cap.textContent='⚡ 插话中 '+items.length+' 条(下一步注入本轮,不结束当前任务;上限 '+STEER_MAX+' 条)';
|
|
794
|
+
strip.appendChild(cap);
|
|
795
|
+
for(const it of items){
|
|
796
|
+
const chip=document.createElement('span'); chip.className='schip';
|
|
797
|
+
const ico=document.createElement('span'); ico.className='si'; ico.textContent=it.icon;
|
|
798
|
+
const txt=document.createElement('span'); txt.className='st'; txt.textContent=it.label; txt.title=it.text;
|
|
799
|
+
// 点文本 → 放回输入框编辑(从插话队列移除,避免"编辑完插两次")
|
|
800
|
+
txt.onclick=()=>{ const r=dequeueMessage(steering,it.id); steering=r.queue; renderSteer(); input.value=r.removed?r.removed.text:input.value; input.focus(); input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; };
|
|
801
|
+
const x=document.createElement('span'); x.className='sx'; x.textContent='✕'; x.title='撤销这条插话';
|
|
802
|
+
x.onclick=()=>{ steering=dequeueMessage(steering,it.id).queue; renderSteer(); };
|
|
803
|
+
chip.appendChild(ico); chip.appendChild(txt); chip.appendChild(x);
|
|
804
|
+
strip.appendChild(chip);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** 注入成功后按文本移除本地 chip(服务端 id 与前端 id 不同源,按文本配对最稳)。 */
|
|
809
|
+
function dropSteerByText(text){
|
|
810
|
+
const t=String(text||'');
|
|
811
|
+
const idx=steering.findIndex(s=>s.text===t);
|
|
812
|
+
if(idx<0) return false;
|
|
813
|
+
steering=[...steering.slice(0,idx),...steering.slice(idx+1)];
|
|
814
|
+
renderSteer();
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** 插话失败 → 回落到 next-turn 队列(绝不静默吞掉用户输入)。 */
|
|
819
|
+
function fallbackSteer(text,why){
|
|
820
|
+
if(!enqueueInput(text)) return;
|
|
821
|
+
input.value=''; input.style.height='44px';
|
|
822
|
+
const d=document.createElement('div'); d.className='msg-sys';
|
|
823
|
+
d.textContent='⚠ 插话失败('+why+'),已改为排队:本轮结束后自动发送。';
|
|
824
|
+
chatEl.appendChild(d); scroll();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** 生成中 Cmd/Ctrl+Enter:插话(POST /api/steer);失败回落排队。 */
|
|
828
|
+
async function steerInput(){
|
|
829
|
+
const text=input.value.trim();
|
|
830
|
+
if(!text) return;
|
|
831
|
+
if(!generating||!curTaskId){ fallbackSteer(text,'当前回合尚未建立任务'); return; }
|
|
832
|
+
try{
|
|
833
|
+
const r=await fetch('/api/steer',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({taskId:curTaskId,message:text})});
|
|
834
|
+
const j=await r.json().catch(()=>({}));
|
|
835
|
+
if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
|
|
836
|
+
steerSeq+=1;
|
|
837
|
+
const en=enqueueSteer(steering,text,'s'+steerSeq,Date.now());
|
|
838
|
+
steering=en.steer;
|
|
839
|
+
if(en.ok) renderSteer();
|
|
840
|
+
input.value=''; input.style.height='44px';
|
|
841
|
+
const ht=$('#hintText');
|
|
842
|
+
if(ht) ht.textContent='⚡ 插话已入队(共 '+(j.queued||steering.length)+' 条):模型下一步会收到,当前任务不会因此结束 · 普通 Enter 仍为「排队到下一轮」';
|
|
843
|
+
}catch(err){
|
|
844
|
+
fallbackSteer(text,(err&&err.message)||'网络错误');
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** 本轮收尾:服务端没来得及注入的插话转回排队(不能因为回合结束就丢掉用户已经写下的内容)。 */
|
|
849
|
+
function settleSteering(){
|
|
850
|
+
if(!steering.length) return;
|
|
851
|
+
const left=steering; steering=[]; renderSteer();
|
|
852
|
+
let moved=0;
|
|
853
|
+
for(const s of left){
|
|
854
|
+
const r=enqueueMessage(queued,s.text,'q'+(queueSeq+=1));
|
|
855
|
+
if(!r.ok) break;
|
|
856
|
+
queued=r.queue; moved+=1;
|
|
857
|
+
}
|
|
858
|
+
const dropped=left.length-moved;
|
|
859
|
+
if(moved) renderQueue();
|
|
860
|
+
const d=document.createElement('div'); d.className='msg-sys';
|
|
861
|
+
d.textContent=moved
|
|
862
|
+
? '⚠ 本轮结束前插话未及注入,已转为排队:'+moved+' 条'+(dropped?'(另有 '+dropped+' 条因排队已满未保留)':'')
|
|
863
|
+
: '⚠ 插话未能注入,且排队已满:'+dropped+' 条未保留。';
|
|
864
|
+
chatEl.appendChild(d); scroll();
|
|
865
|
+
}
|
|
866
|
+
|
|
776
867
|
async function send(){
|
|
777
868
|
const text=input.value.trim();
|
|
778
869
|
console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
|
|
@@ -788,6 +879,12 @@ async function send(){
|
|
|
788
879
|
return;
|
|
789
880
|
}
|
|
790
881
|
if(!text && !attachments.length) return;
|
|
882
|
+
// v0.6.41(§1.1 B1):**流游标** streamSeq——每个 task(每轮 SSE 流)都从 1 重新编号,
|
|
883
|
+
// 所以每轮 send() 从 0 起算、首帧必须是 1。
|
|
884
|
+
// 注意:`/api/session.lastEventSeq` 是**会话事件日志**游标(跨轮单调),与流序号是两套编号域,
|
|
885
|
+
// 绝不能拿来初始化 streamSeq——否则同一会话第二轮首帧 seq=1 会被判成"旧帧"、整轮不渲染。
|
|
886
|
+
// 事件日志游标(eventSeq)留给 B2 的事件派生/重放,B1 不消费。
|
|
887
|
+
let streamSeq = 0;
|
|
791
888
|
const turnCtrl=new AbortController();
|
|
792
889
|
turnAbortCtrl=turnCtrl; // 模块级引用:停止的兜底路径要能断开本轮 SSE(服务端 res.on('close') 同样会收尾)
|
|
793
890
|
// 回合看门狗(无活动超时,非总时长——审计:此前 120s 定时炸弹误杀长时间健康生成,
|
|
@@ -876,11 +973,89 @@ async function send(){
|
|
|
876
973
|
return postAskAnswer(ev, plan.post);
|
|
877
974
|
});
|
|
878
975
|
};
|
|
976
|
+
// —— v0.6.41(§1.1 B1):跳号 → 整窗重取(resync) ——
|
|
977
|
+
// 与 DSH 的 resync() 同语义:漏帧不逐帧续接,整窗重取会话快照。
|
|
978
|
+
// ① incomingSeq > streamSeq+1 → 暂停逐帧渲染(不把缺口当连续);
|
|
979
|
+
// ② 有会话文件 → loadSession 整窗重渲染(本轮已丢的增量不补,最终状态以快照为准);
|
|
980
|
+
// 没有会话文件(新会话首轮,服务端还没落盘)→ 不重建,只留可见提示;
|
|
981
|
+
// ③ 成功后把流游标推进到"触发跳号的那一帧"(该帧与缺口由快照代表),再按 seq 去重重放暂存帧;
|
|
982
|
+
// ④ 重取失败 / 无处可重取 → 在对话流里插可见 msg-sys,绝不静默丢弃。
|
|
983
|
+
// 事件重放(/api/events)不在 B1:它返回的是会话事件而非 SSE 帧,投影是 B2 的活。
|
|
984
|
+
let resyncing = false;
|
|
985
|
+
let resyncGiveUp = false;
|
|
986
|
+
let resyncInFlight = null;
|
|
987
|
+
let resyncAttempts = 0;
|
|
988
|
+
const pausedFrames = [];
|
|
989
|
+
/** @type {(ev:any)=>void} 每帧分派器(重取成功后要按 seq 重放暂存帧,故需同一入口) */
|
|
990
|
+
let onEvent = () => {};
|
|
991
|
+
/**
|
|
992
|
+
* @param {number} gap 缺了多少条
|
|
993
|
+
* @param {number} atSeq 触发跳号的那一帧的 streamSeq(快照代表到它为止)
|
|
994
|
+
*/
|
|
995
|
+
const beginResync = (gap, atSeq) => {
|
|
996
|
+
if (resyncing || resyncGiveUp) return;
|
|
997
|
+
// 连续两次都补不回来就停手:避免与仍在到达的流互相对打,也避免每帧打一次网络。
|
|
998
|
+
if (resyncAttempts >= 2) { resyncGiveUp = true; return; }
|
|
999
|
+
resyncAttempts += 1;
|
|
1000
|
+
resyncing = true;
|
|
1001
|
+
resyncInFlight = (async () => {
|
|
1002
|
+
const plan = resyncPlan({ gap, hasSession: !!currentSession });
|
|
1003
|
+
let taken = false;
|
|
1004
|
+
try {
|
|
1005
|
+
if (plan.mode === 'snapshot') {
|
|
1006
|
+
// 复用「加载会话」的唯一实现:既有的消息加载/工作空间/检查点提示逻辑不另写一套。
|
|
1007
|
+
const j = await loadSession(currentSession);
|
|
1008
|
+
if (j && j.ok !== false && Array.isArray(j.messages)) {
|
|
1009
|
+
// 快照重渲染会清空 chatEl;把在途的流式消息接回窗口末尾,后续帧仍写得进来。
|
|
1010
|
+
if (msg.parentNode !== chatEl) chatEl.appendChild(msg);
|
|
1011
|
+
taken = true;
|
|
1012
|
+
}
|
|
1013
|
+
// 注:j.lastEventSeq 是事件日志游标,与 streamSeq 无关——B1 不拿它当流游标。
|
|
1014
|
+
}
|
|
1015
|
+
} catch { taken = false; }
|
|
1016
|
+
if (!taken) {
|
|
1017
|
+
resyncGiveUp = true;
|
|
1018
|
+
resyncing = false;
|
|
1019
|
+
pausedFrames.length = 0;
|
|
1020
|
+
// 已经警告,但**不把后续帧也丢掉**:把游标推进到 atSeq,让 atSeq+1 起继续渲染,
|
|
1021
|
+
// 用户至少能看到本轮剩余的进展(缺口本身无法补,错误已可见)。
|
|
1022
|
+
streamSeq = Math.max(streamSeq, Number(atSeq) || streamSeq);
|
|
1023
|
+
const d = document.createElement('div');
|
|
1024
|
+
d.className = 'msg-sys';
|
|
1025
|
+
d.textContent = '⚠ 事件流中断且重取失败,请刷新页面';
|
|
1026
|
+
chatEl.appendChild(d);
|
|
1027
|
+
scroll();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
resyncing = false;
|
|
1031
|
+
resyncAttempts = 0; // 成功一次就清零:只有"连续两次补不回来"才停手
|
|
1032
|
+
// 快照代表到 atSeq 为止的窗口:流游标推进到 atSeq,后续帧从 atSeq+1 继续。
|
|
1033
|
+
streamSeq = Math.max(streamSeq, Number(atSeq) || streamSeq);
|
|
1034
|
+
const banner = seqBanner({ gap });
|
|
1035
|
+
if (banner) {
|
|
1036
|
+
const d = document.createElement('div');
|
|
1037
|
+
d.className = 'msg-sys';
|
|
1038
|
+
d.textContent = banner;
|
|
1039
|
+
chatEl.appendChild(d);
|
|
1040
|
+
scroll();
|
|
1041
|
+
}
|
|
1042
|
+
const frames = pausedFrames.splice(0);
|
|
1043
|
+
for (const ev of frames) onEvent(ev);
|
|
1044
|
+
})().catch(() => { resyncing = false; });
|
|
1045
|
+
};
|
|
879
1046
|
try{
|
|
880
1047
|
const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:turnCtrl.signal});
|
|
881
1048
|
if(!resp.ok){ const j=await resp.json().catch(()=>({})); onActivity(); msg.innerHTML='<div class="errline">'+esc(j.error||('HTTP '+resp.status))+'</div>'; updateTasksPanel(); return; }
|
|
882
|
-
|
|
883
|
-
if(ev.taskId){ taskId=ev.taskId; curTaskId=taskId; } // 质检 L1
|
|
1049
|
+
onEvent = ev => {
|
|
1050
|
+
if(ev.taskId){ taskId=ev.taskId; curTaskId=taskId; } // 质检 L1:主停止按钮只断本任务(重取计划要用它)
|
|
1051
|
+
// v0.6.41(§1.1 B1):每帧先过序号闸门,再决定是否走业务分派。
|
|
1052
|
+
// 重取期间不判连续性(基线马上要换),先攒着,成功后再按 streamSeq 去重放行。
|
|
1053
|
+
if (resyncing) { pausedFrames.push(ev); return; }
|
|
1054
|
+
const gate = seqContinuity({ lastSeq: streamSeq, incomingSeq: ev?.streamSeq });
|
|
1055
|
+
if (gate.action === 'accept') streamSeq = Number(ev.streamSeq);
|
|
1056
|
+
else if (gate.action === 'resync') { beginResync(gate.gap, ev.streamSeq); return; }
|
|
1057
|
+
// 重复/乱序旧帧:只跳过这一帧;**无 streamSeq 帧不在这里返回**,业务照常处理(旧服务端兼容)。
|
|
1058
|
+
else if (ev?.streamSeq != null) return;
|
|
884
1059
|
if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
|
|
885
1060
|
if(ev.type==='progress'){ const p=ev.seconds||0; if(ev.phase) curPhase=String(ev.phase); curTools=Number(ev.steps)||0; curTasks=Number(ev.tasks)||0; showThink();
|
|
886
1061
|
// P0-1:同一个 runState 同时喂状态条六字段与悬浮详情(服务端 progress ≈1s 一次,无新增轮询)
|
|
@@ -916,6 +1091,13 @@ async function send(){
|
|
|
916
1091
|
else if(ev.type==='tool'){ onActivity(); update(); runningTools.delete(ev.seq); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; } renderToolEvent(ev); }
|
|
917
1092
|
else if(ev.type==='toolDenied'){ onActivity(); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.denied=ev.reason||'未授权'; } renderToolDeniedEvent(ev); const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
|
|
918
1093
|
else if(ev.type==='banner'){ renderBanner(ev); }
|
|
1094
|
+
else if(ev.type==='steer'){
|
|
1095
|
+
// v0.6.40:queued 是 POST 即时应答(同页 steerInput 已反馈,不重复插行);
|
|
1096
|
+
// injected 才是 agent 真正把它作为 user 消息喂进当前回合——在对话流里留痕。
|
|
1097
|
+
if(ev.queued!==true){ const st=String(ev.text||''); dropSteerByText(st);
|
|
1098
|
+
const d=document.createElement('div'); d.className='msg-sys'; d.textContent='↳ 已插入本轮:'+st.slice(0,60);
|
|
1099
|
+
chatEl.appendChild(d); scroll(); }
|
|
1100
|
+
}
|
|
919
1101
|
else if(ev.type==='ask'){ runState.rawPhase='等待权限确认'; runState.phase='执行工具'; update(); renderWorkStatus(); handleAskEvent(ev); }
|
|
920
1102
|
else if(ev.type==='usage'){ onActivity(); runState.cost=String(ev.cost||'');
|
|
921
1103
|
// P1-4:本**轮**实际用到的模型(服务端 usage.modelName)——不是"当前选择"的猜测。
|
|
@@ -950,7 +1132,9 @@ async function send(){
|
|
|
950
1132
|
if(ev.stalled){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent='⏹ 本轮因长时间无进展已中止(不是步数上限 capped、也不是你点的停止 aborted)——'+(ev.note||'详情见上方告警条。'); msg.appendChild(d); }
|
|
951
1133
|
else if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); }
|
|
952
1134
|
currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
953
|
-
}
|
|
1135
|
+
};
|
|
1136
|
+
await handleEvents(resp.body, onEvent);
|
|
1137
|
+
if (resyncInFlight) await resyncInFlight.catch(() => {});
|
|
954
1138
|
}catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
|
|
955
1139
|
if((e&&e.name==='AbortError')||killedByWatchdog) runState.aborted=true;
|
|
956
1140
|
// 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
|
|
@@ -962,7 +1146,7 @@ async function send(){
|
|
|
962
1146
|
// 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
|
|
963
1147
|
d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
|
|
964
1148
|
msg.appendChild(d); scroll(); }
|
|
965
|
-
finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); drainQueue(); }
|
|
1149
|
+
finally{ clearInterval(hintTimer); disarm(); clearTimeout(stopFallbackTimer); stopFallbackTimer=null; stopping=false; turnAbortCtrl=null; generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; msg._endedAt=Date.now(); finalizePendingSubagent(msg); content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); renderTracePanels(); activeAiMsg=null; curWorkT0=0; if(!runState.aborted){ runState.rawPhase='空闲'; runState.phase='空闲'; } else { runState.rawPhase='已中止'; runState.phase='已中止'; } renderWorkStatus(); scroll(); updateTasksPanel(); settleSteering(); drainQueue(); }
|
|
966
1150
|
}
|
|
967
1151
|
|
|
968
1152
|
let curTaskId=null; // 本轮 SSE 任务 id(质检 L1:主停止按钮只断本任务,不再误伤其他 tab)
|
|
@@ -3038,11 +3222,27 @@ function finalizeCurrentSession(){
|
|
|
3038
3222
|
if(!currentSession) return;
|
|
3039
3223
|
fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
|
|
3040
3224
|
}
|
|
3041
|
-
async function loadSession(file){
|
|
3225
|
+
async function loadSession(file){
|
|
3226
|
+
// v0.6.41(§1.1 B1):先取快照、成功后再清窗——resync 重取失败时当前窗口必须原样保留。
|
|
3227
|
+
if(currentSession && currentSession!==file) finalizeCurrentSession();
|
|
3228
|
+
const r=await fetch('/api/session?file='+encodeURIComponent(file));
|
|
3229
|
+
const j=await r.json().catch(()=>({}));
|
|
3230
|
+
if(!r.ok) return null; // 不抛:既有调用方忽略返回值;resync 看到 null 就走"重取失败"的可见提示
|
|
3231
|
+
currentSession=file; chatEl.innerHTML=''; resetTraceState();
|
|
3232
|
+
for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content, m.ts); else { const el=newAiMsg(m.ts); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } if(j.taskState&&(j.taskState.status==='cap'||j.taskState.status==='interrupted')){ renderBanner({text:'⚠ 该会话有未完成任务(步数上限中断)——直接发送消息即可从断点续跑,已完成文件不会重复做。', warn:true}); } scrollBottom(); renderEmptyState(); renderCrumb();
|
|
3233
|
+
return j; // resync 用 j.messages 确认快照形状;j.lastEventSeq 属 B2(事件日志游标)
|
|
3234
|
+
}
|
|
3042
3235
|
|
|
3043
3236
|
// §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
|
|
3044
3237
|
sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
|
|
3045
|
-
input.addEventListener('keydown',e=>{
|
|
3238
|
+
input.addEventListener('keydown',e=>{
|
|
3239
|
+
if(e.isComposing) return;
|
|
3240
|
+
if(e.key!=='Enter'||e.shiftKey) return;
|
|
3241
|
+
e.preventDefault();
|
|
3242
|
+
// v0.6.40:生成中 Cmd/Ctrl+Enter = 插话(注入当前轮);Enter = 排队(本轮结束后发送)。
|
|
3243
|
+
if((e.metaKey||e.ctrlKey)&&generating){ steerInput(); return; }
|
|
3244
|
+
send();
|
|
3245
|
+
});
|
|
3046
3246
|
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
|
|
3047
3247
|
$('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); refreshSessions(); };
|
|
3048
3248
|
$('#sessMgrBtn').onclick=openSessModal;
|
package/src/web/index.html
CHANGED
|
@@ -225,6 +225,14 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
|
|
|
225
225
|
#queueStrip .qchip .qt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text}
|
|
226
226
|
#queueStrip .qchip .qx{cursor:pointer;color:var(--dim)}
|
|
227
227
|
#queueStrip .qchip .qx:hover{color:#ff6b6b}
|
|
228
|
+
/* v0.6.40 插话(next-step)chips:橙色 + ⚡,与蓝色排队 chips 区分(两条车道不混淆) */
|
|
229
|
+
#steerStrip{max-width:860px;margin:0 auto 6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
|
230
|
+
#steerStrip .scap{font-size:12px;color:var(--warn)}
|
|
231
|
+
#steerStrip .schip{display:inline-flex;align-items:center;gap:6px;max-width:360px;background:rgba(245,158,11,.14);border:1px solid rgba(245,158,11,.48);border-radius:14px;padding:3px 10px;font-size:12.5px;color:var(--fg)}
|
|
232
|
+
#steerStrip .schip .si{color:var(--warn)}
|
|
233
|
+
#steerStrip .schip .st{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text}
|
|
234
|
+
#steerStrip .schip .sx{cursor:pointer;color:var(--dim)}
|
|
235
|
+
#steerStrip .schip .sx:hover{color:#ff6b6b}
|
|
228
236
|
.deliver{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;background:var(--bg2);padding:8px 12px;margin:4px 0;font-size:12.5px}
|
|
229
237
|
.deliver .t{color:var(--accent2);font-weight:600;margin-bottom:2px}
|
|
230
238
|
.deliver .i{color:var(--dim);padding:1px 0;font-family:ui-monospace,Consolas,monospace}
|
|
@@ -868,11 +876,13 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
|
|
|
868
876
|
</div>
|
|
869
877
|
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,.txt,.md,.json,.js,.py,.log,.csv,.html,.css" multiple style="display:none">
|
|
870
878
|
<div id="attachList" style="display:none"></div>
|
|
871
|
-
<!-- v0.6.
|
|
879
|
+
<!-- v0.6.40 插话(next-step):生成中 Cmd/Ctrl+Enter 会插进当前轮,不结束当前任务 -->
|
|
880
|
+
<div id="steerStrip" style="display:none"></div>
|
|
881
|
+
<!-- v0.6.39 输入队列(next-turn):生成中 Enter 排队,本轮结束后自动依次发出(不打断当前任务) -->
|
|
872
882
|
<div id="queueStrip" style="display:none"></div>
|
|
873
883
|
<div id="composer">
|
|
874
884
|
<button id="attachBtn" title="上传图片或文本文件(图片需支持视觉的模型)">📎</button>
|
|
875
|
-
<textarea id="input" rows="1" placeholder="输入任务…(Enter
|
|
885
|
+
<textarea id="input" rows="1" placeholder="输入任务…(Enter 发送;生成中 Enter=排队、⌘/Ctrl+Enter=插话)"></textarea>
|
|
876
886
|
<!-- P1-5 会话设置弹层(v0.6.19 §3.70):模型 / 预设 / 权限三件套收进同一个 popover,
|
|
877
887
|
按钮上直接印当前生效的模型与权限(用户一眼知道"东西没丢")。原三个 select 保留在弹层内
|
|
878
888
|
(send()/init() 仍按既有 id 读取),不再占输入区。 -->
|
|
@@ -1056,9 +1066,9 @@ body.side-collapsed #sideNavSessions .sn-item.on,body.side-mid #sideNavSessions
|
|
|
1056
1066
|
<div class="shortcut-row" data-shortcut="⌘K"><span class="kbd">⌘K / Ctrl+K</span><span class="grow">命令面板(搜索 / 切换 / 执行)</span></div>
|
|
1057
1067
|
<div class="shortcut-row" data-shortcut="⌘/"><span class="kbd">⌘/ / Ctrl+/</span><span class="grow">打开本表</span></div>
|
|
1058
1068
|
<div class="shortcut-row" data-shortcut="Esc"><span class="kbd">Esc</span><span class="grow">分层关闭:浮层 › 确认卡 › 停止本轮</span></div>
|
|
1059
|
-
<div class="shortcut-row" data-shortcut="Enter"><span class="kbd">Enter</span><span class="grow"
|
|
1069
|
+
<div class="shortcut-row" data-shortcut="Enter"><span class="kbd">Enter</span><span class="grow">发送;生成中 = 排队到本轮结束后发送</span></div>
|
|
1060
1070
|
<div class="shortcut-row" data-shortcut="Shift+Enter"><span class="kbd">Shift+Enter</span><span class="grow">换行</span></div>
|
|
1061
|
-
<div class="shortcut-row" data-shortcut="⌘Enter"><span class="kbd">⌘Enter / Ctrl+Enter</span><span class="grow"
|
|
1071
|
+
<div class="shortcut-row" data-shortcut="⌘Enter"><span class="kbd">⌘Enter / Ctrl+Enter</span><span class="grow">发送;生成中 = ⚡ 插话(立即注入当前轮,不结束任务)</span></div>
|
|
1062
1072
|
<div class="shortcut-row" data-shortcut="y/n"><span class="kbd">y / n</span><span class="grow">权限确认:本次放行 / 拒绝(输入框内不触发)</span></div>
|
|
1063
1073
|
<div class="shortcut-note">输入法组合(isComposing)期间不触发上述快捷键;⌘ 在 Windows/Linux 上为 Ctrl。</div>
|
|
1064
1074
|
</div>
|
|
@@ -109,6 +109,12 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
109
109
|
return true;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// v0.6.40:插话(next-step 车道)。鉴权/CSRF 已由 routes/api.js 统一完成,这里只做域分发;
|
|
113
|
+
// 缓冲/上限/SSE 应答的唯一实现在 server.js 的 createSteering(路由层不另写一套判定)。
|
|
114
|
+
if (method === 'POST' && p === '/api/steer') {
|
|
115
|
+
return deps.handleSteer(req, res);
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
if (method === 'POST' && p === '/api/abort') {
|
|
113
119
|
const body = await readBody(req, MAX_API_BODY);
|
|
114
120
|
/**
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// 会话域(Phase C C1):/api/sessions /api/session /api/draft
|
|
2
|
-
//
|
|
1
|
+
// 会话域(Phase C C1):/api/sessions /api/session /api/events /api/draft
|
|
2
|
+
// 会话列表/搜索、载入(聚焦工作空间)、重命名/删除、按会话隔离的草稿槽;
|
|
3
|
+
// v0.7.0 §1.1 切片 B1:`GET /api/events?file=&after=` 按 seq 游标只读事件日志(SSE 续订 / 跳号整窗重取)。
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import { eventFileFor, readAfter } from '../../../session-events.js';
|
|
5
7
|
import {
|
|
6
8
|
listSessions,
|
|
7
9
|
loadSession,
|
|
@@ -105,6 +107,10 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
105
107
|
workspace: ws?.name || null,
|
|
106
108
|
// v0.3.0 P0-2:未完成检查点(供前端提示续跑)
|
|
107
109
|
taskState: loadTaskState(path.basename(file)),
|
|
110
|
+
// v0.7.0 §1.1 切片 B1:**事件游标**(event seq)——该会话事件日志的最大 seq;无日志为 0。
|
|
111
|
+
// 注意:这是「每个会话跨轮单调」的事件日志编号,**不是** SSE 帧的 streamSeq(每个 task 从 1 起),
|
|
112
|
+
// 两者互不推导;前端不得拿它去校验帧号(见 server.js#createSseWriter 的双游标说明)。
|
|
113
|
+
lastEventSeq: readAfter(eventFileFor(path.join(home, 'sessions', path.basename(file))), 0).lastSeq,
|
|
108
114
|
});
|
|
109
115
|
} catch {
|
|
110
116
|
json(res, 404, { error: '会话不存在' });
|
|
@@ -112,6 +118,41 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
112
118
|
return true;
|
|
113
119
|
}
|
|
114
120
|
|
|
121
|
+
// v0.7.0 §1.1 切片 B1:按 **event seq** 游标读取会话事件日志(不是 SSE 帧的 streamSeq!)。
|
|
122
|
+
//
|
|
123
|
+
// 双游标(见 server.js#createSseWriter):本端点与 `lastEventSeq` 用的是「每个会话跨轮单调」的
|
|
124
|
+
// 事件日志 seq;SSE 帧上的 `streamSeq` 是「每个 task 从 1 起」的流游标——两者互不推导,
|
|
125
|
+
// 客户端用 streamSeq 检测漏帧、用本端点的 after/event seq 整窗重取事件。
|
|
126
|
+
//
|
|
127
|
+
// 契约:
|
|
128
|
+
// · `after` 缺省 = 0(返回全部);非数字 / 负数 → 400(**严格**,不把垃圾解析成 0:
|
|
129
|
+
// 否则客户端一次拼错 URL 就会静默收到全量重放,跳号检测反而更难查);
|
|
130
|
+
// · `file` 只接受 basename:含 / \ 或就是 . / .. → 400(事件日志与会话同目录,放开即目录穿越);
|
|
131
|
+
// · 会话 `.jsonl` 不存在 → 404(问的是「这个会话」,会话本身没有);
|
|
132
|
+
// · 会话存在但事件文件不存在(旧会话 / eventLog 关闭)→ **200** + `{lastSeq:0, events:[]}`,
|
|
133
|
+
// 不是 404——事件日志是旁路,缺它不代表会话不可读。
|
|
134
|
+
if (method === 'GET' && p === '/api/events') {
|
|
135
|
+
const file = String(url.searchParams.get('file') || '');
|
|
136
|
+
if (!file) return json(res, 400, { error: '缺少 file 参数' });
|
|
137
|
+
if (/[\\/]/.test(file) || file === '.' || file === '..') {
|
|
138
|
+
return json(res, 400, { error: 'file 只接受会话文件名(不得含路径)' });
|
|
139
|
+
}
|
|
140
|
+
const afterRaw = url.searchParams.get('after');
|
|
141
|
+
let after = 0;
|
|
142
|
+
if (afterRaw !== null) {
|
|
143
|
+
if (!/^\d+$/.test(afterRaw) || !Number.isSafeInteger(Number(afterRaw))) {
|
|
144
|
+
return json(res, 400, { error: 'after 必须是非负整数' });
|
|
145
|
+
}
|
|
146
|
+
after = Number(afterRaw);
|
|
147
|
+
}
|
|
148
|
+
const full = path.join(home, 'sessions', file);
|
|
149
|
+
if (!fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
|
|
150
|
+
const read = readAfter(eventFileFor(full), after);
|
|
151
|
+
if (!read.ok) return json(res, 500, { error: `读取事件日志失败:${read.error || '未知原因'}` });
|
|
152
|
+
// `seqKind:'event'`:机器可读地声明 lastSeq/events[].seq 是**事件日志**编号(不是 streamSeq)。
|
|
153
|
+
return json(res, 200, { ok: true, file, seqKind: 'event', lastSeq: read.lastSeq, events: read.events });
|
|
154
|
+
}
|
|
155
|
+
|
|
115
156
|
if (method === 'POST' && p === '/api/session') {
|
|
116
157
|
const body = await readBody(req, MAX_API_BODY);
|
|
117
158
|
const file = path.basename(String(body.file || ''));
|
package/src/web/server.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
// GET / 前端单页(src/web/index.html)
|
|
5
5
|
// GET /api/state 模型/权限/会话列表
|
|
6
6
|
// GET /api/sessions 会话列表(含预览与相对时间)
|
|
7
|
-
// GET /api/session?file=
|
|
7
|
+
// GET /api/session?file= 载入会话消息(含 lastEventSeq 基线)
|
|
8
|
+
// GET /api/events?file=&after= 按游标读会话事件日志(SSE 续订 / 跳号整窗重取)
|
|
8
9
|
// GET /api/skills 技能列表
|
|
9
10
|
// POST /api/chat {message, file?} → 运行一轮,SSE 事件流
|
|
10
11
|
// POST /api/permission {id, answer} → 应答权限确认
|
|
11
12
|
// POST /api/abort 中断当前生成
|
|
12
|
-
//
|
|
13
|
+
// POST /api/steer {taskId, message} → 插话(下一步边界注入当前轮)
|
|
14
|
+
// SSE 事件:banner/text/reasoning/code/tool/toolDenied/todo/usage/ask/steer/error/done
|
|
13
15
|
|
|
14
16
|
import http from 'node:http';
|
|
15
17
|
import { DEFAULT_MODEL } from '../models.js';
|
|
@@ -41,6 +43,8 @@ import { buildSystemPrompt } from '../prompts.js';
|
|
|
41
43
|
import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
|
|
42
44
|
import { saveTaskStateMergeAsync, clearTaskState, loadTaskState, resumePrompt, checkpointHint } from '../task-state.js';
|
|
43
45
|
import { createWebIO } from './web-io.js';
|
|
46
|
+
// v0.6.40:插话的纯语义(上限/字节口径/全量取走)与前端共用同一份实现,避免服务端另写一套。
|
|
47
|
+
import { enqueueSteer, drainSteer, STEER_MAX, STEER_TEXT_MAX } from './viewmodel.js';
|
|
44
48
|
import { installPipeGuards } from '../proc.js';
|
|
45
49
|
import { startMcpServers } from '../mcp.js';
|
|
46
50
|
import {
|
|
@@ -298,6 +302,111 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
298
302
|
});
|
|
299
303
|
}
|
|
300
304
|
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// SSE 帧写入(v0.7.0 §1.1 切片 B1):**双游标**——本函数只负责流游标,别把两套编号对齐。
|
|
307
|
+
//
|
|
308
|
+
// · `streamSeq`(本函数):**每个 task** 从 1 起的单调帧号,同时写标准 `id: <streamSeq>` 行
|
|
309
|
+
// (EventSource 断线续订的 Last-Event-ID 就是它)。客户端只用它判断「**这条流**有没有漏帧」,
|
|
310
|
+
// 发现跳号 → 整窗重取。作用域必须是 task:全局/会话级计数器会让并发任务互相占号,
|
|
311
|
+
// 两个都正常的流各自看到"跳号",检测直接失效。
|
|
312
|
+
// · 事件日志的 seq(`session-events.js`):**每个会话**跨 task 单调,供 `lastEventSeq` /
|
|
313
|
+
// `GET /api/events?after=` 的事件游标(B2 的事件派生)使用——与 streamSeq 互不推导,
|
|
314
|
+
// 客户端**不得**拿 lastEventSeq 去校验帧号。
|
|
315
|
+
// · 帧上既有的 `seq` 字段(toolStart/tool/toolDenied)是**工具卡片配对号**(同一工具调用的
|
|
316
|
+
// 开始/完成同号),语义早已存在且被前端消费;本函数不碰它,只新增 `streamSeq`。
|
|
317
|
+
//
|
|
318
|
+
// 心跳 progress 也走这一个函数——它同样是状态更新,跳号检测不能漏它。
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
/**
|
|
321
|
+
* 为一个 task 创建 SSE 写入器(计数器闭包在该 task 内,`handleChat` 每次请求新建一个)。
|
|
322
|
+
* @param {any} res
|
|
323
|
+
* @param {string} taskId
|
|
324
|
+
* @returns {(obj: any) => void}
|
|
325
|
+
*/
|
|
326
|
+
function createSseWriter(res, taskId) {
|
|
327
|
+
let streamSeq = 0;
|
|
328
|
+
return (obj) => {
|
|
329
|
+
try {
|
|
330
|
+
streamSeq += 1;
|
|
331
|
+
res.write(`id: ${streamSeq}\ndata: ${JSON.stringify({ ...obj, taskId, streamSeq })}\n\n`);
|
|
332
|
+
} catch {}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// 插话(v0.6.40):生成中 Cmd/Ctrl+Enter → 插入**当前轮**(不是下一轮)。
|
|
338
|
+
//
|
|
339
|
+
// 每个 task 一份缓冲;agent 在**每个 step 边界**全量 drain 一次,所以此刻 POST 的三条
|
|
340
|
+
// 会一起进入下一次请求,而不是一条一步。上限/字节口径复用 viewmodel.js 的纯函数
|
|
341
|
+
// (前端 chip 与服务端拒绝同一套判据),本工厂只负责"按 taskId 存取 + HTTP 应答 + SSE 通知"。
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
/** 普通 JSON 接口的请求体上限(与 routes/api.js 的 MAX_API_BODY 同值)。 */
|
|
344
|
+
const STEER_BODY_LIMIT = 1024 * 1024;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 创建插话缓冲与其唯一消费者(放在 runWebServer 内,多个 server 实例互不串味)。
|
|
348
|
+
*
|
|
349
|
+
* @param {{ tasks: Map<string, any>, srvlog?: (line: string) => void }} deps
|
|
350
|
+
* @returns {{
|
|
351
|
+
* state: {buffers: Map<string, Array<{id:string,text:string,at?:number}>>, seq: number},
|
|
352
|
+
* handleSteer: (req: any, res: any) => Promise<boolean>,
|
|
353
|
+
* drain: (taskId: string) => Array<{id:string,text:string,at?:number}>,
|
|
354
|
+
* clear: (taskId: string) => void,
|
|
355
|
+
* }}
|
|
356
|
+
*/
|
|
357
|
+
export function createSteering({ tasks, srvlog }) {
|
|
358
|
+
const state = { buffers: new Map(), seq: 0 };
|
|
359
|
+
/** @param {string} taskId */
|
|
360
|
+
const clear = (taskId) => {
|
|
361
|
+
state.buffers.delete(taskId);
|
|
362
|
+
};
|
|
363
|
+
/** @param {string} taskId @returns {Array<{id:string,text:string,at?:number}>} */
|
|
364
|
+
const drain = (taskId) => {
|
|
365
|
+
const list = state.buffers.get(taskId) || [];
|
|
366
|
+
if (!list.length) return [];
|
|
367
|
+
const r = drainSteer(list);
|
|
368
|
+
state.buffers.set(taskId, r.steer);
|
|
369
|
+
return r.taken;
|
|
370
|
+
};
|
|
371
|
+
/** POST /api/steer 的唯一实现(鉴权/CSRF 由 routes/api.js 在域分发之前统一完成)。 */
|
|
372
|
+
const handleSteer = async (/** @type {any} */ req, /** @type {any} */ res) => {
|
|
373
|
+
let body;
|
|
374
|
+
try {
|
|
375
|
+
body = await readBody(req, STEER_BODY_LIMIT);
|
|
376
|
+
} catch (/** @type {any} */ e) {
|
|
377
|
+
return json(res, e?.status || 400, { error: e?.message || '请求体读取失败' });
|
|
378
|
+
}
|
|
379
|
+
const taskId = String(body?.taskId || '');
|
|
380
|
+
const entry = tasks.get(taskId);
|
|
381
|
+
// 只接受**运行中**的任务:已完成的任务保留在 tasks 里 10 分钟只是任务面板历史,
|
|
382
|
+
// 往它的缓冲里写东西永远没人消费(泄漏),而且对用户是"假成功"。
|
|
383
|
+
if (!entry || entry.status !== 'running') {
|
|
384
|
+
return json(res, 404, { error: '任务不存在或已结束(插话只能发给运行中的任务)' });
|
|
385
|
+
}
|
|
386
|
+
state.seq += 1;
|
|
387
|
+
const list = state.buffers.get(taskId) || [];
|
|
388
|
+
const r = enqueueSteer(list, String(body?.message == null ? '' : body.message), 's' + state.seq, Date.now());
|
|
389
|
+
if (!r.ok) {
|
|
390
|
+
if (r.reason === 'too-long') {
|
|
391
|
+
return json(res, 413, { error: `插话过长(上限 ${Math.round(STEER_TEXT_MAX / 1024)}KB,UTF-8 字节)——请拆短或改用排队(Enter)分次发送。` });
|
|
392
|
+
}
|
|
393
|
+
if (r.reason === 'full') {
|
|
394
|
+
return json(res, 429, { error: `插话队列已满(上限 ${STEER_MAX} 条)——等下一步注入后再插,或按 Enter 排队。` });
|
|
395
|
+
}
|
|
396
|
+
return json(res, 400, { error: '插话不能为空。' });
|
|
397
|
+
}
|
|
398
|
+
state.buffers.set(taskId, r.steer);
|
|
399
|
+
const text = r.steer[r.steer.length - 1].text;
|
|
400
|
+
// 入队成功 → 即时应答(queued:true);真正注入时 agent 还会再推 injected:true。
|
|
401
|
+
try {
|
|
402
|
+
entry.send?.({ type: 'steer', text, queued: true, queuedCount: r.steer.length });
|
|
403
|
+
} catch {}
|
|
404
|
+
srvlog?.(`chat 插话入队 ${taskId} 第 ${r.steer.length} 条(${String(text).length} 字)`);
|
|
405
|
+
return json(res, 200, { ok: true, queued: r.steer.length });
|
|
406
|
+
};
|
|
407
|
+
return { state, handleSteer, drain, clear };
|
|
408
|
+
}
|
|
409
|
+
|
|
301
410
|
/** @param {{ host?: string, port?: number, authToken?: string|null, onBusy?: (busy: boolean) => void, [key: string]: any }} [opts] */
|
|
302
411
|
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken, onBusy } = {}) {
|
|
303
412
|
// v0.6.2:常驻服务**不能**因日志管道断开而自杀——重新安装为「静默吞掉 EPIPE」策略。
|
|
@@ -391,6 +500,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
391
500
|
// 任务注册表:支持多会话并行——每个任务独立的 SSE 流、权限确认、中断控制
|
|
392
501
|
let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
|
|
393
502
|
const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
|
|
503
|
+
const steering = createSteering({ tasks, srvlog }); // v0.6.40:每任务一份插话缓冲(结束后 clear)
|
|
394
504
|
// v0.4.7(T10):正在跑回合的会话文件集合——同一会话同一时刻只允许一个回合。
|
|
395
505
|
// 此前 /api/chat 各自 loadSession → 各自推理 → 按完成顺序追加:会话记录会交错
|
|
396
506
|
// (A/B 互相看不到对方的 user 消息、回答归属错乱);若其中一路触发自动压缩,
|
|
@@ -568,8 +678,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
568
678
|
try {
|
|
569
679
|
res.end();
|
|
570
680
|
} catch {}
|
|
571
|
-
// ⑦ prune +
|
|
681
|
+
// ⑦ prune + 释放闭包(插话缓冲随任务结束清掉,避免 Map 随会话数无限增长)
|
|
572
682
|
entry.send = null;
|
|
683
|
+
steering.clear(taskId);
|
|
573
684
|
try {
|
|
574
685
|
pruneTasks();
|
|
575
686
|
} catch {}
|
|
@@ -578,12 +689,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
578
689
|
srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
|
|
579
690
|
tasks.set(taskId, entry);
|
|
580
691
|
notifyBusy(); // 进入 running → 忙
|
|
581
|
-
|
|
582
|
-
const send = (
|
|
583
|
-
try {
|
|
584
|
-
res.write(`data: ${JSON.stringify({ ...obj, taskId })}\n\n`);
|
|
585
|
-
} catch {}
|
|
586
|
-
};
|
|
692
|
+
// 每帧带 streamSeq/id(切片 B1):流游标随本 task 新建,两个并发请求互不占号。
|
|
693
|
+
const send = createSseWriter(res, taskId);
|
|
587
694
|
entry.send = send;
|
|
588
695
|
entry.finish = finishChat; // 路由层最外层 try/catch/finally 的入口(见 routes/domains/misc.js)
|
|
589
696
|
// 进度心跳(审计:长时间健康生成的可感知性——模型持续输出工具参数期间可能长时间
|
|
@@ -1010,7 +1117,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1010
1117
|
const agent = createAgent({
|
|
1011
1118
|
provider: providerNow,
|
|
1012
1119
|
permission,
|
|
1013
|
-
io,
|
|
1120
|
+
io, steering: { drain: () => steering.drain(taskId) }, // v0.6.40:每步边界全量取走本任务插话
|
|
1014
1121
|
modelName: runModel,
|
|
1015
1122
|
workingDir: taskDir,
|
|
1016
1123
|
cfg: chatCfg, // v0.4.0:预设覆盖后的配置(工具白名单 presetTools/参数/权限)
|
|
@@ -1192,7 +1299,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1192
1299
|
const dispatch = createApiDispatch({
|
|
1193
1300
|
json, srvlog, readBody, cfg, home,
|
|
1194
1301
|
providerCache, invalidateProviderCache, getProviderFor, tasks,
|
|
1195
|
-
MAX_CONCURRENT, pruneTasks, handleChat,
|
|
1302
|
+
MAX_CONCURRENT, pruneTasks, handleChat, handleSteer: steering.handleSteer,
|
|
1196
1303
|
mcpFacade, provider, validateRemoteUrl, isPrivateHost,
|
|
1197
1304
|
authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd, sessionMemoryCache,
|
|
1198
1305
|
state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
|
package/src/web/viewmodel.js
CHANGED
|
@@ -519,3 +519,161 @@ export function queueView(queue) {
|
|
|
519
519
|
const list = Array.isArray(queue) ? queue : [];
|
|
520
520
|
return list.map((q, i) => ({ id: String(q?.id || ''), index: i + 1, label: queueLabel(q), text: String(q?.text ?? '') }));
|
|
521
521
|
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// 插话(v0.6.40):生成中 Cmd/Ctrl+Enter → **插入当前轮**,不是排到下一轮
|
|
525
|
+
//
|
|
526
|
+
// 与 next-turn 队列(上面的 enqueueMessage 一族)是**两条车道**:
|
|
527
|
+
// · next-turn:本轮全部结束后,才按每轮 1 条依次发出(drainQueue);
|
|
528
|
+
// · next-step:每个 step 边界**全量取走**(drainSteer),作为新的 user 消息插进当前请求;
|
|
529
|
+
// 模型本次没有工具调用、本来要收尾时,只要 next-step 非空就继续下一步(shouldContinueAfterModel)。
|
|
530
|
+
// 纯逻辑仍放在这里:上限、字节口径、全量取走、是否继续,都必须被单测钉住。
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
/** 插话队列上限(Octop 的 STEER_DEPTH_CAP 同值):一次最多积 8 条,多则拒绝。 */
|
|
534
|
+
export const STEER_MAX = 8;
|
|
535
|
+
/** 单条插话文本上限:200KB(UTF-8 字节,不是字符数——中文一字 3 字节)。 */
|
|
536
|
+
export const STEER_TEXT_MAX = 200 * 1024;
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 文本的 UTF-8 字节数(浏览器与 Node 同一口径;TextEncoder 不可用时退回字符数)。
|
|
540
|
+
* @param {unknown} text
|
|
541
|
+
* @returns {number}
|
|
542
|
+
*/
|
|
543
|
+
export function steerBytes(text) {
|
|
544
|
+
const t = String(text == null ? '' : text);
|
|
545
|
+
try {
|
|
546
|
+
return new TextEncoder().encode(t).length;
|
|
547
|
+
} catch {
|
|
548
|
+
return t.length;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 把一条输入加入**插话**队列(下一步注入本轮)。
|
|
554
|
+
* 空文本 / 超长(>200KB)/ 满员(≥8 条)都拒绝并给出可读 reason;超长先于满员判定,
|
|
555
|
+
* 这样"又能插但太长"始终得到 413 而不是 429。
|
|
556
|
+
* @param {Array<{id:string,text:string,at?:number}>} steer
|
|
557
|
+
* @param {string} text
|
|
558
|
+
* @param {string} id
|
|
559
|
+
* @param {number} [at] 入队时刻(由调用方注入,保持本函数纯净;缺省 0)
|
|
560
|
+
* @returns {{ok:boolean, steer:Array<{id:string,text:string,at?:number}>, reason?:string}}
|
|
561
|
+
*/
|
|
562
|
+
export function enqueueSteer(steer, text, id, at) {
|
|
563
|
+
const list = Array.isArray(steer) ? steer : [];
|
|
564
|
+
const t = String(text == null ? '' : text);
|
|
565
|
+
if (!t.trim()) return { ok: false, steer: list, reason: 'empty' };
|
|
566
|
+
if (steerBytes(t) > STEER_TEXT_MAX) return { ok: false, steer: list, reason: 'too-long' };
|
|
567
|
+
if (list.length >= STEER_MAX) return { ok: false, steer: list, reason: 'full' };
|
|
568
|
+
return { ok: true, steer: [...list, { id: String(id || ''), text: t, at: Number(at) || 0 }] };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* **全量取走**插话队列(每个 step 边界消费一次,不是每条一步)。
|
|
573
|
+
* 取走后原队列变空;对空队列再调用返回空 taken(幂等,不抛)。
|
|
574
|
+
* @param {Array<{id:string,text:string,at?:number}>} steer
|
|
575
|
+
* @returns {{steer:Array<{id:string,text:string,at?:number}>, taken:Array<{id:string,text:string,at?:number}>}}
|
|
576
|
+
*/
|
|
577
|
+
export function drainSteer(steer) {
|
|
578
|
+
const list = Array.isArray(steer) ? steer : [];
|
|
579
|
+
return { steer: [], taken: [...list] };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 插话 chip 的渲染模型(与 queueView 同形,另带 kind/icon 供 UI 用不同配色区分)。
|
|
584
|
+
* @param {Array<{id:string,text:string,at?:number}>} steer
|
|
585
|
+
* @returns {Array<{id:string,index:number,label:string,text:string,kind:'steer',icon:'⚡'}>}
|
|
586
|
+
*/
|
|
587
|
+
export function steerView(steer) {
|
|
588
|
+
const list = Array.isArray(steer) ? steer : [];
|
|
589
|
+
return list.map((s, i) => ({
|
|
590
|
+
id: String(s?.id || ''),
|
|
591
|
+
index: i + 1,
|
|
592
|
+
label: queueLabel(s),
|
|
593
|
+
text: String(s?.text ?? ''),
|
|
594
|
+
kind: /** @type {'steer'} */ ('steer'),
|
|
595
|
+
icon: /** @type {'⚡'} */ ('⚡'),
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* 模型本次没有工具调用、本来要收尾时,是否应当**继续下一步**(而不是结束本轮)。
|
|
601
|
+
* 有插话就继续;用户中止 / 无进展看门狗触发时不继续("停下"优先于插话)。
|
|
602
|
+
* @param {{steer?:Array<any>, aborted?:boolean, stalled?:boolean}} st
|
|
603
|
+
* @returns {boolean}
|
|
604
|
+
*/
|
|
605
|
+
export function shouldContinueAfterModel(st) {
|
|
606
|
+
const list = Array.isArray(st?.steer) ? st.steer : [];
|
|
607
|
+
return list.length > 0 && !st?.aborted && !st?.stalled;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
// SSE 序号连续性(v0.6.41 §1.1 切片 B1 的客户端一半)
|
|
612
|
+
//
|
|
613
|
+
// 服务端每帧带 `streamSeq`(**每个 task** 从 1 单调 +1)与 `id:` 行;客户端持流游标 lastSeq。
|
|
614
|
+
// 丢帧(跳号)时**不逐帧续接**,而是按 DSH 的 resync 语义整窗重取快照——
|
|
615
|
+
// 否则界面会停在"少了几段正文/几个工具结果"的错误状态且无人知晓。
|
|
616
|
+
// 三个函数都是纯函数:判定表、救援顺序、给用户的一行文案都必须被单测钉住。
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* SSE 帧序号连续性判定(客户端游标 lastSeq,首帧前为 0)。
|
|
621
|
+
*
|
|
622
|
+
* 判定表:
|
|
623
|
+
* incomingSeq == null(旧服务端 / 无 streamSeq 帧) → `ignore`(**只跳过序号判定,业务照常处理**)
|
|
624
|
+
* lastSeq === 0 且 incomingSeq === 1 → `accept`(首帧基线)
|
|
625
|
+
* lastSeq === 0 且 incomingSeq !== 1 → `resync`(连开头都缺了,gap = incomingSeq-1)
|
|
626
|
+
* incomingSeq === lastSeq + 1 → `accept`
|
|
627
|
+
* incomingSeq <= lastSeq → `ignore`(重复/乱序旧帧,不重复渲染)
|
|
628
|
+
* incomingSeq > lastSeq + 1 → `resync`(gap = incomingSeq - lastSeq - 1)
|
|
629
|
+
*
|
|
630
|
+
* `ok` 只表示"连续性没坏":`accept`/`ignore` 都是 true,只有 `resync` 是 false。
|
|
631
|
+
* @param {{lastSeq?:number, incomingSeq?:number|null}} [frame]
|
|
632
|
+
* @returns {{ok:boolean, gap:number, action:'accept'|'resync'|'ignore'}}
|
|
633
|
+
*/
|
|
634
|
+
export function seqContinuity({ lastSeq, incomingSeq } = {}) {
|
|
635
|
+
const incoming = incomingSeq == null ? null : Number(incomingSeq);
|
|
636
|
+
if (incoming === null || !Number.isInteger(incoming)) {
|
|
637
|
+
// 旧服务端兼容:没有 streamSeq(或它不是整数)→ 不参与连续性判断,业务照常。
|
|
638
|
+
return { ok: true, gap: 0, action: 'ignore' };
|
|
639
|
+
}
|
|
640
|
+
const rawLast = Number(lastSeq);
|
|
641
|
+
const last = Number.isInteger(rawLast) && rawLast > 0 ? rawLast : 0;
|
|
642
|
+
if (last === 0) {
|
|
643
|
+
// 首帧必须是 1:否则说明客户端错过的不是"中间几帧",而是流开头。
|
|
644
|
+
return incoming === 1
|
|
645
|
+
? { ok: true, gap: 0, action: 'accept' }
|
|
646
|
+
: { ok: false, gap: Math.max(0, incoming - 1), action: 'resync' };
|
|
647
|
+
}
|
|
648
|
+
if (incoming === last + 1) return { ok: true, gap: 0, action: 'accept' };
|
|
649
|
+
if (incoming <= last) return { ok: true, gap: 0, action: 'ignore' };
|
|
650
|
+
return { ok: false, gap: incoming - last - 1, action: 'resync' };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 跳号后怎么救(v0.6.41 B1 只有两态):
|
|
655
|
+
* · 有会话文件 → `snapshot`:复用「加载会话」整窗重取/重渲染(DSH `resync()` 语义);
|
|
656
|
+
* · 没有会话文件(新会话首轮,服务端还没落盘)→ `none`:不重建空窗口,调用方插可见提示。
|
|
657
|
+
*
|
|
658
|
+
* 为什么没有 `events`:`GET /api/events` 返回的是**会话事件**(`message.append` / `tool.start`),
|
|
659
|
+
* 不是 SSE 帧(`text` / `toolStart`),塞进现有 SSE 分派器渲染不出来——事件→UI 的投影留到 B2。
|
|
660
|
+
* `hasTask` 只为调用方/B2 预留,B1 的判断不依赖它(没有会话快照就没有可重取的窗口)。
|
|
661
|
+
* @param {{gap?:number, hasSession?:boolean, hasTask?:boolean}} [ctx]
|
|
662
|
+
* @returns {{mode:'snapshot'|'none', reason:string}}
|
|
663
|
+
*/
|
|
664
|
+
export function resyncPlan({ gap, hasSession } = {}) {
|
|
665
|
+
const g = Math.max(0, Math.floor(Number(gap) || 0));
|
|
666
|
+
if (hasSession) return { mode: 'snapshot', reason: `会话文件可用:整窗重取快照(跳过 ${g} 条事件)` };
|
|
667
|
+
return { mode: 'none', reason: `没有可重取的会话快照(新会话尚未落盘):只提示、不重建窗口(跳过 ${g} 条事件)` };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* 重取成功后插进对话流的一行**低调**提示(不是 errline,不吓人)。
|
|
672
|
+
* @param {{gap?:number}} [ctx]
|
|
673
|
+
* @returns {string} gap<=0 / 非法值 → 空串(不插提示)
|
|
674
|
+
*/
|
|
675
|
+
export function seqBanner({ gap } = {}) {
|
|
676
|
+
const g = Math.floor(Number(gap));
|
|
677
|
+
if (!Number.isFinite(g) || g <= 0) return '';
|
|
678
|
+
return `已跳过 ${g} 条事件,正在重新同步…`;
|
|
679
|
+
}
|
package/src/web/web-io.js
CHANGED
|
@@ -115,6 +115,14 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
115
115
|
renderTodo(todos) {
|
|
116
116
|
send({ type: 'todo', todos });
|
|
117
117
|
},
|
|
118
|
+
/**
|
|
119
|
+
* v0.6.40:插话已真正注入当前回合——`injected:true` 与 POST /api/steer 的即时应答
|
|
120
|
+
* (`queued:true`)区分开,前端只对后者之外的事件在对话流里写「↳ 已插入本轮」。
|
|
121
|
+
* @param {any} text
|
|
122
|
+
*/
|
|
123
|
+
steer(text) {
|
|
124
|
+
send({ type: 'steer', text: String(text ?? ''), injected: true });
|
|
125
|
+
},
|
|
118
126
|
/** @param {{ modelName: any, usage: any, durationMs: any }} param0 */
|
|
119
127
|
printUsageLine({ modelName, usage, durationMs }) {
|
|
120
128
|
send({
|