@spzxg/cli-ui 0.6.11 → 0.6.12
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 +9 -1
- package/README.md +9 -3
- package/dist/{cli-ui-embed-CbxWozLO.js → cli-ui-embed-B0HDalUb.js} +13958 -13716
- package/dist/{cli-ui-index-ImgEunSf.js → cli-ui-index-B0juE2Jq.js} +2 -2
- package/dist/{cli-ui-jszip.min-DWmufeZi.js → cli-ui-jszip.min-BBS0YWg7.js} +1 -1
- package/dist/{cli-ui-jszip.min-DGdGLbxr.js → cli-ui-jszip.min-CSeehR5Z.js} +2 -2
- package/dist/{cli-ui-xlsx-s2TcknA1.js → cli-ui-xlsx-Bk3fo5jQ.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/chat/CliChat.tsx +13 -2
- package/src/core/client.ts +4 -0
- package/src/core/conversation.ts +10 -3
- package/src/core/subagents.ts +78 -0
- package/src/core/types.ts +12 -2
- package/src/operator/node/Chat.tsx +10 -4
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/modals.tsx +13 -2
- package/src/operator/node/protocol/types.ts +5 -2
- package/src/operator/node/useTaskStream.ts +8 -4
- package/src/operator.base.css +29 -0
- package/src/operator.css +29 -0
- package/src/ui/SubagentChat.tsx +197 -0
- package/src/ui/Transcript.tsx +5 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import type { Msg } from "../core/types";
|
|
3
|
+
import { childMessages, subagentFields, type LoadSubagent, type SubagentInfo, type SubagentTranscript } from "../core/subagents";
|
|
4
|
+
import { Icon } from "./icons";
|
|
5
|
+
|
|
6
|
+
const STATUS = { running: "运行中", completed: "已完成", failed: "失败", cancelled: "已取消", unknown: "状态未知" };
|
|
7
|
+
type Selection = { scope: string; generation: number; info: SubagentInfo };
|
|
8
|
+
const SubagentContext = createContext<((info: SubagentInfo, generation: number) => void) | null>(null);
|
|
9
|
+
|
|
10
|
+
export function SubagentHistoryBoundary({ children }: { children: ReactNode }) { return <SubagentContext.Provider value={null}>{children}</SubagentContext.Provider>; }
|
|
11
|
+
|
|
12
|
+
export function SubagentEntry({ info, generation }: { info: SubagentInfo; generation: number }) {
|
|
13
|
+
const open = useContext(SubagentContext);
|
|
14
|
+
return <div className="msg msg-tool">
|
|
15
|
+
<button type="button" className="subagent-entry" disabled={!open} onClick={() => open?.(info, generation)} aria-label={`查看子代理:${info.title}`}>
|
|
16
|
+
<span className="subagent-entry-icon">{Icon.spark()}</span>
|
|
17
|
+
<span className="subagent-entry-name">{info.title}</span>
|
|
18
|
+
<span className={`subagent-status ${info.status}`}>{STATUS[info.status] ?? STATUS.unknown}</span>
|
|
19
|
+
{Icon.chevRight()}
|
|
20
|
+
</button>
|
|
21
|
+
</div>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One shared viewer for embedded Chat and the native Operator chat. It never controls execution. */
|
|
25
|
+
export function SubagentChatScope({ scopeKey, messages, load, renderMessage, children }: {
|
|
26
|
+
scopeKey: string; messages: Msg[]; load?: LoadSubagent;
|
|
27
|
+
renderMessage: (message: Msg, agentName: string) => ReactNode; children: ReactNode;
|
|
28
|
+
}) {
|
|
29
|
+
const [selection, setSelection] = useState<Selection | null>(null);
|
|
30
|
+
const selected = selection?.scope === scopeKey ? selection : null;
|
|
31
|
+
const open = useCallback((info: SubagentInfo, generation: number) => setSelection({ scope: scopeKey, generation, info }), [scopeKey]);
|
|
32
|
+
const close = useCallback(() => setSelection(null), []);
|
|
33
|
+
const root = useRef<HTMLDivElement>(null);
|
|
34
|
+
const [narrow, setNarrow] = useState(true);
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
const element = root.current;
|
|
37
|
+
if (!element) return;
|
|
38
|
+
const measure = () => { const width = element.getBoundingClientRect().width; if (width > 0) setNarrow(width < 900); };
|
|
39
|
+
measure();
|
|
40
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
41
|
+
observer?.observe(element);
|
|
42
|
+
return () => observer?.disconnect();
|
|
43
|
+
}, []);
|
|
44
|
+
return <SubagentContext.Provider value={open}>
|
|
45
|
+
<div ref={root} className={`subagent-chat-scope${selected ? " has-subagent" : ""}${narrow ? " narrow" : ""}`}>
|
|
46
|
+
<div className="subagent-chat-main" inert={!!selected && narrow || undefined}>{children}</div>
|
|
47
|
+
{selected && <SubagentPanel key={`${scopeKey}:${selected.generation}:${selected.info.id}`} selection={selected} messages={messages} load={load} renderMessage={renderMessage} onClose={close} modal={narrow} />}
|
|
48
|
+
</div>
|
|
49
|
+
</SubagentContext.Provider>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function SubagentPanel({ selection, messages, load, renderMessage, onClose, modal }: {
|
|
53
|
+
selection: Selection; messages: Msg[]; load?: LoadSubagent;
|
|
54
|
+
renderMessage: (message: Msg, agentName: string) => ReactNode; onClose: () => void; modal: boolean;
|
|
55
|
+
}) {
|
|
56
|
+
const [snapshot, setSnapshot] = useState<(SubagentTranscript & { before?: string }) | null>(null);
|
|
57
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
58
|
+
const [loading, setLoading] = useState(!!load);
|
|
59
|
+
const [error, setError] = useState<string | null>(null);
|
|
60
|
+
const [attempt, setAttempt] = useState(0);
|
|
61
|
+
const panel = useRef<HTMLElement>(null);
|
|
62
|
+
const closeButton = useRef<HTMLButtonElement>(null);
|
|
63
|
+
const scroll = useRef<HTMLDivElement>(null);
|
|
64
|
+
const follow = useRef(true);
|
|
65
|
+
const [following, setFollowing] = useState(true);
|
|
66
|
+
const [shownCount, setShownCount] = useState(120);
|
|
67
|
+
const { id } = selection.info;
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let disposed = false;
|
|
71
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
72
|
+
if (!load) { setLoading(false); return; }
|
|
73
|
+
setLoading(true); setError(null);
|
|
74
|
+
const refresh = async (initial = false) => {
|
|
75
|
+
if (disposed) return;
|
|
76
|
+
if (!initial && document.visibilityState === "hidden") { timer = setTimeout(() => void refresh(), 5000); return; }
|
|
77
|
+
try {
|
|
78
|
+
const value = await load(id, selection.generation);
|
|
79
|
+
if (value.subagent?.id !== id || !Array.isArray(value.messages)) throw new Error("子代理记录与当前选择不匹配");
|
|
80
|
+
if (disposed) return;
|
|
81
|
+
setSnapshot(previous => {
|
|
82
|
+
const overlap = previous?.messages.some(old => value.messages.some(row => row.id === old.id));
|
|
83
|
+
const byId = new Map((previous?.messages ?? []).map(row => [row.id, row]));
|
|
84
|
+
for (const row of value.messages) byId.set(row.id, row);
|
|
85
|
+
return { ...value, messages: [...byId.values()],
|
|
86
|
+
older: overlap ? previous?.older ?? value.older : value.older,
|
|
87
|
+
before: overlap ? previous?.before : value.messages[0]?.id };
|
|
88
|
+
}); setError(null);
|
|
89
|
+
if (value.refreshable && ["running", "unknown"].includes(value.subagent.status)) timer = setTimeout(() => void refresh(), 5000);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
if (!disposed) setError(cause instanceof Error ? cause.message : String(cause));
|
|
92
|
+
} finally { if (!disposed) setLoading(false); }
|
|
93
|
+
};
|
|
94
|
+
void refresh(true);
|
|
95
|
+
return () => { disposed = true; if (timer) clearTimeout(timer); };
|
|
96
|
+
}, [load, id, selection.generation, attempt]);
|
|
97
|
+
|
|
98
|
+
const info = useMemo(() => {
|
|
99
|
+
let latest = snapshot?.subagent ?? selection.info;
|
|
100
|
+
for (const message of messages) {
|
|
101
|
+
if (message.generation !== selection.generation) continue;
|
|
102
|
+
const candidate = subagentFields(message).subagent;
|
|
103
|
+
if (candidate?.id === id && (candidate.updatedAt ?? 0) >= (latest.updatedAt ?? 0)) latest = candidate;
|
|
104
|
+
}
|
|
105
|
+
return latest;
|
|
106
|
+
}, [messages, snapshot, selection, id]);
|
|
107
|
+
const rows = useMemo(() => {
|
|
108
|
+
const merged = new Map((snapshot?.messages ?? []).map(row => [row.id, row]));
|
|
109
|
+
for (const row of childMessages(messages, id, selection.generation)) {
|
|
110
|
+
const previous = merged.get(row.id);
|
|
111
|
+
const updated = (value: typeof row) => {
|
|
112
|
+
const fields = subagentFields(value);
|
|
113
|
+
return fields.subagentMessage?.updatedAt ?? fields.subagent?.updatedAt ?? 0;
|
|
114
|
+
};
|
|
115
|
+
if (!previous || updated(row) >= updated(previous)) merged.set(row.id, row);
|
|
116
|
+
}
|
|
117
|
+
return childMessages([...merged.values()], id, selection.generation).sort((a, b) => (a.ts ?? a.created_at ?? 0) - (b.ts ?? b.created_at ?? 0)).map(row => {
|
|
118
|
+
const child = subagentFields(row).subagentMessage;
|
|
119
|
+
return { id: row.id, generation: row.generation, ts: row.ts ?? row.created_at,
|
|
120
|
+
role: child?.role ?? "tool", content: child?.tool ? JSON.stringify(child.tool) : child?.content ?? row.content } as Msg;
|
|
121
|
+
});
|
|
122
|
+
}, [snapshot, messages, id, selection.generation]);
|
|
123
|
+
const shown = rows.slice(-shownCount);
|
|
124
|
+
const promptAlreadyShown = rows.some(row => row.role === "user" && row.content === info.task);
|
|
125
|
+
const loadOlder = async () => {
|
|
126
|
+
if (rows.length > shownCount) { setShownCount(value => value + 120); return; }
|
|
127
|
+
if (!load || !snapshot?.before || loadingOlder) return;
|
|
128
|
+
const before = snapshot.before;
|
|
129
|
+
setLoadingOlder(true); setError(null);
|
|
130
|
+
try {
|
|
131
|
+
const value = await load(id, selection.generation, before);
|
|
132
|
+
if (value.subagent?.id !== id || !Array.isArray(value.messages)) throw new Error("子代理记录与当前选择不匹配");
|
|
133
|
+
setSnapshot(previous => {
|
|
134
|
+
const byId = new Map(value.messages.map(row => [row.id, row]));
|
|
135
|
+
for (const row of previous?.messages ?? []) byId.set(row.id, row);
|
|
136
|
+
return { ...(previous ?? value), messages: [...byId.values()],
|
|
137
|
+
...(previous?.before === before ? { older: value.older, before: value.messages[0]?.id ?? before } : {}) };
|
|
138
|
+
});
|
|
139
|
+
setShownCount(count => count + 200);
|
|
140
|
+
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
|
141
|
+
finally { setLoadingOlder(false); }
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
146
|
+
closeButton.current?.focus({ preventScroll: true });
|
|
147
|
+
return () => {
|
|
148
|
+
if ((document.activeElement === document.body || panel.current?.contains(document.activeElement)) && previous?.isConnected && !previous.closest("[inert],[hidden]")) previous.focus({ preventScroll: true });
|
|
149
|
+
};
|
|
150
|
+
}, []);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const onKey = (event: KeyboardEvent) => {
|
|
153
|
+
if (event.defaultPrevented) return;
|
|
154
|
+
const other = (document.activeElement as HTMLElement | null)?.closest('[role="dialog"]');
|
|
155
|
+
if (other && other !== panel.current) return;
|
|
156
|
+
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
|
|
157
|
+
if (!modal || event.key !== "Tab" || !panel.current) return;
|
|
158
|
+
const items = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled),a[href],input:not(:disabled),textarea:not(:disabled),[tabindex="0"]')]
|
|
159
|
+
.filter(item => !item.closest("[hidden],[inert]") && getComputedStyle(item).display !== "none");
|
|
160
|
+
const first = items[0], last = items.at(-1);
|
|
161
|
+
if (!first || !last) return;
|
|
162
|
+
if (!panel.current.contains(document.activeElement) || (event.shiftKey && document.activeElement === first)) { event.preventDefault(); (event.shiftKey ? last : first).focus(); }
|
|
163
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
164
|
+
};
|
|
165
|
+
window.addEventListener("keydown", onKey);
|
|
166
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
167
|
+
}, [modal, onClose]);
|
|
168
|
+
useLayoutEffect(() => { if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; }, [rows, loading]);
|
|
169
|
+
|
|
170
|
+
return <>
|
|
171
|
+
{modal && <button className="subagent-chat-scrim" type="button" tabIndex={-1} aria-label="收起子代理详情" onClick={onClose} />}
|
|
172
|
+
<aside ref={panel} className="subagent-chat-panel" role="dialog" aria-modal={modal || undefined} aria-label={`子代理:${info.title}`}>
|
|
173
|
+
<div className="subagent-chat-header">
|
|
174
|
+
<div><span className="subagent-chat-caption">子代理</span><strong>{info.title}</strong></div>
|
|
175
|
+
<span className={`subagent-status ${info.status}`}>{STATUS[info.status] ?? STATUS.unknown}</span>
|
|
176
|
+
<button ref={closeButton} type="button" className="icon-btn" title="关闭子代理详情" aria-label="关闭子代理详情" onClick={onClose}>{Icon.x()}</button>
|
|
177
|
+
</div>
|
|
178
|
+
<div ref={scroll} className="subagent-chat-transcript transcript" onScroll={() => {
|
|
179
|
+
const element = scroll.current;
|
|
180
|
+
follow.current = !!element && element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
|
181
|
+
setFollowing(follow.current);
|
|
182
|
+
}}>
|
|
183
|
+
<div className="tw">
|
|
184
|
+
{loading && <div className="subagent-chat-note" role="status">正在读取子代理记录…</div>}
|
|
185
|
+
{error && <div className="subagent-chat-note error" role="alert">读取历史失败:{error} <button className="btn btn-ghost btn-sm" onClick={() => setAttempt(value => value + 1)}>重试</button></div>}
|
|
186
|
+
{!load && <div className="subagent-chat-note">当前接入未提供子代理历史接口,以下为已加载的记录。</div>}
|
|
187
|
+
{info.detailAvailability !== "full" && <div className="subagent-chat-note">{info.detailReason || (info.detailAvailability === "unavailable" ? "此智能体暂未提供子代理运行过程。" : "此智能体仅提供了部分运行记录。")}</div>}
|
|
188
|
+
{info.task && !promptAlreadyShown && renderMessage({ id: `prompt:${id}`, role: "user", content: info.task, generation: selection.generation }, info.title)}
|
|
189
|
+
{(rows.length > shownCount || (snapshot?.older ?? 0) > 0) && <div className="load-older"><button type="button" className="btn btn-ghost btn-sm" disabled={loadingOlder} onClick={() => { follow.current = false; setFollowing(false); void loadOlder(); }}>{loadingOlder ? "正在读取更早的记录…" : "加载更早的子代理记录"}</button></div>}
|
|
190
|
+
{shown.map(row => <div key={row.id}>{renderMessage(row, info.title)}</div>)}
|
|
191
|
+
{!loading && !error && !rows.length && <div className="subagent-chat-note">尚无可展示的运行记录。</div>}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{!following && <button type="button" className="btn btn-ghost btn-sm subagent-chat-latest" onClick={() => { follow.current = true; setFollowing(true); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; }}>跳到最新</button>}
|
|
195
|
+
</aside>
|
|
196
|
+
</>;
|
|
197
|
+
}
|
package/src/ui/Transcript.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// 转录里的一行。移植自 operator-oss/app/orchestrator/Transcript.tsx,类名、结构、文案保持一致;
|
|
2
2
|
// 去掉了对话任务用不到的部分(/clear、重新登录、浏览器交还……这些动作 center 不提供)。
|
|
3
|
+
import { subagentFields } from "../core/subagents";
|
|
4
|
+
import { SubagentEntry } from "./SubagentChat";
|
|
3
5
|
import { memo, useContext, useId, useState } from "react";
|
|
4
6
|
import { FileOpenContext, FileLinkContext } from "./fileLinks";
|
|
5
7
|
import type { AskAnswers, AskQuestion, InputRequest, Msg, ToolData, ToolPeek } from "../core/types";
|
|
@@ -370,6 +372,9 @@ export const MessageView = memo(function MessageView({ m, initial: isInitial, hi
|
|
|
370
372
|
}
|
|
371
373
|
if (m.role === "tool") {
|
|
372
374
|
const data = toolDataOf(m);
|
|
375
|
+
const child = subagentFields(m);
|
|
376
|
+
if (child.subagentMessage) return null;
|
|
377
|
+
if (child.subagent) return <SubagentEntry info={child.subagent} generation={m.generation} />;
|
|
373
378
|
if (data.input) return <div className="msg msg-tool"><InputView input={data.input} agentLabel={agentLabel} onSubmit={handlers.onInput} onBrowser={handlers.onShowBrowser} /></div>;
|
|
374
379
|
if (data.ask) return <div className="msg msg-tool"><AskView data={data} agentLabel={agentLabel} onAnswer={handlers.onAnswer ? (answers) => handlers.onAnswer!(data.ask?.id || m.toolId || "", data.ask?.questions ?? [], answers) : undefined} /></div>;
|
|
375
380
|
return <div className="msg msg-tool"><ToolView data={data} /></div>;
|