@spzxg/cli-ui 0.0.0-stage → 0.1.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 +101 -2
- package/dist/cli-ui-embed-BeBZKDZO.js +36487 -0
- package/dist/cli-ui-index-CuwqVeUk.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +11 -0
- package/package.json +49 -4
- package/src/chat/CliChat.tsx +516 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +230 -0
- package/src/core/config.ts +220 -0
- package/src/core/conversation.ts +371 -0
- package/src/core/provider.tsx +89 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +491 -0
- package/src/core/types.ts +278 -0
- package/src/elements.tsx +122 -0
- package/src/embed.ts +47 -0
- package/src/index.ts +34 -0
- package/src/msglist/CliMsgList.tsx +248 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +50 -0
- package/src/ui/Markdown.tsx +26 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +76 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
|
|
2
|
+
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
|
+
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
|
+
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
|
|
6
|
+
import type { ConversationCallback } from "../core/types.js";
|
|
7
|
+
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
|
|
8
|
+
import { emptyConv, isRunning, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
|
|
9
|
+
import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
|
|
10
|
+
import { Icon } from "../ui/icons.js";
|
|
11
|
+
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
|
|
12
|
+
import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
|
|
13
|
+
import { MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
|
|
14
|
+
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
|
|
15
|
+
import { Composer, type ComposerOptions } from "./Composer.js";
|
|
16
|
+
import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
|
|
17
|
+
import { exportMarkdown } from "./export.js";
|
|
18
|
+
|
|
19
|
+
export interface CliChatProps extends CliStoreOptions {
|
|
20
|
+
/** 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。 */
|
|
21
|
+
jobId?: string | null;
|
|
22
|
+
/** 面包屑里的名称与色块(如业务 / 项目名)。 */
|
|
23
|
+
title?: string;
|
|
24
|
+
color?: string;
|
|
25
|
+
/** 只看不发。 */
|
|
26
|
+
readOnly?: boolean;
|
|
27
|
+
/** 是否显示「结束对话」按钮,缺省 true(等同 config.header.close)。 */
|
|
28
|
+
allowClose?: boolean;
|
|
29
|
+
/** 界面配置:顶部栏、输入框、对话区各模块的显示与文案,见 core/config.ts。 */
|
|
30
|
+
config?: CliChatConfig;
|
|
31
|
+
/** 一轮结束(≈ center 回调 conversation_turn)、对话结束、job 变化等。 */
|
|
32
|
+
onEvent?: (event: CliEvent) => void;
|
|
33
|
+
/** 前端回调:载荷与 center 回调 conversation_turn / conversation_closed 的请求体同名同义。 */
|
|
34
|
+
onCallback?: (payload: ConversationCallback) => void;
|
|
35
|
+
theme?: "light" | "dark";
|
|
36
|
+
className?: string;
|
|
37
|
+
style?: CSSProperties;
|
|
38
|
+
store?: CliStore;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CliChat(props: CliChatProps) {
|
|
42
|
+
const ctx = useCliStoreContext();
|
|
43
|
+
return withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
|
|
47
|
+
const FINAL_REASONS = new Set(["closed", "cancelled"]);
|
|
48
|
+
const REASON_TEXT: Record<string, string> = {
|
|
49
|
+
closed: "对话已结束", cancelled: "对话已取消", expired: "对话空闲太久已过期",
|
|
50
|
+
node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
|
|
54
|
+
const QUIET_AFTER_MS = 180_000;
|
|
55
|
+
function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
|
|
56
|
+
const [now, setNow] = useState(() => Date.now());
|
|
57
|
+
useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []);
|
|
58
|
+
const elapsed = startedAt ? Math.max(0, now - startedAt) : null;
|
|
59
|
+
const quiet = now - lastActivityRef.current;
|
|
60
|
+
const clock = (ms: number) => {
|
|
61
|
+
const s = Math.floor(ms / 1000);
|
|
62
|
+
const h = Math.floor(s / 3600);
|
|
63
|
+
const m = Math.floor((s % 3600) / 60);
|
|
64
|
+
const ss = String(s % 60).padStart(2, "0");
|
|
65
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
|
|
66
|
+
};
|
|
67
|
+
return (
|
|
68
|
+
<div className="msg assistant">
|
|
69
|
+
<div className="who"><Avatar who="cc" agent={agent} /> {agentName}</div>
|
|
70
|
+
<div className="msg-body run-ind">
|
|
71
|
+
<span className="typing"><i /><i /><i /></span>
|
|
72
|
+
{elapsed !== null && <span className="run-ind-t">运行中 · {clock(elapsed)}</span>}
|
|
73
|
+
{quiet >= QUIET_AFTER_MS && <span className="run-ind-quiet">已 {Math.floor(quiet / 60_000)} 分钟没有新输出(可能在执行耗时操作);确认卡住时可点输入框右侧的停止按钮</span>}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onCallback, inProvider, newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
80
|
+
useComponentEvents(onEvent, onCallback, inProvider);
|
|
81
|
+
// 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
|
|
82
|
+
const configKey = JSON.stringify(config ?? {});
|
|
83
|
+
const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show;
|
|
85
|
+
const store = useCliStore();
|
|
86
|
+
const selected = useCliState((s) => s.selected);
|
|
87
|
+
// 显示哪个对话:
|
|
88
|
+
// · 不传 jobId → 跟仓库的选中走(和同一 Provider 里的 cli-msglist 联动);
|
|
89
|
+
// · 传了 jobId(含 null = 新对话)→ 组件自己接管:从传入的 id 开始,之后自己新建出的对话、
|
|
90
|
+
// center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId 就以宿主为准。
|
|
91
|
+
// 新对话的草稿按组件实例存(对话的草稿按 job_id 存,看同一个对话的组件共用一份是对的)。
|
|
92
|
+
const instanceId = useId();
|
|
93
|
+
const controlled = jobIdProp !== undefined;
|
|
94
|
+
const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: jobIdProp, id: jobIdProp ?? null }));
|
|
95
|
+
let viewId = followed.id;
|
|
96
|
+
if (followed.prop !== jobIdProp) {
|
|
97
|
+
viewId = jobIdProp ?? null;
|
|
98
|
+
setShown({ prop: jobIdProp, id: viewId });
|
|
99
|
+
}
|
|
100
|
+
const jobId = controlled ? viewId : selected;
|
|
101
|
+
const go = useCallback((id: string | null) => {
|
|
102
|
+
if (controlled) setShown((v) => (v.id === id ? v : { ...v, id }));
|
|
103
|
+
else store.select(id);
|
|
104
|
+
}, [controlled, store]);
|
|
105
|
+
const conv = useCliState((s) => (jobId ? s.convs[jobId] : undefined)) ?? (jobId ? emptyConv(jobId) : null);
|
|
106
|
+
const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
|
|
107
|
+
|
|
108
|
+
// 看这个对话:开实时流(同一对话多处看只连一次)。
|
|
109
|
+
useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
|
|
110
|
+
// 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
|
|
111
|
+
const jobIdRef = useRef(jobId);
|
|
112
|
+
jobIdRef.current = jobId;
|
|
113
|
+
useEffect(() => store.on((event) => {
|
|
114
|
+
if (event.type === "job_change" && event.previousJobId && event.previousJobId === jobIdRef.current) go(event.jobId);
|
|
115
|
+
}), [store, go]);
|
|
116
|
+
|
|
117
|
+
const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
|
|
118
|
+
// config.transcript.tools=false:去掉工具卡片,只留文字往来(提问 / 输入卡片要用户操作,照常显示)。
|
|
119
|
+
const messages = useMemo(() => (cfg.transcript.tools ? allMessages : allMessages.filter((m) => {
|
|
120
|
+
if (m.role !== "tool") return true;
|
|
121
|
+
const data = toolDataOf(m);
|
|
122
|
+
return !!(data.ask || data.input);
|
|
123
|
+
})), [allMessages, cfg.transcript.tools]);
|
|
124
|
+
const running = conv ? isRunning(conv) : false;
|
|
125
|
+
const job = entry?.job;
|
|
126
|
+
const task = job?.tasks?.[0];
|
|
127
|
+
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
128
|
+
const agentText = labelOf(agent);
|
|
129
|
+
const status = conv?.job?.status ?? job?.status ?? null;
|
|
130
|
+
const closedReason = conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
|
|
131
|
+
const ended = !!status && TERMINAL_JOB.has(status);
|
|
132
|
+
const finalEnd = ended && FINAL_REASONS.has(closedReason ?? "closed");
|
|
133
|
+
const continuedFrom = job?.conversation?.continued_from_job_id ?? null;
|
|
134
|
+
const continuedBy = conv?.continuedBy ?? job?.conversation?.continued_by_job_id ?? null;
|
|
135
|
+
const convTitle = job?.title ?? conv?.job?.title ?? (jobId ? "对话" : "新对话");
|
|
136
|
+
const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
|
|
137
|
+
const lastTurn = conv?.turns[conv.turns.length - 1];
|
|
138
|
+
const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
|
|
139
|
+
const loading = !!jobId && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
|
|
140
|
+
|
|
141
|
+
// 用量:任务详情里的累计 + 实时流里上次刷新后的增量。
|
|
142
|
+
const fresh = (task ? Math.max(0, task.total_tokens - task.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
|
|
143
|
+
const cacheRead = (task?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
|
|
144
|
+
const resolvedModel = conv?.resolvedModel ?? task?.resolved_model ?? null;
|
|
145
|
+
|
|
146
|
+
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
147
|
+
if (!jobId) return { dotCls: "r", statusText: "未开始" };
|
|
148
|
+
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
149
|
+
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
150
|
+
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
151
|
+
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
152
|
+
return { dotCls: "a", statusText: "进行中" };
|
|
153
|
+
}, [jobId, ended, status, closedReason, awaiting, running]);
|
|
154
|
+
|
|
155
|
+
// ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
|
|
156
|
+
// 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
|
|
157
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
158
|
+
useEffect(() => setActionError(null), [jobId]);
|
|
159
|
+
const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
|
|
160
|
+
try {
|
|
161
|
+
const result = await work;
|
|
162
|
+
setActionError(null);
|
|
163
|
+
return result;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}, []);
|
|
169
|
+
const latest = useRef({ jobId, messages, conv });
|
|
170
|
+
latest.current = { jobId, messages, conv };
|
|
171
|
+
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
172
|
+
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
173
|
+
sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
|
|
174
|
+
const send = useCallback(async (text: string) => {
|
|
175
|
+
const id = latest.current.jobId;
|
|
176
|
+
const target = await store.send(id ?? null, text, sendOpts.current);
|
|
177
|
+
if (target !== id) go(target);
|
|
178
|
+
}, [store, go]);
|
|
179
|
+
const handlers = useMemo<MessageHandlers>(() => ({
|
|
180
|
+
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
181
|
+
onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
182
|
+
const id = latest.current.jobId;
|
|
183
|
+
if (!id) return;
|
|
184
|
+
const sent = askId
|
|
185
|
+
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
|
|
186
|
+
: send(formatAnswersText(questions, answers));
|
|
187
|
+
await report(sent, "回答没有发出去");
|
|
188
|
+
},
|
|
189
|
+
onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.jobId; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
|
|
190
|
+
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
191
|
+
onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
192
|
+
const id = latest.current.jobId;
|
|
193
|
+
if (!id) return "对话还没开始";
|
|
194
|
+
try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
195
|
+
},
|
|
196
|
+
onHandback: readOnly || !store.client.handback ? undefined : async () => {
|
|
197
|
+
const id = latest.current.jobId;
|
|
198
|
+
if (!id) return "对话还没开始";
|
|
199
|
+
try { return await store.client.handback!(id); } catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
200
|
+
},
|
|
201
|
+
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
202
|
+
const list = latest.current.messages;
|
|
203
|
+
const at = list.findIndex((m) => m.id === msgId);
|
|
204
|
+
for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
|
|
205
|
+
},
|
|
206
|
+
}), [readOnly, send, store, go, report]);
|
|
207
|
+
const stop = useCallback(async () => {
|
|
208
|
+
const { jobId: id, conv: c } = latest.current;
|
|
209
|
+
const turn = c ? runningTurn(c) : null;
|
|
210
|
+
if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
|
|
211
|
+
}, [store, report]);
|
|
212
|
+
const [closing, setClosing] = useState<"idle" | "confirm" | "busy">("idle");
|
|
213
|
+
|
|
214
|
+
// ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
|
|
215
|
+
const lastUserTs = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null; return null; }, [messages]);
|
|
216
|
+
const [runSeenAt, setRunSeenAt] = useState<number | null>(null);
|
|
217
|
+
const lastActivityRef = useRef(Date.now());
|
|
218
|
+
const wasRunningRef = useRef(running);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (running && !wasRunningRef.current) { setRunSeenAt(Date.now()); lastActivityRef.current = Date.now(); }
|
|
221
|
+
else if (!running) setRunSeenAt(null);
|
|
222
|
+
wasRunningRef.current = running;
|
|
223
|
+
}, [running]);
|
|
224
|
+
useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
|
|
225
|
+
const turnStartedAt = runSeenAt ?? lastUserTs;
|
|
226
|
+
|
|
227
|
+
// ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
|
|
228
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
229
|
+
const pinned = useRef(true);
|
|
230
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
231
|
+
const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
|
|
232
|
+
const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
|
|
233
|
+
const items = useMemo(() => buildTranscriptItems(committed), [committed]);
|
|
234
|
+
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
235
|
+
const { startIdx, endIdx } = useMemo(() => {
|
|
236
|
+
const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
|
|
237
|
+
const e = find(win.end);
|
|
238
|
+
const last = e >= 0 ? e : items.length - 1;
|
|
239
|
+
let s = find(win.start);
|
|
240
|
+
if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
|
|
241
|
+
return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
|
|
242
|
+
}, [items, win]);
|
|
243
|
+
const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
|
|
244
|
+
const hiddenAbove = useMemo(() => { let n = 0; for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++; return n; }, [items, startIdx]);
|
|
245
|
+
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]);
|
|
246
|
+
const itemsRef = useRef(items); itemsRef.current = items;
|
|
247
|
+
const rangeRef = useRef({ startIdx, endIdx }); rangeRef.current = { startIdx, endIdx };
|
|
248
|
+
const winRef = useRef(win); winRef.current = win;
|
|
249
|
+
type Anchor = { key: string; top: number };
|
|
250
|
+
const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
|
|
251
|
+
|
|
252
|
+
const measure = useCallback((key?: string): Anchor | null => {
|
|
253
|
+
const el = scrollRef.current;
|
|
254
|
+
const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
|
|
255
|
+
return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
|
|
256
|
+
}, []);
|
|
257
|
+
const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
|
|
258
|
+
const its = itemsRef.current;
|
|
259
|
+
let a = s; while (a < its.length && its[a].kind !== "msg") a++;
|
|
260
|
+
let b = e; while (b > 0 && its[b].kind !== "msg") b--;
|
|
261
|
+
const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
|
|
262
|
+
if (winRef.current.start === next.start && winRef.current.end === next.end) return;
|
|
263
|
+
if (anchor) anchorRef.current = { ...anchor, win: next };
|
|
264
|
+
setWin(next);
|
|
265
|
+
}, []);
|
|
266
|
+
const revealEarlier = useCallback(() => {
|
|
267
|
+
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
268
|
+
if (s <= 0) return;
|
|
269
|
+
const ns = Math.max(0, s - RENDER_STEP);
|
|
270
|
+
setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
|
|
271
|
+
}, [measure, setRange]);
|
|
272
|
+
const revealLater = useCallback(() => {
|
|
273
|
+
const its = itemsRef.current;
|
|
274
|
+
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
275
|
+
if (e >= its.length - 1) return;
|
|
276
|
+
const ne = Math.min(its.length - 1, e + RENDER_STEP);
|
|
277
|
+
const ns = Math.max(s, ne - RENDER_MAX + 1);
|
|
278
|
+
setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
|
|
279
|
+
}, [measure, setRange]);
|
|
280
|
+
const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
|
|
281
|
+
revealRef.current = { earlier: revealEarlier, later: revealLater };
|
|
282
|
+
|
|
283
|
+
const onScroll = useCallback(() => {
|
|
284
|
+
const el = scrollRef.current;
|
|
285
|
+
if (!el) return;
|
|
286
|
+
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
|
|
287
|
+
pinned.current = bottom;
|
|
288
|
+
setAtBottom(bottom);
|
|
289
|
+
if (!bottom && winRef.current.start === null) {
|
|
290
|
+
const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
|
|
291
|
+
if (first) setWin((w) => ({ ...w, start: first.key }));
|
|
292
|
+
} else if (bottom && winRef.current.start !== null) {
|
|
293
|
+
setWin({ start: null, end: null });
|
|
294
|
+
}
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
298
|
+
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
|
299
|
+
const hasAbove = hiddenAbove > 0;
|
|
300
|
+
const hasBelow = hiddenBelow > 0;
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
const root = scrollRef.current;
|
|
303
|
+
if (!root || typeof IntersectionObserver === "undefined") return;
|
|
304
|
+
const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
|
|
305
|
+
if (!target) return null;
|
|
306
|
+
const io = new IntersectionObserver((entries) => { if (!pinned.current && entries.some((e) => e.isIntersecting)) go(); }, { root, rootMargin: margin });
|
|
307
|
+
io.observe(target);
|
|
308
|
+
return io;
|
|
309
|
+
};
|
|
310
|
+
const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => revealRef.current.earlier()) : null;
|
|
311
|
+
const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
|
|
312
|
+
return () => { up?.disconnect(); down?.disconnect(); };
|
|
313
|
+
}, [hasAbove, hasBelow]);
|
|
314
|
+
|
|
315
|
+
useLayoutEffect(() => {
|
|
316
|
+
if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
|
|
317
|
+
const el = scrollRef.current;
|
|
318
|
+
if (!el) return;
|
|
319
|
+
if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) setRange(startIdx, startIdx + RENDER_MAX - 1);
|
|
320
|
+
else { const ns = endIdx - RENDER_MAX + 1; setRange(ns, endIdx, measure(itemsRef.current[ns].key)); }
|
|
321
|
+
}, [win, startIdx, endIdx, setRange, measure]);
|
|
322
|
+
|
|
323
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
324
|
+
const el = scrollRef.current;
|
|
325
|
+
if (!el) return;
|
|
326
|
+
pinned.current = true;
|
|
327
|
+
setAtBottom(true);
|
|
328
|
+
if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
|
|
329
|
+
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
// 在「你」的消息之间跳。
|
|
333
|
+
const scrollToUserMsg = useCallback((dir: -1 | 1) => {
|
|
334
|
+
const el = scrollRef.current;
|
|
335
|
+
if (!el) return;
|
|
336
|
+
const base = el.getBoundingClientRect().top;
|
|
337
|
+
const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)")).map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
|
|
338
|
+
const cur = el.scrollTop;
|
|
339
|
+
const candidates = dir < 0 ? tops.filter((t) => t < cur - 8) : tops.filter((t) => t > cur + 8);
|
|
340
|
+
if (!candidates.length) { if (dir < 0) revealRef.current.earlier(); else revealRef.current.later(); }
|
|
341
|
+
if (!tops.length) return;
|
|
342
|
+
const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
|
|
343
|
+
el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
|
|
344
|
+
}, []);
|
|
345
|
+
|
|
346
|
+
useLayoutEffect(() => {
|
|
347
|
+
const a = anchorRef.current;
|
|
348
|
+
const el = scrollRef.current;
|
|
349
|
+
if (a && a.win === win) {
|
|
350
|
+
anchorRef.current = null;
|
|
351
|
+
const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
|
|
352
|
+
if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
|
|
353
|
+
}
|
|
354
|
+
}, [win]);
|
|
355
|
+
useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx]);
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
const el = scrollRef.current;
|
|
358
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
359
|
+
const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
|
|
360
|
+
ro.observe(el);
|
|
361
|
+
return () => ro.disconnect();
|
|
362
|
+
}, []);
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
pinned.current = true;
|
|
365
|
+
setAtBottom(true);
|
|
366
|
+
setWin({ start: null, end: null });
|
|
367
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
368
|
+
}, [jobId]);
|
|
369
|
+
|
|
370
|
+
const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
|
|
371
|
+
const h = cfg.header;
|
|
372
|
+
const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent }), [cfg]);
|
|
373
|
+
const composerOptions = useMemo<ComposerOptions>(() => ({
|
|
374
|
+
placeholderRunning: cfg.composer.placeholderRunning, hints: cfg.composer.hints, charCount: cfg.composer.charCount,
|
|
375
|
+
fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
|
|
376
|
+
}), [cfg]);
|
|
377
|
+
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
378
|
+
const disabledHint = readOnly ? "只读" : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
382
|
+
<div className="session">
|
|
383
|
+
{h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
384
|
+
{(h.crumb || h.export || h.title) && <div className="sh-main">
|
|
385
|
+
{(h.crumb || h.export) && <div className="crumb">
|
|
386
|
+
{h.crumb && <>
|
|
387
|
+
<span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{title[0]}</span>
|
|
388
|
+
{title} <span className="sep">/</span> 对话
|
|
389
|
+
</>}
|
|
390
|
+
{h.export && jobId && allMessages.length > 0 && (
|
|
391
|
+
<button className="crumb-export" onClick={() => exportMarkdown(convTitle, jobId, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
|
|
392
|
+
)}
|
|
393
|
+
</div>}
|
|
394
|
+
{h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
|
|
395
|
+
</div>}
|
|
396
|
+
<div className="sh-tools">
|
|
397
|
+
{h.agent && agent && <AgentBadge label={agentText} />}
|
|
398
|
+
{h.usage && (fresh > 0 || cacheRead > 0) && (
|
|
399
|
+
<span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
|
|
400
|
+
{fmtTokens(fresh)} tok
|
|
401
|
+
{cacheRead > 0 && <> <span className="usage-dot">·</span> <span className="usage-cached">{fmtTokens(cacheRead)} 缓存</span></>}
|
|
402
|
+
</span>
|
|
403
|
+
)}
|
|
404
|
+
{h.model && jobId && (
|
|
405
|
+
<span className="status-ctl" style={{ cursor: "default" }} title="模型由任务中心按产品线配置分配">
|
|
406
|
+
{Icon.spark()}<span className="cv">默认</span>
|
|
407
|
+
{resolvedModel && <span className="model-badge" title={`上次运行于 ${resolvedModel}`}>{modelLabel(resolvedModel)}</span>}
|
|
408
|
+
</span>
|
|
409
|
+
)}
|
|
410
|
+
{h.status && <span className={`status-ctl ${awaiting ? "awaiting" : ""}`} style={{ cursor: "default" }}>
|
|
411
|
+
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
412
|
+
<span className="cv">{statusText}</span>
|
|
413
|
+
</span>}
|
|
414
|
+
{h.close && jobId && allowClose && !readOnly && !ended && (
|
|
415
|
+
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title="结束这个对话:之后不再接收消息,正在跑的一轮会被中止">{Icon.stop()} 结束对话</button>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
</div>}
|
|
419
|
+
|
|
420
|
+
{closing !== "idle" && jobId && (
|
|
421
|
+
<div className="clear-confirm">
|
|
422
|
+
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}。</span>
|
|
423
|
+
<span className="spacer" />
|
|
424
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setClosing("idle")} disabled={closing === "busy"}>取消</button>
|
|
425
|
+
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(jobId), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
{actionError && <div className="sync-banner conflict"><span className="sync-msg">⚠ {actionError}</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => setActionError(null)}>知道了</button></div>}
|
|
429
|
+
{conv?.stream === "reconnecting" && <div className="sync-banner"><span className="sync-msg">实时连接中断,正在重连…{conv.streamError ? `(${conv.streamError})` : ""}</span></div>}
|
|
430
|
+
{conv?.stream === "error" && <div className="sync-banner conflict"><span className="sync-msg">⚠ {conv.streamError}</span></div>}
|
|
431
|
+
|
|
432
|
+
<div className="transcript-wrap">
|
|
433
|
+
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
434
|
+
<div className="tw">
|
|
435
|
+
{!jobId && cfg.empty.show && (
|
|
436
|
+
<div className="hero cli-hero">
|
|
437
|
+
<div className="h-ic">{Icon.bolt()}</div>
|
|
438
|
+
<div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
|
|
439
|
+
<h2>{cfg.empty.title}</h2>
|
|
440
|
+
{cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
{continuedFrom && (
|
|
444
|
+
<div className="msg system info"><div className="msg-body">ℹ 这个对话接续自上一个已结束的对话({continuedFrom})。工作区文件沿用,之前的对话内容没有带进来。
|
|
445
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFrom)}>查看上一个对话</button>
|
|
446
|
+
</div></div>
|
|
447
|
+
)}
|
|
448
|
+
{contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
|
|
449
|
+
{loading && (
|
|
450
|
+
<div aria-hidden>
|
|
451
|
+
<div className="session-label"><span className="ln" />正在加载会话<span className="ln" /></div>
|
|
452
|
+
<div className="msg user" style={{ opacity: .7 }}>
|
|
453
|
+
<div className="who"><Skel w={18} h={18} r={5} /><Skel w={34} h={9} /></div>
|
|
454
|
+
<div className="msg-body"><Skel w="72%" h={12} /><Skel w="46%" h={12} style={{ marginTop: 9 }} /></div>
|
|
455
|
+
</div>
|
|
456
|
+
<div className="msg" style={{ opacity: .7 }}>
|
|
457
|
+
<div className="who"><Skel w={18} h={18} r={5} /><Skel w={70} h={9} /></div>
|
|
458
|
+
<Skel w="90%" h={11} /><Skel w="83%" h={11} style={{ marginTop: 8 }} /><Skel w="58%" h={11} style={{ marginTop: 8 }} />
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
{hasAbove && (
|
|
463
|
+
<div ref={topSentinelRef} className="load-older">
|
|
464
|
+
<button className="btn btn-ghost btn-sm" onClick={revealEarlier}>加载更早的消息(还有 {hiddenAbove} 条)</button>
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
{shown.map((it) =>
|
|
468
|
+
it.kind === "label" ? (
|
|
469
|
+
cfg.transcript.sessionLabel ? <div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div> : null
|
|
470
|
+
) : it.kind === "break" ? (
|
|
471
|
+
<div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
|
|
472
|
+
) : (
|
|
473
|
+
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
474
|
+
<MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />
|
|
475
|
+
</div>
|
|
476
|
+
),
|
|
477
|
+
)}
|
|
478
|
+
{hasBelow && (
|
|
479
|
+
<div ref={bottomSentinelRef} className="load-older">
|
|
480
|
+
<button className="btn btn-ghost btn-sm" onClick={revealLater}>显示后面的消息(还有 {hiddenBelow} 条)</button>
|
|
481
|
+
<button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
485
|
+
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
486
|
+
{ended && (
|
|
487
|
+
<div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
|
|
488
|
+
)}
|
|
489
|
+
{continuedBy && (
|
|
490
|
+
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
{cfg.transcript.nav && <div className="msg-nav">
|
|
495
|
+
<button className="msg-nav-btn" onClick={() => scrollToUserMsg(-1)} title="上一条消息" aria-label="滚动到上一条消息">{Icon.chevUp()}</button>
|
|
496
|
+
<button className="msg-nav-btn" onClick={() => scrollToUserMsg(1)} title="下一条消息" aria-label="滚动到下一条消息">{Icon.chevDown()}</button>
|
|
497
|
+
{!atBottom && <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">{Icon.toBottom()}</button>}
|
|
498
|
+
</div>}
|
|
499
|
+
</div>
|
|
500
|
+
{!readOnly && (
|
|
501
|
+
<Composer
|
|
502
|
+
key={jobId ?? "new"}
|
|
503
|
+
draft={jobId ?? `new:${instanceId}`}
|
|
504
|
+
placeholder={fillTemplate(jobId ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
505
|
+
disabled={composerDisabled}
|
|
506
|
+
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
507
|
+
running={running}
|
|
508
|
+
onSend={send}
|
|
509
|
+
onStop={cfg.composer.stop && jobId && conv && runningTurn(conv) ? stop : undefined}
|
|
510
|
+
options={composerOptions}
|
|
511
|
+
/>
|
|
512
|
+
)}
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
}
|