wezard 1.2.31 → 1.3.1
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/README.md +0 -2
- package/config.example.jsonc +1 -1
- package/dist/daemon/mirror-bridge.js +182 -405
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -2
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +4 -6
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/md-chunk.js +21 -5
- package/dist/shared/md-chunk.js.map +1 -1
- package/dist/shared/session-label.js +4 -10
- package/dist/shared/session-label.js.map +1 -1
- package/package.json +1 -1
|
@@ -354,20 +354,6 @@ const oneLineSummary = (s, max = 40) => {
|
|
|
354
354
|
const flat = s.replace(/\s+/g, " ").trim();
|
|
355
355
|
return truncate(flat, max);
|
|
356
356
|
};
|
|
357
|
-
// think-style 收口去重: 软后端最终正文会先作为非 final 文本进 briefThinking, 收口
|
|
358
|
-
// 再当正文用一次。剥掉 think 尾部恰好等于 body 的那段 (含其前的 \n\n 分隔), 让正文
|
|
359
|
-
// 只出现在 </think> 之后。只剥完整尾段, 中途出现过的同句子片段不动 —— 那是真的思考。
|
|
360
|
-
const stripTrailingBody = (think, body) => {
|
|
361
|
-
if (!think || !body)
|
|
362
|
-
return think || undefined;
|
|
363
|
-
if (think === body)
|
|
364
|
-
return undefined;
|
|
365
|
-
if (think.endsWith(body)) {
|
|
366
|
-
const cut = think.slice(0, think.length - body.length).replace(/\n{2,}$/, "").trimEnd();
|
|
367
|
-
return cut || undefined;
|
|
368
|
-
}
|
|
369
|
-
return think;
|
|
370
|
-
};
|
|
371
357
|
// WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
|
|
372
358
|
// code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
|
|
373
359
|
// `<noreply@x>` etc. silently swallows the rest of the line plus the closing
|
|
@@ -536,8 +522,8 @@ const renderLine = (raw, deps) => {
|
|
|
536
522
|
const sr = line.message?.stop_reason;
|
|
537
523
|
const isFinal = sr === "end_turn";
|
|
538
524
|
// final 是三态: true=终句, false=已知的中途输出, undefined=后端说不清 (软收口)。
|
|
539
|
-
// 软后端不能填 false ——
|
|
540
|
-
//
|
|
525
|
+
// 软后端不能填 false —— 那会让每句叙述都被当成中途输出标记上 briefHadTool, 一句
|
|
526
|
+
// 话答完的 turn 也要被当成"有工具"处理。
|
|
541
527
|
const textFinal = isFinal ? true : line.softTurnEnd ? undefined : false;
|
|
542
528
|
let pending = [];
|
|
543
529
|
const flushPending = () => {
|
|
@@ -570,7 +556,7 @@ const renderLine = (raw, deps) => {
|
|
|
570
556
|
if (t && !deps.isOwnAssistantSend?.(t))
|
|
571
557
|
out.push({ kind: "text", body: t, final: textFinal });
|
|
572
558
|
}
|
|
573
|
-
else if (b?.type === "tool_use" &&
|
|
559
|
+
else if (b?.type === "tool_use" && deps.includeTools) {
|
|
574
560
|
const name = b.name ?? "tool";
|
|
575
561
|
const toolUseId = b.id ?? "";
|
|
576
562
|
// 同名扩展当前 group; 不同名先 flush 再起新组。
|
|
@@ -578,14 +564,13 @@ const renderLine = (raw, deps) => {
|
|
|
578
564
|
flushPending();
|
|
579
565
|
pending.push({ toolUseId, name, input: b.input });
|
|
580
566
|
}
|
|
581
|
-
else if (b?.type === "thinking" &&
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
if (
|
|
586
|
-
out.push({ kind: "thinking", body:
|
|
567
|
+
else if (b?.type === "thinking" && typeof b.thinking === "string") {
|
|
568
|
+
// codebuddy 的 reasoning 记录已被 normalizeLine 归一成这个形状。
|
|
569
|
+
// 只作为 CoT 进度源发出: onItem 里它唯一的下游是 brief 进度行, 不进正文。
|
|
570
|
+
const thought = b.thinking.trim();
|
|
571
|
+
if (thought)
|
|
572
|
+
out.push({ kind: "thinking", body: thought });
|
|
587
573
|
}
|
|
588
|
-
// thinking blocks otherwise intentionally skipped (只进详情页)
|
|
589
574
|
}
|
|
590
575
|
flushPending();
|
|
591
576
|
// Emit per-line usage snapshot BEFORE turn_end so brief store gets the last
|
|
@@ -666,8 +651,9 @@ const renderLine = (raw, deps) => {
|
|
|
666
651
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
667
652
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
668
653
|
const splitChunks = splitMarkdown;
|
|
669
|
-
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
670
|
-
|
|
654
|
+
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
655
|
+
// (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
|
|
656
|
+
const TAG_HEADER_BUDGET = 64;
|
|
671
657
|
export const startMirrorTail = (deps) => {
|
|
672
658
|
const { log } = deps;
|
|
673
659
|
// A single logical tail follows ONE sessionId whose transcript file may move
|
|
@@ -1353,8 +1339,6 @@ const enqueue = (key, job) => {
|
|
|
1353
1339
|
};
|
|
1354
1340
|
export const startMirror = (deps) => {
|
|
1355
1341
|
const { cfg, log, client } = deps;
|
|
1356
|
-
// think-style: brief 模式下把 thinking 直接流进气泡, 且全程不下发任何详情链接。
|
|
1357
|
-
const thinkStyle = cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief;
|
|
1358
1342
|
// Multi-mirror: each (sessionId, target) pair is one Attachment. Same
|
|
1359
1343
|
// sessionId reattaches → replace. Same target with different sessionId →
|
|
1360
1344
|
// replace too (one WeCom chat can only show one mirror at a time).
|
|
@@ -1440,8 +1424,6 @@ export const startMirror = (deps) => {
|
|
|
1440
1424
|
// for >60s mid-turn and we don't want to drop the bubble while it's chewing.
|
|
1441
1425
|
const FLUSH_MS = 250;
|
|
1442
1426
|
const HARD_TIMEOUT_MS = 350_000;
|
|
1443
|
-
/** 首条 CLI 产出到达前的早期超时: 3s 无响应则先收气泡为详情链接。 */
|
|
1444
|
-
const EARLY_LINK_MS = 3_000;
|
|
1445
1427
|
/** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
|
|
1446
1428
|
* 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
|
|
1447
1429
|
* 这一段, 与模型思考/工具执行时长无关。
|
|
@@ -1452,19 +1434,6 @@ export const startMirror = (deps) => {
|
|
|
1452
1434
|
const softTurnEndMsFor = (a) => cfg.wrc.mirror.softTurnEndMs ?? (backendForPath(a.jsonlPath).name === "codebuddy" ? SOFT_TURN_END_DEFAULT_CB : SOFT_TURN_END_DEFAULT_CLAUDE);
|
|
1453
1435
|
const STREAM_SOFT_CAP = 18_000;
|
|
1454
1436
|
const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1455
|
-
/** Standalone 发送前正文检查: 内容无正文 (纯 think 标记 / 工具行) 时,
|
|
1456
|
-
* 回队列等这么久。期间有新消息入队则重置; 到期且无新消息才发出。 */
|
|
1457
|
-
const STANDALONE_BODY_WAIT_MS = 8_000;
|
|
1458
|
-
// 判断 standalone 内容是否含有正文 (非纯 <think>…</think> / 工具行)。
|
|
1459
|
-
const standaloneHasBody = (content) => {
|
|
1460
|
-
// 剥掉 <think>…</think> 包裹 (含 tag header)
|
|
1461
|
-
const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, "")
|
|
1462
|
-
// 工具行
|
|
1463
|
-
.replace(/^🔧 .*/gm, "")
|
|
1464
|
-
.replace(/^↳ .*/gm, "")
|
|
1465
|
-
.trim();
|
|
1466
|
-
return stripped.length > 0;
|
|
1467
|
-
};
|
|
1468
1437
|
const turnRegistry = new Map();
|
|
1469
1438
|
const evictTurns = () => {
|
|
1470
1439
|
const now = Date.now();
|
|
@@ -1523,15 +1492,18 @@ export const startMirror = (deps) => {
|
|
|
1523
1492
|
clearTimeout(s.hardTimer);
|
|
1524
1493
|
s.hardTimer = undefined;
|
|
1525
1494
|
}
|
|
1526
|
-
|
|
1495
|
+
// 没正文不写: finish=true 是整条覆盖, 空正文只会把 "…" ack 覆盖成光秃秃的
|
|
1496
|
+
// 链接头, 结束处理自己制造错发。气泡保持现有内容, 由 WeCom 6min 窗口自然
|
|
1497
|
+
// 到期。dead 流同理只做本地清理。
|
|
1498
|
+
if (!s.dead && s.acc.trim()) {
|
|
1527
1499
|
const card = detailCardFor(s, a.target);
|
|
1528
1500
|
try {
|
|
1529
1501
|
if (card) {
|
|
1530
1502
|
s.cardSent = true;
|
|
1531
|
-
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, s.acc
|
|
1503
|
+
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, s.acc, undefined, s.turnId), true, { templateCard: card });
|
|
1532
1504
|
}
|
|
1533
1505
|
else {
|
|
1534
|
-
await client.replyStream(s.frame, s.streamId, withLinkedTag(a, s.acc
|
|
1506
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTag(a, s.acc, undefined, s.turnId), true);
|
|
1535
1507
|
}
|
|
1536
1508
|
log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
|
|
1537
1509
|
}
|
|
@@ -1541,7 +1513,7 @@ export const startMirror = (deps) => {
|
|
|
1541
1513
|
}
|
|
1542
1514
|
}
|
|
1543
1515
|
else {
|
|
1544
|
-
log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length }, "stream finalize (
|
|
1516
|
+
log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, dead: s.dead }, "stream finalize (no body — not overwriting)");
|
|
1545
1517
|
}
|
|
1546
1518
|
if (s.tools.length > 0) {
|
|
1547
1519
|
turnRegistry.set(s.turnId, {
|
|
@@ -1572,8 +1544,6 @@ export const startMirror = (deps) => {
|
|
|
1572
1544
|
// Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
|
|
1573
1545
|
// plain withSessionTag when no turnId is available (no active turn to link).
|
|
1574
1546
|
const linkedTagPrefix = (target, turnId) => {
|
|
1575
|
-
if (thinkStyle)
|
|
1576
|
-
return ""; // think-style: 全程不带详情链接, 一律退回 withSessionTag。
|
|
1577
1547
|
if (!turnId)
|
|
1578
1548
|
return "";
|
|
1579
1549
|
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
@@ -1590,7 +1560,7 @@ export const startMirror = (deps) => {
|
|
|
1590
1560
|
};
|
|
1591
1561
|
const sendStandalone = (a, content) => {
|
|
1592
1562
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1593
|
-
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.
|
|
1563
|
+
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1594
1564
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1595
1565
|
a.standalonePending = a.standalonePending
|
|
1596
1566
|
.then(async () => {
|
|
@@ -1642,54 +1612,16 @@ export const startMirror = (deps) => {
|
|
|
1642
1612
|
log.warn({ sessionId: a.sessionId, err: e.message }, "pane preamble capture failed");
|
|
1643
1613
|
}
|
|
1644
1614
|
};
|
|
1645
|
-
// thinkStyle 格式化: 将 merged standalone 内容分拣为 <think> 包裹的工具行和正文。
|
|
1646
|
-
// 统一在发送层处理, pushThink / handleBriefItem 等上游只需发原始文本。
|
|
1647
|
-
const formatThinkStandalone = (a, merged) => {
|
|
1648
|
-
const lines = merged.split("\n");
|
|
1649
|
-
const thinkLines = [];
|
|
1650
|
-
const bodyLines = [];
|
|
1651
|
-
for (const line of lines) {
|
|
1652
|
-
if (line.startsWith("🔧 ") || line.startsWith("↳ ") || line.startsWith("💭 "))
|
|
1653
|
-
thinkLines.push(line);
|
|
1654
|
-
else
|
|
1655
|
-
bodyLines.push(line);
|
|
1656
|
-
}
|
|
1657
|
-
// `💭 ` 只是 pushThink 打在 standalone 路径上的传输标记 (让本函数认出 think 来源),
|
|
1658
|
-
// 没有展示价值 —— 剥掉。🔧 / ↳ 是给用户的工具行标记, 保留。
|
|
1659
|
-
const think = thinkLines.map((l) => l.replace(/^💭 /, "")).join("\n").trim();
|
|
1660
|
-
const body = bodyLines.join("\n").trim();
|
|
1661
|
-
if (think && body)
|
|
1662
|
-
return `${openThink(a.target, think)}</think>\n\n${body}`;
|
|
1663
|
-
if (think)
|
|
1664
|
-
return `${openThink(a.target, think)}</think>`;
|
|
1665
|
-
return body;
|
|
1666
|
-
};
|
|
1667
1615
|
// Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
|
|
1668
|
-
// 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
|
|
1669
|
-
|
|
1670
|
-
// 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才丢弃。
|
|
1671
|
-
// force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
|
|
1672
|
-
const flushStandalone = (a, force = false) => {
|
|
1616
|
+
// 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
|
|
1617
|
+
const flushStandalone = (a) => {
|
|
1673
1618
|
const buf = a.standaloneBuf;
|
|
1674
1619
|
if (!buf)
|
|
1675
1620
|
return;
|
|
1676
1621
|
const merged = buf.parts.join("\n\n");
|
|
1677
|
-
if (!force && !standaloneHasBody(merged)) {
|
|
1678
|
-
if (buf.bodyWaitLen !== buf.parts.length) {
|
|
1679
|
-
// 无正文 & 队列有新增 → 再等一轮, 看后续 item 是否带正文。
|
|
1680
|
-
buf.bodyWaitLen = buf.parts.length;
|
|
1681
|
-
buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
// 8s 无新 item 且仍无正文 → 丢弃 (纯 think/工具行不值得单独发一条)。
|
|
1685
|
-
a.standaloneBuf = undefined;
|
|
1686
|
-
return;
|
|
1687
|
-
}
|
|
1688
1622
|
a.standaloneBuf = undefined;
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
if (content)
|
|
1692
|
-
sendStandalone(a, content);
|
|
1623
|
+
if (merged)
|
|
1624
|
+
sendStandalone(a, merged);
|
|
1693
1625
|
};
|
|
1694
1626
|
const enqueueStandalone = (a, content) => {
|
|
1695
1627
|
const ms = cfg.wrc.mirror.standaloneDebounceMs;
|
|
@@ -1740,7 +1672,7 @@ export const startMirror = (deps) => {
|
|
|
1740
1672
|
if (!a.standaloneBuf)
|
|
1741
1673
|
return;
|
|
1742
1674
|
clearTimeout(a.standaloneBuf.timer);
|
|
1743
|
-
flushStandalone(a
|
|
1675
|
+
flushStandalone(a);
|
|
1744
1676
|
};
|
|
1745
1677
|
// Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
|
|
1746
1678
|
// standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
|
|
@@ -1793,7 +1725,10 @@ export const startMirror = (deps) => {
|
|
|
1793
1725
|
flushPendingStandalone(a);
|
|
1794
1726
|
void (async () => {
|
|
1795
1727
|
try {
|
|
1796
|
-
|
|
1728
|
+
// 有正文才写旧 streamId (收入 stream); 空缓冲一个字都不写 —— 否则
|
|
1729
|
+
// loading 气泡被 " " 覆盖, 结束处理自己凭空制造一条空消息。
|
|
1730
|
+
if (bubbleMd)
|
|
1731
|
+
await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd), true);
|
|
1797
1732
|
}
|
|
1798
1733
|
catch (e) {
|
|
1799
1734
|
log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
|
|
@@ -1880,24 +1815,77 @@ export const startMirror = (deps) => {
|
|
|
1880
1815
|
}
|
|
1881
1816
|
};
|
|
1882
1817
|
// ── Brief mode ────────────────────────────────────────────────────────
|
|
1883
|
-
// 每个 turn
|
|
1884
|
-
//
|
|
1885
|
-
// •
|
|
1818
|
+
// 每个 turn 挂一条气泡: ack 时就写进 `tag 详情链接 …` (finish=false, 不立刻关掉),
|
|
1819
|
+
// 让群里从收到消息那一刻起就有详情页入口。收口时:
|
|
1820
|
+
// • 正文到位 → 以 `链接 正文` 覆盖这条气泡。
|
|
1821
|
+
// • 始终没正文 / 过 6min 窗口 → 以 ack 时那条纯 `链接` 收口。
|
|
1822
|
+
// • 气泡已收口或无气泡 turn (CLI 侧发起) → 正文另发一条 standalone。
|
|
1886
1823
|
// 其它所有 item 只写入 turn detail store。
|
|
1887
1824
|
// 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
|
|
1888
1825
|
const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
|
|
1826
|
+
// ── CoT 进度行 (brief 气泡) ─────────────────────────────────────────
|
|
1827
|
+
// 一轮里正文要等 final text 才落地, 中间几十秒到几分钟气泡只有一个 "…"。这里把最新的
|
|
1828
|
+
// thinking / 工具调用覆盖进这条还没收口的气泡, 让群里看得到进展。三条硬约束:
|
|
1829
|
+
// • 只写"还没收口的气泡" —— 结论一到 finishBubble 就用 `链接 正文` 整条替换, 进度
|
|
1830
|
+
// 不会留在最终气泡里 (这是它与已下线的 thinkStyle 的分界线: 那次是把整轮 reasoning
|
|
1831
|
+
// 拼进正文, 单条体积翻几倍被 md-chunk 切成多页刷屏);
|
|
1832
|
+
// • 单行定长 (COT_MAX_CHARS), 无论 thinking 多长恒定一行, 不存在刷屏可能;
|
|
1833
|
+
// • 节流 —— 气泡刷新是整条内容重发, 高频 thinking 不能 1:1 打到 stream 上。
|
|
1834
|
+
const COT_MAX_CHARS = 100;
|
|
1835
|
+
const COT_FLUSH_MS = 1500;
|
|
1836
|
+
// 压成一行的进度片段。safeForMarkdown 必须先于截断: thinking 里一个裸反引号就能提前
|
|
1837
|
+
// 闭合代码段, 让后面的正文裸奔在链接旁边。尾随的省略号 = "还在进行中"; 截断与进行中
|
|
1838
|
+
// 共用同一个 …, 不叠加两个。
|
|
1839
|
+
const cotLine = (s) => {
|
|
1840
|
+
const flat = safeForMarkdown(s.replace(/\s+/g, " ").trim());
|
|
1841
|
+
return flat ? `${flat.slice(0, COT_MAX_CHARS)}…` : "";
|
|
1842
|
+
};
|
|
1843
|
+
const cotToolLabel = (calls) => calls.map((c) => `${c.name} ${renderToolInputCompact(c.input, 60)}`.trim()).join(" / ");
|
|
1844
|
+
const clearCot = (a) => {
|
|
1845
|
+
if (a.cotTimer) {
|
|
1846
|
+
clearTimeout(a.cotTimer);
|
|
1847
|
+
a.cotTimer = undefined;
|
|
1848
|
+
}
|
|
1849
|
+
a.cotText = undefined;
|
|
1850
|
+
};
|
|
1851
|
+
const flushCot = async (a) => {
|
|
1852
|
+
a.cotTimer = undefined;
|
|
1853
|
+
const text = a.cotText;
|
|
1854
|
+
a.cotText = undefined;
|
|
1855
|
+
const b = a.briefBubble;
|
|
1856
|
+
const turnId = a.briefTurnId;
|
|
1857
|
+
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
1858
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
1859
|
+
return;
|
|
1860
|
+
try {
|
|
1861
|
+
await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
|
|
1862
|
+
}
|
|
1863
|
+
catch (e) {
|
|
1864
|
+
log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
/** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
|
|
1868
|
+
const updateBriefProgress = (a, raw) => {
|
|
1869
|
+
const b = a.briefBubble;
|
|
1870
|
+
if (!b || b.done || a.briefConcluded)
|
|
1871
|
+
return;
|
|
1872
|
+
const line = cotLine(raw);
|
|
1873
|
+
if (!line)
|
|
1874
|
+
return;
|
|
1875
|
+
a.cotText = line;
|
|
1876
|
+
if (a.cotTimer)
|
|
1877
|
+
return;
|
|
1878
|
+
a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
|
|
1879
|
+
};
|
|
1889
1880
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
1890
1881
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
1891
1882
|
// target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
1892
1883
|
const briefDetailLink = (turnId, target) => {
|
|
1893
1884
|
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
1894
1885
|
const tag = tagOfKey(target);
|
|
1895
|
-
return tag
|
|
1896
|
-
? `[${labelFor(tag)} #${tag}](${url}) ← View chat details`
|
|
1897
|
-
: `[🧙](${url}) ← View chat details`;
|
|
1886
|
+
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
1898
1887
|
};
|
|
1899
1888
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
1900
|
-
// 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
|
|
1901
1889
|
// raw=true skips withSessionTag (used when content already contains the linked tag header).
|
|
1902
1890
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
1903
1891
|
// standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
|
|
@@ -1909,17 +1897,10 @@ export const startMirror = (deps) => {
|
|
|
1909
1897
|
return;
|
|
1910
1898
|
b.done = true;
|
|
1911
1899
|
clearTimeout(b.hardTimer);
|
|
1912
|
-
if (b.earlyTimer) {
|
|
1913
|
-
clearTimeout(b.earlyTimer);
|
|
1914
|
-
b.earlyTimer = undefined;
|
|
1915
|
-
}
|
|
1916
|
-
// think-style: 清除待发的 flush 定时器, 避免 doStreamThink 在 done 后空跑。
|
|
1917
|
-
if (a.briefBubble === b && a.thinkFlushTimer) {
|
|
1918
|
-
clearTimeout(a.thinkFlushTimer);
|
|
1919
|
-
a.thinkFlushTimer = undefined;
|
|
1920
|
-
}
|
|
1921
1900
|
if (a.briefBubble === b)
|
|
1922
1901
|
a.briefBubble = undefined;
|
|
1902
|
+
// 气泡要定稿了 —— 排队中的 CoT 进度必须撤掉, 否则它会把 `链接 正文` 覆盖回进度行。
|
|
1903
|
+
clearCot(a);
|
|
1923
1904
|
const p = (async () => {
|
|
1924
1905
|
try {
|
|
1925
1906
|
await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
|
|
@@ -1937,62 +1918,6 @@ export const startMirror = (deps) => {
|
|
|
1937
1918
|
return p;
|
|
1938
1919
|
};
|
|
1939
1920
|
const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
|
|
1940
|
-
// think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
|
|
1941
|
-
const thinkTag = (target) => {
|
|
1942
|
-
const tag = tagOfKey(target);
|
|
1943
|
-
return tag ? `${labelFor(tag)} #${tag}` : "🧙";
|
|
1944
|
-
};
|
|
1945
|
-
const openThink = (target, think) => `<think>${thinkTag(target)} ${think}`;
|
|
1946
|
-
// think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
|
|
1947
|
-
// (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
|
|
1948
|
-
// raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
|
|
1949
|
-
// streamPending → 首次调用时才真正开流 (fix: off-by-one race)。
|
|
1950
|
-
// fix: 加去抖 (FLUSH_MS) + 背压 (await 上一次完成), 消除 SDK 串行队列积压。
|
|
1951
|
-
const doStreamThink = async (a) => {
|
|
1952
|
-
const b = a.briefBubble;
|
|
1953
|
-
const think = a.briefThinking?.trim();
|
|
1954
|
-
if (!b || b.done || !think)
|
|
1955
|
-
return;
|
|
1956
|
-
// 首条 replyStream 延迟到这里发出 (streamPending), 携带真实内容, 避免空开流竞态。
|
|
1957
|
-
b.streamPending = false;
|
|
1958
|
-
try {
|
|
1959
|
-
await client.replyStream(b.frame, b.streamId, openThink(a.target, think), false);
|
|
1960
|
-
log.debug({ sessionId: a.sessionId, streamId: b.streamId, thinkLen: think.length }, "brief: think stream ok");
|
|
1961
|
-
}
|
|
1962
|
-
catch (e) {
|
|
1963
|
-
log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed");
|
|
1964
|
-
}
|
|
1965
|
-
};
|
|
1966
|
-
const scheduleThinkFlush = (a) => {
|
|
1967
|
-
if (!a.briefBubble || a.briefBubble.done || a.thinkFlushTimer || a.thinkFlushing)
|
|
1968
|
-
return;
|
|
1969
|
-
a.thinkFlushTimer = setTimeout(() => {
|
|
1970
|
-
a.thinkFlushTimer = undefined;
|
|
1971
|
-
if (a.thinkFlushing)
|
|
1972
|
-
return; // 上一次还在飞 → 下次 pushThink 再排
|
|
1973
|
-
a.thinkFlushing = true;
|
|
1974
|
-
void doStreamThink(a).finally(() => { a.thinkFlushing = false; });
|
|
1975
|
-
}, FLUSH_MS);
|
|
1976
|
-
};
|
|
1977
|
-
// think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
|
|
1978
|
-
// 气泡还活 → 累积进 briefThinking 并排一次去抖 flush (replyStream);
|
|
1979
|
-
// 气泡已收口 / 无气泡 → enqueueStandalone 即时推送 (自带 3s debounce 防抖合并)。
|
|
1980
|
-
const pushThink = (a, chunk) => {
|
|
1981
|
-
const c = chunk.trim();
|
|
1982
|
-
if (!c)
|
|
1983
|
-
return;
|
|
1984
|
-
if (a.briefBubble && !a.briefBubble.done) {
|
|
1985
|
-
a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
|
|
1986
|
-
scheduleThinkFlush(a);
|
|
1987
|
-
}
|
|
1988
|
-
else if (a.briefTurnId) {
|
|
1989
|
-
// 气泡已收口 / 无气泡 turn: 走 standalone, 避免长 turn 期间 chat 静默。
|
|
1990
|
-
// 原始内容入队, flushStandalone 统一做 thinkStyle 格式化 (工具行→<think>, 其余→正文)。
|
|
1991
|
-
// fix: thinkStyle 加 `💭 ` 前缀, formatThinkStandalone 才能识别为 think 行 (否则
|
|
1992
|
-
// 纯 reasoning 被当 body 整段裸奔到 chat)。
|
|
1993
|
-
enqueueStandalone(a, thinkStyle ? `💭 ${c}` : c);
|
|
1994
|
-
}
|
|
1995
|
-
};
|
|
1996
1921
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
1997
1922
|
const openBriefTurn = (a, q) => {
|
|
1998
1923
|
a.briefTurnId = q.turnId;
|
|
@@ -2001,18 +1926,10 @@ export const startMirror = (deps) => {
|
|
|
2001
1926
|
a.briefHadTool = false;
|
|
2002
1927
|
a.briefConcluded = false;
|
|
2003
1928
|
a.briefLastText = undefined;
|
|
2004
|
-
a
|
|
2005
|
-
if (a.thinkFlushTimer) {
|
|
2006
|
-
clearTimeout(a.thinkFlushTimer);
|
|
2007
|
-
a.thinkFlushTimer = undefined;
|
|
2008
|
-
}
|
|
2009
|
-
a.thinkFlushing = false;
|
|
1929
|
+
clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
|
|
2010
1930
|
log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
|
|
2011
1931
|
};
|
|
2012
|
-
// WeCom 侧发起一个 turn
|
|
2013
|
-
// 上一 turn 还在跑时绝不能强关它 —— CC 那边新消息也是排队的, 当前轮的后半段还会
|
|
2014
|
-
// 继续落盘, 强关会把它们改挂到新 turn 上, 旧 turn 页则提前变「已完成」。
|
|
2015
|
-
// 断点是一次性的: 只归给断点后的第一轮, 之后的轮次上下文又连续了。
|
|
1932
|
+
// WeCom 侧发起一个 turn。断点是一次性的: 只归给断点后的第一轮, 之后的轮次上下文又连续了。
|
|
2016
1933
|
const consumeCut = (a) => {
|
|
2017
1934
|
const cut = a.ctxCut;
|
|
2018
1935
|
a.ctxCut = undefined;
|
|
@@ -2034,62 +1951,24 @@ export const startMirror = (deps) => {
|
|
|
2034
1951
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2035
1952
|
const q = { turnId, bubble, isSlash };
|
|
2036
1953
|
bubble.hardTimer = setTimeout(() => {
|
|
2037
|
-
//
|
|
2038
|
-
|
|
2039
|
-
// 收成详情链接即可, 真正要紧的是让本 turn 激活。
|
|
2040
|
-
if (a.briefQueue?.includes(q)) {
|
|
2041
|
-
log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
|
|
2042
|
-
closeBriefTurn(a);
|
|
2043
|
-
}
|
|
2044
|
-
// think-style: WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
|
|
2045
|
-
// 保留当前已流进气泡的 thinking 内容作为最终快照 (用户看到的内容不变),
|
|
2046
|
-
// 后续正文走 standalone。如果 turn 尚未结论, 标记 done 让 concludeBriefTurn
|
|
2047
|
-
// 知道气泡已收口、走 else 分支 (sendRaw / sendStandalone)。
|
|
2048
|
-
if (thinkStyle) {
|
|
2049
|
-
// 活跃 turn 的 thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
|
|
2050
|
-
const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
|
|
2051
|
-
const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
|
|
2052
|
-
// 气泡带走了已累积的 thinking; 清空 briefThinking, 后续 pushThink 走 standalone。
|
|
2053
|
-
if (a.briefBubble === bubble)
|
|
2054
|
-
a.briefThinking = undefined;
|
|
2055
|
-
void finishBubble(a, bubble, content, true);
|
|
2056
|
-
}
|
|
2057
|
-
else {
|
|
2058
|
-
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
2059
|
-
}
|
|
1954
|
+
// WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
|
|
1955
|
+
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
2060
1956
|
}, HARD_TIMEOUT_MS);
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
2065
|
-
//
|
|
2066
|
-
//
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
}, EARLY_LINK_MS);
|
|
2075
|
-
if (a.briefTurnId) {
|
|
2076
|
-
(a.briefQueue ??= []).push(q);
|
|
2077
|
-
log.info({ sessionId: a.sessionId, turnId, queued: a.briefQueue.length }, "brief: turn queued (previous still running)");
|
|
2078
|
-
}
|
|
2079
|
-
else {
|
|
2080
|
-
openBriefTurn(a, q);
|
|
2081
|
-
}
|
|
1957
|
+
// 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
|
|
1958
|
+
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
1959
|
+
// 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
|
|
1960
|
+
// 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
|
|
1961
|
+
// 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
|
|
1962
|
+
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。
|
|
1963
|
+
if (a.briefTurnId)
|
|
1964
|
+
closeBriefTurn(a);
|
|
1965
|
+
openBriefTurn(a, q);
|
|
1966
|
+
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
1967
|
+
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
1968
|
+
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
1969
|
+
// 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
|
|
2082
1970
|
try {
|
|
2083
|
-
|
|
2084
|
-
// 避免空 `<think>` 与紧随的 pushThink 产生两次快速 replyStream 触发 WeCom
|
|
2085
|
-
// 服务端合并/丢弃 (off-by-one)。flushStreamPending 在 pushThink/doStreamThink 首次
|
|
2086
|
-
// 调用时开流, 保证只有一次 replyStream 携带真实内容。
|
|
2087
|
-
if (thinkStyle) {
|
|
2088
|
-
bubble.streamPending = true;
|
|
2089
|
-
}
|
|
2090
|
-
else {
|
|
2091
|
-
await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
|
|
2092
|
-
}
|
|
1971
|
+
await client.replyStream(frame, streamId, `${briefDetailLink(turnId, a.target)} …`, false);
|
|
2093
1972
|
}
|
|
2094
1973
|
catch (e) {
|
|
2095
1974
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
@@ -2112,101 +1991,44 @@ export const startMirror = (deps) => {
|
|
|
2112
1991
|
a.briefHadTool = false;
|
|
2113
1992
|
a.briefConcluded = false;
|
|
2114
1993
|
a.briefLastText = undefined;
|
|
2115
|
-
a
|
|
2116
|
-
|
|
2117
|
-
if (!thinkStyle)
|
|
2118
|
-
sendRaw(a, briefDetailLink(turnId, a.target));
|
|
1994
|
+
clearCot(a);
|
|
1995
|
+
sendRaw(a, briefDetailLink(turnId, a.target));
|
|
2119
1996
|
log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
|
|
2120
1997
|
};
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
const earlyLinkBubble = (a) => {
|
|
2126
|
-
if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
|
|
2127
|
-
return;
|
|
2128
|
-
// think-style: 不收气泡、不发链接 —— 气泡留着继续流 thinking / 最终答复。
|
|
2129
|
-
if (thinkStyle) {
|
|
2130
|
-
a.briefHadTool = true;
|
|
1998
|
+
// 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
|
|
1999
|
+
// 就挂在气泡里, 无需再推一次。气泡仍然不关, 等正文到来后以 `链接 正文` 覆盖收口。
|
|
2000
|
+
const markBriefTool = (a) => {
|
|
2001
|
+
if (!a.briefTurnId)
|
|
2131
2002
|
return;
|
|
2132
|
-
}
|
|
2133
2003
|
a.briefHadTool = true;
|
|
2134
|
-
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
2135
2004
|
};
|
|
2136
2005
|
// 本轮结论落地, 只生效一次:
|
|
2137
|
-
// •
|
|
2138
|
-
// •
|
|
2139
|
-
//
|
|
2006
|
+
// • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口;
|
|
2007
|
+
// • 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → body 走 standalone,
|
|
2008
|
+
// 那条 standalone 自带 `链接` 头 (withLinkedTag / ensureBriefTurn 已发过详情链接)。
|
|
2140
2009
|
const concludeBriefTurn = (a, body) => {
|
|
2141
2010
|
const turnId = a.briefTurnId;
|
|
2142
2011
|
if (!turnId || a.briefConcluded || !body.trim())
|
|
2143
2012
|
return;
|
|
2144
2013
|
a.briefConcluded = true;
|
|
2145
|
-
if (
|
|
2146
|
-
|
|
2147
|
-
// 再接正文; 无 thinking 时退回普通 tag 头正文。
|
|
2148
|
-
// 软后端 (softTurnEnd) 的最终正文 final===undefined, 上游按"非 final 中途文本"
|
|
2149
|
-
// 也 pushThink 进过 briefThinking —— 收口再拿它当正文 = think 与正文各一份重复。
|
|
2150
|
-
// 收口前把 briefThinking 尾部等于 body 的那段剥掉, 保证正文只出现在 <think> 之后。
|
|
2151
|
-
const think = stripTrailingBody(a.briefThinking?.trim(), body.trim());
|
|
2152
|
-
if (a.briefBubble && !a.briefBubble.done) {
|
|
2153
|
-
// 气泡还活: 整段 `<think>🧙 …</think>\n\n正文` 收进这一条 (raw, tag 已在 <think> 内)。
|
|
2154
|
-
const out = think ? `${openThink(a.target, think)}</think>\n\n${body}` : withSessionTag(a.target, body);
|
|
2155
|
-
void finishBriefBubble(a, out, true);
|
|
2156
|
-
}
|
|
2157
|
-
else {
|
|
2158
|
-
// 无气泡 turn / 气泡已收口: briefThinking 此时只含气泡期间累积的残余
|
|
2159
|
-
// (气泡收口后 pushThink 直接走 standalone, 不再累积)。有残余则拼成
|
|
2160
|
-
// think+body 一条; 无残余则只发 body。
|
|
2161
|
-
if (think) {
|
|
2162
|
-
sendRaw(a, `${openThink(a.target, think)}</think>\n\n${body}`);
|
|
2163
|
-
}
|
|
2164
|
-
else {
|
|
2165
|
-
sendStandalone(a, body);
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
else if (a.briefHadTool || !a.briefBubble) {
|
|
2170
|
-
sendStandalone(a, body);
|
|
2014
|
+
if (a.briefBubble && !a.briefBubble.done) {
|
|
2015
|
+
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2171
2016
|
}
|
|
2172
2017
|
else {
|
|
2173
|
-
|
|
2018
|
+
sendStandalone(a, body);
|
|
2174
2019
|
}
|
|
2175
|
-
//
|
|
2176
|
-
|
|
2177
|
-
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
|
|
2020
|
+
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
2021
|
+
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
2178
2022
|
};
|
|
2179
|
-
/**
|
|
2180
|
-
*
|
|
2181
|
-
|
|
2023
|
+
/** 收口一个 turn (软/硬/对话边界统一语义):
|
|
2024
|
+
* 有正文 (briefLastText) → 收入本 turn 自己的气泡 (`链接 正文`) 收口, 不另发消息;
|
|
2025
|
+
* 气泡已收/不在 → 正文走 standalone (有内容才有资格发)。
|
|
2026
|
+
* 没有正文 → 一个字都不写: finish=true 是整条覆盖, 空收口只会把 `链接 …`/CoT
|
|
2027
|
+
* 进度行覆盖成光秃秃的链接。气泡保持现有内容, 由 WeCom 6min 窗口自然到期。 */
|
|
2028
|
+
const closeBriefTurn = (a) => {
|
|
2182
2029
|
if (!a.briefTurnId)
|
|
2183
2030
|
return;
|
|
2184
|
-
|
|
2185
|
-
concludeBriefTurn(a, a.briefLastText ?? "");
|
|
2186
|
-
// 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
|
|
2187
|
-
// 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
|
|
2188
|
-
if (a.briefBubble && !a.briefBubble.done) {
|
|
2189
|
-
if (thinkStyle) {
|
|
2190
|
-
// 已结论 → 气泡已在 concludeBriefTurn 的 else 分支通过 sendRaw 发出
|
|
2191
|
-
// think+body 整条消息,这里只需收掉气泡(避免重复下发)。
|
|
2192
|
-
// 未结论 → doStreamThink 可能已把半成品 thinking 流进气泡 (无 </think> 闭合),
|
|
2193
|
-
// 用闭合的 thinking 快照收口, 避免 WeCom 渲染标签未闭合的乱排版。
|
|
2194
|
-
// 如果连 thinking 都没有 (streamPending 还在), 空收。
|
|
2195
|
-
if (a.briefConcluded) {
|
|
2196
|
-
void finishBriefBubble(a, withSessionTag(a.target, " "), true);
|
|
2197
|
-
}
|
|
2198
|
-
else {
|
|
2199
|
-
const think = a.briefThinking?.trim();
|
|
2200
|
-
const content = think
|
|
2201
|
-
? `${openThink(a.target, think)}</think>`
|
|
2202
|
-
: withSessionTag(a.target, " ");
|
|
2203
|
-
void finishBriefBubble(a, content, true);
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
else {
|
|
2207
|
-
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
2208
|
-
}
|
|
2209
|
-
}
|
|
2031
|
+
concludeBriefTurn(a, a.briefLastText ?? "");
|
|
2210
2032
|
recordTurnClose(a.briefTurnId);
|
|
2211
2033
|
log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
|
|
2212
2034
|
a.briefTurnId = undefined;
|
|
@@ -2215,16 +2037,7 @@ export const startMirror = (deps) => {
|
|
|
2215
2037
|
a.briefIsSlash = false;
|
|
2216
2038
|
a.briefConcluded = false;
|
|
2217
2039
|
a.briefLastText = undefined;
|
|
2218
|
-
a
|
|
2219
|
-
if (a.thinkFlushTimer) {
|
|
2220
|
-
clearTimeout(a.thinkFlushTimer);
|
|
2221
|
-
a.thinkFlushTimer = undefined;
|
|
2222
|
-
}
|
|
2223
|
-
a.thinkFlushing = false;
|
|
2224
|
-
// 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
|
|
2225
|
-
const next = a.briefQueue?.shift();
|
|
2226
|
-
if (next)
|
|
2227
|
-
openBriefTurn(a, next);
|
|
2040
|
+
clearCot(a);
|
|
2228
2041
|
};
|
|
2229
2042
|
// 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
|
|
2230
2043
|
//
|
|
@@ -2235,19 +2048,11 @@ export const startMirror = (deps) => {
|
|
|
2235
2048
|
// 状态的清算, Esc 之类要碰 pane 的动作留给调用方在这之后自己尽力去做。
|
|
2236
2049
|
const teardownOutbound = (a, note) => {
|
|
2237
2050
|
let closed = 0;
|
|
2238
|
-
// 排队中的 turn 先清空再动当前 turn —— 反序会让 closeBriefTurn 顺手 shift
|
|
2239
|
-
// 一个僵尸 turn 上来当活跃 turn, 白收一遍。
|
|
2240
|
-
for (const q of a.briefQueue ?? []) {
|
|
2241
|
-
closed++;
|
|
2242
|
-
// think-style: 不发链接, 排队气泡收为"已取消"提示 (避免空白气泡闪过)。
|
|
2243
|
-
void finishBubble(a, q.bubble, thinkStyle ? withSessionTag(a.target, "⏳ (queued, cancelled)") : briefDetailLink(q.turnId, a.target), thinkStyle);
|
|
2244
|
-
recordTurnClose(q.turnId);
|
|
2245
|
-
}
|
|
2246
|
-
a.briefQueue = [];
|
|
2247
2051
|
if (a.briefTurnId) {
|
|
2248
2052
|
closed++;
|
|
2249
2053
|
closeBriefTurn(a);
|
|
2250
2054
|
}
|
|
2055
|
+
clearCot(a); // 无 turn 也要撤: 它可能正挂在一个已被 finish 掉的气泡上
|
|
2251
2056
|
if (a.softEnd) {
|
|
2252
2057
|
clearTimeout(a.softEnd);
|
|
2253
2058
|
a.softEnd = undefined;
|
|
@@ -2259,7 +2064,7 @@ export const startMirror = (deps) => {
|
|
|
2259
2064
|
}
|
|
2260
2065
|
if (a.standaloneBuf) {
|
|
2261
2066
|
clearTimeout(a.standaloneBuf.timer);
|
|
2262
|
-
flushStandalone(a
|
|
2067
|
+
flushStandalone(a);
|
|
2263
2068
|
}
|
|
2264
2069
|
if (a.liveStream && !a.liveStream.closed) {
|
|
2265
2070
|
closed++;
|
|
@@ -2304,15 +2109,10 @@ export const startMirror = (deps) => {
|
|
|
2304
2109
|
const turnId = a.briefTurnId;
|
|
2305
2110
|
if (!turnId)
|
|
2306
2111
|
return;
|
|
2307
|
-
// 首条 item 到达 → 清除 earlyTimer (CLI 已有响应, 不需要超时收链接了)。
|
|
2308
|
-
if (a.briefBubble?.earlyTimer) {
|
|
2309
|
-
clearTimeout(a.briefBubble.earlyTimer);
|
|
2310
|
-
a.briefBubble.earlyTimer = undefined;
|
|
2311
|
-
}
|
|
2312
2112
|
const now = Date.now();
|
|
2313
2113
|
if (item.kind === "tool_use") {
|
|
2314
|
-
a
|
|
2315
|
-
|
|
2114
|
+
markBriefTool(a);
|
|
2115
|
+
updateBriefProgress(a, cotToolLabel(item.calls));
|
|
2316
2116
|
for (const c of item.calls) {
|
|
2317
2117
|
// 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
|
|
2318
2118
|
if (c.toolUseId) {
|
|
@@ -2332,67 +2132,28 @@ export const startMirror = (deps) => {
|
|
|
2332
2132
|
ts: now,
|
|
2333
2133
|
});
|
|
2334
2134
|
}
|
|
2335
|
-
// think-style: 工具调用也算中间过程, 以 `🔧 [Name compact](url)` 摘要进 think,
|
|
2336
|
-
// 复用 tool call detail url, 让 think 内容里的工具调用也可点开独立详情。
|
|
2337
|
-
if (thinkStyle) {
|
|
2338
|
-
const lines = item.calls
|
|
2339
|
-
.map((c) => {
|
|
2340
|
-
const compact = safeForMarkdown(renderToolInputCompact(c.input, cfg.wrc.mirror.toolUseInlineMaxChars));
|
|
2341
|
-
const url = detailUrlFor(c.toolUseId, a.target);
|
|
2342
|
-
return url ? `🔧 [${c.name} ${compact}](${url})` : `🔧 ${c.name} ${compact}`;
|
|
2343
|
-
})
|
|
2344
|
-
.join("\n");
|
|
2345
|
-
pushThink(a, lines);
|
|
2346
|
-
}
|
|
2347
2135
|
return;
|
|
2348
2136
|
}
|
|
2349
2137
|
if (item.kind === "tool_result") {
|
|
2350
|
-
|
|
2138
|
+
markBriefTool(a);
|
|
2351
2139
|
recordTurnItem(turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2352
|
-
// think-style: 工具结果 (中间输出) 也进 think, 单行截断免刷屏。
|
|
2353
|
-
if (thinkStyle)
|
|
2354
|
-
pushThink(a, `↳ ${oneLineSummary(item.full, cfg.wrc.mirror.toolUseInlineMaxChars)}`);
|
|
2355
|
-
return;
|
|
2356
|
-
}
|
|
2357
|
-
if (item.kind === "thinking") {
|
|
2358
|
-
recordTurnItem(turnId, { t: "text", body: item.body, ts: now });
|
|
2359
|
-
// thinking items only emitted by parser when thinkStyle=true (line 736 gate).
|
|
2360
|
-
pushThink(a, item.body); // reasoning 进 think 流
|
|
2361
2140
|
return;
|
|
2362
2141
|
}
|
|
2363
2142
|
if (item.kind === "text") {
|
|
2364
2143
|
recordTurnItem(turnId, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2365
|
-
|
|
2366
|
-
// 软后端的不确定态 (final===undefined) 两可 — 见下方分支里的分流说明。
|
|
2367
|
-
if (thinkStyle && item.final === false) {
|
|
2368
|
-
pushThink(a, item.body);
|
|
2369
|
-
return;
|
|
2370
|
-
}
|
|
2371
|
-
if (thinkStyle && item.final === undefined) {
|
|
2372
|
-
// 软后端说不清这句是不是终句, 两可:
|
|
2373
|
-
// 气泡还活 → 进 think 累积 (收口时 stripTrailingBody 会剥掉重复), 同时记
|
|
2374
|
-
// briefLastText 当正文候选; 终句身份留给 closeBriefTurn(soft) 定夺。
|
|
2375
|
-
// 气泡已死 → 没有收口阶段可纠偏, 只能按正文发 —— 它很可能就是终句,
|
|
2376
|
-
// 走 pushThink 会给它打 💭 前缀, 把答复整个塞进 <think>。
|
|
2377
|
-
if (a.briefBubble && !a.briefBubble.done) {
|
|
2378
|
-
a.briefLastText = item.body;
|
|
2379
|
-
pushThink(a, item.body);
|
|
2380
|
-
}
|
|
2381
|
-
else {
|
|
2382
|
-
enqueueStandalone(a, item.body);
|
|
2383
|
-
}
|
|
2384
|
-
return;
|
|
2385
|
-
}
|
|
2386
|
-
a.briefLastText = item.body; // 软收口时拿它当结论 (那条路径上没有 final 标记)
|
|
2387
|
-
// final===false 才是"确知的中途输出"; undefined 是后端说不清, 不能据此提前收气泡。
|
|
2144
|
+
a.briefLastText = item.body;
|
|
2388
2145
|
if (item.final === false)
|
|
2389
|
-
|
|
2146
|
+
markBriefTool(a);
|
|
2390
2147
|
if (item.final === true)
|
|
2391
2148
|
concludeBriefTurn(a, item.body);
|
|
2149
|
+
// 非终句文本 (中途叙述, 软后端含最终答案本身) 也是 CoT —— 推进进度行。
|
|
2150
|
+
// 终句走 conclude 直接定稿气泡, clearCot 会撤掉可能挂着的进度刷新。
|
|
2151
|
+
else
|
|
2152
|
+
updateBriefProgress(a, item.body);
|
|
2392
2153
|
return;
|
|
2393
2154
|
}
|
|
2394
2155
|
if (item.kind === "turn_end") {
|
|
2395
|
-
closeBriefTurn(a
|
|
2156
|
+
closeBriefTurn(a);
|
|
2396
2157
|
return;
|
|
2397
2158
|
}
|
|
2398
2159
|
if (item.kind === "turn_usage") {
|
|
@@ -2431,10 +2192,10 @@ export const startMirror = (deps) => {
|
|
|
2431
2192
|
void finishBriefBubble(a, banner);
|
|
2432
2193
|
else
|
|
2433
2194
|
enqueueStandalone(a, banner);
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
|
|
2437
|
-
closeBriefTurn(a);
|
|
2195
|
+
// 活跃 brief turn 也收掉: goal 期间所有 item 走 handleGoalItem, 它永远等不到
|
|
2196
|
+
// 自己的 turn_end。气泡已 done → closeBriefTurn 不会重复收口。
|
|
2197
|
+
if (a.briefTurnId)
|
|
2198
|
+
closeBriefTurn(a);
|
|
2438
2199
|
a.goalActive = true;
|
|
2439
2200
|
log.info({ sessionId: a.sessionId, target: a.target, condition }, "goal: entered progress mode");
|
|
2440
2201
|
};
|
|
@@ -2465,7 +2226,7 @@ export const startMirror = (deps) => {
|
|
|
2465
2226
|
return;
|
|
2466
2227
|
}
|
|
2467
2228
|
if (cfg.wrc.mirror.brief && a.briefTurnId) {
|
|
2468
|
-
closeBriefTurn(a
|
|
2229
|
+
closeBriefTurn(a);
|
|
2469
2230
|
return;
|
|
2470
2231
|
}
|
|
2471
2232
|
if (a.liveStream && !a.liveStream.closed)
|
|
@@ -2519,6 +2280,14 @@ export const startMirror = (deps) => {
|
|
|
2519
2280
|
clearTimeout(a.softEnd);
|
|
2520
2281
|
a.softEnd = undefined;
|
|
2521
2282
|
}
|
|
2283
|
+
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
2284
|
+
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
2285
|
+
// 那就变回已下线的 thinkStyle 了。
|
|
2286
|
+
if (item.kind === "thinking") {
|
|
2287
|
+
if (cfg.wrc.mirror.brief && a.briefTurnId)
|
|
2288
|
+
updateBriefProgress(a, item.body);
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2522
2291
|
if (item.kind === "turn_end" && item.soft === true) {
|
|
2523
2292
|
a.softEnd = setTimeout(() => fireSoftTurnEnd(a), softTurnEndMsFor(a));
|
|
2524
2293
|
return;
|
|
@@ -2765,13 +2534,13 @@ export const startMirror = (deps) => {
|
|
|
2765
2534
|
clearTimeout(a.softEnd);
|
|
2766
2535
|
a.softEnd = undefined;
|
|
2767
2536
|
}
|
|
2768
|
-
|
|
2769
|
-
closeBriefTurn(a); // 活跃
|
|
2537
|
+
if (a.briefTurnId)
|
|
2538
|
+
closeBriefTurn(a); // 活跃 turn 收掉 (队列已随对话边界策略移除)
|
|
2770
2539
|
if (a.liveStream && !a.liveStream.closed)
|
|
2771
2540
|
void finalizeStream(a, a.liveStream);
|
|
2772
2541
|
if (a.standaloneBuf) {
|
|
2773
2542
|
clearTimeout(a.standaloneBuf.timer);
|
|
2774
|
-
flushStandalone(a
|
|
2543
|
+
flushStandalone(a);
|
|
2775
2544
|
}
|
|
2776
2545
|
a.tail.stop();
|
|
2777
2546
|
bySessionId.delete(a.sessionId);
|
|
@@ -2836,7 +2605,6 @@ export const startMirror = (deps) => {
|
|
|
2836
2605
|
log: log.child({ sub: "tail", sessionId }),
|
|
2837
2606
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
2838
2607
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
2839
|
-
thinkStyle,
|
|
2840
2608
|
includeToolResults: cfg.wrc.mirror.includeToolResults,
|
|
2841
2609
|
toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
|
|
2842
2610
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
@@ -3048,7 +2816,7 @@ export const startMirror = (deps) => {
|
|
|
3048
2816
|
log.info("mirror: no persisted attachments and no pinned sessionId — waiting for /new or /mirror/attach");
|
|
3049
2817
|
}
|
|
3050
2818
|
// Render full tool details for a finalized turn into one-or-more markdown
|
|
3051
|
-
// chunks (split at
|
|
2819
|
+
// chunks (split at chunkBytes boundaries).
|
|
3052
2820
|
const renderToolDetails = (tools) => {
|
|
3053
2821
|
const parts = [];
|
|
3054
2822
|
let i = 0;
|
|
@@ -3068,7 +2836,7 @@ export const startMirror = (deps) => {
|
|
|
3068
2836
|
parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncate(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncate(result, 4000)}\n\`\`\``);
|
|
3069
2837
|
}
|
|
3070
2838
|
const merged = parts.join("\n\n---\n\n");
|
|
3071
|
-
return splitChunks(merged, Math.max(200, cfg.wrc.mirror.
|
|
2839
|
+
return splitChunks(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
3072
2840
|
};
|
|
3073
2841
|
const resolveToolDetail = (turnId) => {
|
|
3074
2842
|
evictTurns();
|
|
@@ -3218,7 +2986,6 @@ export const startMirror = (deps) => {
|
|
|
3218
2986
|
log: log.child({ sub: "tail", sessionId: newSessionId }),
|
|
3219
2987
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
3220
2988
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
3221
|
-
thinkStyle,
|
|
3222
2989
|
includeToolResults: cfg.wrc.mirror.includeToolResults,
|
|
3223
2990
|
toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
|
|
3224
2991
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
@@ -4258,7 +4025,7 @@ export const startMirror = (deps) => {
|
|
|
4258
4025
|
// 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
|
|
4259
4026
|
if (a.standaloneBuf) {
|
|
4260
4027
|
clearTimeout(a.standaloneBuf.timer);
|
|
4261
|
-
flushStandalone(a
|
|
4028
|
+
flushStandalone(a);
|
|
4262
4029
|
}
|
|
4263
4030
|
// 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
|
|
4264
4031
|
// 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
|
|
@@ -4594,22 +4361,31 @@ export const startMirror = (deps) => {
|
|
|
4594
4361
|
// 否则这次清空在 chat 详情里毫无痕迹, 前后两轮看着还是连续的。
|
|
4595
4362
|
if (armMigration)
|
|
4596
4363
|
a.ctxCut = "clear";
|
|
4597
|
-
//
|
|
4598
|
-
//
|
|
4599
|
-
//
|
|
4600
|
-
//
|
|
4601
|
-
//
|
|
4364
|
+
// 新消息 = 对话边界: 上一 turn 的出站状态在此清算。softEnd 是旧 turn 的
|
|
4365
|
+
// 静默期收口判决, 残留到新 turn 会在中途开火、提前收掉新气泡/新流;
|
|
4366
|
+
// deferred 缓冲里是已从 tail 消费但还没下发的 item, 收入旧 streamId 收口
|
|
4367
|
+
// (exitDeferredAsFinalStream): 正文进流关掉 loading 气泡, 只有 trailing 终句
|
|
4368
|
+
// 才走 standalone, 空缓冲一个字都不写 —— 不再整体另发 standalone。旧 frame
|
|
4369
|
+
// 就此作废; 用户之后点旧审批卡, terminateLiveStream 找不到 outbound 槽位会
|
|
4370
|
+
// 优雅 no-op。
|
|
4371
|
+
if (a.softEnd) {
|
|
4372
|
+
clearTimeout(a.softEnd);
|
|
4373
|
+
a.softEnd = undefined;
|
|
4374
|
+
}
|
|
4602
4375
|
if (a.outbound) {
|
|
4603
|
-
if (a.outbound.kind === "deferred")
|
|
4604
|
-
|
|
4605
|
-
|
|
4376
|
+
if (a.outbound.kind === "deferred") {
|
|
4377
|
+
exitDeferredAsFinalStream(a);
|
|
4378
|
+
}
|
|
4379
|
+
else {
|
|
4380
|
+
a.outbound = undefined;
|
|
4381
|
+
}
|
|
4606
4382
|
}
|
|
4607
4383
|
// Drain any pending standalone debounce so a prior turn's tail tail
|
|
4608
4384
|
// doesn't get sandwiched into this turn's pre-card flush (Path A) or
|
|
4609
4385
|
// race against the new stream's first content (Path B).
|
|
4610
4386
|
if (a.standaloneBuf) {
|
|
4611
4387
|
clearTimeout(a.standaloneBuf.timer);
|
|
4612
|
-
flushStandalone(a
|
|
4388
|
+
flushStandalone(a);
|
|
4613
4389
|
}
|
|
4614
4390
|
// /clear produces no assistant output; opening a stream would leave a
|
|
4615
4391
|
// stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
|
|
@@ -4634,7 +4410,8 @@ export const startMirror = (deps) => {
|
|
|
4634
4410
|
}
|
|
4635
4411
|
}
|
|
4636
4412
|
if (brief) {
|
|
4637
|
-
// 挂 loading 气泡并起新 turn
|
|
4413
|
+
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
4414
|
+
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
4638
4415
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
4639
4416
|
await startBriefTurn(a, frame, streamId, isSlash, text);
|
|
4640
4417
|
}
|