wezard 1.2.25 → 1.2.27
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.
|
@@ -368,6 +368,17 @@ const stripTrailingBody = (think, body) => {
|
|
|
368
368
|
}
|
|
369
369
|
return think;
|
|
370
370
|
};
|
|
371
|
+
// thinkStyle 兜底: turn 收口时无正式 body, 从累积的 thinking 里摘出最后一段
|
|
372
|
+
// 看起来像正文的内容当作 body 下发。工具调用行 (🔧) 和工具结果行 (↳) 跳过。
|
|
373
|
+
const rescueBodyFromThinking = (thinking) => {
|
|
374
|
+
const paragraphs = thinking.split(/\n\n/).map(s => s.trim()).filter((s) => s.length > 0);
|
|
375
|
+
for (let i = paragraphs.length - 1; i >= 0; i--) {
|
|
376
|
+
const p = paragraphs[i];
|
|
377
|
+
if (!p.startsWith("🔧 ") && !p.startsWith("↳ "))
|
|
378
|
+
return p;
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
};
|
|
371
382
|
// WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
|
|
372
383
|
// code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
|
|
373
384
|
// `<noreply@x>` etc. silently swallows the rest of the line plus the closing
|
|
@@ -1448,6 +1459,22 @@ export const startMirror = (deps) => {
|
|
|
1448
1459
|
const SOFT_TURN_END_MS = 4_000;
|
|
1449
1460
|
const STREAM_SOFT_CAP = 18_000;
|
|
1450
1461
|
const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1462
|
+
/** thinkStyle 兜底超时: turn 开始 2min 后仍未 conclude → 强制软收口。
|
|
1463
|
+
* 比 HARD_TIMEOUT_MS (350s) 短, 气泡还活着时走 finishBriefBubble 路径。 */
|
|
1464
|
+
const THINK_RESCUE_TIMEOUT_MS = 120_000;
|
|
1465
|
+
/** Standalone 发送前正文检查: 内容无正文 (纯 think 标记 / 工具行) 时,
|
|
1466
|
+
* 回队列等这么久。期间有新消息入队则重置; 到期且无新消息才发出。 */
|
|
1467
|
+
const STANDALONE_BODY_WAIT_MS = 8_000;
|
|
1468
|
+
// 判断 standalone 内容是否含有正文 (非纯 <think>…</think> / 工具行)。
|
|
1469
|
+
const standaloneHasBody = (content) => {
|
|
1470
|
+
// 剥掉 <think>…</think> 包裹 (含 tag header)
|
|
1471
|
+
const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, "")
|
|
1472
|
+
// 工具行
|
|
1473
|
+
.replace(/^🔧 .*/gm, "")
|
|
1474
|
+
.replace(/^↳ .*/gm, "")
|
|
1475
|
+
.trim();
|
|
1476
|
+
return stripped.length > 0;
|
|
1477
|
+
};
|
|
1451
1478
|
const turnRegistry = new Map();
|
|
1452
1479
|
const evictTurns = () => {
|
|
1453
1480
|
const now = Date.now();
|
|
@@ -1627,12 +1654,22 @@ export const startMirror = (deps) => {
|
|
|
1627
1654
|
};
|
|
1628
1655
|
// Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
|
|
1629
1656
|
// 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
|
|
1630
|
-
|
|
1657
|
+
// 正文检查: 合并内容无正文 (纯 think/工具) 时, 回队列等 STANDALONE_BODY_WAIT_MS;
|
|
1658
|
+
// 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才强制发出。
|
|
1659
|
+
// force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
|
|
1660
|
+
const flushStandalone = (a, force = false) => {
|
|
1631
1661
|
const buf = a.standaloneBuf;
|
|
1632
1662
|
if (!buf)
|
|
1633
1663
|
return;
|
|
1664
|
+
const merged = buf.parts.join("\n\n");
|
|
1665
|
+
// 无正文 & 队列有新增 → 再等一轮 (force 跳过)
|
|
1666
|
+
if (!force && !standaloneHasBody(merged) && buf.bodyWaitLen !== buf.parts.length) {
|
|
1667
|
+
buf.bodyWaitLen = buf.parts.length;
|
|
1668
|
+
buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1634
1671
|
a.standaloneBuf = undefined;
|
|
1635
|
-
sendStandalone(a,
|
|
1672
|
+
sendStandalone(a, merged);
|
|
1636
1673
|
};
|
|
1637
1674
|
const enqueueStandalone = (a, content) => {
|
|
1638
1675
|
const ms = cfg.wrc.mirror.standaloneDebounceMs;
|
|
@@ -1683,7 +1720,7 @@ export const startMirror = (deps) => {
|
|
|
1683
1720
|
if (!a.standaloneBuf)
|
|
1684
1721
|
return;
|
|
1685
1722
|
clearTimeout(a.standaloneBuf.timer);
|
|
1686
|
-
flushStandalone(a);
|
|
1723
|
+
flushStandalone(a, true);
|
|
1687
1724
|
};
|
|
1688
1725
|
// Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
|
|
1689
1726
|
// standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
|
|
@@ -1842,6 +1879,11 @@ export const startMirror = (deps) => {
|
|
|
1842
1879
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
1843
1880
|
// 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
|
|
1844
1881
|
// raw=true skips withSessionTag (used when content already contains the linked tag header).
|
|
1882
|
+
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
1883
|
+
// standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
|
|
1884
|
+
// 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
|
|
1885
|
+
// standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
|
|
1886
|
+
const BUBBLE_FINISH_SETTLE_MS = 600;
|
|
1845
1887
|
const finishBubble = async (a, b, content, raw = false) => {
|
|
1846
1888
|
if (!b || b.done)
|
|
1847
1889
|
return;
|
|
@@ -1851,16 +1893,28 @@ export const startMirror = (deps) => {
|
|
|
1851
1893
|
clearTimeout(b.earlyTimer);
|
|
1852
1894
|
b.earlyTimer = undefined;
|
|
1853
1895
|
}
|
|
1896
|
+
// think-style: 清除待发的 flush 定时器, 避免 doStreamThink 在 done 后空跑。
|
|
1897
|
+
if (a.briefBubble === b && a.thinkFlushTimer) {
|
|
1898
|
+
clearTimeout(a.thinkFlushTimer);
|
|
1899
|
+
a.thinkFlushTimer = undefined;
|
|
1900
|
+
}
|
|
1854
1901
|
if (a.briefBubble === b)
|
|
1855
1902
|
a.briefBubble = undefined;
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1903
|
+
const p = (async () => {
|
|
1904
|
+
try {
|
|
1905
|
+
await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
|
|
1906
|
+
// 给 WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
|
|
1907
|
+
await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
|
|
1908
|
+
}
|
|
1909
|
+
catch (e) {
|
|
1910
|
+
log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
|
|
1911
|
+
if (content.trim())
|
|
1912
|
+
raw ? sendRaw(a, content) : sendStandalone(a, content);
|
|
1913
|
+
}
|
|
1914
|
+
})();
|
|
1915
|
+
// 链入 standalonePending, 后续 sendStandalone/sendRaw 自然等 finish 完成。
|
|
1916
|
+
a.standalonePending = a.standalonePending.then(() => p).catch(() => undefined);
|
|
1917
|
+
return p;
|
|
1864
1918
|
};
|
|
1865
1919
|
const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
|
|
1866
1920
|
// think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
|
|
@@ -1872,17 +1926,36 @@ export const startMirror = (deps) => {
|
|
|
1872
1926
|
// think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
|
|
1873
1927
|
// (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
|
|
1874
1928
|
// raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
|
|
1875
|
-
|
|
1929
|
+
// streamPending → 首次调用时才真正开流 (fix: off-by-one race)。
|
|
1930
|
+
// fix: 加去抖 (FLUSH_MS) + 背压 (await 上一次完成), 消除 SDK 串行队列积压。
|
|
1931
|
+
const doStreamThink = async (a) => {
|
|
1876
1932
|
const b = a.briefBubble;
|
|
1877
1933
|
const think = a.briefThinking?.trim();
|
|
1878
1934
|
if (!b || b.done || !think)
|
|
1879
1935
|
return;
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1936
|
+
// 首条 replyStream 延迟到这里发出 (streamPending), 携带真实内容, 避免空开流竞态。
|
|
1937
|
+
b.streamPending = false;
|
|
1938
|
+
try {
|
|
1939
|
+
await client.replyStream(b.frame, b.streamId, openThink(a.target, think), false);
|
|
1940
|
+
log.debug({ sessionId: a.sessionId, streamId: b.streamId, thinkLen: think.length }, "brief: think stream ok");
|
|
1941
|
+
}
|
|
1942
|
+
catch (e) {
|
|
1943
|
+
log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed");
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
const scheduleThinkFlush = (a) => {
|
|
1947
|
+
if (!a.briefBubble || a.briefBubble.done || a.thinkFlushTimer || a.thinkFlushing)
|
|
1948
|
+
return;
|
|
1949
|
+
a.thinkFlushTimer = setTimeout(() => {
|
|
1950
|
+
a.thinkFlushTimer = undefined;
|
|
1951
|
+
if (a.thinkFlushing)
|
|
1952
|
+
return; // 上一次还在飞 → 下次 pushThink 再排
|
|
1953
|
+
a.thinkFlushing = true;
|
|
1954
|
+
void doStreamThink(a).finally(() => { a.thinkFlushing = false; });
|
|
1955
|
+
}, FLUSH_MS);
|
|
1883
1956
|
};
|
|
1884
1957
|
// think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
|
|
1885
|
-
// 气泡还活 → 累积进 briefThinking
|
|
1958
|
+
// 气泡还活 → 累积进 briefThinking 并排一次去抖 flush; 否则 (无气泡 turn / 气泡已收口)
|
|
1886
1959
|
// 只累积, 绝不单独 standalone —— 中间纯思考过程不下发消息, 等正式文本回复出现时
|
|
1887
1960
|
// 与正文拼成一条 `<think>…</think>\n\n正文` 整段下发 (见 concludeBriefTurn)。
|
|
1888
1961
|
const pushThink = (a, chunk) => {
|
|
@@ -1891,7 +1964,7 @@ export const startMirror = (deps) => {
|
|
|
1891
1964
|
return;
|
|
1892
1965
|
a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
|
|
1893
1966
|
if (a.briefBubble && !a.briefBubble.done)
|
|
1894
|
-
|
|
1967
|
+
scheduleThinkFlush(a);
|
|
1895
1968
|
};
|
|
1896
1969
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
1897
1970
|
const openBriefTurn = (a, q) => {
|
|
@@ -1902,6 +1975,32 @@ export const startMirror = (deps) => {
|
|
|
1902
1975
|
a.briefConcluded = false;
|
|
1903
1976
|
a.briefLastText = undefined;
|
|
1904
1977
|
a.briefThinking = undefined;
|
|
1978
|
+
if (a.thinkFlushTimer) {
|
|
1979
|
+
clearTimeout(a.thinkFlushTimer);
|
|
1980
|
+
a.thinkFlushTimer = undefined;
|
|
1981
|
+
}
|
|
1982
|
+
a.thinkFlushing = false;
|
|
1983
|
+
if (a.thinkRescueTimer) {
|
|
1984
|
+
clearTimeout(a.thinkRescueTimer);
|
|
1985
|
+
a.thinkRescueTimer = undefined;
|
|
1986
|
+
}
|
|
1987
|
+
if (thinkStyle) {
|
|
1988
|
+
a.thinkRescueTimer = setTimeout(() => {
|
|
1989
|
+
if (a.briefTurnId === q.turnId && !a.briefConcluded) {
|
|
1990
|
+
log.warn({ sessionId: a.sessionId, turnId: q.turnId, hasThinking: !!a.briefThinking }, "brief: thinkStyle rescue timeout — forcing close");
|
|
1991
|
+
// 立即刷一次最新 thinking 到气泡 (跳过去抖), 让用户看到完整积累内容,
|
|
1992
|
+
// 而不是停在上一次 250ms 去抖窗口的快照。
|
|
1993
|
+
if (a.thinkFlushTimer) {
|
|
1994
|
+
clearTimeout(a.thinkFlushTimer);
|
|
1995
|
+
a.thinkFlushTimer = undefined;
|
|
1996
|
+
}
|
|
1997
|
+
if (a.briefBubble && !a.briefBubble.done && a.briefThinking?.trim()) {
|
|
1998
|
+
void doStreamThink(a);
|
|
1999
|
+
}
|
|
2000
|
+
closeBriefTurn(a, true);
|
|
2001
|
+
}
|
|
2002
|
+
}, THINK_RESCUE_TIMEOUT_MS);
|
|
2003
|
+
}
|
|
1905
2004
|
log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
|
|
1906
2005
|
};
|
|
1907
2006
|
// WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
|
|
@@ -1936,10 +2035,18 @@ export const startMirror = (deps) => {
|
|
|
1936
2035
|
log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
|
|
1937
2036
|
closeBriefTurn(a);
|
|
1938
2037
|
}
|
|
1939
|
-
// think-style:
|
|
1940
|
-
//
|
|
2038
|
+
// think-style: WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
|
|
2039
|
+
// 保留当前已流进气泡的 thinking 内容作为最终快照 (用户看到的内容不变),
|
|
2040
|
+
// 后续正文走 standalone。如果 turn 尚未结论, 标记 done 让 concludeBriefTurn
|
|
2041
|
+
// 知道气泡已收口、走 else 分支 (sendRaw / sendStandalone)。
|
|
1941
2042
|
if (thinkStyle) {
|
|
1942
|
-
|
|
2043
|
+
// 活跃 turn 的 thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
|
|
2044
|
+
const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
|
|
2045
|
+
const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
|
|
2046
|
+
// 标记: thinking 快照已写进气泡, 后续 concludeBriefTurn 走 else 分支时不再重复发 thinking。
|
|
2047
|
+
if (think)
|
|
2048
|
+
bubble.thinkSnapshotFlushed = true;
|
|
2049
|
+
void finishBubble(a, bubble, content, true);
|
|
1943
2050
|
}
|
|
1944
2051
|
else {
|
|
1945
2052
|
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
@@ -1949,7 +2056,11 @@ export const startMirror = (deps) => {
|
|
|
1949
2056
|
// 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
|
|
1950
2057
|
// 因为排队中的 turn 还没成为活跃 turn, a.briefBubble 指向的是前一个。
|
|
1951
2058
|
// think-style: 不设 earlyTimer —— 不发链接, 气泡留着等 thinking / 答复流进来。
|
|
1952
|
-
|
|
2059
|
+
// codebuddy: jsonl 按完整消息落盘 (status:"completed"), 不像 Claude Code 增量写,
|
|
2060
|
+
// 首条 item 常在 3s 之后才到达 → earlyTimer 提前收口气泡 → 结论被迫走 standalone。
|
|
2061
|
+
// 跳过 earlyTimer, 让气泡等到真正的内容写入 (hardTimer 兜底 6min 窗口)。
|
|
2062
|
+
const skipEarly = thinkStyle || backendForPath(a.jsonlPath).name === "codebuddy";
|
|
2063
|
+
if (!skipEarly)
|
|
1953
2064
|
bubble.earlyTimer = setTimeout(() => {
|
|
1954
2065
|
if (bubble.done)
|
|
1955
2066
|
return;
|
|
@@ -1963,10 +2074,12 @@ export const startMirror = (deps) => {
|
|
|
1963
2074
|
openBriefTurn(a, q);
|
|
1964
2075
|
}
|
|
1965
2076
|
try {
|
|
1966
|
-
// think-style:
|
|
1967
|
-
//
|
|
2077
|
+
// think-style: 延迟首条 replyStream 到首个内容到达 (streamPending=true),
|
|
2078
|
+
// 避免空 `<think>` 与紧随的 pushThink 产生两次快速 replyStream 触发 WeCom
|
|
2079
|
+
// 服务端合并/丢弃 (off-by-one)。flushStreamPending 在 pushThink/doStreamThink 首次
|
|
2080
|
+
// 调用时开流, 保证只有一次 replyStream 携带真实内容。
|
|
1968
2081
|
if (thinkStyle) {
|
|
1969
|
-
|
|
2082
|
+
bubble.streamPending = true;
|
|
1970
2083
|
}
|
|
1971
2084
|
else {
|
|
1972
2085
|
await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
|
|
@@ -2038,7 +2151,10 @@ export const startMirror = (deps) => {
|
|
|
2038
2151
|
else {
|
|
2039
2152
|
// 无气泡 turn / 气泡已收口: 中间 thinking 不单独 standalone, 累积的整段思考与
|
|
2040
2153
|
// 正式正文拼成一条下发 (think 取自 stripTrailingBody, 已摘掉尾部等于正文的重复段)。
|
|
2041
|
-
|
|
2154
|
+
// fix #1: 若 hardTimer 已把 thinking 快照写进气泡 (thinkSnapshotFlushed), 只发 body,
|
|
2155
|
+
// 不再重复发 thinking —— 用户已经在气泡里看到了完整 thinking。
|
|
2156
|
+
const snapshotFlushed = a.briefBubble?.thinkSnapshotFlushed;
|
|
2157
|
+
const t = snapshotFlushed ? undefined : think;
|
|
2042
2158
|
if (t)
|
|
2043
2159
|
sendRaw(a, `${openThink(a.target, t)}</think>\n\n${body}`);
|
|
2044
2160
|
else
|
|
@@ -2062,19 +2178,35 @@ export const startMirror = (deps) => {
|
|
|
2062
2178
|
return;
|
|
2063
2179
|
if (soft)
|
|
2064
2180
|
concludeBriefTurn(a, a.briefLastText ?? "");
|
|
2181
|
+
// ──── thinkStyle 兜底: turn 将收口但 concludeBriefTurn 未生效 ────
|
|
2182
|
+
// 正常路径 (final===true / 软收口 + briefLastText) 上面已经 conclude 过了;
|
|
2183
|
+
// 这里只处理"有 thinking 但正文为空"的漏网之鱼: 从 briefThinking 尾部摘出最后
|
|
2184
|
+
// 一段非工具行当作正文, 再试一次 conclude。保证 thinking 内容不被静默丢弃。
|
|
2185
|
+
if (thinkStyle && !a.briefConcluded && a.briefThinking) {
|
|
2186
|
+
const rescued = rescueBodyFromThinking(a.briefThinking);
|
|
2187
|
+
if (rescued) {
|
|
2188
|
+
log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: thinkStyle rescue — concluding with last text from thinking");
|
|
2189
|
+
concludeBriefTurn(a, rescued);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2065
2192
|
// 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
|
|
2066
2193
|
// 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
|
|
2067
2194
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2068
2195
|
if (thinkStyle) {
|
|
2069
2196
|
// 已结论 → 气泡已在 concludeBriefTurn 的 else 分支通过 sendRaw 发出
|
|
2070
2197
|
// think+body 整条消息,这里只需收掉气泡(避免重复下发)。
|
|
2071
|
-
// 未结论 →
|
|
2072
|
-
//
|
|
2198
|
+
// 未结论 → doStreamThink 可能已把半成品 thinking 流进气泡 (无 </think> 闭合),
|
|
2199
|
+
// 用闭合的 thinking 快照收口, 避免 WeCom 渲染标签未闭合的乱排版。
|
|
2200
|
+
// 如果连 thinking 都没有 (streamPending 还在), 空收。
|
|
2073
2201
|
if (a.briefConcluded) {
|
|
2074
2202
|
void finishBriefBubble(a, withSessionTag(a.target, " "), true);
|
|
2075
2203
|
}
|
|
2076
2204
|
else {
|
|
2077
|
-
|
|
2205
|
+
const think = a.briefThinking?.trim();
|
|
2206
|
+
const content = think
|
|
2207
|
+
? `${openThink(a.target, think)}</think>`
|
|
2208
|
+
: withSessionTag(a.target, " ");
|
|
2209
|
+
void finishBriefBubble(a, content, true);
|
|
2078
2210
|
}
|
|
2079
2211
|
}
|
|
2080
2212
|
else {
|
|
@@ -2090,6 +2222,15 @@ export const startMirror = (deps) => {
|
|
|
2090
2222
|
a.briefConcluded = false;
|
|
2091
2223
|
a.briefLastText = undefined;
|
|
2092
2224
|
a.briefThinking = undefined;
|
|
2225
|
+
if (a.thinkFlushTimer) {
|
|
2226
|
+
clearTimeout(a.thinkFlushTimer);
|
|
2227
|
+
a.thinkFlushTimer = undefined;
|
|
2228
|
+
}
|
|
2229
|
+
a.thinkFlushing = false;
|
|
2230
|
+
if (a.thinkRescueTimer) {
|
|
2231
|
+
clearTimeout(a.thinkRescueTimer);
|
|
2232
|
+
a.thinkRescueTimer = undefined;
|
|
2233
|
+
}
|
|
2093
2234
|
// 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
|
|
2094
2235
|
const next = a.briefQueue?.shift();
|
|
2095
2236
|
if (next)
|
|
@@ -2108,8 +2249,8 @@ export const startMirror = (deps) => {
|
|
|
2108
2249
|
// 一个僵尸 turn 上来当活跃 turn, 白收一遍。
|
|
2109
2250
|
for (const q of a.briefQueue ?? []) {
|
|
2110
2251
|
closed++;
|
|
2111
|
-
// think-style: 不发链接,
|
|
2112
|
-
void finishBubble(a, q.bubble, thinkStyle ? " " : briefDetailLink(q.turnId, a.target), thinkStyle);
|
|
2252
|
+
// think-style: 不发链接, 排队气泡收为"已取消"提示 (避免空白气泡闪过)。
|
|
2253
|
+
void finishBubble(a, q.bubble, thinkStyle ? withSessionTag(a.target, "⏳ (queued, cancelled)") : briefDetailLink(q.turnId, a.target), thinkStyle);
|
|
2113
2254
|
recordTurnClose(q.turnId);
|
|
2114
2255
|
}
|
|
2115
2256
|
a.briefQueue = [];
|
|
@@ -2128,7 +2269,7 @@ export const startMirror = (deps) => {
|
|
|
2128
2269
|
}
|
|
2129
2270
|
if (a.standaloneBuf) {
|
|
2130
2271
|
clearTimeout(a.standaloneBuf.timer);
|
|
2131
|
-
flushStandalone(a);
|
|
2272
|
+
flushStandalone(a, true);
|
|
2132
2273
|
}
|
|
2133
2274
|
if (a.liveStream && !a.liveStream.closed) {
|
|
2134
2275
|
closed++;
|
|
@@ -2270,10 +2411,15 @@ export const startMirror = (deps) => {
|
|
|
2270
2411
|
if (item.quiet)
|
|
2271
2412
|
return;
|
|
2272
2413
|
// slash 命令 (/context…) 的 skill_output 即本轮答案 (无 final text 收口) —— 写进
|
|
2273
|
-
// loading 气泡。非 slash 场景的 skill_output
|
|
2414
|
+
// loading 气泡。非 slash 场景的 skill_output 是中间反馈:
|
|
2415
|
+
// think-style → 进 think 流 (与工具调用等中间过程一致);
|
|
2416
|
+
// 非 think-style → standalone。
|
|
2274
2417
|
if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
|
|
2275
2418
|
void finishBriefBubble(a, item.body);
|
|
2276
2419
|
}
|
|
2420
|
+
else if (thinkStyle) {
|
|
2421
|
+
pushThink(a, item.body);
|
|
2422
|
+
}
|
|
2277
2423
|
else {
|
|
2278
2424
|
sendStandalone(a, item.body);
|
|
2279
2425
|
}
|
|
@@ -2636,7 +2782,7 @@ export const startMirror = (deps) => {
|
|
|
2636
2782
|
void finalizeStream(a, a.liveStream);
|
|
2637
2783
|
if (a.standaloneBuf) {
|
|
2638
2784
|
clearTimeout(a.standaloneBuf.timer);
|
|
2639
|
-
flushStandalone(a);
|
|
2785
|
+
flushStandalone(a, true);
|
|
2640
2786
|
}
|
|
2641
2787
|
a.tail.stop();
|
|
2642
2788
|
bySessionId.delete(a.sessionId);
|
|
@@ -4123,7 +4269,7 @@ export const startMirror = (deps) => {
|
|
|
4123
4269
|
// 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
|
|
4124
4270
|
if (a.standaloneBuf) {
|
|
4125
4271
|
clearTimeout(a.standaloneBuf.timer);
|
|
4126
|
-
flushStandalone(a);
|
|
4272
|
+
flushStandalone(a, true);
|
|
4127
4273
|
}
|
|
4128
4274
|
// 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
|
|
4129
4275
|
// 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
|
|
@@ -4181,18 +4327,13 @@ export const startMirror = (deps) => {
|
|
|
4181
4327
|
}
|
|
4182
4328
|
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
4183
4329
|
// is still warming up so the verifier in injectViaTmux needs the slack.
|
|
4330
|
+
// 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
|
|
4331
|
+
a.muteUntilInject = false;
|
|
4332
|
+
a.justSpawned = false;
|
|
4184
4333
|
const r = await inject({
|
|
4185
4334
|
text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
4186
4335
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
|
|
4187
4336
|
});
|
|
4188
|
-
// 与 dispatch 同一约定: 首条 inject 落地 = 解除新 pane 的初始输出静默。
|
|
4189
|
-
// 漏掉这一步的后果不只是少几条气泡 —— onItem 在 mute 分支整体早退, 连
|
|
4190
|
-
// recordTurnStart 都不会跑, 于是 graph/send_peer 拉起来的节点在 chat 列表
|
|
4191
|
-
// 和 chat 详情里彻底不存在(它从来没被 WeCom 侧 dispatch 过, 没有第二次机会)。
|
|
4192
|
-
if (r.ok) {
|
|
4193
|
-
a.muteUntilInject = false;
|
|
4194
|
-
a.justSpawned = false;
|
|
4195
|
-
}
|
|
4196
4337
|
return r;
|
|
4197
4338
|
},
|
|
4198
4339
|
interruptPane: async (target, opts) => {
|
|
@@ -4479,7 +4620,7 @@ export const startMirror = (deps) => {
|
|
|
4479
4620
|
// race against the new stream's first content (Path B).
|
|
4480
4621
|
if (a.standaloneBuf) {
|
|
4481
4622
|
clearTimeout(a.standaloneBuf.timer);
|
|
4482
|
-
flushStandalone(a);
|
|
4623
|
+
flushStandalone(a, true);
|
|
4483
4624
|
}
|
|
4484
4625
|
// /clear produces no assistant output; opening a stream would leave a
|
|
4485
4626
|
// stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
|
|
@@ -4590,12 +4731,18 @@ export const startMirror = (deps) => {
|
|
|
4590
4731
|
return;
|
|
4591
4732
|
}
|
|
4592
4733
|
rememberInject(text);
|
|
4734
|
+
// 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
|
|
4735
|
+
// (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
|
|
4736
|
+
// 发出后 2-3s 就落盘 —— tail 在 mute 期间读到的 item 会被 onItem 永久丢弃,
|
|
4737
|
+
// 导致 turn_end 丢失, brief turn 无法收口, 后续消息全部错位 (off-by-one)。
|
|
4738
|
+
// 安全性: newSession 的 fresh pane 在 inject 前只写过 system/init (→ 无 item),
|
|
4739
|
+
// 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
|
|
4740
|
+
a.muteUntilInject = false;
|
|
4593
4741
|
const r = await inject({
|
|
4594
4742
|
text, images, cfg, log: log.child({ principal, sessionId: sid }),
|
|
4595
4743
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
4596
4744
|
});
|
|
4597
4745
|
if (!r.ok) {
|
|
4598
|
-
a.muteUntilInject = false;
|
|
4599
4746
|
if (s) {
|
|
4600
4747
|
s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
|
|
4601
4748
|
await finalizeStream(a, s);
|
|
@@ -4619,8 +4766,6 @@ export const startMirror = (deps) => {
|
|
|
4619
4766
|
}
|
|
4620
4767
|
return;
|
|
4621
4768
|
}
|
|
4622
|
-
// inject 成功 → 解除初始输出静默, 后续 onItem 正常流转。
|
|
4623
|
-
a.muteUntilInject = false;
|
|
4624
4769
|
// Inject "succeeded" but the input box never cleared — the target
|
|
4625
4770
|
// session may be busy / not consuming input (long task, full context).
|
|
4626
4771
|
// Hint the user once so a silently-dropped message isn't mistaken for a
|