wezard 1.2.30 → 1.3.0
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 +156 -380
- 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();
|
|
@@ -1572,8 +1541,6 @@ export const startMirror = (deps) => {
|
|
|
1572
1541
|
// Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
|
|
1573
1542
|
// plain withSessionTag when no turnId is available (no active turn to link).
|
|
1574
1543
|
const linkedTagPrefix = (target, turnId) => {
|
|
1575
|
-
if (thinkStyle)
|
|
1576
|
-
return ""; // think-style: 全程不带详情链接, 一律退回 withSessionTag。
|
|
1577
1544
|
if (!turnId)
|
|
1578
1545
|
return "";
|
|
1579
1546
|
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
@@ -1590,7 +1557,7 @@ export const startMirror = (deps) => {
|
|
|
1590
1557
|
};
|
|
1591
1558
|
const sendStandalone = (a, content) => {
|
|
1592
1559
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1593
|
-
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.
|
|
1560
|
+
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1594
1561
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1595
1562
|
a.standalonePending = a.standalonePending
|
|
1596
1563
|
.then(async () => {
|
|
@@ -1642,52 +1609,16 @@ export const startMirror = (deps) => {
|
|
|
1642
1609
|
log.warn({ sessionId: a.sessionId, err: e.message }, "pane preamble capture failed");
|
|
1643
1610
|
}
|
|
1644
1611
|
};
|
|
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("↳ "))
|
|
1653
|
-
thinkLines.push(line);
|
|
1654
|
-
else
|
|
1655
|
-
bodyLines.push(line);
|
|
1656
|
-
}
|
|
1657
|
-
const think = thinkLines.join("\n").trim();
|
|
1658
|
-
const body = bodyLines.join("\n").trim();
|
|
1659
|
-
if (think && body)
|
|
1660
|
-
return `${openThink(a.target, think)}</think>\n\n${body}`;
|
|
1661
|
-
if (think)
|
|
1662
|
-
return `${openThink(a.target, think)}</think>`;
|
|
1663
|
-
return body;
|
|
1664
|
-
};
|
|
1665
1612
|
// Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
|
|
1666
|
-
// 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
|
|
1667
|
-
|
|
1668
|
-
// 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才丢弃。
|
|
1669
|
-
// force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
|
|
1670
|
-
const flushStandalone = (a, force = false) => {
|
|
1613
|
+
// 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
|
|
1614
|
+
const flushStandalone = (a) => {
|
|
1671
1615
|
const buf = a.standaloneBuf;
|
|
1672
1616
|
if (!buf)
|
|
1673
1617
|
return;
|
|
1674
1618
|
const merged = buf.parts.join("\n\n");
|
|
1675
|
-
if (!force && !standaloneHasBody(merged)) {
|
|
1676
|
-
if (buf.bodyWaitLen !== buf.parts.length) {
|
|
1677
|
-
// 无正文 & 队列有新增 → 再等一轮, 看后续 item 是否带正文。
|
|
1678
|
-
buf.bodyWaitLen = buf.parts.length;
|
|
1679
|
-
buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
// 8s 无新 item 且仍无正文 → 丢弃 (纯 think/工具行不值得单独发一条)。
|
|
1683
|
-
a.standaloneBuf = undefined;
|
|
1684
|
-
return;
|
|
1685
|
-
}
|
|
1686
1619
|
a.standaloneBuf = undefined;
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
if (content)
|
|
1690
|
-
sendStandalone(a, content);
|
|
1620
|
+
if (merged)
|
|
1621
|
+
sendStandalone(a, merged);
|
|
1691
1622
|
};
|
|
1692
1623
|
const enqueueStandalone = (a, content) => {
|
|
1693
1624
|
const ms = cfg.wrc.mirror.standaloneDebounceMs;
|
|
@@ -1738,7 +1669,7 @@ export const startMirror = (deps) => {
|
|
|
1738
1669
|
if (!a.standaloneBuf)
|
|
1739
1670
|
return;
|
|
1740
1671
|
clearTimeout(a.standaloneBuf.timer);
|
|
1741
|
-
flushStandalone(a
|
|
1672
|
+
flushStandalone(a);
|
|
1742
1673
|
};
|
|
1743
1674
|
// Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
|
|
1744
1675
|
// standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
|
|
@@ -1878,24 +1809,77 @@ export const startMirror = (deps) => {
|
|
|
1878
1809
|
}
|
|
1879
1810
|
};
|
|
1880
1811
|
// ── Brief mode ────────────────────────────────────────────────────────
|
|
1881
|
-
// 每个 turn
|
|
1882
|
-
//
|
|
1883
|
-
// •
|
|
1812
|
+
// 每个 turn 挂一条气泡: ack 时就写进 `tag 详情链接 …` (finish=false, 不立刻关掉),
|
|
1813
|
+
// 让群里从收到消息那一刻起就有详情页入口。收口时:
|
|
1814
|
+
// • 正文到位 → 以 `链接 正文` 覆盖这条气泡。
|
|
1815
|
+
// • 始终没正文 / 过 6min 窗口 → 以 ack 时那条纯 `链接` 收口。
|
|
1816
|
+
// • 气泡已收口或无气泡 turn (CLI 侧发起) → 正文另发一条 standalone。
|
|
1884
1817
|
// 其它所有 item 只写入 turn detail store。
|
|
1885
1818
|
// 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
|
|
1886
1819
|
const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
|
|
1820
|
+
// ── CoT 进度行 (brief 气泡) ─────────────────────────────────────────
|
|
1821
|
+
// 一轮里正文要等 final text 才落地, 中间几十秒到几分钟气泡只有一个 "…"。这里把最新的
|
|
1822
|
+
// thinking / 工具调用覆盖进这条还没收口的气泡, 让群里看得到进展。三条硬约束:
|
|
1823
|
+
// • 只写"还没收口的气泡" —— 结论一到 finishBubble 就用 `链接 正文` 整条替换, 进度
|
|
1824
|
+
// 不会留在最终气泡里 (这是它与已下线的 thinkStyle 的分界线: 那次是把整轮 reasoning
|
|
1825
|
+
// 拼进正文, 单条体积翻几倍被 md-chunk 切成多页刷屏);
|
|
1826
|
+
// • 单行定长 (COT_MAX_CHARS), 无论 thinking 多长恒定一行, 不存在刷屏可能;
|
|
1827
|
+
// • 节流 —— 气泡刷新是整条内容重发, 高频 thinking 不能 1:1 打到 stream 上。
|
|
1828
|
+
const COT_MAX_CHARS = 100;
|
|
1829
|
+
const COT_FLUSH_MS = 1500;
|
|
1830
|
+
// 压成一行的进度片段。safeForMarkdown 必须先于截断: thinking 里一个裸反引号就能提前
|
|
1831
|
+
// 闭合代码段, 让后面的正文裸奔在链接旁边。尾随的省略号 = "还在进行中"; 截断与进行中
|
|
1832
|
+
// 共用同一个 …, 不叠加两个。
|
|
1833
|
+
const cotLine = (s) => {
|
|
1834
|
+
const flat = safeForMarkdown(s.replace(/\s+/g, " ").trim());
|
|
1835
|
+
return flat ? `${flat.slice(0, COT_MAX_CHARS)}…` : "";
|
|
1836
|
+
};
|
|
1837
|
+
const cotToolLabel = (calls) => calls.map((c) => `${c.name} ${renderToolInputCompact(c.input, 60)}`.trim()).join(" / ");
|
|
1838
|
+
const clearCot = (a) => {
|
|
1839
|
+
if (a.cotTimer) {
|
|
1840
|
+
clearTimeout(a.cotTimer);
|
|
1841
|
+
a.cotTimer = undefined;
|
|
1842
|
+
}
|
|
1843
|
+
a.cotText = undefined;
|
|
1844
|
+
};
|
|
1845
|
+
const flushCot = async (a) => {
|
|
1846
|
+
a.cotTimer = undefined;
|
|
1847
|
+
const text = a.cotText;
|
|
1848
|
+
a.cotText = undefined;
|
|
1849
|
+
const b = a.briefBubble;
|
|
1850
|
+
const turnId = a.briefTurnId;
|
|
1851
|
+
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
1852
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
1853
|
+
return;
|
|
1854
|
+
try {
|
|
1855
|
+
await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
|
|
1856
|
+
}
|
|
1857
|
+
catch (e) {
|
|
1858
|
+
log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
/** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
|
|
1862
|
+
const updateBriefProgress = (a, raw) => {
|
|
1863
|
+
const b = a.briefBubble;
|
|
1864
|
+
if (!b || b.done || a.briefConcluded)
|
|
1865
|
+
return;
|
|
1866
|
+
const line = cotLine(raw);
|
|
1867
|
+
if (!line)
|
|
1868
|
+
return;
|
|
1869
|
+
a.cotText = line;
|
|
1870
|
+
if (a.cotTimer)
|
|
1871
|
+
return;
|
|
1872
|
+
a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
|
|
1873
|
+
};
|
|
1887
1874
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
1888
1875
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
1889
1876
|
// target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
1890
1877
|
const briefDetailLink = (turnId, target) => {
|
|
1891
1878
|
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
1892
1879
|
const tag = tagOfKey(target);
|
|
1893
|
-
return tag
|
|
1894
|
-
? `[${labelFor(tag)} #${tag}](${url}) ← View chat details`
|
|
1895
|
-
: `[🧙](${url}) ← View chat details`;
|
|
1880
|
+
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
1896
1881
|
};
|
|
1897
1882
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
1898
|
-
// 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
|
|
1899
1883
|
// raw=true skips withSessionTag (used when content already contains the linked tag header).
|
|
1900
1884
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
1901
1885
|
// standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
|
|
@@ -1907,17 +1891,10 @@ export const startMirror = (deps) => {
|
|
|
1907
1891
|
return;
|
|
1908
1892
|
b.done = true;
|
|
1909
1893
|
clearTimeout(b.hardTimer);
|
|
1910
|
-
if (b.earlyTimer) {
|
|
1911
|
-
clearTimeout(b.earlyTimer);
|
|
1912
|
-
b.earlyTimer = undefined;
|
|
1913
|
-
}
|
|
1914
|
-
// think-style: 清除待发的 flush 定时器, 避免 doStreamThink 在 done 后空跑。
|
|
1915
|
-
if (a.briefBubble === b && a.thinkFlushTimer) {
|
|
1916
|
-
clearTimeout(a.thinkFlushTimer);
|
|
1917
|
-
a.thinkFlushTimer = undefined;
|
|
1918
|
-
}
|
|
1919
1894
|
if (a.briefBubble === b)
|
|
1920
1895
|
a.briefBubble = undefined;
|
|
1896
|
+
// 气泡要定稿了 —— 排队中的 CoT 进度必须撤掉, 否则它会把 `链接 正文` 覆盖回进度行。
|
|
1897
|
+
clearCot(a);
|
|
1921
1898
|
const p = (async () => {
|
|
1922
1899
|
try {
|
|
1923
1900
|
await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
|
|
@@ -1935,60 +1912,6 @@ export const startMirror = (deps) => {
|
|
|
1935
1912
|
return p;
|
|
1936
1913
|
};
|
|
1937
1914
|
const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
|
|
1938
|
-
// think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
|
|
1939
|
-
const thinkTag = (target) => {
|
|
1940
|
-
const tag = tagOfKey(target);
|
|
1941
|
-
return tag ? `${labelFor(tag)} #${tag}` : "🧙";
|
|
1942
|
-
};
|
|
1943
|
-
const openThink = (target, think) => `<think>${thinkTag(target)} ${think}`;
|
|
1944
|
-
// think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
|
|
1945
|
-
// (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
|
|
1946
|
-
// raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
|
|
1947
|
-
// streamPending → 首次调用时才真正开流 (fix: off-by-one race)。
|
|
1948
|
-
// fix: 加去抖 (FLUSH_MS) + 背压 (await 上一次完成), 消除 SDK 串行队列积压。
|
|
1949
|
-
const doStreamThink = async (a) => {
|
|
1950
|
-
const b = a.briefBubble;
|
|
1951
|
-
const think = a.briefThinking?.trim();
|
|
1952
|
-
if (!b || b.done || !think)
|
|
1953
|
-
return;
|
|
1954
|
-
// 首条 replyStream 延迟到这里发出 (streamPending), 携带真实内容, 避免空开流竞态。
|
|
1955
|
-
b.streamPending = false;
|
|
1956
|
-
try {
|
|
1957
|
-
await client.replyStream(b.frame, b.streamId, openThink(a.target, think), false);
|
|
1958
|
-
log.debug({ sessionId: a.sessionId, streamId: b.streamId, thinkLen: think.length }, "brief: think stream ok");
|
|
1959
|
-
}
|
|
1960
|
-
catch (e) {
|
|
1961
|
-
log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed");
|
|
1962
|
-
}
|
|
1963
|
-
};
|
|
1964
|
-
const scheduleThinkFlush = (a) => {
|
|
1965
|
-
if (!a.briefBubble || a.briefBubble.done || a.thinkFlushTimer || a.thinkFlushing)
|
|
1966
|
-
return;
|
|
1967
|
-
a.thinkFlushTimer = setTimeout(() => {
|
|
1968
|
-
a.thinkFlushTimer = undefined;
|
|
1969
|
-
if (a.thinkFlushing)
|
|
1970
|
-
return; // 上一次还在飞 → 下次 pushThink 再排
|
|
1971
|
-
a.thinkFlushing = true;
|
|
1972
|
-
void doStreamThink(a).finally(() => { a.thinkFlushing = false; });
|
|
1973
|
-
}, FLUSH_MS);
|
|
1974
|
-
};
|
|
1975
|
-
// think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
|
|
1976
|
-
// 气泡还活 → 累积进 briefThinking 并排一次去抖 flush (replyStream);
|
|
1977
|
-
// 气泡已收口 / 无气泡 → enqueueStandalone 即时推送 (自带 3s debounce 防抖合并)。
|
|
1978
|
-
const pushThink = (a, chunk) => {
|
|
1979
|
-
const c = chunk.trim();
|
|
1980
|
-
if (!c)
|
|
1981
|
-
return;
|
|
1982
|
-
if (a.briefBubble && !a.briefBubble.done) {
|
|
1983
|
-
a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
|
|
1984
|
-
scheduleThinkFlush(a);
|
|
1985
|
-
}
|
|
1986
|
-
else if (a.briefTurnId) {
|
|
1987
|
-
// 气泡已收口 / 无气泡 turn: 走 standalone, 避免长 turn 期间 chat 静默。
|
|
1988
|
-
// 原始内容入队, flushStandalone 统一做 thinkStyle 格式化 (工具行→<think>, 其余→正文)。
|
|
1989
|
-
enqueueStandalone(a, c);
|
|
1990
|
-
}
|
|
1991
|
-
};
|
|
1992
1915
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
1993
1916
|
const openBriefTurn = (a, q) => {
|
|
1994
1917
|
a.briefTurnId = q.turnId;
|
|
@@ -1997,18 +1920,10 @@ export const startMirror = (deps) => {
|
|
|
1997
1920
|
a.briefHadTool = false;
|
|
1998
1921
|
a.briefConcluded = false;
|
|
1999
1922
|
a.briefLastText = undefined;
|
|
2000
|
-
a
|
|
2001
|
-
if (a.thinkFlushTimer) {
|
|
2002
|
-
clearTimeout(a.thinkFlushTimer);
|
|
2003
|
-
a.thinkFlushTimer = undefined;
|
|
2004
|
-
}
|
|
2005
|
-
a.thinkFlushing = false;
|
|
1923
|
+
clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
|
|
2006
1924
|
log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
|
|
2007
1925
|
};
|
|
2008
|
-
// WeCom 侧发起一个 turn
|
|
2009
|
-
// 上一 turn 还在跑时绝不能强关它 —— CC 那边新消息也是排队的, 当前轮的后半段还会
|
|
2010
|
-
// 继续落盘, 强关会把它们改挂到新 turn 上, 旧 turn 页则提前变「已完成」。
|
|
2011
|
-
// 断点是一次性的: 只归给断点后的第一轮, 之后的轮次上下文又连续了。
|
|
1926
|
+
// WeCom 侧发起一个 turn。断点是一次性的: 只归给断点后的第一轮, 之后的轮次上下文又连续了。
|
|
2012
1927
|
const consumeCut = (a) => {
|
|
2013
1928
|
const cut = a.ctxCut;
|
|
2014
1929
|
a.ctxCut = undefined;
|
|
@@ -2030,62 +1945,23 @@ export const startMirror = (deps) => {
|
|
|
2030
1945
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2031
1946
|
const q = { turnId, bubble, isSlash };
|
|
2032
1947
|
bubble.hardTimer = setTimeout(() => {
|
|
2033
|
-
//
|
|
2034
|
-
|
|
2035
|
-
// 收成详情链接即可, 真正要紧的是让本 turn 激活。
|
|
2036
|
-
if (a.briefQueue?.includes(q)) {
|
|
2037
|
-
log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
|
|
2038
|
-
closeBriefTurn(a);
|
|
2039
|
-
}
|
|
2040
|
-
// think-style: WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
|
|
2041
|
-
// 保留当前已流进气泡的 thinking 内容作为最终快照 (用户看到的内容不变),
|
|
2042
|
-
// 后续正文走 standalone。如果 turn 尚未结论, 标记 done 让 concludeBriefTurn
|
|
2043
|
-
// 知道气泡已收口、走 else 分支 (sendRaw / sendStandalone)。
|
|
2044
|
-
if (thinkStyle) {
|
|
2045
|
-
// 活跃 turn 的 thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
|
|
2046
|
-
const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
|
|
2047
|
-
const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
|
|
2048
|
-
// 气泡带走了已累积的 thinking; 清空 briefThinking, 后续 pushThink 走 standalone。
|
|
2049
|
-
if (a.briefBubble === bubble)
|
|
2050
|
-
a.briefThinking = undefined;
|
|
2051
|
-
void finishBubble(a, bubble, content, true);
|
|
2052
|
-
}
|
|
2053
|
-
else {
|
|
2054
|
-
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
2055
|
-
}
|
|
1948
|
+
// WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
|
|
1949
|
+
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
2056
1950
|
}, HARD_TIMEOUT_MS);
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
2070
|
-
}, EARLY_LINK_MS);
|
|
2071
|
-
if (a.briefTurnId) {
|
|
2072
|
-
(a.briefQueue ??= []).push(q);
|
|
2073
|
-
log.info({ sessionId: a.sessionId, turnId, queued: a.briefQueue.length }, "brief: turn queued (previous still running)");
|
|
2074
|
-
}
|
|
2075
|
-
else {
|
|
2076
|
-
openBriefTurn(a, q);
|
|
2077
|
-
}
|
|
1951
|
+
// 新消息 = 对话边界: 立刻收掉上一 turn (气泡以详情链接收口), 新 turn 直接
|
|
1952
|
+
// 激活、不排队。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新
|
|
1953
|
+
// 气泡"正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下,
|
|
1954
|
+
// 但远小于排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟)
|
|
1955
|
+
// 过期, 激活后 replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。
|
|
1956
|
+
if (a.briefTurnId)
|
|
1957
|
+
closeBriefTurn(a);
|
|
1958
|
+
openBriefTurn(a, q);
|
|
1959
|
+
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
1960
|
+
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
1961
|
+
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
1962
|
+
// 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
|
|
2078
1963
|
try {
|
|
2079
|
-
|
|
2080
|
-
// 避免空 `<think>` 与紧随的 pushThink 产生两次快速 replyStream 触发 WeCom
|
|
2081
|
-
// 服务端合并/丢弃 (off-by-one)。flushStreamPending 在 pushThink/doStreamThink 首次
|
|
2082
|
-
// 调用时开流, 保证只有一次 replyStream 携带真实内容。
|
|
2083
|
-
if (thinkStyle) {
|
|
2084
|
-
bubble.streamPending = true;
|
|
2085
|
-
}
|
|
2086
|
-
else {
|
|
2087
|
-
await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
|
|
2088
|
-
}
|
|
1964
|
+
await client.replyStream(frame, streamId, `${briefDetailLink(turnId, a.target)} …`, false);
|
|
2089
1965
|
}
|
|
2090
1966
|
catch (e) {
|
|
2091
1967
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
@@ -2108,69 +1984,34 @@ export const startMirror = (deps) => {
|
|
|
2108
1984
|
a.briefHadTool = false;
|
|
2109
1985
|
a.briefConcluded = false;
|
|
2110
1986
|
a.briefLastText = undefined;
|
|
2111
|
-
a
|
|
2112
|
-
|
|
2113
|
-
if (!thinkStyle)
|
|
2114
|
-
sendRaw(a, briefDetailLink(turnId, a.target));
|
|
1987
|
+
clearCot(a);
|
|
1988
|
+
sendRaw(a, briefDetailLink(turnId, a.target));
|
|
2115
1989
|
log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
|
|
2116
1990
|
};
|
|
2117
|
-
//
|
|
2118
|
-
//
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
const earlyLinkBubble = (a) => {
|
|
2122
|
-
if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
|
|
2123
|
-
return;
|
|
2124
|
-
// think-style: 不收气泡、不发链接 —— 气泡留着继续流 thinking / 最终答复。
|
|
2125
|
-
if (thinkStyle) {
|
|
2126
|
-
a.briefHadTool = true;
|
|
1991
|
+
// 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
|
|
1992
|
+
// 就挂在气泡里, 无需再推一次。气泡仍然不关, 等正文到来后以 `链接 正文` 覆盖收口。
|
|
1993
|
+
const markBriefTool = (a) => {
|
|
1994
|
+
if (!a.briefTurnId)
|
|
2127
1995
|
return;
|
|
2128
|
-
}
|
|
2129
1996
|
a.briefHadTool = true;
|
|
2130
|
-
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
2131
1997
|
};
|
|
2132
1998
|
// 本轮结论落地, 只生效一次:
|
|
2133
|
-
// •
|
|
2134
|
-
// •
|
|
2135
|
-
//
|
|
1999
|
+
// • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口;
|
|
2000
|
+
// • 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → body 走 standalone,
|
|
2001
|
+
// 那条 standalone 自带 `链接` 头 (withLinkedTag / ensureBriefTurn 已发过详情链接)。
|
|
2136
2002
|
const concludeBriefTurn = (a, body) => {
|
|
2137
2003
|
const turnId = a.briefTurnId;
|
|
2138
2004
|
if (!turnId || a.briefConcluded || !body.trim())
|
|
2139
2005
|
return;
|
|
2140
2006
|
a.briefConcluded = true;
|
|
2141
|
-
if (
|
|
2142
|
-
|
|
2143
|
-
// 再接正文; 无 thinking 时退回普通 tag 头正文。
|
|
2144
|
-
// 软后端 (softTurnEnd) 的最终正文 final===undefined, 上游按"非 final 中途文本"
|
|
2145
|
-
// 也 pushThink 进过 briefThinking —— 收口再拿它当正文 = think 与正文各一份重复。
|
|
2146
|
-
// 收口前把 briefThinking 尾部等于 body 的那段剥掉, 保证正文只出现在 <think> 之后。
|
|
2147
|
-
const think = stripTrailingBody(a.briefThinking?.trim(), body.trim());
|
|
2148
|
-
if (a.briefBubble && !a.briefBubble.done) {
|
|
2149
|
-
// 气泡还活: 整段 `<think>🧙 …</think>\n\n正文` 收进这一条 (raw, tag 已在 <think> 内)。
|
|
2150
|
-
const out = think ? `${openThink(a.target, think)}</think>\n\n${body}` : withSessionTag(a.target, body);
|
|
2151
|
-
void finishBriefBubble(a, out, true);
|
|
2152
|
-
}
|
|
2153
|
-
else {
|
|
2154
|
-
// 无气泡 turn / 气泡已收口: briefThinking 此时只含气泡期间累积的残余
|
|
2155
|
-
// (气泡收口后 pushThink 直接走 standalone, 不再累积)。有残余则拼成
|
|
2156
|
-
// think+body 一条; 无残余则只发 body。
|
|
2157
|
-
if (think) {
|
|
2158
|
-
sendRaw(a, `${openThink(a.target, think)}</think>\n\n${body}`);
|
|
2159
|
-
}
|
|
2160
|
-
else {
|
|
2161
|
-
sendStandalone(a, body);
|
|
2162
|
-
}
|
|
2163
|
-
}
|
|
2164
|
-
}
|
|
2165
|
-
else if (a.briefHadTool || !a.briefBubble) {
|
|
2166
|
-
sendStandalone(a, body);
|
|
2007
|
+
if (a.briefBubble && !a.briefBubble.done) {
|
|
2008
|
+
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2167
2009
|
}
|
|
2168
2010
|
else {
|
|
2169
|
-
|
|
2011
|
+
sendStandalone(a, body);
|
|
2170
2012
|
}
|
|
2171
|
-
//
|
|
2172
|
-
|
|
2173
|
-
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
|
|
2013
|
+
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
2014
|
+
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
2174
2015
|
};
|
|
2175
2016
|
/** soft=true: 收口信号来自静默期确认 (后端无终句标记), 拿本轮最后一句 text 当结论。
|
|
2176
2017
|
* 硬信号路径上结论早已由 final text 落地, briefConcluded 会挡住重复推送。 */
|
|
@@ -2179,29 +2020,9 @@ export const startMirror = (deps) => {
|
|
|
2179
2020
|
return;
|
|
2180
2021
|
if (soft)
|
|
2181
2022
|
concludeBriefTurn(a, a.briefLastText ?? "");
|
|
2182
|
-
// 气泡还开着 (
|
|
2183
|
-
// 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
|
|
2023
|
+
// 气泡还开着 (正文始终没到) —— 以 ack 时就挂出的纯详情链接收口。
|
|
2184
2024
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2185
|
-
|
|
2186
|
-
// 已结论 → 气泡已在 concludeBriefTurn 的 else 分支通过 sendRaw 发出
|
|
2187
|
-
// think+body 整条消息,这里只需收掉气泡(避免重复下发)。
|
|
2188
|
-
// 未结论 → doStreamThink 可能已把半成品 thinking 流进气泡 (无 </think> 闭合),
|
|
2189
|
-
// 用闭合的 thinking 快照收口, 避免 WeCom 渲染标签未闭合的乱排版。
|
|
2190
|
-
// 如果连 thinking 都没有 (streamPending 还在), 空收。
|
|
2191
|
-
if (a.briefConcluded) {
|
|
2192
|
-
void finishBriefBubble(a, withSessionTag(a.target, " "), true);
|
|
2193
|
-
}
|
|
2194
|
-
else {
|
|
2195
|
-
const think = a.briefThinking?.trim();
|
|
2196
|
-
const content = think
|
|
2197
|
-
? `${openThink(a.target, think)}</think>`
|
|
2198
|
-
: withSessionTag(a.target, " ");
|
|
2199
|
-
void finishBriefBubble(a, content, true);
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
else {
|
|
2203
|
-
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
2204
|
-
}
|
|
2025
|
+
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
2205
2026
|
}
|
|
2206
2027
|
recordTurnClose(a.briefTurnId);
|
|
2207
2028
|
log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
|
|
@@ -2211,16 +2032,7 @@ export const startMirror = (deps) => {
|
|
|
2211
2032
|
a.briefIsSlash = false;
|
|
2212
2033
|
a.briefConcluded = false;
|
|
2213
2034
|
a.briefLastText = undefined;
|
|
2214
|
-
a
|
|
2215
|
-
if (a.thinkFlushTimer) {
|
|
2216
|
-
clearTimeout(a.thinkFlushTimer);
|
|
2217
|
-
a.thinkFlushTimer = undefined;
|
|
2218
|
-
}
|
|
2219
|
-
a.thinkFlushing = false;
|
|
2220
|
-
// 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
|
|
2221
|
-
const next = a.briefQueue?.shift();
|
|
2222
|
-
if (next)
|
|
2223
|
-
openBriefTurn(a, next);
|
|
2035
|
+
clearCot(a);
|
|
2224
2036
|
};
|
|
2225
2037
|
// 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
|
|
2226
2038
|
//
|
|
@@ -2231,19 +2043,11 @@ export const startMirror = (deps) => {
|
|
|
2231
2043
|
// 状态的清算, Esc 之类要碰 pane 的动作留给调用方在这之后自己尽力去做。
|
|
2232
2044
|
const teardownOutbound = (a, note) => {
|
|
2233
2045
|
let closed = 0;
|
|
2234
|
-
// 排队中的 turn 先清空再动当前 turn —— 反序会让 closeBriefTurn 顺手 shift
|
|
2235
|
-
// 一个僵尸 turn 上来当活跃 turn, 白收一遍。
|
|
2236
|
-
for (const q of a.briefQueue ?? []) {
|
|
2237
|
-
closed++;
|
|
2238
|
-
// think-style: 不发链接, 排队气泡收为"已取消"提示 (避免空白气泡闪过)。
|
|
2239
|
-
void finishBubble(a, q.bubble, thinkStyle ? withSessionTag(a.target, "⏳ (queued, cancelled)") : briefDetailLink(q.turnId, a.target), thinkStyle);
|
|
2240
|
-
recordTurnClose(q.turnId);
|
|
2241
|
-
}
|
|
2242
|
-
a.briefQueue = [];
|
|
2243
2046
|
if (a.briefTurnId) {
|
|
2244
2047
|
closed++;
|
|
2245
2048
|
closeBriefTurn(a);
|
|
2246
2049
|
}
|
|
2050
|
+
clearCot(a); // 无 turn 也要撤: 它可能正挂在一个已被 finish 掉的气泡上
|
|
2247
2051
|
if (a.softEnd) {
|
|
2248
2052
|
clearTimeout(a.softEnd);
|
|
2249
2053
|
a.softEnd = undefined;
|
|
@@ -2255,7 +2059,7 @@ export const startMirror = (deps) => {
|
|
|
2255
2059
|
}
|
|
2256
2060
|
if (a.standaloneBuf) {
|
|
2257
2061
|
clearTimeout(a.standaloneBuf.timer);
|
|
2258
|
-
flushStandalone(a
|
|
2062
|
+
flushStandalone(a);
|
|
2259
2063
|
}
|
|
2260
2064
|
if (a.liveStream && !a.liveStream.closed) {
|
|
2261
2065
|
closed++;
|
|
@@ -2300,15 +2104,10 @@ export const startMirror = (deps) => {
|
|
|
2300
2104
|
const turnId = a.briefTurnId;
|
|
2301
2105
|
if (!turnId)
|
|
2302
2106
|
return;
|
|
2303
|
-
// 首条 item 到达 → 清除 earlyTimer (CLI 已有响应, 不需要超时收链接了)。
|
|
2304
|
-
if (a.briefBubble?.earlyTimer) {
|
|
2305
|
-
clearTimeout(a.briefBubble.earlyTimer);
|
|
2306
|
-
a.briefBubble.earlyTimer = undefined;
|
|
2307
|
-
}
|
|
2308
2107
|
const now = Date.now();
|
|
2309
2108
|
if (item.kind === "tool_use") {
|
|
2310
|
-
a
|
|
2311
|
-
|
|
2109
|
+
markBriefTool(a);
|
|
2110
|
+
updateBriefProgress(a, cotToolLabel(item.calls));
|
|
2312
2111
|
for (const c of item.calls) {
|
|
2313
2112
|
// 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
|
|
2314
2113
|
if (c.toolUseId) {
|
|
@@ -2328,56 +2127,18 @@ export const startMirror = (deps) => {
|
|
|
2328
2127
|
ts: now,
|
|
2329
2128
|
});
|
|
2330
2129
|
}
|
|
2331
|
-
// think-style: 工具调用也算中间过程, 以 `🔧 [Name compact](url)` 摘要进 think,
|
|
2332
|
-
// 复用 tool call detail url, 让 think 内容里的工具调用也可点开独立详情。
|
|
2333
|
-
if (thinkStyle) {
|
|
2334
|
-
const lines = item.calls
|
|
2335
|
-
.map((c) => {
|
|
2336
|
-
const compact = safeForMarkdown(renderToolInputCompact(c.input, cfg.wrc.mirror.toolUseInlineMaxChars));
|
|
2337
|
-
const url = detailUrlFor(c.toolUseId, a.target);
|
|
2338
|
-
return url ? `🔧 [${c.name} ${compact}](${url})` : `🔧 ${c.name} ${compact}`;
|
|
2339
|
-
})
|
|
2340
|
-
.join("\n");
|
|
2341
|
-
pushThink(a, lines);
|
|
2342
|
-
}
|
|
2343
2130
|
return;
|
|
2344
2131
|
}
|
|
2345
2132
|
if (item.kind === "tool_result") {
|
|
2346
|
-
|
|
2133
|
+
markBriefTool(a);
|
|
2347
2134
|
recordTurnItem(turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2348
|
-
// think-style: 工具结果 (中间输出) 也进 think, 单行截断免刷屏。
|
|
2349
|
-
if (thinkStyle)
|
|
2350
|
-
pushThink(a, `↳ ${oneLineSummary(item.full, cfg.wrc.mirror.toolUseInlineMaxChars)}`);
|
|
2351
|
-
return;
|
|
2352
|
-
}
|
|
2353
|
-
if (item.kind === "thinking") {
|
|
2354
|
-
recordTurnItem(turnId, { t: "text", body: item.body, ts: now });
|
|
2355
|
-
// thinking items only emitted by parser when thinkStyle=true (line 736 gate).
|
|
2356
|
-
pushThink(a, item.body); // reasoning 进 think 流
|
|
2357
2135
|
return;
|
|
2358
2136
|
}
|
|
2359
2137
|
if (item.kind === "text") {
|
|
2360
2138
|
recordTurnItem(turnId, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2361
|
-
|
|
2362
|
-
// 软后端的不确定态 (final===undefined) 也 pushThink — 气泡活时累积到
|
|
2363
|
-
// briefThinking + 写 briefLastText (收口用); 气泡死时直接 standalone 发出,
|
|
2364
|
-
// 不写 briefLastText (已发, 收口不再重复)。
|
|
2365
|
-
if (thinkStyle && item.final === false) {
|
|
2366
|
-
pushThink(a, item.body);
|
|
2367
|
-
return;
|
|
2368
|
-
}
|
|
2369
|
-
if (thinkStyle && item.final === undefined) {
|
|
2370
|
-
pushThink(a, item.body);
|
|
2371
|
-
// 气泡还活时 pushThink 只累积 briefThinking, 收口需要 briefLastText 当 body;
|
|
2372
|
-
// 气泡已死时 pushThink 走了 standalone 已发出, 不写 briefLastText 避免收口重复。
|
|
2373
|
-
if (a.briefBubble && !a.briefBubble.done)
|
|
2374
|
-
a.briefLastText = item.body;
|
|
2375
|
-
return;
|
|
2376
|
-
}
|
|
2377
|
-
a.briefLastText = item.body; // 软收口时拿它当结论 (那条路径上没有 final 标记)
|
|
2378
|
-
// final===false 才是"确知的中途输出"; undefined 是后端说不清, 不能据此提前收气泡。
|
|
2139
|
+
a.briefLastText = item.body;
|
|
2379
2140
|
if (item.final === false)
|
|
2380
|
-
|
|
2141
|
+
markBriefTool(a);
|
|
2381
2142
|
if (item.final === true)
|
|
2382
2143
|
concludeBriefTurn(a, item.body);
|
|
2383
2144
|
return;
|
|
@@ -2422,10 +2183,10 @@ export const startMirror = (deps) => {
|
|
|
2422
2183
|
void finishBriefBubble(a, banner);
|
|
2423
2184
|
else
|
|
2424
2185
|
enqueueStandalone(a, banner);
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
|
|
2428
|
-
closeBriefTurn(a);
|
|
2186
|
+
// 活跃 brief turn 也收掉: goal 期间所有 item 走 handleGoalItem, 它永远等不到
|
|
2187
|
+
// 自己的 turn_end。气泡已 done → closeBriefTurn 不会重复收口。
|
|
2188
|
+
if (a.briefTurnId)
|
|
2189
|
+
closeBriefTurn(a);
|
|
2429
2190
|
a.goalActive = true;
|
|
2430
2191
|
log.info({ sessionId: a.sessionId, target: a.target, condition }, "goal: entered progress mode");
|
|
2431
2192
|
};
|
|
@@ -2510,6 +2271,14 @@ export const startMirror = (deps) => {
|
|
|
2510
2271
|
clearTimeout(a.softEnd);
|
|
2511
2272
|
a.softEnd = undefined;
|
|
2512
2273
|
}
|
|
2274
|
+
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
2275
|
+
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
2276
|
+
// 那就变回已下线的 thinkStyle 了。
|
|
2277
|
+
if (item.kind === "thinking") {
|
|
2278
|
+
if (cfg.wrc.mirror.brief && a.briefTurnId)
|
|
2279
|
+
updateBriefProgress(a, item.body);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2513
2282
|
if (item.kind === "turn_end" && item.soft === true) {
|
|
2514
2283
|
a.softEnd = setTimeout(() => fireSoftTurnEnd(a), softTurnEndMsFor(a));
|
|
2515
2284
|
return;
|
|
@@ -2756,13 +2525,13 @@ export const startMirror = (deps) => {
|
|
|
2756
2525
|
clearTimeout(a.softEnd);
|
|
2757
2526
|
a.softEnd = undefined;
|
|
2758
2527
|
}
|
|
2759
|
-
|
|
2760
|
-
closeBriefTurn(a); // 活跃
|
|
2528
|
+
if (a.briefTurnId)
|
|
2529
|
+
closeBriefTurn(a); // 活跃 turn 收掉 (队列已随对话边界策略移除)
|
|
2761
2530
|
if (a.liveStream && !a.liveStream.closed)
|
|
2762
2531
|
void finalizeStream(a, a.liveStream);
|
|
2763
2532
|
if (a.standaloneBuf) {
|
|
2764
2533
|
clearTimeout(a.standaloneBuf.timer);
|
|
2765
|
-
flushStandalone(a
|
|
2534
|
+
flushStandalone(a);
|
|
2766
2535
|
}
|
|
2767
2536
|
a.tail.stop();
|
|
2768
2537
|
bySessionId.delete(a.sessionId);
|
|
@@ -2827,7 +2596,6 @@ export const startMirror = (deps) => {
|
|
|
2827
2596
|
log: log.child({ sub: "tail", sessionId }),
|
|
2828
2597
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
2829
2598
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
2830
|
-
thinkStyle,
|
|
2831
2599
|
includeToolResults: cfg.wrc.mirror.includeToolResults,
|
|
2832
2600
|
toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
|
|
2833
2601
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
@@ -3039,7 +2807,7 @@ export const startMirror = (deps) => {
|
|
|
3039
2807
|
log.info("mirror: no persisted attachments and no pinned sessionId — waiting for /new or /mirror/attach");
|
|
3040
2808
|
}
|
|
3041
2809
|
// Render full tool details for a finalized turn into one-or-more markdown
|
|
3042
|
-
// chunks (split at
|
|
2810
|
+
// chunks (split at chunkBytes boundaries).
|
|
3043
2811
|
const renderToolDetails = (tools) => {
|
|
3044
2812
|
const parts = [];
|
|
3045
2813
|
let i = 0;
|
|
@@ -3059,7 +2827,7 @@ export const startMirror = (deps) => {
|
|
|
3059
2827
|
parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncate(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncate(result, 4000)}\n\`\`\``);
|
|
3060
2828
|
}
|
|
3061
2829
|
const merged = parts.join("\n\n---\n\n");
|
|
3062
|
-
return splitChunks(merged, Math.max(200, cfg.wrc.mirror.
|
|
2830
|
+
return splitChunks(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
3063
2831
|
};
|
|
3064
2832
|
const resolveToolDetail = (turnId) => {
|
|
3065
2833
|
evictTurns();
|
|
@@ -3209,7 +2977,6 @@ export const startMirror = (deps) => {
|
|
|
3209
2977
|
log: log.child({ sub: "tail", sessionId: newSessionId }),
|
|
3210
2978
|
includeUser: cfg.wrc.mirror.includeUser,
|
|
3211
2979
|
includeTools: cfg.wrc.mirror.includeTools,
|
|
3212
|
-
thinkStyle,
|
|
3213
2980
|
includeToolResults: cfg.wrc.mirror.includeToolResults,
|
|
3214
2981
|
toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
|
|
3215
2982
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
@@ -4249,7 +4016,7 @@ export const startMirror = (deps) => {
|
|
|
4249
4016
|
// 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
|
|
4250
4017
|
if (a.standaloneBuf) {
|
|
4251
4018
|
clearTimeout(a.standaloneBuf.timer);
|
|
4252
|
-
flushStandalone(a
|
|
4019
|
+
flushStandalone(a);
|
|
4253
4020
|
}
|
|
4254
4021
|
// 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
|
|
4255
4022
|
// 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
|
|
@@ -4585,14 +4352,22 @@ export const startMirror = (deps) => {
|
|
|
4585
4352
|
// 否则这次清空在 chat 详情里毫无痕迹, 前后两轮看着还是连续的。
|
|
4586
4353
|
if (armMigration)
|
|
4587
4354
|
a.ctxCut = "clear";
|
|
4588
|
-
//
|
|
4589
|
-
//
|
|
4590
|
-
//
|
|
4591
|
-
//
|
|
4592
|
-
//
|
|
4355
|
+
// 新消息 = 对话边界: 上一 turn 的出站状态在此清算。softEnd 是旧 turn 的
|
|
4356
|
+
// 静默期收口判决, 残留到新 turn 会在中途开火、提前收掉新气泡/新流;
|
|
4357
|
+
// deferred 缓冲里是已从 tail 消费但还没下发的 item, 直接丢弃 = 静默丢失,
|
|
4358
|
+
// 冲成 standalone 收尾。旧 frame 就此作废; 用户之后点旧审批卡,
|
|
4359
|
+
// terminateLiveStream 找不到 outbound 槽位会优雅 no-op。
|
|
4360
|
+
if (a.softEnd) {
|
|
4361
|
+
clearTimeout(a.softEnd);
|
|
4362
|
+
a.softEnd = undefined;
|
|
4363
|
+
}
|
|
4593
4364
|
if (a.outbound) {
|
|
4594
|
-
if (a.outbound.kind === "deferred")
|
|
4365
|
+
if (a.outbound.kind === "deferred") {
|
|
4595
4366
|
clearTimeout(a.outbound.timer);
|
|
4367
|
+
const md = renderBuf(a.outbound.buf);
|
|
4368
|
+
if (md)
|
|
4369
|
+
sendStandalone(a, md);
|
|
4370
|
+
}
|
|
4596
4371
|
a.outbound = undefined;
|
|
4597
4372
|
}
|
|
4598
4373
|
// Drain any pending standalone debounce so a prior turn's tail tail
|
|
@@ -4600,7 +4375,7 @@ export const startMirror = (deps) => {
|
|
|
4600
4375
|
// race against the new stream's first content (Path B).
|
|
4601
4376
|
if (a.standaloneBuf) {
|
|
4602
4377
|
clearTimeout(a.standaloneBuf.timer);
|
|
4603
|
-
flushStandalone(a
|
|
4378
|
+
flushStandalone(a);
|
|
4604
4379
|
}
|
|
4605
4380
|
// /clear produces no assistant output; opening a stream would leave a
|
|
4606
4381
|
// stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
|
|
@@ -4625,7 +4400,8 @@ export const startMirror = (deps) => {
|
|
|
4625
4400
|
}
|
|
4626
4401
|
}
|
|
4627
4402
|
if (brief) {
|
|
4628
|
-
// 挂 loading 气泡并起新 turn
|
|
4403
|
+
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
4404
|
+
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
4629
4405
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
4630
4406
|
await startBriefTurn(a, frame, streamId, isSlash, text);
|
|
4631
4407
|
}
|