@xgspz/cli-ui 0.6.14 → 0.6.16
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/CHANGELOG.md +37 -4
- package/README.md +29 -4
- package/dist/{cli-ui-embed-D-3DvyTN.js → cli-ui-embed-D9xgur2y.js} +18376 -18648
- package/dist/{cli-ui-index-C34qFb4u.js → cli-ui-index-WBtxmw23.js} +2 -2
- package/dist/{cli-ui-jszip.min-DojbZPiE.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
- package/dist/{cli-ui-jszip.min-BObcE2nn.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
- package/dist/{cli-ui-xlsx-JVLtx0qR.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/ChangesPane.tsx +10 -4
- package/src/board/CliBoard.tsx +48 -16
- package/src/board/FileViewer.tsx +34 -1
- package/src/board/diffRefresh.ts +63 -0
- package/src/board/linkage.ts +68 -0
- package/src/board/pathResolve.ts +108 -0
- package/src/chat/CliChat.tsx +105 -182
- package/src/chat/Composer.tsx +4 -3
- package/src/core/conversation.ts +16 -4
- package/src/core/store.ts +120 -40
- package/src/core/types.ts +2 -0
- package/src/msglist/CliMsgList.tsx +3 -2
- package/src/operator/node/AgentConnect.tsx +19 -15
- package/src/operator/node/AppearancePanel.tsx +1 -1
- package/src/operator/node/BranchNavigator.tsx +1 -1
- package/src/operator/node/BrowserPane.tsx +1 -1
- package/src/operator/node/Chat.tsx +16 -261
- package/src/operator/node/CommandPalette.tsx +1 -1
- package/src/operator/node/Composer.tsx +1 -1
- package/src/operator/node/Layout.tsx +1 -1
- package/src/operator/node/Markdown.tsx +6 -36
- package/src/operator/node/Modal.tsx +1 -1
- package/src/operator/node/OnboardingWizard.tsx +9 -5
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/ProjectLanding.tsx +1 -1
- package/src/operator/node/ProjectsColumn.tsx +1 -1
- package/src/operator/node/ProviderAuth.tsx +12 -10
- package/src/operator/node/RunSwitch.tsx +1 -1
- package/src/operator/node/Services.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +27 -8
- package/src/operator/node/SessionView.tsx +1 -1
- package/src/operator/node/SettingsView.tsx +1 -1
- package/src/operator/node/TaskBoard.tsx +1 -1
- package/src/operator/node/TaskChanges.tsx +34 -5
- package/src/operator/node/TaskHero.tsx +1 -1
- package/src/operator/node/TasksColumn.tsx +1 -1
- package/src/operator/node/Transcript.tsx +10 -237
- package/src/operator/node/Welcome.tsx +1 -1
- package/src/operator/node/WorktreePrune.tsx +1 -1
- package/src/operator/node/boardInteraction.ts +6 -3
- package/src/operator/node/boardState.ts +12 -13
- package/src/operator/node/fileLinks.ts +4 -38
- package/src/operator/node/format.ts +12 -104
- package/src/operator/node/github.tsx +10 -8
- package/src/operator/node/modals.tsx +15 -13
- package/src/operator/node/recheckOnReturn.ts +28 -0
- package/src/operator/node/shared.tsx +10 -50
- package/src/operator/node/useTaskStream.ts +2 -2
- package/src/operator.base.css +3 -0
- package/src/operator.css +3 -0
- package/src/styles.css +5 -0
- package/src/ui/Markdown.tsx +36 -21
- package/src/ui/Transcript.tsx +8 -5
- package/src/ui/fileLinks.ts +6 -3
- package/src/ui/format.ts +11 -5
- package/src/ui/shared.tsx +11 -7
- package/src/ui/transcriptModel.ts +40 -9
- package/src/ui/useTranscriptWindow.ts +253 -0
- package/src/operator/node/boundedPoll.ts +0 -47
- package/src/operator/node/icons.tsx +0 -88
- package/src/operator/node/transcriptModel.ts +0 -129
package/src/chat/CliChat.tsx
CHANGED
|
@@ -15,12 +15,14 @@ import { Icon } from "../ui/icons";
|
|
|
15
15
|
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
|
|
16
16
|
import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
|
|
17
17
|
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
|
|
18
|
-
import {
|
|
18
|
+
import { buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
|
|
19
|
+
import { useTranscriptWindow } from "../ui/useTranscriptWindow";
|
|
19
20
|
import { Composer, type ComposerOptions } from "./Composer";
|
|
20
21
|
import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
|
|
21
22
|
import { exportMarkdown } from "./export";
|
|
22
23
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
23
24
|
import { FileViewer } from "../board/FileViewer";
|
|
25
|
+
import { pathHints, resolveMissingPath } from "../board/pathResolve";
|
|
24
26
|
import { BrowserPane } from "../board/BrowserPane";
|
|
25
27
|
import { CliBoard } from "../board/CliBoard";
|
|
26
28
|
import { WorkspaceTabs } from "../ui/WorkspaceTabs";
|
|
@@ -122,6 +124,11 @@ function batchStatusView(taskStatus: string | null, jobStatus: string | null): {
|
|
|
122
124
|
|
|
123
125
|
/** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
|
|
124
126
|
const QUIET_AFTER_MS = 180_000;
|
|
127
|
+
/**
|
|
128
|
+
* 「正在加载会话」最多等这么久:会话记录还没到(执行节点暂时读不到、网关缓冲了实时数据、旧版中枢补发失败后
|
|
129
|
+
* 不再重试)就改成说明 +「重新读取」,不一直转圈。中枢报了 transcript_unavailable 时立刻说明。
|
|
130
|
+
*/
|
|
131
|
+
export const TRANSCRIPT_WAIT_MS = 20_000;
|
|
125
132
|
function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
|
|
126
133
|
const [now, setNow] = useState(() => Date.now());
|
|
127
134
|
useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []);
|
|
@@ -288,6 +295,24 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
288
295
|
&& (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
|
|
289
296
|
// 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
|
|
290
297
|
const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
|
|
298
|
+
// 记录迟迟不来:到时改成说明(见 TRANSCRIPT_WAIT_MS);「重新读取」后重新计时。
|
|
299
|
+
const [loadStalled, setLoadStalled] = useState(false);
|
|
300
|
+
const [loadAttempt, setLoadAttempt] = useState(0);
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
setLoadStalled(false);
|
|
303
|
+
if (!loading) return;
|
|
304
|
+
const timer = setTimeout(() => setLoadStalled(true), TRANSCRIPT_WAIT_MS);
|
|
305
|
+
return () => clearTimeout(timer);
|
|
306
|
+
}, [loading, key, loadAttempt]);
|
|
307
|
+
// 已结束的一次性任务:中枢补不到记录时关流前会先报 transcript_unavailable。
|
|
308
|
+
const transcriptProblem = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length
|
|
309
|
+
? conv.transcriptError ?? (loading && loadStalled
|
|
310
|
+
? conv.stream === "connecting" || (conv.stream === "live" && !conv.job)
|
|
311
|
+
? "实时连接还没有收到数据:网络或网关可能缓冲了实时推送。"
|
|
312
|
+
: "会话记录还没读到:执行节点暂时没有响应。"
|
|
313
|
+
: null)
|
|
314
|
+
: null;
|
|
315
|
+
const rereadTranscript = () => { if (!key) return; setLoadAttempt((n) => n + 1); store.reconnect(key, { force: true }); };
|
|
291
316
|
const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
|
|
292
317
|
|
|
293
318
|
// 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
|
|
@@ -363,13 +388,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
363
388
|
if (target !== id && latest.current.key === refKey) go(target);
|
|
364
389
|
}, [store, go, passiveContext]);
|
|
365
390
|
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
366
|
-
const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
|
|
391
|
+
const passiveText=useMemo(()=>passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'',[passiveContext,passiveView,key]);
|
|
367
392
|
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
368
393
|
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
369
394
|
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
370
395
|
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
371
396
|
const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
372
|
-
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
397
|
+
const shownRefs = useMemo(() => refs?.filter((r) => refShown(cfg, r.kind)), [refs, cfg]);
|
|
373
398
|
const chatRef = useRef<HTMLDivElement>(null);
|
|
374
399
|
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
375
400
|
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
@@ -389,7 +414,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
389
414
|
}, [key, conv, fileOpen, store, inlineOwner]);
|
|
390
415
|
|
|
391
416
|
// 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
|
|
392
|
-
const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
|
|
417
|
+
const [popup, setPopup] = useState<{ kind: "file"; path: string; resolvedFrom?: string } | { kind: "browser" } | null>(null);
|
|
418
|
+
// 弹窗里打不开的相对路径:和看板一样按这个对话里的线索补全(board/pathResolve.ts)。
|
|
419
|
+
const resolvePopupPath = useCallback((path: string, signal: AbortSignal) => {
|
|
420
|
+
const board = store.client.board, id = latest.current.key;
|
|
421
|
+
if (!board || !id) return Promise.resolve([]);
|
|
422
|
+
const seen = (store.getState().boardContext[id]?.history ?? []).map((f) => f.path);
|
|
423
|
+
return resolveMissingPath(board, id, path, pathHints(store.getState().convs[id]?.msgs ?? [], seen), signal);
|
|
424
|
+
}, [store]);
|
|
393
425
|
useEffect(() => setPopup(null), [key]);
|
|
394
426
|
useEffect(() => {
|
|
395
427
|
if (!popup) return;
|
|
@@ -488,167 +520,27 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
488
520
|
useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
|
|
489
521
|
const turnStartedAt = runSeenAt ?? lastUserTs;
|
|
490
522
|
|
|
491
|
-
// ──
|
|
523
|
+
// ── 分窗渲染(与节点 Chat 共用 useTranscriptWindow):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
|
|
492
524
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
493
525
|
useChatScrollbar(scrollRef, key);
|
|
494
|
-
const pinned = useRef(true);
|
|
495
|
-
const [atBottom, setAtBottom] = useState(true);
|
|
496
526
|
const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
|
|
497
|
-
|
|
527
|
+
// 排队消息每次都是重新生成的对象:内容没变就沿用上一份,输入框上方的排队区不随流式输出重画。
|
|
528
|
+
const queuedNow = messages.filter((m) => m.role === "queued");
|
|
529
|
+
const queuedSig = queuedNow.map((m) => [m.id, m.content, m.note ?? "", m.turnId ?? "", m.ts ?? ""].join("\u0001")).join("\u0002");
|
|
530
|
+
const queued = useMemo(() => queuedNow, [queuedSig]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
498
531
|
// 「立即发送」只在有一轮正在执行、且接入开放了停止时可用。
|
|
499
532
|
const queueSendNow = !readOnly && !batch && cfg.composer.stop && key && conv && runningTurn(conv) ? sendQueuedNow : undefined;
|
|
500
533
|
const queueSendNowHint = !cfg.composer.stop && conv && runningTurn(conv) ? "当前接入没有开放停止,排队消息会在这一轮结束后发送" : undefined;
|
|
501
534
|
// 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
|
|
502
535
|
const older = conv?.older ?? 0;
|
|
503
|
-
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
504
|
-
const olderRef = useRef(older); olderRef.current = older;
|
|
505
|
-
const keyRef2 = useRef(key); keyRef2.current = key;
|
|
506
536
|
const items = useMemo(() => buildTranscriptItems(committed, older > 0)
|
|
507
537
|
.filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
|
|
508
538
|
// 展开了的「续等」折叠行(按折叠行的 key)。
|
|
509
539
|
const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
|
|
510
|
-
const
|
|
511
|
-
const {
|
|
512
|
-
|
|
513
|
-
const e = find(win.end);
|
|
514
|
-
const last = e >= 0 ? e : items.length - 1;
|
|
515
|
-
let s = find(win.start);
|
|
516
|
-
if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
|
|
517
|
-
return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
|
|
518
|
-
}, [items, win]);
|
|
519
|
-
const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
|
|
520
|
-
const hiddenAbove = useMemo(() => { let n = 0; for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++; return n; }, [items, startIdx]);
|
|
521
|
-
const hiddenBelow = useMemo(() => { let n = 0; for (let i = endIdx + 1; i < items.length; i++) if (items[i].kind === "msg") n++; return n; }, [items, endIdx]);
|
|
522
|
-
const itemsRef = useRef(items); itemsRef.current = items;
|
|
523
|
-
const rangeRef = useRef({ startIdx, endIdx }); rangeRef.current = { startIdx, endIdx };
|
|
524
|
-
const winRef = useRef(win); winRef.current = win;
|
|
525
|
-
type Anchor = { key: string; top: number };
|
|
526
|
-
const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
|
|
527
|
-
|
|
528
|
-
const measure = useCallback((key?: string): Anchor | null => {
|
|
529
|
-
const el = scrollRef.current;
|
|
530
|
-
const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
|
|
531
|
-
return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
|
|
532
|
-
}, []);
|
|
533
|
-
const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
|
|
534
|
-
const its = itemsRef.current;
|
|
535
|
-
let a = s; while (a < its.length && its[a].kind !== "msg") a++;
|
|
536
|
-
let b = e; while (b > 0 && its[b].kind !== "msg") b--;
|
|
537
|
-
const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
|
|
538
|
-
if (winRef.current.start === next.start && winRef.current.end === next.end) return;
|
|
539
|
-
if (anchor) anchorRef.current = { ...anchor, win: next };
|
|
540
|
-
setWin(next);
|
|
541
|
-
}, []);
|
|
542
|
-
const revealEarlier = useCallback(() => {
|
|
543
|
-
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
544
|
-
if (s <= 0) {
|
|
545
|
-
// 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
|
|
546
|
-
const k = keyRef2.current;
|
|
547
|
-
if (!k || olderRef.current <= 0) return;
|
|
548
|
-
setLoadingOlder(true);
|
|
549
|
-
void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
const ns = Math.max(0, s - RENDER_STEP);
|
|
553
|
-
setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
|
|
554
|
-
}, [measure, setRange, store]);
|
|
555
|
-
const revealLater = useCallback(() => {
|
|
556
|
-
const its = itemsRef.current;
|
|
557
|
-
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
558
|
-
if (e >= its.length - 1) return;
|
|
559
|
-
const ne = Math.min(its.length - 1, e + RENDER_STEP);
|
|
560
|
-
const ns = Math.max(s, ne - RENDER_MAX + 1);
|
|
561
|
-
setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
|
|
562
|
-
}, [measure, setRange]);
|
|
563
|
-
const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
|
|
564
|
-
revealRef.current = { earlier: revealEarlier, later: revealLater };
|
|
565
|
-
|
|
566
|
-
const onScroll = useCallback(() => {
|
|
567
|
-
const el = scrollRef.current;
|
|
568
|
-
if (!el) return;
|
|
569
|
-
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
|
|
570
|
-
pinned.current = bottom;
|
|
571
|
-
setAtBottom(bottom);
|
|
572
|
-
if (!bottom && winRef.current.start === null) {
|
|
573
|
-
const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
|
|
574
|
-
if (first) setWin((w) => ({ ...w, start: first.key }));
|
|
575
|
-
} else if (bottom && winRef.current.start !== null) {
|
|
576
|
-
setWin({ start: null, end: null });
|
|
577
|
-
}
|
|
578
|
-
}, []);
|
|
579
|
-
|
|
580
|
-
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
581
|
-
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
|
582
|
-
const hasAbove = hiddenAbove > 0 || older > 0;
|
|
540
|
+
const loadOlderPage = useCallback((beforeApply: () => void) => (key ? store.loadOlder(key, { beforeApply }) : Promise.resolve(false)), [store, key]);
|
|
541
|
+
const { shown, hiddenAbove, hiddenBelow, loadingOlder, atBottom, onScroll, revealEarlier, revealLater, scrollToBottom, scrollToUserMsg, topSentinelRef, bottomSentinelRef } =
|
|
542
|
+
useTranscriptWindow({ scrollRef, items, older, loadOlder: loadOlderPage, messages, running, resetKey: key });
|
|
583
543
|
const hasBelow = hiddenBelow > 0;
|
|
584
|
-
useEffect(() => {
|
|
585
|
-
const root = scrollRef.current;
|
|
586
|
-
if (!root || typeof IntersectionObserver === "undefined") return;
|
|
587
|
-
const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
|
|
588
|
-
if (!target) return null;
|
|
589
|
-
const io = new IntersectionObserver((entries) => { if (!pinned.current && entries.some((e) => e.isIntersecting)) go(); }, { root, rootMargin: margin });
|
|
590
|
-
io.observe(target);
|
|
591
|
-
return io;
|
|
592
|
-
};
|
|
593
|
-
const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => revealRef.current.earlier()) : null;
|
|
594
|
-
const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
|
|
595
|
-
return () => { up?.disconnect(); down?.disconnect(); };
|
|
596
|
-
}, [hasAbove, hasBelow]);
|
|
597
|
-
|
|
598
|
-
useLayoutEffect(() => {
|
|
599
|
-
if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
|
|
600
|
-
const el = scrollRef.current;
|
|
601
|
-
if (!el) return;
|
|
602
|
-
if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) setRange(startIdx, startIdx + RENDER_MAX - 1);
|
|
603
|
-
else { const ns = endIdx - RENDER_MAX + 1; setRange(ns, endIdx, measure(itemsRef.current[ns].key)); }
|
|
604
|
-
}, [win, startIdx, endIdx, setRange, measure]);
|
|
605
|
-
|
|
606
|
-
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
607
|
-
const el = scrollRef.current;
|
|
608
|
-
if (!el) return;
|
|
609
|
-
pinned.current = true;
|
|
610
|
-
setAtBottom(true);
|
|
611
|
-
if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
|
|
612
|
-
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
613
|
-
}, []);
|
|
614
|
-
|
|
615
|
-
// 在「你」的消息之间跳。
|
|
616
|
-
const scrollToUserMsg = useCallback((dir: -1 | 1) => {
|
|
617
|
-
const el = scrollRef.current;
|
|
618
|
-
if (!el) return;
|
|
619
|
-
const base = el.getBoundingClientRect().top;
|
|
620
|
-
const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)")).map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
|
|
621
|
-
const cur = el.scrollTop;
|
|
622
|
-
const candidates = dir < 0 ? tops.filter((t) => t < cur - 8) : tops.filter((t) => t > cur + 8);
|
|
623
|
-
if (!candidates.length) { if (dir < 0) revealRef.current.earlier(); else revealRef.current.later(); }
|
|
624
|
-
if (!tops.length) return;
|
|
625
|
-
const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
|
|
626
|
-
el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
|
|
627
|
-
}, []);
|
|
628
|
-
|
|
629
|
-
useLayoutEffect(() => {
|
|
630
|
-
const a = anchorRef.current;
|
|
631
|
-
const el = scrollRef.current;
|
|
632
|
-
if (a && a.win === win) {
|
|
633
|
-
anchorRef.current = null;
|
|
634
|
-
const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
|
|
635
|
-
if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
|
|
636
|
-
}
|
|
637
|
-
}, [win]);
|
|
638
|
-
useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
|
|
639
|
-
useEffect(() => {
|
|
640
|
-
const el = scrollRef.current;
|
|
641
|
-
if (!el || typeof ResizeObserver === "undefined") return;
|
|
642
|
-
const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
|
|
643
|
-
ro.observe(el);
|
|
644
|
-
return () => ro.disconnect();
|
|
645
|
-
}, []);
|
|
646
|
-
useEffect(() => {
|
|
647
|
-
pinned.current = true;
|
|
648
|
-
setAtBottom(true);
|
|
649
|
-
setWin({ start: null, end: null });
|
|
650
|
-
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
651
|
-
}, [key]);
|
|
652
544
|
|
|
653
545
|
const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
|
|
654
546
|
const h = cfg.header;
|
|
@@ -712,6 +604,51 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
712
604
|
};
|
|
713
605
|
}, [store, key, isNew, board, openFile]);
|
|
714
606
|
|
|
607
|
+
// ── 输入框和内嵌看板的参数:保持引用稳定(输入框、看板都是 memo),智能体流式输出时不跟着每一块重画 ──
|
|
608
|
+
const saveExecution = useRef(execution.save);
|
|
609
|
+
saveExecution.current = execution.save;
|
|
610
|
+
const runControls = useMemo(() => <>
|
|
611
|
+
<RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
|
|
612
|
+
model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
|
|
613
|
+
agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
|
|
614
|
+
showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
|
|
615
|
+
unavailableReason={batch ? "一次性任务沿用任务执行设置,不支持在对话中修改。" : executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (!executionEnabled || execution.loading ? "正在读取执行设置…" : undefined)}
|
|
616
|
+
disabled={!executionEnabled || composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
|
|
617
|
+
onSetAgent={agent => saveExecution.current({ agent, model: null, reasoning: null, permission_mode: null })}
|
|
618
|
+
onSetModel={model => saveExecution.current({ model })} onSetReasoning={reasoning => saveExecution.current({ reasoning })}
|
|
619
|
+
onSetPermission={permission_mode => saveExecution.current({ permission_mode })} />
|
|
620
|
+
{slots?.composerToolbar}
|
|
621
|
+
</>, [draftScope, execution.config, agent, initialOptions?.agent, executionAgent, cfg, batch, executionReadOnly, execution.error, executionEnabled, execution.loading,
|
|
622
|
+
composerDisabled, running, batchPending, configurationBusy, slots?.composerToolbar]);
|
|
623
|
+
const focusComposer = useCallback(() => chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }), []);
|
|
624
|
+
const composerReferences = useMemo(() => (!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
|
|
625
|
+
{viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); focusComposer(); }} />}
|
|
626
|
+
{(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); focusComposer(); }} />)}
|
|
627
|
+
</div>, [shownRefs, viewing, key, composerDisabled, configurationBusy, store, focusComposer]);
|
|
628
|
+
const queuedMessages = useMemo(() => <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} onEdit={handlers.onCancelQueued ? editQueued : undefined} attachmentUrl={handlers.attachmentUrl} />,
|
|
629
|
+
[key, queued, handlers, queueSendNow, queueSendNowHint, editQueued]);
|
|
630
|
+
const onClearRef = useRef(onClear);
|
|
631
|
+
onClearRef.current = onClear;
|
|
632
|
+
// 发出前记下当时是否在执行:在执行时发的消息会排队,不滚到底。
|
|
633
|
+
const busyRef = useRef(false);
|
|
634
|
+
busyRef.current = running || batchPending;
|
|
635
|
+
const sendFromComposer = useCallback(async (text: string) => {
|
|
636
|
+
if (text.trim() === "/clear" && onClearRef.current) { onClearRef.current(); return; }
|
|
637
|
+
const sentFrom = key;
|
|
638
|
+
const wasBusy = busyRef.current;
|
|
639
|
+
await send(text);
|
|
640
|
+
if (!wasBusy && latest.current.key === sentFrom) scrollToBottom("auto");
|
|
641
|
+
}, [key, send, scrollToBottom]);
|
|
642
|
+
const pauseResume = useMemo(() => cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
|
|
643
|
+
paused: controlTask.status === "paused",
|
|
644
|
+
onToggle: async () => {
|
|
645
|
+
const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
|
|
646
|
+
await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
|
|
647
|
+
},
|
|
648
|
+
} : undefined, [cfg, readOnlyProp, controlTask, jobId, store, report]);
|
|
649
|
+
const inlineLayout = useMemo(() => (mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }),
|
|
650
|
+
[mobileWorkspace, tabsHost, workspaceActive, showWorkspace, showChat]);
|
|
651
|
+
|
|
715
652
|
return (
|
|
716
653
|
<FileLinkContext.Provider value={resolveFile}>
|
|
717
654
|
<FileOpenContext.Provider value={openFile}>
|
|
@@ -790,7 +727,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
790
727
|
</div></div>
|
|
791
728
|
)}
|
|
792
729
|
{contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
|
|
793
|
-
{
|
|
730
|
+
{transcriptProblem && (
|
|
731
|
+
<div className="msg system info cli-transcript-problem" role="status"><div className="msg-body">⚠ {transcriptProblem}
|
|
732
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={rereadTranscript}>重新读取</button>
|
|
733
|
+
</div></div>
|
|
734
|
+
)}
|
|
735
|
+
{loading && !transcriptProblem && (
|
|
794
736
|
<div aria-hidden>
|
|
795
737
|
<div className="session-label"><span className="ln" />正在加载会话<span className="ln" /></div>
|
|
796
738
|
<div className="msg user" style={{ opacity: .7 }}>
|
|
@@ -803,9 +745,9 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
803
745
|
</div>
|
|
804
746
|
</div>
|
|
805
747
|
)}
|
|
806
|
-
{
|
|
748
|
+
{hiddenAbove > 0 && (
|
|
807
749
|
<div ref={topSentinelRef} className="load-older">
|
|
808
|
-
<button className="btn btn-ghost btn-sm" onClick={revealEarlier} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove
|
|
750
|
+
<button className="btn btn-ghost btn-sm" onClick={() => void revealEarlier()} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove} 条)`}</button>
|
|
809
751
|
</div>
|
|
810
752
|
)}
|
|
811
753
|
{shown.map((it) =>
|
|
@@ -868,36 +810,16 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
868
810
|
uploadDocument={uploadDocument}
|
|
869
811
|
key={key ?? "new"}
|
|
870
812
|
draft={draftScope}
|
|
871
|
-
context={
|
|
872
|
-
{viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />}
|
|
873
|
-
{(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />)}
|
|
874
|
-
</div>}
|
|
813
|
+
context={composerReferences}
|
|
875
814
|
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
876
815
|
disabled={composerDisabled || settingsBusy}
|
|
877
816
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
878
817
|
running={running || batchPending}
|
|
879
|
-
toolbar={
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
|
|
883
|
-
showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
|
|
884
|
-
unavailableReason={batch ? "一次性任务沿用任务执行设置,不支持在对话中修改。" : executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (!executionEnabled || execution.loading ? "正在读取执行设置…" : undefined)}
|
|
885
|
-
disabled={!executionEnabled || composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
|
|
886
|
-
onSetAgent={agent => execution.save({ agent, model: null, reasoning: null, permission_mode: null })}
|
|
887
|
-
onSetModel={model => execution.save({ model })} onSetReasoning={reasoning => execution.save({ reasoning })}
|
|
888
|
-
onSetPermission={permission_mode => execution.save({ permission_mode })} />
|
|
889
|
-
{slots?.composerToolbar}
|
|
890
|
-
</>}
|
|
891
|
-
queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} onEdit={handlers.onCancelQueued ? editQueued : undefined} attachmentUrl={handlers.attachmentUrl} />}
|
|
892
|
-
onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } const sentFrom = key; await send(text); if (!running && !batchPending && latest.current.key === sentFrom) scrollToBottom("auto"); }}
|
|
818
|
+
toolbar={runControls}
|
|
819
|
+
queued={queuedMessages}
|
|
820
|
+
onSend={sendFromComposer}
|
|
893
821
|
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
894
|
-
taskControl={
|
|
895
|
-
paused: controlTask.status === "paused",
|
|
896
|
-
onToggle: async () => {
|
|
897
|
-
const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
|
|
898
|
-
await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
|
|
899
|
-
},
|
|
900
|
-
} : undefined}
|
|
822
|
+
taskControl={pauseResume}
|
|
901
823
|
options={composerOptions}
|
|
902
824
|
/>
|
|
903
825
|
)}
|
|
@@ -907,7 +829,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
907
829
|
readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
|
|
908
830
|
acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
|
|
909
831
|
visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
|
|
910
|
-
inlineLayout={
|
|
832
|
+
inlineLayout={inlineLayout} />}
|
|
911
833
|
</WorkspaceDrawer>
|
|
912
834
|
</div>
|
|
913
835
|
</SubagentChatScope>
|
|
@@ -915,7 +837,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
915
837
|
<div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
|
|
916
838
|
<div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
|
|
917
839
|
{popup.kind === "file" ? (
|
|
918
|
-
<FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })}
|
|
840
|
+
<FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })}
|
|
841
|
+
resolveMissing={resolvePopupPath} onResolved={(to) => setPopup({ kind: "file", path: to, resolvedFrom: popup.resolvedFrom ?? popup.path })} resolvedFrom={popup.resolvedFrom} />
|
|
919
842
|
) : (
|
|
920
843
|
<>
|
|
921
844
|
<div className="cli-fv-h"><span className="cli-fv-name">任务浏览器</span><span className="cli-fv-sp" /><button className="cli-fv-icon" onClick={() => setPopup(null)} title="关闭">{Icon.x()}</button></div>
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 输入框与 Operator 共用原件上传。上传完成只保留附件引用,用户发送时才交给 Agent。
|
|
2
2
|
// 没有原件上传适配器的宿主继续使用原有文档文本解析。
|
|
3
|
-
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
3
|
+
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import {
|
|
5
5
|
ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
6
6
|
} from "../core/blocks";
|
|
@@ -24,7 +24,8 @@ export interface ComposerOptions {
|
|
|
24
24
|
queue: boolean;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// memo:对话每次重画(流式输出时每来一块一次)不带着输入框重画;参数由 CliChat 保持引用稳定。
|
|
28
|
+
export const Composer = memo(function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar, queued, context, uploadDocument }: {
|
|
28
29
|
/** 草稿按这个键存在 localStorage(一般是对话 id)。 */
|
|
29
30
|
draft: string;
|
|
30
31
|
placeholder: string;
|
|
@@ -236,4 +237,4 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
236
237
|
</div>
|
|
237
238
|
</div>
|
|
238
239
|
);
|
|
239
|
-
}
|
|
240
|
+
});
|
package/src/core/conversation.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface ConvState {
|
|
|
68
68
|
/** 节点转录(Operator 消息)。 */
|
|
69
69
|
msgs: Msg[];
|
|
70
70
|
hasTranscript: boolean;
|
|
71
|
+
/** 中枢说这段对话记录暂时读不到(transcript_unavailable);读到转录后清掉。 */
|
|
72
|
+
transcriptError: string | null;
|
|
71
73
|
/** 节点转录里比 msgs 更早、还没取的条数(实时流只给最新一段;往上翻时 store.loadOlder 分页取)。 */
|
|
72
74
|
older: number;
|
|
73
75
|
nodeRunning: boolean;
|
|
@@ -76,6 +78,8 @@ export interface ConvState {
|
|
|
76
78
|
* 断线重连后补齐时 +1。首次连上不算。看板的「改动」据此重读 diff。
|
|
77
79
|
*/
|
|
78
80
|
settled: number;
|
|
81
|
+
/** 运行中工具执行完的次数(节点 tool_result 事件):工具可能改了文件,看板的「改动」据此在一轮当中节流重读。 */
|
|
82
|
+
edits: number;
|
|
79
83
|
resolvedModel: string | null;
|
|
80
84
|
/** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
|
|
81
85
|
liveUsage: { fresh: number; cacheRead: number };
|
|
@@ -97,7 +101,7 @@ export interface ConvState {
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
export const emptyConv = (key: string): ConvState => ({
|
|
100
|
-
key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, older: 0, nodeRunning: false, settled: 0, resolvedModel: null,
|
|
104
|
+
key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, transcriptError: null, older: 0, nodeRunning: false, settled: 0, edits: 0, resolvedModel: null,
|
|
101
105
|
liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
|
|
102
106
|
batch: false, batchTask: null, remoteTaskId: null,
|
|
103
107
|
});
|
|
@@ -161,7 +165,7 @@ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvStat
|
|
|
161
165
|
const batchTask = task ? { ...kept, ...task } : null;
|
|
162
166
|
return {
|
|
163
167
|
...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
|
|
164
|
-
...(sameRemote ? {} : { msgs: [], hasTranscript: false, older: 0, nodeRunning: false }),
|
|
168
|
+
...(sameRemote ? {} : { msgs: [], hasTranscript: false, transcriptError: null, older: 0, nodeRunning: false }),
|
|
165
169
|
};
|
|
166
170
|
}
|
|
167
171
|
|
|
@@ -190,12 +194,14 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
|
|
|
190
194
|
let next = conv;
|
|
191
195
|
for (const item of items) {
|
|
192
196
|
// 一次性任务:只认看的这个子任务所在的节点对话。
|
|
193
|
-
if (next.batch && (item.type === "transcript" || item.type === "node") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
|
|
197
|
+
if (next.batch && (item.type === "transcript" || item.type === "node" || item.type === "transcript_unavailable") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
|
|
194
198
|
if (item.type === "transcript") {
|
|
195
199
|
// 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
|
|
196
200
|
const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
|
|
197
201
|
const merged = mergeSnapshot(next.msgs, messages, typeof item.older === "number" ? item.older : 0);
|
|
198
|
-
next = { ...next, msgs: merged.msgs, older: merged.older, hasTranscript: true, nodeRunning: item.running === true };
|
|
202
|
+
next = { ...next, msgs: merged.msgs, older: merged.older, hasTranscript: true, transcriptError: null, nodeRunning: item.running === true };
|
|
203
|
+
} else if (item.type === "transcript_unavailable") {
|
|
204
|
+
if (!next.hasTranscript) next = { ...next, transcriptError: typeof item.error === "string" && item.error ? item.error : "暂时读不到这段对话记录" };
|
|
199
205
|
} else if (item.type === "node") {
|
|
200
206
|
next = applyNodeEvent(next, item.event);
|
|
201
207
|
} else if (item.type === "turn") {
|
|
@@ -226,6 +232,12 @@ const localId = (prefix: string) => `${prefix}-${Date.now()}-${++synthetic}`;
|
|
|
226
232
|
|
|
227
233
|
/** 节点的一个回合事件。与 Operator 的 handleStreamEvent 同一套规则。 */
|
|
228
234
|
export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
|
|
235
|
+
const next = applyTranscriptEvent(conv, ev);
|
|
236
|
+
// 工具执行完(不管对不对得上卡片)都算一次:看板的「改动」据此重读。
|
|
237
|
+
return ev.type === "tool_result" ? { ...next, edits: (next.edits ?? 0) + 1 } : next;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function applyTranscriptEvent(conv: ConvState, ev: NodeEvent): ConvState {
|
|
229
241
|
const e = ev as Record<string, any>;
|
|
230
242
|
const gen = typeof e.generation === "number" ? e.generation : 1;
|
|
231
243
|
const set = (msgs: Msg[]) => ({ ...conv, msgs });
|