mingdao-harness 0.6.40 → 0.6.42
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/docs/CONFIG.md +19 -12
- package/package.json +5 -2
- package/src/cli.js +2 -2
- package/src/commands/repl.js +4 -4
- package/src/session-events.js +91 -12
- package/src/session.js +56 -2
- package/src/tasks/worker.js +1 -1
- package/src/web/app.js +101 -4
- package/src/web/routes/domains/sessions.js +43 -2
- package/src/web/server.js +35 -7
- package/src/web/viewmodel.js +71 -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` 追加
|
|
99
|
+
- `session-events.js` —— 会话事件日志(v0.7.0 §1.1 切片 A / B2-a):`<session>.events.ndjson` 追加 13 类事件 `{seq,time,type,data}`(session/turn/step/tool/message/usage/compact/messages.replace/interrupt/checkpoint)—— **seq 是唯一关联键**:跨进程短临界区锁内「读尾行 last+1」、单次 append 后回读校验(单条事件超过尾窗时回退整文件校验最后一行);读侧跳过 torn 行/未知 type 并返回 `skipped`(可忽略性:旧读者遇到新类型只当它不存在);`replayToMessages()` 是纯函数(`message.append` 提供消息本体、`compact`/`messages.replace` 重置基线、`interrupt` 延迟补 tool 占位),由事件流重放的消息数组必须与原消息数组逐字节等价;事件文件是**旁路**:`loadSession` 不读它,`rewriteSession` 只在会话写盘成功后追加 `messages.replace`(写失败只 warn,不改变重写结果),`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/docs/CONFIG.md
CHANGED
|
@@ -90,21 +90,28 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
|
|
|
90
90
|
|
|
91
91
|
每个会话在消息文件旁多一条 append-only 的事件流 `<session>.events.ndjson`(0600,与
|
|
92
92
|
`<session>.jsonl` 同级):每条 `{seq,time,type,data}`,seq 单调、是 turn/step/tool/message
|
|
93
|
-
之间的唯一关联键。当前
|
|
94
|
-
`step.end` / `tool.start` / `tool.end` / `message.append` / `usage` / `compact` /
|
|
95
|
-
`checkpoint`。
|
|
96
|
-
|
|
97
|
-
- **双写、旁路**:`appendMessages()` 在会话消息落盘**之后**追加 `message.append`;
|
|
93
|
+
之间的唯一关联键。当前 13 类事件:`session.start` / `turn.start` / `turn.end` / `step.start` /
|
|
94
|
+
`step.end` / `tool.start` / `tool.end` / `message.append` / `usage` / `compact` /
|
|
95
|
+
`messages.replace` / `interrupt` / `checkpoint`。
|
|
96
|
+
|
|
97
|
+
- **双写、旁路**:`appendMessages()` 在会话消息落盘**之后**追加 `message.append`;
|
|
98
|
+
`rewriteSession()`(压缩 / `--resume` / `/clear` / CLI 全量重写)在会话文件**写盘成功之后**
|
|
99
|
+
追加 `messages.replace`(`data={messages,reason}`,reason 为 `compact` / `resume` / `rewrite`)——
|
|
100
|
+
语义是「把当前派生窗口整体替换为 data.messages」,append-only、不删旧行。agent 在
|
|
98
101
|
turn/step/工具/用量/压缩/中断边界追加结构事件。事件日志写失败只 warn,**不影响会话写入**,
|
|
99
102
|
也不改变 `loadSession()` / `rewriteSession()` 的返回值。
|
|
100
|
-
- **确定性重放**:`replayToMessages(events)` 由事件流派生消息数组(`compact`
|
|
101
|
-
`interrupt` 补 tool 占位),已对拍单轮 / 工具调用 / 压缩 /
|
|
103
|
+
- **确定性重放**:`replayToMessages(events)` 由事件流派生消息数组(`compact` 与
|
|
104
|
+
`messages.replace` 重置基线、`interrupt` 补 tool 占位),已对拍单轮 / 工具调用 / 压缩 /
|
|
105
|
+
resume / 空会话 / `tool_calls` 等场景与消息数组 deepEqual。
|
|
102
106
|
- **回滚开关**:`"eventLog": false`(默认开)或环境变量 `MINGDAO_EVENT_LOG=0`。关闭后**完全**
|
|
103
|
-
停止事件写入(含 `message.append` 双写),会话文件照常写入。
|
|
104
|
-
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
停止事件写入(含 `message.append` 与 `messages.replace` 双写),会话文件照常写入。
|
|
108
|
+
- **兼容**:新增类型只追加、不改变既有事件形状;读侧对未知 type 一律跳过并计入 `skipped`,
|
|
109
|
+
因此旧版本读到 `messages.replace` 只会当它不存在,旧日志(没有该类型)在新版本照常可读。
|
|
110
|
+
旧会话没有 events 文件时读取返回空流;事件侧车不会出现在会话列表/最近会话中;事件文件不
|
|
111
|
+
参与云同步。SSE 帧与 UI 不受影响。
|
|
112
|
+
- **已知边界**:自动标题重命名会话文件不会搬迁事件侧车;WebUI 的 `onCompact` 目前按默认
|
|
113
|
+
`rewrite` 记 reason(本轮不改 `src/web/**`);本版之前写下的日志没有 `messages.replace`,
|
|
114
|
+
`scripts/event-parity.mjs` 将这类「不可回溯」会话判为 `incomplete`(不计失败)。
|
|
108
115
|
|
|
109
116
|
## 会话级工作空间(P3-4)
|
|
110
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mingdao-harness",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.42",
|
|
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/cli.js
CHANGED
|
@@ -508,7 +508,7 @@ async function main() {
|
|
|
508
508
|
sessionRef,
|
|
509
509
|
// 自动压缩后重写会话文件 + 同步落盘游标(经 tuiState 共享槽与 REPL 会话联动)
|
|
510
510
|
onCompact: (/** @type {any} */ msgs) => {
|
|
511
|
-
if (tuiState.session) rewriteSession(tuiState.session.file, msgs);
|
|
511
|
+
if (tuiState.session) rewriteSession(tuiState.session.file, msgs, 'compact');
|
|
512
512
|
tuiState.persisted = msgs.length;
|
|
513
513
|
},
|
|
514
514
|
});
|
|
@@ -547,7 +547,7 @@ async function main() {
|
|
|
547
547
|
mcp: mcpFacade,
|
|
548
548
|
sessionRef: oneShotRef,
|
|
549
549
|
onCompact: (/** @type {any} */ msgs) => {
|
|
550
|
-
rewriteSession(session.file, msgs);
|
|
550
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
551
551
|
oneShotPersisted = msgs.length;
|
|
552
552
|
},
|
|
553
553
|
});
|
package/src/commands/repl.js
CHANGED
|
@@ -238,7 +238,7 @@ export async function runRepl(ctx) {
|
|
|
238
238
|
mcp: mcpFacade,
|
|
239
239
|
sessionRef,
|
|
240
240
|
onCompact: (/** @type {any} */ msgs) => {
|
|
241
|
-
rewriteSession(session.file, msgs);
|
|
241
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
242
242
|
tuiState.persisted = msgs.length;
|
|
243
243
|
},
|
|
244
244
|
});
|
|
@@ -327,7 +327,7 @@ export async function runRepl(ctx) {
|
|
|
327
327
|
provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
|
|
328
328
|
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
329
329
|
onCompact: (/** @type {any} */ msgs) => {
|
|
330
|
-
rewriteSession(session.file, msgs);
|
|
330
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
331
331
|
tuiState.persisted = msgs.length;
|
|
332
332
|
},
|
|
333
333
|
});
|
|
@@ -363,7 +363,7 @@ export async function runRepl(ctx) {
|
|
|
363
363
|
provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
|
|
364
364
|
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
365
365
|
onCompact: (/** @type {any} */ msgs) => {
|
|
366
|
-
rewriteSession(session.file, msgs);
|
|
366
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
367
367
|
tuiState.persisted = msgs.length;
|
|
368
368
|
},
|
|
369
369
|
});
|
|
@@ -415,7 +415,7 @@ export async function runRepl(ctx) {
|
|
|
415
415
|
// v0.6.3(H-4):手动 /compact 此前用**追加**落盘(再补一条"压缩点"标记),而压缩前的历史
|
|
416
416
|
// 早已在文件里 → 每次 /compact 文件近乎翻倍,`-c/--continue` 恢复后历史重复并立刻再次触发压缩。
|
|
417
417
|
// 自动压缩走的是 rewriteSession(原子重写)——两处写盘口径不一致,这里统一到重写。
|
|
418
|
-
rewriteSession(session.file, messages);
|
|
418
|
+
rewriteSession(session.file, messages, 'compact');
|
|
419
419
|
tuiState.persisted = messages.length;
|
|
420
420
|
// v0.6.3(M-1):压缩把早期消息(含文件正文)换成了摘要,但读取去重缓存仍记着"这个文件你看过"
|
|
421
421
|
// → 模型再读同一文件只会拿到「内容与上次读取一致」的占位串,而正文已不在上下文里。
|
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
|
// 可靠性约定:
|
|
@@ -26,7 +26,14 @@ import fs from 'node:fs';
|
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { appendFilePrivateSync } from './atomic-write.js';
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* 公开事件类型(v0.7.0 B2-a:12 → 13,新增 `messages.replace`)。
|
|
31
|
+
*
|
|
32
|
+
* **可忽略性是新增类型的前提**:读侧对未知 type 一律跳过并计入 `skipped`(见 `readEvents`),
|
|
33
|
+
* 因此旧版读者遇到 `messages.replace` 只会当它不存在,不会把整份日志读坏;反过来,旧日志
|
|
34
|
+
* (没有该类型)在本版照常可读——语义退化为「只有 compact/message.append」,不改变任何
|
|
35
|
+
* 既有事件形状。新增类型必须同时更新这里与 `test/session-events.js` 的清单。
|
|
36
|
+
*/
|
|
30
37
|
export const EVENT_TYPES = Object.freeze([
|
|
31
38
|
'session.start',
|
|
32
39
|
'turn.start',
|
|
@@ -38,6 +45,7 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
38
45
|
'message.append',
|
|
39
46
|
'usage',
|
|
40
47
|
'compact',
|
|
48
|
+
'messages.replace',
|
|
41
49
|
'interrupt',
|
|
42
50
|
'checkpoint',
|
|
43
51
|
]);
|
|
@@ -45,14 +53,14 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
45
53
|
/**
|
|
46
54
|
* 审批事件(v0.6.40 悬空闭合切片):`approval.asked` / `approval.decided` 由审批轨道写入。
|
|
47
55
|
*
|
|
48
|
-
* 为什么不并进 `EVENT_TYPES
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
56
|
+
* 为什么不并进 `EVENT_TYPES`:那份清单是**对外公开**的事件类型(v0.7.0 B2-a 起 13 类),
|
|
57
|
+
* 条数被回归套件(`test/session-events.js` ①)逐字钉死;本切片不允许改那套断言。但读取与
|
|
58
|
+
* 追加又必须认它们——否则修补器看不见「永远在等审批」的悬空 ask,也没法把合成的 decided
|
|
59
|
+
* 追加进日志。因此:`EVENT_TYPES` 仍是 13 类;内部可读写集合 = 13 类 + 审批 2 类。
|
|
52
60
|
*/
|
|
53
61
|
export const APPROVAL_EVENT_TYPES = Object.freeze(['approval.asked', 'approval.decided']);
|
|
54
62
|
|
|
55
|
-
/** 内部接受的事件类型:公共
|
|
63
|
+
/** 内部接受的事件类型:公共 13 类 + 审批 2 类(变量名保持 `EVENT_TYPE_SET`,既有变异锚点不变)。 */
|
|
56
64
|
const EVENT_TYPE_SET = new Set([...EVENT_TYPES, ...APPROVAL_EVENT_TYPES]);
|
|
57
65
|
|
|
58
66
|
/** 审批/工具的关联键字段(按优先级;没有键时按「最近一条」兜底配对)。 */
|
|
@@ -327,13 +335,24 @@ export function appendEvent(eventFile, { type = '', data = {}, at = null } = {})
|
|
|
327
335
|
const newSize = tail.size + Buffer.byteLength(prefix + json + '\n', 'utf8');
|
|
328
336
|
// 末尾校验:确认最后一行就是刚写下的事件(锁内不会有第二个写方)。
|
|
329
337
|
const verify = readTail(file);
|
|
330
|
-
const
|
|
331
|
-
|
|
338
|
+
const lastLineOf = (/** @type {string} */ text) => {
|
|
339
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
340
|
+
return lines.length ? lines[lines.length - 1] : '';
|
|
341
|
+
};
|
|
332
342
|
let ok = false;
|
|
333
343
|
try {
|
|
334
|
-
const parsed = JSON.parse(
|
|
344
|
+
const parsed = JSON.parse(lastLineOf(verify.text).replace(/\r$/, ''));
|
|
335
345
|
ok = parsed?.seq === seq && parsed?.type === type;
|
|
336
346
|
} catch {}
|
|
347
|
+
// v0.7.0 B2-a:单条事件可能 > TAIL_BYTES(压缩摘要/工具结果/整份 messages.replace)。
|
|
348
|
+
// 尾窗会把最后一行截断,JSON.parse 必然失败——这不是写失败。只在「尾窗确实没覆盖
|
|
349
|
+
// 整个文件」时回退整文件重验最后一行(大事件才走这条路,代价与事件大小同阶)。
|
|
350
|
+
if (!ok && verify.size > TAIL_BYTES) {
|
|
351
|
+
try {
|
|
352
|
+
const parsed = JSON.parse(lastLineOf(fs.readFileSync(file, 'utf8')).replace(/\r$/, ''));
|
|
353
|
+
ok = parsed?.seq === seq && parsed?.type === type;
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
337
356
|
if (!ok) return { ok: false, error: `事件写入后校验失败(seq=${seq})` };
|
|
338
357
|
seqCache.set(file, { seq, size: newSize });
|
|
339
358
|
return { ok: true, error: null, seq };
|
|
@@ -403,6 +422,64 @@ export function readEvents(eventFile) {
|
|
|
403
422
|
}
|
|
404
423
|
}
|
|
405
424
|
|
|
425
|
+
/**
|
|
426
|
+
* 从游标处读取事件(v0.7.0 §1.1 切片 B1):`GET /api/events?after=` 的数据源。
|
|
427
|
+
*
|
|
428
|
+
* **两套编号域(别混)**:本文件所有的 seq 都是**事件日志游标**——每个会话跨轮单调
|
|
429
|
+
* (appendEvent 从 1 开始只增)。SSE 帧上的 `streamSeq` 是**每个 task 从 1 起**的流游标
|
|
430
|
+
* (见 `web/server.js#createSseWriter`),用于客户端漏帧检测;两者互不推导,`after` 只收事件 seq。
|
|
431
|
+
*
|
|
432
|
+
* 语义(纯函数:只读文件、不改入参):
|
|
433
|
+
* · 只返回 `seq > afterSeq` 的事件——**严格大于**:客户端持有的 `afterSeq` 那一帧已经收到过,
|
|
434
|
+
* 重复下发会让「跳号检测」把重帧误判成跳号(DSH 的 cursor 语义同此);
|
|
435
|
+
* · `lastSeq` 是**整条日志**的最大 seq(不是过滤后的最后一条):客户端拿它建基线、判断是否追平;
|
|
436
|
+
* · `eventFile` 不存在(旧会话 / `eventLog=false`)是**正常形态**:`{ok:true, events:[], lastSeq:0}`,
|
|
437
|
+
* 不是错误(事件日志是旁路,绝不能让旧会话读不了);
|
|
438
|
+
* · `afterSeq` 非法(非数字 / 负数 / 非安全整数)按 0 处理,与 HTTP 层「after 缺省 = 0」同口径。
|
|
439
|
+
* · 复用 `readEvents`:torn 行 / 未知 type / 缺 seq 的行照旧跳过并计入 `skipped`(可见,不静默丢)。
|
|
440
|
+
*
|
|
441
|
+
* @param {string} eventFile
|
|
442
|
+
* @param {number} [afterSeq]
|
|
443
|
+
* @returns {{ok: boolean, error: string|null, events: SessionEvent[], lastSeq: number, skipped: number}}
|
|
444
|
+
*/
|
|
445
|
+
export function readAfter(eventFile, afterSeq = 0) {
|
|
446
|
+
const read = readEvents(eventFile);
|
|
447
|
+
if (!read.ok) return { ok: false, error: read.error, events: [], lastSeq: 0, skipped: read.skipped };
|
|
448
|
+
const n = Number(afterSeq);
|
|
449
|
+
const after = Number.isSafeInteger(n) && n > 0 ? n : 0;
|
|
450
|
+
let lastSeq = 0;
|
|
451
|
+
for (const ev of read.events) if (ev.seq > lastSeq) lastSeq = ev.seq;
|
|
452
|
+
return { ok: true, error: null, events: read.events.filter((ev) => ev.seq > after), lastSeq, skipped: read.skipped };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 序号连续性自检(v0.7.0 §1.1 切片 B1):给测试与服务端自检用,回答「这条流/这份日志有没有漏帧」。
|
|
457
|
+
*
|
|
458
|
+
* 判定:序号必须从 **1** 起、逐条 +1;`[]` 视为连续(没有事件就没有缺口)。任何不合法的序号
|
|
459
|
+
* (非整数 / ≤0)同样算作缺口——「这一帧的编号都读不出来」与丢帧在客户端是同一件事。
|
|
460
|
+
* 发现缺口后以**实际值**继续比较(`[1,3,4]` 只报一个缺口,而不是把 4 也连带报错)。
|
|
461
|
+
*
|
|
462
|
+
* @param {number[]} seqs 按到达顺序(应为升序)的 seq 列表
|
|
463
|
+
* @returns {{ok: boolean, gaps: {after: number, before: number}[]}}
|
|
464
|
+
* `after` = 缺口前一条的 seq(首条缺 1 时为 0),`before` = 缺口后一条的 seq
|
|
465
|
+
*/
|
|
466
|
+
export function continuityOf(seqs) {
|
|
467
|
+
const list = Array.isArray(seqs) ? seqs : [];
|
|
468
|
+
/** @type {{after: number, before: number}[]} */
|
|
469
|
+
const gaps = [];
|
|
470
|
+
let expected = 1;
|
|
471
|
+
for (const raw of list) {
|
|
472
|
+
const seq = Number(raw);
|
|
473
|
+
if (!Number.isSafeInteger(seq) || seq < 1) {
|
|
474
|
+
gaps.push({ after: expected - 1, before: Number.isFinite(seq) && seq > 0 ? seq : 0 });
|
|
475
|
+
continue; // 坏序号不推进 expected:下一条合法序号仍按原期望判定
|
|
476
|
+
}
|
|
477
|
+
if (seq !== expected) gaps.push({ after: expected - 1, before: seq });
|
|
478
|
+
expected = seq + 1;
|
|
479
|
+
}
|
|
480
|
+
return { ok: gaps.length === 0, gaps };
|
|
481
|
+
}
|
|
482
|
+
|
|
406
483
|
/**
|
|
407
484
|
* v0.6.40:中断 / 崩溃 / 等审批时关应用会留下**没有配对**的事件。这里把它们找出来。
|
|
408
485
|
*
|
|
@@ -634,7 +711,9 @@ export function missingToolCallIds(messages) {
|
|
|
634
711
|
*
|
|
635
712
|
* 语义(与双写顺序一致):
|
|
636
713
|
* · `message.append`:`data.message` 追加到末尾;
|
|
637
|
-
* · `compact`:`data.messages`
|
|
714
|
+
* · `compact` / `messages.replace`:`data.messages` 是**新基线**,整体替换当前窗口
|
|
715
|
+
* (后续 message.append 按 seq 顺序接在它后面)。两者语义相同:`compact` 是切片 A 的旧
|
|
716
|
+
* 类型,`messages.replace`(v0.7.0 B2-a)覆盖压缩 / rewrite / resume 等所有整份重写。
|
|
638
717
|
* · `interrupt`:`data.toolCallIds` 里的 id 若在最终消息里仍没有 tool 回填,则在对应
|
|
639
718
|
* assistant 之后补一条 `(已中断,未执行)` 占位——**延迟到所有事件处理完再补**,这样
|
|
640
719
|
* 随后到达的 message.append 若已含占位就不会重复,也不会插错位置。
|
|
@@ -650,7 +729,7 @@ export function replayToMessages(events) {
|
|
|
650
729
|
if (ev.type === 'message.append') {
|
|
651
730
|
const m = ev.data?.message;
|
|
652
731
|
if (m && typeof m === 'object' && !Array.isArray(m)) messages.push(cloneJson(m));
|
|
653
|
-
} else if (ev.type === 'compact') {
|
|
732
|
+
} else if (ev.type === 'compact' || ev.type === 'messages.replace') {
|
|
654
733
|
const base = ev.data?.messages;
|
|
655
734
|
if (Array.isArray(base)) {
|
|
656
735
|
messages = base.filter((m) => m && typeof m === 'object' && !Array.isArray(m)).map(cloneJson);
|
package/src/session.js
CHANGED
|
@@ -161,11 +161,65 @@ export function appendMessages(file, messages) {
|
|
|
161
161
|
doubleWriteSessionEvents(file, messages); // 旁路:失败只 warn,不影响上面的会话写入
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** 允许的整份重写原因;认不出的一律按 `rewrite`(事件里的 reason 永远是有限集合)。 */
|
|
165
|
+
const REWRITE_REASONS = new Set(['compact', 'resume', 'rewrite']);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 归一化 `rewriteSession` 的 reason:`compact` / `resume` / `rewrite`,其余(含缺省)→ `rewrite`。
|
|
169
|
+
* @param {any} value
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function normalizeRewriteReason(value) {
|
|
173
|
+
const s = String(value ?? '').trim();
|
|
174
|
+
return REWRITE_REASONS.has(s) ? s : 'rewrite';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* v0.7.0 B2-a:整份重写(压缩 / --resume / /clear / CLI 全量重写)的双写。
|
|
179
|
+
*
|
|
180
|
+
* 必须在会话文件**写盘成功之后**调用:`messages.replace` 的语义是「把当前派生窗口整体替换为
|
|
181
|
+
* data.messages」,不是增量。先写事件后写会话,一旦会话写失败就会留下「事件侧车已前进、消息
|
|
182
|
+
* 文件没动」的分叉。事件写入失败只 warn(与 appendMessages 同口径),绝不回滚也不抛出——
|
|
183
|
+
* 会话文件已经落盘,事件的完整性不能反过来决定会话是否成功。
|
|
184
|
+
* @param {any} file
|
|
185
|
+
* @param {any[]} messages
|
|
186
|
+
* @param {any} reason
|
|
187
|
+
*/
|
|
188
|
+
function doubleWriteReplaceEvents(file, messages, reason) {
|
|
189
|
+
if (!eventLogEnabled()) return;
|
|
190
|
+
const eventFile = eventFileFor(file);
|
|
191
|
+
if (!eventFile) return;
|
|
192
|
+
try {
|
|
193
|
+
// 与 appendMessages 一致:事件流的第一条 session.start 只在文件尚不存在时写一次。
|
|
194
|
+
if (!fs.existsSync(eventFile)) {
|
|
195
|
+
const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)) } });
|
|
196
|
+
if (!start.ok) {
|
|
197
|
+
warnEventFailure(eventFile, start.error ?? null);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const r = appendEvent(eventFile, {
|
|
202
|
+
type: 'messages.replace',
|
|
203
|
+
data: { messages, reason: normalizeRewriteReason(reason) },
|
|
204
|
+
});
|
|
205
|
+
if (!r.ok) warnEventFailure(eventFile, r.error ?? null);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
warnEventFailure(eventFile, String(/** @type {any} */ (err)?.message ?? err));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
164
211
|
// 整文件原子重写:自动压缩后把会话文件同步为压缩形态(否则每次加载历史都会重新触发压缩)
|
|
165
|
-
/**
|
|
166
|
-
|
|
212
|
+
/**
|
|
213
|
+
* @param {any} file
|
|
214
|
+
* @param {any} messages
|
|
215
|
+
* @param {string} [reason] `compact` / `resume` / `rewrite`(缺省或非法 = `rewrite`)
|
|
216
|
+
*/
|
|
217
|
+
export function rewriteSession(file, messages, reason = 'rewrite') {
|
|
167
218
|
const lines = messages.map((/** @type {any} */ m) => JSON.stringify(m)).join('\n');
|
|
168
219
|
atomicWriteFileSync(file, lines + (lines ? '\n' : ''), { mode: 0o600 }); // 质检 H4:会话文件原子写
|
|
220
|
+
// v0.7.0 B2-a:重写是「整体替换窗口」,必须以 messages.replace 落事件(append-only,不删旧行)。
|
|
221
|
+
// 只有会话文件写成功之后才写;失败只 warn(见 doubleWriteReplaceEvents)。
|
|
222
|
+
doubleWriteReplaceEvents(file, messages, reason);
|
|
169
223
|
}
|
|
170
224
|
|
|
171
225
|
/** 每个会话文件每进程只尝试修补一次;失败也不重试(避免每次读取都整份读事件日志)。 */
|
package/src/tasks/worker.js
CHANGED
|
@@ -107,7 +107,7 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
|
|
|
107
107
|
mcp: mcpFacade || undefined,
|
|
108
108
|
sessionRef,
|
|
109
109
|
onCompact: (/** @type {any} */ msgs) => {
|
|
110
|
-
rewriteSession(session.file, msgs);
|
|
110
|
+
rewriteSession(session.file, msgs, 'compact');
|
|
111
111
|
persistedCount = msgs.length;
|
|
112
112
|
},
|
|
113
113
|
});
|
package/src/web/app.js
CHANGED
|
@@ -2,6 +2,8 @@ import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, result
|
|
|
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
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 头;无令牌时行为与旧版一致。
|
|
@@ -877,6 +879,12 @@ async function send(){
|
|
|
877
879
|
return;
|
|
878
880
|
}
|
|
879
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;
|
|
880
888
|
const turnCtrl=new AbortController();
|
|
881
889
|
turnAbortCtrl=turnCtrl; // 模块级引用:停止的兜底路径要能断开本轮 SSE(服务端 res.on('close') 同样会收尾)
|
|
882
890
|
// 回合看门狗(无活动超时,非总时长——审计:此前 120s 定时炸弹误杀长时间健康生成,
|
|
@@ -965,11 +973,89 @@ async function send(){
|
|
|
965
973
|
return postAskAnswer(ev, plan.post);
|
|
966
974
|
});
|
|
967
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
|
+
};
|
|
968
1046
|
try{
|
|
969
1047
|
const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:turnCtrl.signal});
|
|
970
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; }
|
|
971
|
-
|
|
972
|
-
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;
|
|
973
1059
|
if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
|
|
974
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();
|
|
975
1061
|
// P0-1:同一个 runState 同时喂状态条六字段与悬浮详情(服务端 progress ≈1s 一次,无新增轮询)
|
|
@@ -1046,7 +1132,9 @@ async function send(){
|
|
|
1046
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); }
|
|
1047
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); }
|
|
1048
1134
|
currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
1049
|
-
}
|
|
1135
|
+
};
|
|
1136
|
+
await handleEvents(resp.body, onEvent);
|
|
1137
|
+
if (resyncInFlight) await resyncInFlight.catch(() => {});
|
|
1050
1138
|
}catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
|
|
1051
1139
|
if((e&&e.name==='AbortError')||killedByWatchdog) runState.aborted=true;
|
|
1052
1140
|
// 「手点停止 / 看门狗 / fetch 流静默断裂」。v0.4.3 修:第二参改 JSON 字符串——Electron 日志
|
|
@@ -3134,7 +3222,16 @@ function finalizeCurrentSession(){
|
|
|
3134
3222
|
if(!currentSession) return;
|
|
3135
3223
|
fetch('/api/session-finalize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:currentSession})}).catch(()=>{});
|
|
3136
3224
|
}
|
|
3137
|
-
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
|
+
}
|
|
3138
3235
|
|
|
3139
3236
|
// §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
|
|
3140
3237
|
sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
|
|
@@ -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,7 +4,8 @@
|
|
|
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} → 应答权限确认
|
|
@@ -301,6 +302,37 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
301
302
|
});
|
|
302
303
|
}
|
|
303
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
|
+
|
|
304
336
|
// ---------------------------------------------------------------------------
|
|
305
337
|
// 插话(v0.6.40):生成中 Cmd/Ctrl+Enter → 插入**当前轮**(不是下一轮)。
|
|
306
338
|
//
|
|
@@ -657,12 +689,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
657
689
|
srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
|
|
658
690
|
tasks.set(taskId, entry);
|
|
659
691
|
notifyBusy(); // 进入 running → 忙
|
|
660
|
-
|
|
661
|
-
const send = (
|
|
662
|
-
try {
|
|
663
|
-
res.write(`data: ${JSON.stringify({ ...obj, taskId })}\n\n`);
|
|
664
|
-
} catch {}
|
|
665
|
-
};
|
|
692
|
+
// 每帧带 streamSeq/id(切片 B1):流游标随本 task 新建,两个并发请求互不占号。
|
|
693
|
+
const send = createSseWriter(res, taskId);
|
|
666
694
|
entry.send = send;
|
|
667
695
|
entry.finish = finishChat; // 路由层最外层 try/catch/finally 的入口(见 routes/domains/misc.js)
|
|
668
696
|
// 进度心跳(审计:长时间健康生成的可感知性——模型持续输出工具参数期间可能长时间
|
package/src/web/viewmodel.js
CHANGED
|
@@ -606,3 +606,74 @@ export function shouldContinueAfterModel(st) {
|
|
|
606
606
|
const list = Array.isArray(st?.steer) ? st.steer : [];
|
|
607
607
|
return list.length > 0 && !st?.aborted && !st?.stalled;
|
|
608
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
|
+
}
|