mingdao-harness 0.6.39 → 0.6.40
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/package.json +1 -1
- package/src/agent.js +50 -2
- package/src/session-events.js +196 -1
- package/src/session.js +46 -3
- package/src/web/app.js +106 -3
- package/src/web/index.html +14 -4
- package/src/web/routes/domains/misc.js +6 -0
- package/src/web/server.js +83 -4
- package/src/web/viewmodel.js +87 -0
- package/src/web/web-io.js +8 -0
package/package.json
CHANGED
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
|
@@ -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,166 @@ export function readEvents(eventFile) {
|
|
|
383
403
|
}
|
|
384
404
|
}
|
|
385
405
|
|
|
406
|
+
/**
|
|
407
|
+
* v0.6.40:中断 / 崩溃 / 等审批时关应用会留下**没有配对**的事件。这里把它们找出来。
|
|
408
|
+
*
|
|
409
|
+
* 纯函数:不读写文件、不改入参。配对规则(全部按 `seq` 升序处理):
|
|
410
|
+
* · `approval.asked` ↔ `approval.decided`:按 requestId/approvalId/id/callId 配对;
|
|
411
|
+
* 没有 id 时 decided 关闭最近一条无 id 的 ask(审批不嵌套);
|
|
412
|
+
* · `tool.start` ↔ `tool.end`:按 callId/toolCallId/id 配对;没有 id 时同样关闭最近一条;
|
|
413
|
+
* · `turn.start` ↔ `turn.end`:turn 没有 id,用栈配对(end 关闭最近一次 start);
|
|
414
|
+
* · **同 id 多次出现以最后一次为准**:后一次 start/asked 覆盖前一次,不会把已覆盖的旧事件报成悬空。
|
|
415
|
+
*
|
|
416
|
+
* @param {any[]} events
|
|
417
|
+
* @returns {{asks: SessionEvent[], tools: SessionEvent[], turns: SessionEvent[]}}
|
|
418
|
+
*/
|
|
419
|
+
export function findDangling(events) {
|
|
420
|
+
/** @type {Map<string, any>} */
|
|
421
|
+
const asks = new Map();
|
|
422
|
+
/** @type {Map<string, any>} */
|
|
423
|
+
const tools = new Map();
|
|
424
|
+
/** @type {any[]} */
|
|
425
|
+
const turns = [];
|
|
426
|
+
/** @param {any} ev @param {readonly string[]} fields @returns {string} */
|
|
427
|
+
const idOf = (ev, fields) => {
|
|
428
|
+
for (const f of fields) {
|
|
429
|
+
const v = ev?.data?.[f];
|
|
430
|
+
if (v !== null && v !== undefined && String(v) !== '') return String(v);
|
|
431
|
+
}
|
|
432
|
+
return '';
|
|
433
|
+
};
|
|
434
|
+
/**
|
|
435
|
+
* 关闭 map 里最近一条(无 id 的 end/decided 兜底)。
|
|
436
|
+
* @param {Map<string, any>} map
|
|
437
|
+
*/
|
|
438
|
+
const closeLatest = (map) => {
|
|
439
|
+
const keys = [...map.keys()];
|
|
440
|
+
const last = keys.length ? keys[keys.length - 1] : '';
|
|
441
|
+
if (last) map.delete(last);
|
|
442
|
+
};
|
|
443
|
+
for (const ev of ordered(events)) {
|
|
444
|
+
if (ev.type === 'approval.asked') {
|
|
445
|
+
const key = idOf(ev, APPROVAL_ID_FIELDS) || `#${ev.seq}`;
|
|
446
|
+
asks.delete(key); // 先删再插:Map 的插入顺序也要反映「最后一次出现」
|
|
447
|
+
asks.set(key, ev);
|
|
448
|
+
} else if (ev.type === 'approval.decided') {
|
|
449
|
+
const key = idOf(ev, APPROVAL_ID_FIELDS);
|
|
450
|
+
if (key) asks.delete(key);
|
|
451
|
+
else closeLatest(asks);
|
|
452
|
+
} else if (ev.type === 'tool.start') {
|
|
453
|
+
const key = idOf(ev, TOOL_ID_FIELDS) || `#${ev.seq}`;
|
|
454
|
+
tools.delete(key); // 同上:覆盖时刷新插入顺序
|
|
455
|
+
tools.set(key, ev);
|
|
456
|
+
} else if (ev.type === 'tool.end') {
|
|
457
|
+
const key = idOf(ev, TOOL_ID_FIELDS);
|
|
458
|
+
if (key) tools.delete(key);
|
|
459
|
+
else closeLatest(tools);
|
|
460
|
+
} else if (ev.type === 'turn.start') {
|
|
461
|
+
turns.push(ev);
|
|
462
|
+
} else if (ev.type === 'turn.end') {
|
|
463
|
+
turns.pop();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** @param {any} a @param {any} b @returns {number} */
|
|
467
|
+
const bySeq = (a, b) => (Number(a?.seq) || 0) - (Number(b?.seq) || 0);
|
|
468
|
+
return {
|
|
469
|
+
asks: [...asks.values()].sort(bySeq).map(cloneSessionEvent),
|
|
470
|
+
tools: [...tools.values()].sort(bySeq).map(cloneSessionEvent),
|
|
471
|
+
turns: turns.sort(bySeq).map(cloneSessionEvent),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* v0.6.40:为悬空事件合成**要追加**的闭合事件(纯函数,不写文件)。
|
|
477
|
+
*
|
|
478
|
+
* 顺序是嵌套的自然顺序:先 `approval.decided`(内层)→ 再 `tool.end` → 最后 `turn.end`。
|
|
479
|
+
* 每条都带 `data.synthetic=true` 与 `data.reason`——重放/审计能一眼看出这是修补出来的事实,
|
|
480
|
+
* 而不是真实发生过的结束。合成事件不带 `seq`:由 `appendEvent` 在锁内分配,保证接在尾部不重号。
|
|
481
|
+
*
|
|
482
|
+
* @param {{asks?: any[], tools?: any[], turns?: any[]}} dangling
|
|
483
|
+
* @param {{at?: number|Date|null, reason?: string}} [opts]
|
|
484
|
+
* @returns {{type: string, time: number, data: any}[]}
|
|
485
|
+
*/
|
|
486
|
+
export function synthClosers(dangling, { at = null, reason = '' } = {}) {
|
|
487
|
+
const rawTime = Number(at instanceof Date ? at.getTime() : at);
|
|
488
|
+
const time = Number.isFinite(rawTime) && rawTime > 0 ? rawTime : Date.now();
|
|
489
|
+
const why = String(reason || SYNTHETIC_REASON);
|
|
490
|
+
/** @type {{type: string, time: number, data: any}[]} */
|
|
491
|
+
const closers = [];
|
|
492
|
+
const asks = Array.isArray(dangling?.asks) ? dangling.asks : [];
|
|
493
|
+
const tools = Array.isArray(dangling?.tools) ? dangling.tools : [];
|
|
494
|
+
const turns = Array.isArray(dangling?.turns) ? dangling.turns : [];
|
|
495
|
+
/** @param {any} ev @param {readonly string[]} fields @returns {{field: string, value: any}|null} */
|
|
496
|
+
const keyOf = (ev, fields) => {
|
|
497
|
+
for (const f of fields) {
|
|
498
|
+
const v = ev?.data?.[f];
|
|
499
|
+
if (v !== null && v !== undefined && String(v) !== '') return { field: f, value: v };
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
};
|
|
503
|
+
for (const ask of asks) {
|
|
504
|
+
/** @type {any} */
|
|
505
|
+
const data = { decision: 'aborted', synthetic: true, reason: why };
|
|
506
|
+
const key = keyOf(ask, APPROVAL_ID_FIELDS);
|
|
507
|
+
if (key) data[key.field] = key.value; // 带上原 ask 的关联键,否则第二次 findDangling 仍配不上
|
|
508
|
+
closers.push({ type: 'approval.decided', time, data });
|
|
509
|
+
}
|
|
510
|
+
for (const tool of tools) {
|
|
511
|
+
/** @type {any} */
|
|
512
|
+
const data = { ok: false, error: SYNTHETIC_TOOL_ERROR, synthetic: true, reason: why };
|
|
513
|
+
if (tool?.data?.name !== undefined) data.name = String(tool.data.name);
|
|
514
|
+
const key = keyOf(tool, TOOL_ID_FIELDS);
|
|
515
|
+
if (key) data[key.field] = key.value;
|
|
516
|
+
closers.push({ type: 'tool.end', time, data });
|
|
517
|
+
}
|
|
518
|
+
for (let i = 0; i < turns.length; i += 1) {
|
|
519
|
+
// 每条悬空 turn 各补一条;事件形状不需要 turn 的任何字段。
|
|
520
|
+
closers.push({ type: 'turn.end', time, data: { status: 'aborted', synthetic: true, reason: why } });
|
|
521
|
+
}
|
|
522
|
+
return closers;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* v0.6.40:闭合一个事件日志里的悬空事件。**只追加,不删除/不改写既有行**。
|
|
527
|
+
*
|
|
528
|
+
* 流程:读全量事件 → `findDangling` → 有悬空才 `synthClosers` → 逐条走 `appendEvent`
|
|
529
|
+
* (从而拿到锁内分配的连续 seq)。没有悬空时**连文件都不碰**(返回 repaired:0)。
|
|
530
|
+
*
|
|
531
|
+
* 损坏日志:`readEvents` 会把半截 JSON / 未知 type / 缺 seq 的行计入 `skipped`。只要
|
|
532
|
+
* `skipped > 0` 就视为日志损坏,返回 `{ok:false}` 且**一个字节都不写**——修补器的前提是
|
|
533
|
+
* 「日志是完整的、只是缺收尾」,在坏日志上追加只会把损坏固化进事实源。
|
|
534
|
+
*
|
|
535
|
+
* @param {string} eventFile 事件文件(`.events.ndjson`,不是会话 `.jsonl`)
|
|
536
|
+
* @param {{reason?: string}} [opts]
|
|
537
|
+
* @returns {{ok: boolean, error: string|null, repaired: number, dangling: {asks:any[],tools:any[],turns:any[]}, disabled?: boolean}}
|
|
538
|
+
*/
|
|
539
|
+
export function repairEventLog(eventFile, { reason = '' } = {}) {
|
|
540
|
+
/** @type {{asks:any[],tools:any[],turns:any[]}} */
|
|
541
|
+
const empty = { asks: [], tools: [], turns: [] };
|
|
542
|
+
try {
|
|
543
|
+
const file = path.resolve(String(eventFile || ''));
|
|
544
|
+
if (!String(eventFile || '')) return { ok: false, error: '事件文件路径为空', repaired: 0, dangling: empty };
|
|
545
|
+
if (!eventLogEnabled()) return { ok: true, error: null, repaired: 0, dangling: empty, disabled: true };
|
|
546
|
+
const read = readEvents(file);
|
|
547
|
+
if (!read.ok) return { ok: false, error: read.error ?? '读取事件日志失败', repaired: 0, dangling: empty };
|
|
548
|
+
if (read.skipped > 0) {
|
|
549
|
+
return { ok: false, error: `事件日志损坏(跳过 ${read.skipped} 行),拒绝修补`, repaired: 0, dangling: empty };
|
|
550
|
+
}
|
|
551
|
+
const dangling = findDangling(read.events);
|
|
552
|
+
const closers = synthClosers(dangling, { reason });
|
|
553
|
+
if (!closers.length) return { ok: true, error: null, repaired: 0, dangling };
|
|
554
|
+
let repaired = 0;
|
|
555
|
+
for (const closer of closers) {
|
|
556
|
+
const r = appendEvent(file, { type: closer.type, data: closer.data, at: closer.time });
|
|
557
|
+
if (!r.ok) return { ok: false, error: r.error ?? '追加闭合事件失败', repaired, dangling };
|
|
558
|
+
repaired += 1;
|
|
559
|
+
}
|
|
560
|
+
return { ok: true, error: null, repaired, dangling };
|
|
561
|
+
} catch (err) {
|
|
562
|
+
return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err), repaired: 0, dangling: empty };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
386
566
|
/** @param {any} value @returns {any} 深拷贝(事件里只会有 JSON 值)。 */
|
|
387
567
|
function cloneJson(value) {
|
|
388
568
|
try {
|
|
@@ -392,6 +572,21 @@ function cloneJson(value) {
|
|
|
392
572
|
}
|
|
393
573
|
}
|
|
394
574
|
|
|
575
|
+
/**
|
|
576
|
+
* 悬空事件的对外快照:只保留 `seq/time/type/data`,data 深拷贝。
|
|
577
|
+
* 找悬空是纯函数,调用方拿到的对象不能反过来改到入参上。
|
|
578
|
+
* @param {any} ev
|
|
579
|
+
* @returns {SessionEvent}
|
|
580
|
+
*/
|
|
581
|
+
function cloneSessionEvent(ev) {
|
|
582
|
+
return {
|
|
583
|
+
seq: Number.isInteger(Number(ev?.seq)) ? Number(ev.seq) : 0,
|
|
584
|
+
time: Number.isFinite(Number(ev?.time)) ? Number(ev.time) : 0,
|
|
585
|
+
type: String(ev?.type || ''),
|
|
586
|
+
data: cloneJson(ev?.data && typeof ev.data === 'object' ? ev.data : {}),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
395
590
|
/**
|
|
396
591
|
* 事件按 seq 升序(seq 是唯一关联键);缺 seq 的保持原序在后。
|
|
397
592
|
* @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,7 @@
|
|
|
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
5
|
|
|
6
6
|
// 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
|
|
7
7
|
// 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
|
|
@@ -773,6 +773,95 @@ function drainQueue(){
|
|
|
773
773
|
setTimeout(()=>{ try{ send(); }catch(e){ console.error('[MingDao] 队列续发失败',e); } },120);
|
|
774
774
|
}
|
|
775
775
|
|
|
776
|
+
// —— 插话队列(v0.6.40,next-step 车道)——
|
|
777
|
+
// 语义:生成中 **Cmd/Ctrl+Enter** → POST /api/steer,服务端在每个 step 边界全量取走,
|
|
778
|
+
// 作为新的 user 消息插进**当前轮**——当前任务不会因此结束;与 Enter 的"排队到下一轮"是两条车道。
|
|
779
|
+
// 失败(网络/404 已结束/429 满/413 超长)一律回落到排队并明确提示,输入不丢。
|
|
780
|
+
let steering=[]; // [{id,text,at}]
|
|
781
|
+
let steerSeq=0;
|
|
782
|
+
|
|
783
|
+
/** 插话 chips:橙色 + ⚡,与蓝色排队 chips 视觉区分。 */
|
|
784
|
+
function renderSteer(){
|
|
785
|
+
const strip=$('#steerStrip'); if(!strip) return;
|
|
786
|
+
const items=steerView(steering);
|
|
787
|
+
if(!items.length){ strip.style.display='none'; strip.innerHTML=''; return; }
|
|
788
|
+
strip.style.display='flex';
|
|
789
|
+
strip.innerHTML='';
|
|
790
|
+
const cap=document.createElement('span'); cap.className='scap';
|
|
791
|
+
cap.textContent='⚡ 插话中 '+items.length+' 条(下一步注入本轮,不结束当前任务;上限 '+STEER_MAX+' 条)';
|
|
792
|
+
strip.appendChild(cap);
|
|
793
|
+
for(const it of items){
|
|
794
|
+
const chip=document.createElement('span'); chip.className='schip';
|
|
795
|
+
const ico=document.createElement('span'); ico.className='si'; ico.textContent=it.icon;
|
|
796
|
+
const txt=document.createElement('span'); txt.className='st'; txt.textContent=it.label; txt.title=it.text;
|
|
797
|
+
// 点文本 → 放回输入框编辑(从插话队列移除,避免"编辑完插两次")
|
|
798
|
+
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'; };
|
|
799
|
+
const x=document.createElement('span'); x.className='sx'; x.textContent='✕'; x.title='撤销这条插话';
|
|
800
|
+
x.onclick=()=>{ steering=dequeueMessage(steering,it.id).queue; renderSteer(); };
|
|
801
|
+
chip.appendChild(ico); chip.appendChild(txt); chip.appendChild(x);
|
|
802
|
+
strip.appendChild(chip);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** 注入成功后按文本移除本地 chip(服务端 id 与前端 id 不同源,按文本配对最稳)。 */
|
|
807
|
+
function dropSteerByText(text){
|
|
808
|
+
const t=String(text||'');
|
|
809
|
+
const idx=steering.findIndex(s=>s.text===t);
|
|
810
|
+
if(idx<0) return false;
|
|
811
|
+
steering=[...steering.slice(0,idx),...steering.slice(idx+1)];
|
|
812
|
+
renderSteer();
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** 插话失败 → 回落到 next-turn 队列(绝不静默吞掉用户输入)。 */
|
|
817
|
+
function fallbackSteer(text,why){
|
|
818
|
+
if(!enqueueInput(text)) return;
|
|
819
|
+
input.value=''; input.style.height='44px';
|
|
820
|
+
const d=document.createElement('div'); d.className='msg-sys';
|
|
821
|
+
d.textContent='⚠ 插话失败('+why+'),已改为排队:本轮结束后自动发送。';
|
|
822
|
+
chatEl.appendChild(d); scroll();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** 生成中 Cmd/Ctrl+Enter:插话(POST /api/steer);失败回落排队。 */
|
|
826
|
+
async function steerInput(){
|
|
827
|
+
const text=input.value.trim();
|
|
828
|
+
if(!text) return;
|
|
829
|
+
if(!generating||!curTaskId){ fallbackSteer(text,'当前回合尚未建立任务'); return; }
|
|
830
|
+
try{
|
|
831
|
+
const r=await fetch('/api/steer',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({taskId:curTaskId,message:text})});
|
|
832
|
+
const j=await r.json().catch(()=>({}));
|
|
833
|
+
if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
|
|
834
|
+
steerSeq+=1;
|
|
835
|
+
const en=enqueueSteer(steering,text,'s'+steerSeq,Date.now());
|
|
836
|
+
steering=en.steer;
|
|
837
|
+
if(en.ok) renderSteer();
|
|
838
|
+
input.value=''; input.style.height='44px';
|
|
839
|
+
const ht=$('#hintText');
|
|
840
|
+
if(ht) ht.textContent='⚡ 插话已入队(共 '+(j.queued||steering.length)+' 条):模型下一步会收到,当前任务不会因此结束 · 普通 Enter 仍为「排队到下一轮」';
|
|
841
|
+
}catch(err){
|
|
842
|
+
fallbackSteer(text,(err&&err.message)||'网络错误');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** 本轮收尾:服务端没来得及注入的插话转回排队(不能因为回合结束就丢掉用户已经写下的内容)。 */
|
|
847
|
+
function settleSteering(){
|
|
848
|
+
if(!steering.length) return;
|
|
849
|
+
const left=steering; steering=[]; renderSteer();
|
|
850
|
+
let moved=0;
|
|
851
|
+
for(const s of left){
|
|
852
|
+
const r=enqueueMessage(queued,s.text,'q'+(queueSeq+=1));
|
|
853
|
+
if(!r.ok) break;
|
|
854
|
+
queued=r.queue; moved+=1;
|
|
855
|
+
}
|
|
856
|
+
const dropped=left.length-moved;
|
|
857
|
+
if(moved) renderQueue();
|
|
858
|
+
const d=document.createElement('div'); d.className='msg-sys';
|
|
859
|
+
d.textContent=moved
|
|
860
|
+
? '⚠ 本轮结束前插话未及注入,已转为排队:'+moved+' 条'+(dropped?'(另有 '+dropped+' 条因排队已满未保留)':'')
|
|
861
|
+
: '⚠ 插话未能注入,且排队已满:'+dropped+' 条未保留。';
|
|
862
|
+
chatEl.appendChild(d); scroll();
|
|
863
|
+
}
|
|
864
|
+
|
|
776
865
|
async function send(){
|
|
777
866
|
const text=input.value.trim();
|
|
778
867
|
console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
|
|
@@ -916,6 +1005,13 @@ async function send(){
|
|
|
916
1005
|
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
1006
|
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
1007
|
else if(ev.type==='banner'){ renderBanner(ev); }
|
|
1008
|
+
else if(ev.type==='steer'){
|
|
1009
|
+
// v0.6.40:queued 是 POST 即时应答(同页 steerInput 已反馈,不重复插行);
|
|
1010
|
+
// injected 才是 agent 真正把它作为 user 消息喂进当前回合——在对话流里留痕。
|
|
1011
|
+
if(ev.queued!==true){ const st=String(ev.text||''); dropSteerByText(st);
|
|
1012
|
+
const d=document.createElement('div'); d.className='msg-sys'; d.textContent='↳ 已插入本轮:'+st.slice(0,60);
|
|
1013
|
+
chatEl.appendChild(d); scroll(); }
|
|
1014
|
+
}
|
|
919
1015
|
else if(ev.type==='ask'){ runState.rawPhase='等待权限确认'; runState.phase='执行工具'; update(); renderWorkStatus(); handleAskEvent(ev); }
|
|
920
1016
|
else if(ev.type==='usage'){ onActivity(); runState.cost=String(ev.cost||'');
|
|
921
1017
|
// P1-4:本**轮**实际用到的模型(服务端 usage.modelName)——不是"当前选择"的猜测。
|
|
@@ -962,7 +1058,7 @@ async function send(){
|
|
|
962
1058
|
// 具体错误形态已进日志(fetch failed 等不再绕开续跑提示)。
|
|
963
1059
|
d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。');
|
|
964
1060
|
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(); }
|
|
1061
|
+
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
1062
|
}
|
|
967
1063
|
|
|
968
1064
|
let curTaskId=null; // 本轮 SSE 任务 id(质检 L1:主停止按钮只断本任务,不再误伤其他 tab)
|
|
@@ -3042,7 +3138,14 @@ async function loadSession(file){ if(currentSession && currentSession!==file) fi
|
|
|
3042
3138
|
|
|
3043
3139
|
// §138:主按钮在生成态=停止,必须走**同一个**停止出口(stopTurnFromButton → stopTurn → POST /api/abort)
|
|
3044
3140
|
sendBtn.onclick=()=>{ if(generating){ stopTurnFromButton(); } else { send(); } };
|
|
3045
|
-
input.addEventListener('keydown',e=>{
|
|
3141
|
+
input.addEventListener('keydown',e=>{
|
|
3142
|
+
if(e.isComposing) return;
|
|
3143
|
+
if(e.key!=='Enter'||e.shiftKey) return;
|
|
3144
|
+
e.preventDefault();
|
|
3145
|
+
// v0.6.40:生成中 Cmd/Ctrl+Enter = 插话(注入当前轮);Enter = 排队(本轮结束后发送)。
|
|
3146
|
+
if((e.metaKey||e.ctrlKey)&&generating){ steerInput(); return; }
|
|
3147
|
+
send();
|
|
3148
|
+
});
|
|
3046
3149
|
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
|
|
3047
3150
|
$('#newChat').onclick=()=>{ finalizeCurrentSession(); currentSession=null; chatEl.innerHTML=''; resetTraceState(); renderEmptyState(); renderCrumb(); refreshSessions(); };
|
|
3048
3151
|
$('#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
|
/**
|
package/src/web/server.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// POST /api/chat {message, file?} → 运行一轮,SSE 事件流
|
|
10
10
|
// POST /api/permission {id, answer} → 应答权限确认
|
|
11
11
|
// POST /api/abort 中断当前生成
|
|
12
|
-
//
|
|
12
|
+
// POST /api/steer {taskId, message} → 插话(下一步边界注入当前轮)
|
|
13
|
+
// SSE 事件:banner/text/reasoning/code/tool/toolDenied/todo/usage/ask/steer/error/done
|
|
13
14
|
|
|
14
15
|
import http from 'node:http';
|
|
15
16
|
import { DEFAULT_MODEL } from '../models.js';
|
|
@@ -41,6 +42,8 @@ import { buildSystemPrompt } from '../prompts.js';
|
|
|
41
42
|
import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
|
|
42
43
|
import { saveTaskStateMergeAsync, clearTaskState, loadTaskState, resumePrompt, checkpointHint } from '../task-state.js';
|
|
43
44
|
import { createWebIO } from './web-io.js';
|
|
45
|
+
// v0.6.40:插话的纯语义(上限/字节口径/全量取走)与前端共用同一份实现,避免服务端另写一套。
|
|
46
|
+
import { enqueueSteer, drainSteer, STEER_MAX, STEER_TEXT_MAX } from './viewmodel.js';
|
|
44
47
|
import { installPipeGuards } from '../proc.js';
|
|
45
48
|
import { startMcpServers } from '../mcp.js';
|
|
46
49
|
import {
|
|
@@ -298,6 +301,80 @@ function readBody(req, limit = 40 * 1024 * 1024) {
|
|
|
298
301
|
});
|
|
299
302
|
}
|
|
300
303
|
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// 插话(v0.6.40):生成中 Cmd/Ctrl+Enter → 插入**当前轮**(不是下一轮)。
|
|
306
|
+
//
|
|
307
|
+
// 每个 task 一份缓冲;agent 在**每个 step 边界**全量 drain 一次,所以此刻 POST 的三条
|
|
308
|
+
// 会一起进入下一次请求,而不是一条一步。上限/字节口径复用 viewmodel.js 的纯函数
|
|
309
|
+
// (前端 chip 与服务端拒绝同一套判据),本工厂只负责"按 taskId 存取 + HTTP 应答 + SSE 通知"。
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
/** 普通 JSON 接口的请求体上限(与 routes/api.js 的 MAX_API_BODY 同值)。 */
|
|
312
|
+
const STEER_BODY_LIMIT = 1024 * 1024;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 创建插话缓冲与其唯一消费者(放在 runWebServer 内,多个 server 实例互不串味)。
|
|
316
|
+
*
|
|
317
|
+
* @param {{ tasks: Map<string, any>, srvlog?: (line: string) => void }} deps
|
|
318
|
+
* @returns {{
|
|
319
|
+
* state: {buffers: Map<string, Array<{id:string,text:string,at?:number}>>, seq: number},
|
|
320
|
+
* handleSteer: (req: any, res: any) => Promise<boolean>,
|
|
321
|
+
* drain: (taskId: string) => Array<{id:string,text:string,at?:number}>,
|
|
322
|
+
* clear: (taskId: string) => void,
|
|
323
|
+
* }}
|
|
324
|
+
*/
|
|
325
|
+
export function createSteering({ tasks, srvlog }) {
|
|
326
|
+
const state = { buffers: new Map(), seq: 0 };
|
|
327
|
+
/** @param {string} taskId */
|
|
328
|
+
const clear = (taskId) => {
|
|
329
|
+
state.buffers.delete(taskId);
|
|
330
|
+
};
|
|
331
|
+
/** @param {string} taskId @returns {Array<{id:string,text:string,at?:number}>} */
|
|
332
|
+
const drain = (taskId) => {
|
|
333
|
+
const list = state.buffers.get(taskId) || [];
|
|
334
|
+
if (!list.length) return [];
|
|
335
|
+
const r = drainSteer(list);
|
|
336
|
+
state.buffers.set(taskId, r.steer);
|
|
337
|
+
return r.taken;
|
|
338
|
+
};
|
|
339
|
+
/** POST /api/steer 的唯一实现(鉴权/CSRF 由 routes/api.js 在域分发之前统一完成)。 */
|
|
340
|
+
const handleSteer = async (/** @type {any} */ req, /** @type {any} */ res) => {
|
|
341
|
+
let body;
|
|
342
|
+
try {
|
|
343
|
+
body = await readBody(req, STEER_BODY_LIMIT);
|
|
344
|
+
} catch (/** @type {any} */ e) {
|
|
345
|
+
return json(res, e?.status || 400, { error: e?.message || '请求体读取失败' });
|
|
346
|
+
}
|
|
347
|
+
const taskId = String(body?.taskId || '');
|
|
348
|
+
const entry = tasks.get(taskId);
|
|
349
|
+
// 只接受**运行中**的任务:已完成的任务保留在 tasks 里 10 分钟只是任务面板历史,
|
|
350
|
+
// 往它的缓冲里写东西永远没人消费(泄漏),而且对用户是"假成功"。
|
|
351
|
+
if (!entry || entry.status !== 'running') {
|
|
352
|
+
return json(res, 404, { error: '任务不存在或已结束(插话只能发给运行中的任务)' });
|
|
353
|
+
}
|
|
354
|
+
state.seq += 1;
|
|
355
|
+
const list = state.buffers.get(taskId) || [];
|
|
356
|
+
const r = enqueueSteer(list, String(body?.message == null ? '' : body.message), 's' + state.seq, Date.now());
|
|
357
|
+
if (!r.ok) {
|
|
358
|
+
if (r.reason === 'too-long') {
|
|
359
|
+
return json(res, 413, { error: `插话过长(上限 ${Math.round(STEER_TEXT_MAX / 1024)}KB,UTF-8 字节)——请拆短或改用排队(Enter)分次发送。` });
|
|
360
|
+
}
|
|
361
|
+
if (r.reason === 'full') {
|
|
362
|
+
return json(res, 429, { error: `插话队列已满(上限 ${STEER_MAX} 条)——等下一步注入后再插,或按 Enter 排队。` });
|
|
363
|
+
}
|
|
364
|
+
return json(res, 400, { error: '插话不能为空。' });
|
|
365
|
+
}
|
|
366
|
+
state.buffers.set(taskId, r.steer);
|
|
367
|
+
const text = r.steer[r.steer.length - 1].text;
|
|
368
|
+
// 入队成功 → 即时应答(queued:true);真正注入时 agent 还会再推 injected:true。
|
|
369
|
+
try {
|
|
370
|
+
entry.send?.({ type: 'steer', text, queued: true, queuedCount: r.steer.length });
|
|
371
|
+
} catch {}
|
|
372
|
+
srvlog?.(`chat 插话入队 ${taskId} 第 ${r.steer.length} 条(${String(text).length} 字)`);
|
|
373
|
+
return json(res, 200, { ok: true, queued: r.steer.length });
|
|
374
|
+
};
|
|
375
|
+
return { state, handleSteer, drain, clear };
|
|
376
|
+
}
|
|
377
|
+
|
|
301
378
|
/** @param {{ host?: string, port?: number, authToken?: string|null, onBusy?: (busy: boolean) => void, [key: string]: any }} [opts] */
|
|
302
379
|
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken, onBusy } = {}) {
|
|
303
380
|
// v0.6.2:常驻服务**不能**因日志管道断开而自杀——重新安装为「静默吞掉 EPIPE」策略。
|
|
@@ -391,6 +468,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
391
468
|
// 任务注册表:支持多会话并行——每个任务独立的 SSE 流、权限确认、中断控制
|
|
392
469
|
let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
|
|
393
470
|
const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
|
|
471
|
+
const steering = createSteering({ tasks, srvlog }); // v0.6.40:每任务一份插话缓冲(结束后 clear)
|
|
394
472
|
// v0.4.7(T10):正在跑回合的会话文件集合——同一会话同一时刻只允许一个回合。
|
|
395
473
|
// 此前 /api/chat 各自 loadSession → 各自推理 → 按完成顺序追加:会话记录会交错
|
|
396
474
|
// (A/B 互相看不到对方的 user 消息、回答归属错乱);若其中一路触发自动压缩,
|
|
@@ -568,8 +646,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
568
646
|
try {
|
|
569
647
|
res.end();
|
|
570
648
|
} catch {}
|
|
571
|
-
// ⑦ prune +
|
|
649
|
+
// ⑦ prune + 释放闭包(插话缓冲随任务结束清掉,避免 Map 随会话数无限增长)
|
|
572
650
|
entry.send = null;
|
|
651
|
+
steering.clear(taskId);
|
|
573
652
|
try {
|
|
574
653
|
pruneTasks();
|
|
575
654
|
} catch {}
|
|
@@ -1010,7 +1089,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1010
1089
|
const agent = createAgent({
|
|
1011
1090
|
provider: providerNow,
|
|
1012
1091
|
permission,
|
|
1013
|
-
io,
|
|
1092
|
+
io, steering: { drain: () => steering.drain(taskId) }, // v0.6.40:每步边界全量取走本任务插话
|
|
1014
1093
|
modelName: runModel,
|
|
1015
1094
|
workingDir: taskDir,
|
|
1016
1095
|
cfg: chatCfg, // v0.4.0:预设覆盖后的配置(工具白名单 presetTools/参数/权限)
|
|
@@ -1192,7 +1271,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
|
|
|
1192
1271
|
const dispatch = createApiDispatch({
|
|
1193
1272
|
json, srvlog, readBody, cfg, home,
|
|
1194
1273
|
providerCache, invalidateProviderCache, getProviderFor, tasks,
|
|
1195
|
-
MAX_CONCURRENT, pruneTasks, handleChat,
|
|
1274
|
+
MAX_CONCURRENT, pruneTasks, handleChat, handleSteer: steering.handleSteer,
|
|
1196
1275
|
mcpFacade, provider, validateRemoteUrl, isPrivateHost,
|
|
1197
1276
|
authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd, sessionMemoryCache,
|
|
1198
1277
|
state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
|
package/src/web/viewmodel.js
CHANGED
|
@@ -519,3 +519,90 @@ 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
|
+
}
|
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({
|