@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/src/styles.css ADDED
@@ -0,0 +1,50 @@
1
+ /* @spzxg/cli-ui 样式入口:Operator 原样式(作用域在 .cli-ui 内)+ 组件自己的补充。
2
+ 用法:import "@spzxg/cli-ui/styles.css";
3
+ 字体:沿用 Operator 的 Space Grotesk / JetBrains Mono,宿主页面没加载时退回系统字体。 */
4
+ @import "./operator.css";
5
+
6
+ /* ── 组件根容器:填满宿主给的空间 ── */
7
+ .cli-ui.cli-msglist,
8
+ .cli-ui.cli-chat{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;}
9
+ .cli-ui.cli-msglist .col-tasks{flex:1 1 auto;min-height:0;border-right:none;}
10
+ .cli-ui.cli-chat .session{height:100%;}
11
+ .cli-ui .task .tdesc{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
12
+
13
+ /* 新对话的空白页:放在转录区里,不撑满高度 */
14
+ .cli-ui .cli-hero{flex:none;padding:60px 20px 20px;}
15
+
16
+ /* ── 文本块(文件 / 长粘贴):输入框和转录共用 ── */
17
+ .cli-ui .cli-blocks{display:flex;flex-direction:column;gap:8px;padding:10px 12px 0;max-height:46vh;overflow-y:auto;}
18
+ .cli-ui .cli-blocks .attach-chip.file{max-width:100%;align-self:flex-start;}
19
+ .cli-ui .cli-blocks .attach-chip.file .attach-badge{position:static;margin-left:8px;}
20
+ .cli-ui .cli-block{border:1px solid var(--line-strong);border-radius:var(--r);background:var(--surface-2);overflow:hidden;}
21
+ .cli-ui .msg-body .cli-block{margin:6px 0;background:var(--surface);}
22
+ .cli-ui .cli-block-h{display:flex;align-items:center;gap:8px;padding:7px 8px 7px 10px;font-size:12.5px;}
23
+ .cli-ui .cli-block-ic{display:inline-flex;color:var(--ink-3);}
24
+ .cli-ui .cli-block-ic svg{width:14px;height:14px;}
25
+ .cli-ui .cli-block-name{font-weight:600;color:var(--ink);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26
+ .cli-ui .cli-block-meta{font-family:var(--mono);font-size:11px;color:var(--ink-4);white-space:nowrap;}
27
+ .cli-ui .cli-block-sp{flex:1;}
28
+ .cli-ui .cli-block-btn{font-size:12px;color:var(--accent);padding:2px 8px;border-radius:6px;}
29
+ .cli-ui .cli-block-btn:hover{background:var(--accent-soft);}
30
+ .cli-ui .cli-block-x{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:6px;color:var(--ink-3);}
31
+ .cli-ui .cli-block-x:hover{background:var(--row-sel);color:var(--ink);}
32
+ .cli-ui .cli-block-x svg{width:13px;height:13px;}
33
+ .cli-ui .cli-block-body{margin:0;padding:8px 12px 10px;border-top:1px solid var(--line-2);font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:calc(4.65em + 18px);overflow:hidden;}
34
+ .cli-ui .cli-block.open .cli-block-body{max-height:360px;overflow-y:auto;}
35
+ .cli-ui .cli-block-more{display:block;width:100%;padding:6px;font-size:12px;color:var(--accent);border-top:1px solid var(--line-2);}
36
+ .cli-ui .cli-block-more:hover{background:var(--accent-soft);}
37
+
38
+ /* ── 很长的消息正文:折叠显示开头 ── */
39
+ .cli-ui .cli-long-pre{white-space:pre-wrap;word-break:break-word;font-size:inherit;font-family:inherit;margin:0;}
40
+ .cli-ui .cli-long-pre.clamp{max-height:10.5em;overflow:hidden;-webkit-mask-image:linear-gradient(#000 60%,transparent);mask-image:linear-gradient(#000 60%,transparent);}
41
+ .cli-ui .cli-long-btn{margin-top:6px;font-size:12px;color:var(--accent);padding:2px 0;}
42
+ .cli-ui .cli-long-btn:hover{text-decoration:underline;}
43
+
44
+ /* ── 输入框补充 ── */
45
+ .cli-ui .cli-comp-err{margin:0 0 8px;padding:7px 11px;border-radius:var(--r-sm);font-size:12.5px;color:var(--red);background:var(--red-soft);}
46
+ .cli-ui .cli-count{font-family:var(--mono);}
47
+ .cli-ui .cli-count.over{color:var(--red);}
48
+
49
+ /* 排队气泡下的等待原因(中枢给的:节点连不上、没有可用节点……) */
50
+ .cli-ui .cli-queued-note{margin-top:6px;font-size:12px;color:var(--amber);}
@@ -0,0 +1,26 @@
1
+ import { memo, type ComponentProps } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeHighlight from "rehype-highlight";
5
+
6
+ // 渲染 Agent 的 markdown 输出:标题、列表、表格、带语言的代码块(高亮)、行内代码、链接。
7
+ // 与 Operator 的 Markdown.tsx 一致:只高亮写明语言的代码块(自动识别在流式输出时很耗),
8
+ // 并 memo,文字没变的消息重渲染时跳过整个解析。
9
+ // Operator 里本地文件链接会改走任务的文件接口;这里没有文件接口(见 PLAN.md 的看板组件),链接原样打开。
10
+ function MdLink(props: ComponentProps<"a">) {
11
+ return <a {...props} target="_blank" rel="noreferrer" />;
12
+ }
13
+
14
+ export const Markdown = memo(function Markdown({ children }: { children: string }) {
15
+ return (
16
+ <div className="md">
17
+ <ReactMarkdown
18
+ remarkPlugins={[remarkGfm]}
19
+ rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
20
+ components={{ a: MdLink }}
21
+ >
22
+ {children}
23
+ </ReactMarkdown>
24
+ </div>
25
+ );
26
+ });
@@ -0,0 +1,49 @@
1
+ import { memo, useState } from "react";
2
+ import { charCount, type TextBlock } from "../core/blocks.js";
3
+ import { fmtCount } from "./format.js";
4
+ import { Icon } from "./icons.js";
5
+
6
+ /** 展开后一次最多渲染这么多字;更长的再点「显示全部」,避免一次塞几十万字进 DOM。 */
7
+ const EXPANDED_STEP = 20_000;
8
+ const PREVIEW_LINES = 3;
9
+
10
+ /**
11
+ * 文本块卡片:输入框里待发送的文件 / 长粘贴,和转录里已发出的附件,用同一个样子。
12
+ * 默认只显示开头几行,点开才显示正文(分段渲染),长内容不会把页面卡住。
13
+ */
14
+ export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: { block: TextBlock; onRemove?: () => void }) {
15
+ const [open, setOpen] = useState(false);
16
+ const [limit, setLimit] = useState(EXPANDED_STEP);
17
+ const chars = charCount(block.text);
18
+ const lines = block.text ? block.text.split("\n").length : 0;
19
+ const preview = block.text.split("\n", PREVIEW_LINES + 1).slice(0, PREVIEW_LINES).join("\n").slice(0, 400);
20
+ const shown = open ? block.text.slice(0, limit) : preview;
21
+ const rest = block.text.length - limit;
22
+ return (
23
+ <div className={`cli-block ${open ? "open" : ""}`}>
24
+ <div className="cli-block-h">
25
+ <span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : Icon.clip()}</span>
26
+ <span className="cli-block-name" title={block.name}>{block.name}</span>
27
+ <span className="cli-block-meta">{fmtCount(chars)} 字 · {fmtCount(lines)} 行</span>
28
+ <span className="cli-block-sp" />
29
+ <button type="button" className="cli-block-btn" onClick={() => { setOpen((o) => !o); setLimit(EXPANDED_STEP); }}>
30
+ {open ? "收起" : "展开"}
31
+ </button>
32
+ {onRemove && <button type="button" className="cli-block-x" title="移除" aria-label={`移除 ${block.name}`} onClick={onRemove}>{Icon.x()}</button>}
33
+ </div>
34
+ <pre className="cli-block-body">{shown}{!open && block.text.length > preview.length ? "…" : ""}</pre>
35
+ {open && rest > 0 && (
36
+ <button type="button" className="cli-block-more" onClick={() => setLimit((n) => n + EXPANDED_STEP * 5)}>
37
+ 还有 {fmtCount(rest)} 字,继续显示
38
+ </button>
39
+ )}
40
+ </div>
41
+ );
42
+ });
43
+
44
+ /** 很长的普通文字(没走附件格式、直接打进来的):折叠显示开头,点开看全部。 */
45
+ export const LONG_MESSAGE_CHARS = 3_000;
46
+ export const LONG_MESSAGE_LINES = 40;
47
+ export function isLongMessage(text: string) {
48
+ return text.length > LONG_MESSAGE_CHARS || text.split("\n", LONG_MESSAGE_LINES + 1).length > LONG_MESSAGE_LINES;
49
+ }
@@ -0,0 +1,408 @@
1
+ // 转录里的一行。移植自 operator-oss/app/orchestrator/Transcript.tsx,类名、结构、文案保持一致;
2
+ // 去掉了对话任务用不到的部分(/clear、重新登录、浏览器交还……这些动作 center 不提供)。
3
+ import { memo, useState } from "react";
4
+ import type { AskAnswers, AskQuestion, InputRequest, Msg, ToolData, ToolPeek } from "../core/types.js";
5
+ import { splitMessage } from "../core/blocks.js";
6
+ import { Icon } from "./icons.js";
7
+ import { Markdown } from "./Markdown.js";
8
+ import { clockTime, diffCls, splitAttachments, type MsgAttachment } from "./format.js";
9
+ import { Avatar } from "./shared.js";
10
+ import { toolDataOf } from "./transcriptModel.js";
11
+ import { TextBlockCard, isLongMessage } from "./TextBlock.js";
12
+
13
+ // Operator 写进转录的几类系统提示(取开头一段做匹配):按「上下文超限」那种醒目样式显示。
14
+ const LOUD_NOTICES = [
15
+ "本次会话的上下文已超出模型上限",
16
+ "该智能体的连接已失效",
17
+ "该智能体的用量已达上限",
18
+ "智能体的审批策略拦截了这一轮",
19
+ ];
20
+ const APPROVAL_BLOCKED = LOUD_NOTICES[3];
21
+
22
+ function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable: boolean; onExpand: () => void }) {
23
+ const corner = <span className="tcorner">⎿</span>;
24
+ if (peek.kind === "count") {
25
+ return (
26
+ <button className="tpeek tpeek-count" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && onExpand()}>
27
+ {corner}<span className="tpeek-txt">{peek.text}</span>
28
+ {expandable && <span className="tpeek-more">展开</span>}
29
+ </button>
30
+ );
31
+ }
32
+ if (peek.kind === "todos") {
33
+ return (
34
+ <div className="tpeek tpeek-todos">
35
+ {peek.items.map((t, i) => (
36
+ <div className={`tdo ${t.status}`} key={i}>
37
+ <span className="tdo-box">{t.status === "completed" ? "✔" : t.status === "in_progress" ? "▣" : "▢"}</span>
38
+ <span className="tdo-txt">{t.text}</span>
39
+ </div>
40
+ ))}
41
+ </div>
42
+ );
43
+ }
44
+ if (peek.kind === "diff") {
45
+ return (
46
+ <div className="tpeek tpeek-diff">
47
+ <div className="tpeek-sum">{corner}<span className="dstat add">+{peek.added}</span><span className="dstat del">−{peek.removed}</span>{peek.label && <span className="tpeek-txt">{peek.label}</span>}</div>
48
+ <pre className="tpeek-pre diff">{peek.lines.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
49
+ {peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
50
+ </div>
51
+ );
52
+ }
53
+ return (
54
+ <div className="tpeek tpeek-lines">
55
+ {peek.label && <div className="tpeek-sum">{corner}<span className="tpeek-txt">{peek.label}</span></div>}
56
+ <pre className="tpeek-pre">{peek.lines.join("\n") || "(无输出)"}</pre>
57
+ {peek.truncated ? <button className="tpeek-more btn-link" onClick={onExpand}>还有 {peek.truncated} 行</button> : null}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function ToolView({ data }: { data: ToolData }) {
63
+ const [open, setOpen] = useState(false);
64
+ const hasDiff = !!data.diff?.length;
65
+ const expandable = !!(data.detail || hasDiff || data.result !== undefined);
66
+ // 失败的工具自动展开输出,和 Claude Code 一样。
67
+ const showBody = open || (!!data.isError && data.result !== undefined);
68
+ return (
69
+ <div className="tool">
70
+ <button className="tool-h" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen((o) => !o)}>
71
+ {expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
72
+ <span className="tg">{data.title}</span>
73
+ {data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
74
+ </button>
75
+ {data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
76
+ {showBody && (
77
+ <div className="tool-body">
78
+ {data.detail && <pre className="tool-pre">{data.detail}</pre>}
79
+ {hasDiff && <pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>}
80
+ {data.result !== undefined && (
81
+ <>
82
+ {(data.detail || hasDiff) && <div className="tool-divider">结果</div>}
83
+ <pre className={`tool-pre ${data.isError ? "err" : ""}`}>{data.result || "(无输出)"}</pre>
84
+ </>
85
+ )}
86
+ </div>
87
+ )}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ // AskUserQuestion 卡片。对话任务里回答 = 发下一条消息(节点上的回合已经结束),所以提交后由上层组成文字发出。
93
+ function AskView({ data, agentLabel, onAnswer }: { data: ToolData; agentLabel: string; onAnswer?: (answers: AskAnswers) => Promise<void> | void }) {
94
+ const questions = data.ask?.questions ?? [];
95
+ const existing = data.ask?.answers;
96
+ const [state, setState] = useState(() => questions.map(() => ({ picked: [] as string[], other: "" })));
97
+ const [submitted, setSubmitted] = useState(false);
98
+
99
+ if (existing) {
100
+ return (
101
+ <div className="ask answered">
102
+ <div className="ask-head">{Icon.spark()} 你的回答</div>
103
+ {questions.map((q, i) => (
104
+ <div className="ask-q" key={i}>
105
+ <div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}</div>
106
+ <div className="ask-picked">{(existing[i] ?? []).join("、") || "—"}</div>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ const toggle = (qi: number, label: string, multi: boolean) =>
114
+ setState((s) => s.map((st, i) => {
115
+ if (i !== qi) return st;
116
+ if (multi) {
117
+ const has = st.picked.includes(label);
118
+ return { ...st, picked: has ? st.picked.filter((l) => l !== label) : [...st.picked, label] };
119
+ }
120
+ return { picked: [label], other: "" };
121
+ }));
122
+ const setOther = (qi: number, v: string) =>
123
+ setState((s) => s.map((st, i) => (i === qi ? (questions[i].multiSelect ? { ...st, other: v } : { picked: [], other: v }) : st)));
124
+
125
+ const answers: AskAnswers = state.map((st) => [...st.picked, ...(st.other.trim() ? [st.other.trim()] : [])]);
126
+ const complete = answers.every((a) => a.length > 0);
127
+ // 发送失败时恢复成可提交(选择保留),不让卡片卡在「发送中…」。
128
+ const submit = () => {
129
+ if (!complete || submitted || !onAnswer) return;
130
+ setSubmitted(true);
131
+ void Promise.resolve(onAnswer(answers)).catch(() => setSubmitted(false));
132
+ };
133
+
134
+ return (
135
+ <div className="ask">
136
+ <div className="ask-head">{Icon.spark()} {agentLabel} 需要你的输入</div>
137
+ {questions.map((q, i) => (
138
+ <div className="ask-q" key={i}>
139
+ <div className="ask-qh"><span className="ask-chip">{q.header}</span>{q.question}{q.multiSelect && <span className="ask-multi">可多选</span>}</div>
140
+ <div className="ask-opts">
141
+ {q.options.map((o) => (
142
+ <button key={o.label} className={`ask-opt ${state[i].picked.includes(o.label) ? "on" : ""}`} onClick={() => toggle(i, o.label, !!q.multiSelect)} disabled={submitted}>
143
+ <span className="ask-opt-l">{o.label}</span>
144
+ {o.description && <span className="ask-opt-d">{o.description}</span>}
145
+ </button>
146
+ ))}
147
+ <input className="ask-other" placeholder="其他…" value={state[i].other} disabled={submitted} onChange={(e) => setOther(i, e.target.value)} />
148
+ </div>
149
+ </div>
150
+ ))}
151
+ <div className="ask-foot">
152
+ <button className="btn btn-accent btn-sm" onClick={submit} disabled={!complete || submitted || !onAnswer}>{submitted ? "发送中…" : "发送回答"}</button>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ // browser_ask_user 卡片:移植自 Operator 的 InputView(已答 / 已取消 / 等待中)。
159
+ // 能不能在这里提交取决于数据后端:Operator 后端可以直接把值交给浏览器脚本(onSubmit);
160
+ // center 的对话任务在 Agent 等输入时会结束那一轮,卡片随即变成「已取消」,这里不提供提交,只显示等待中的字段。
161
+ function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputRequest; agentLabel: string; onSubmit?: (id: string, values: string[]) => Promise<string | null>; onBrowser?: () => void }) {
162
+ const [vals, setVals] = useState<string[]>(() => input.fields.map(() => ""));
163
+ const [shown, setShown] = useState<boolean[]>(() => input.fields.map(() => false));
164
+ const [busy, setBusy] = useState(false);
165
+ const [err, setErr] = useState<string | null>(null);
166
+
167
+ if (input.status !== "pending") {
168
+ return (
169
+ <div className="ask answered">
170
+ <div className="ask-head">{Icon.lock()} {input.status === "answered" ? "你提供的信息" : "已取消 —— 这一轮被停止了"}</div>
171
+ <div className="ask-q"><div className="ask-qh">{input.message}</div></div>
172
+ {input.status === "answered" && input.fields.map((f, i) => (
173
+ <div className="ask-q" key={i}>
174
+ <div className="ask-qh"><span className="ask-chip">{f.label}</span></div>
175
+ <div className="ask-picked">{f.secret ? "••••••(只交给浏览器脚本,没有保存,也没有发给模型)" : input.values?.[i] ?? "—"}</div>
176
+ </div>
177
+ ))}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ const canSubmit = !!onSubmit;
183
+ const set = (i: number, v: string) => setVals((a) => a.map((x, j) => (j === i ? v : x)));
184
+ const complete = vals.every((v) => v.trim());
185
+ const submit = async () => {
186
+ if (!complete || busy || !onSubmit) return;
187
+ setBusy(true);
188
+ setErr(null);
189
+ const e = await onSubmit(input.id, vals.map((v) => v.trim()));
190
+ if (e) { setErr(e); setBusy(false); }
191
+ };
192
+ return (
193
+ <div className="ask">
194
+ <div className="ask-head">{Icon.lock()} {agentLabel} 需要你提供信息 · 浏览器正停在这一页等待</div>
195
+ <div className="ask-q"><div className="ask-qh" style={{ whiteSpace: "pre-wrap" }}>{input.message}</div></div>
196
+ {input.fields.map((f, i) => (
197
+ <div className="ask-q" key={i}>
198
+ <div className="ask-qh"><span className="ask-chip">{f.label}</span>{f.secret && <span className="ask-multi">保密</span>}</div>
199
+ <div className="ask-opts">
200
+ {f.options?.map((o) => (
201
+ <button key={o} className={`ask-opt ${vals[i] === o ? "on" : ""}`} onClick={() => set(i, o)} disabled={busy || !canSubmit}>
202
+ <span className="ask-opt-l">{o}</span>
203
+ </button>
204
+ ))}
205
+ <div className="input-row">
206
+ <input
207
+ className="ask-other"
208
+ type={f.secret && !shown[i] ? "password" : "text"}
209
+ autoComplete={f.secret ? "new-password" : "off"}
210
+ placeholder={f.options?.length ? "其他…" : f.placeholder || `输入${f.label}`}
211
+ value={f.options?.includes(vals[i]) ? "" : vals[i]}
212
+ disabled={busy || !canSubmit}
213
+ onChange={(e) => set(i, e.target.value)}
214
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) void submit(); }}
215
+ />
216
+ {f.secret && canSubmit && (
217
+ <button className="btn btn-ghost btn-sm" type="button" onClick={() => setShown((a) => a.map((x, j) => (j === i ? !x : x)))}>
218
+ {shown[i] ? "隐藏" : "显示"}
219
+ </button>
220
+ )}
221
+ </div>
222
+ </div>
223
+ </div>
224
+ ))}
225
+ <div className="ask-foot" style={{ gap: 8, alignItems: "center" }}>
226
+ {err && <span className="input-err">{err}</span>}
227
+ {!err && canSubmit && input.fields.some((f) => f.secret) && <span className="input-note">密码只交给浏览器脚本,不保存、不发给模型</span>}
228
+ {!canSubmit && <span className="input-note">请在执行节点的 Operator 界面里提交(密码只交给浏览器脚本,不经对话发送)</span>}
229
+ <span style={{ flex: 1 }} />
230
+ {onBrowser && <button className="btn btn-line btn-sm" onClick={onBrowser}>查看浏览器</button>}
231
+ <button className="btn btn-accent btn-sm" onClick={() => void submit()} disabled={!complete || busy || !canSubmit}>{busy ? "发送中…" : "提交"}</button>
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ // 浏览器交接提示(Operator 的 browser_handoff / 页面权限请求写进转录的「🖐 …」行):要用户在页面里亲自操作。
238
+ // 与 Operator 一致:有浏览器能力时带「查看浏览器」「我已完成,交还给 AI」两个按钮,没有时只显示文字。
239
+ export const HANDOFF_NOTICE_PREFIX = "🖐 ";
240
+ function HandoffLine({ text, onBrowser, onHandback }: { text: string; onBrowser?: () => void; onHandback?: () => Promise<string | null> }) {
241
+ const [state, setState] = useState<"idle" | "busy" | "done">("idle");
242
+ const [err, setErr] = useState<string | null>(null);
243
+ return (
244
+ <div className="msg system info">
245
+ <div className="msg-body">
246
+ {text}
247
+ {(onBrowser || onHandback) && (
248
+ <div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap", alignItems: "center" }}>
249
+ {onBrowser && <button className="btn btn-line btn-sm" onClick={onBrowser}>查看浏览器</button>}
250
+ {onHandback && (
251
+ <button className="btn btn-accent btn-sm" disabled={state !== "idle"} onClick={async () => {
252
+ setState("busy");
253
+ const e = await onHandback();
254
+ if (e) { setErr(e); setState("idle"); } else setState("done");
255
+ }}>{state === "done" ? "已交还" : state === "busy" ? "交还中…" : "我已完成,交还给 AI"}</button>
256
+ )}
257
+ {err && <span className="input-err">{err}</span>}
258
+ </div>
259
+ )}
260
+ </div>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ // 附件条:Operator 的用户消息里,图片 / 大段粘贴以「[Attached image|file: 路径]」标记行出现。
266
+ // 能拿到文件地址时(attachmentUrl)显示缩略图 / 可点的文件片,拿不到时显示不可点的片。
267
+ function AttachmentStrip({ items, attachmentUrl }: { items: MsgAttachment[]; attachmentUrl?: (a: MsgAttachment) => string | null }) {
268
+ if (!items.length) return null;
269
+ return (
270
+ <div className="msg-attachments">
271
+ {items.map((a, i) => {
272
+ const url = attachmentUrl?.(a) ?? null;
273
+ if (a.kind === "image" && url) {
274
+ return <a key={i} href={url} target="_blank" rel="noreferrer" title="查看原图"><img src={url} alt="附件图片" loading="lazy" /></a>;
275
+ }
276
+ const label = a.kind === "image" ? "附件图片" : "附件文件";
277
+ return url
278
+ ? <a key={i} href={url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}>{Icon.clip()} <span>{label}</span></a>
279
+ : <span key={i} className="file-chip" title={a.name}>{Icon.clip()} <span>{label}</span></span>;
280
+ })}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ /** 用户消息正文:普通文字按 markdown 渲染,附件 / 长粘贴是可折叠卡片,很长的纯文字也折叠。 */
286
+ function UserBody({ content, attachmentUrl }: { content: string; attachmentUrl?: (a: MsgAttachment) => string | null }) {
287
+ const { text, attachments } = splitAttachments(content);
288
+ const parts = text ? splitMessage(text) : [];
289
+ return (
290
+ <>
291
+ {parts.map((part, i) => part.kind === "block"
292
+ ? <TextBlockCard key={i} block={part.block} />
293
+ : <LongText key={i} text={part.text} />)}
294
+ <AttachmentStrip items={attachments} attachmentUrl={attachmentUrl} />
295
+ </>
296
+ );
297
+ }
298
+
299
+ function LongText({ text }: { text: string }) {
300
+ const [open, setOpen] = useState(false);
301
+ if (!isLongMessage(text)) return <Markdown>{text}</Markdown>;
302
+ return (
303
+ <div className={`cli-long ${open ? "open" : ""}`}>
304
+ {open ? (text.length > 50_000 ? <pre className="cli-long-pre">{text}</pre> : <Markdown>{text}</Markdown>) : <div className="cli-long-pre clamp">{text.slice(0, 1_200)}</div>}
305
+ <button type="button" className="cli-long-btn" onClick={() => setOpen((o) => !o)}>
306
+ {open ? "收起" : `展开全部(${text.length.toLocaleString("zh-CN")} 字)`}
307
+ </button>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ /** 消息行的显示选项(来自组件配置)。要引用稳定,否则 memo 失效。 */
313
+ export interface MessageViewOptions { timestamps: boolean; initialBadge: boolean; userLabel: string; agentName: string }
314
+ const DEFAULT_VIEW: MessageViewOptions = { timestamps: true, initialBadge: true, userLabel: "你", agentName: "智能体" };
315
+
316
+ export interface MessageHandlers {
317
+ /** browser_ask_user 卡片提交(返回错误文案或 null)。后端不支持时不给,卡片只读。 */
318
+ onInput?: (inputId: string, values: string[]) => Promise<string | null>;
319
+ /** 「查看浏览器」:宿主有浏览器面板时给。 */
320
+ onShowBrowser?: () => void;
321
+ /** 浏览器交接后「我已完成,交还给 AI」(返回错误文案或 null)。 */
322
+ onHandback?: () => Promise<string | null>;
323
+ /** 附件标记行的文件地址(Operator 后端才有)。 */
324
+ attachmentUrl?: (a: MsgAttachment) => string | null;
325
+ /** 与 Operator 相同的签名:askId 是提问的 tool_use id。 */
326
+ onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => Promise<void> | void;
327
+ onCancelQueued?: (msg: Msg) => void;
328
+ onRetry?: (msgId: string) => void;
329
+ }
330
+
331
+ // memo:实时回合里每个事件都会让上层重渲染,但消息对象只在内容变化时替换,没变的行直接跳过。
332
+ // 调用方要传引用稳定的回调,否则 memo 失效。
333
+ export const MessageView = memo(function MessageView({ m, initial: isInitial, hideWho, running, agent, agentLabel = "智能体", handlers, view = DEFAULT_VIEW }: {
334
+ m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; handlers: MessageHandlers; view?: MessageViewOptions;
335
+ }) {
336
+ const initial = isInitial && view.initialBadge;
337
+ const time = view.timestamps && m.ts != null ? <span className="msg-time">{clockTime(m.ts)}</span> : null;
338
+ if (m.role === "queued") {
339
+ return (
340
+ <div className="msg user queued">
341
+ <div className="who"><Avatar who="user" /> {view.userLabel}<span className="badge queued-badge">排队中</span>{time}</div>
342
+ <div className="msg-body">
343
+ <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} />
344
+ {m.note && <div className="cli-queued-note">⏳ {m.note}</div>}
345
+ {handlers.onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => handlers.onCancelQueued!(m)}>{Icon.x()}</button>}
346
+ </div>
347
+ </div>
348
+ );
349
+ }
350
+ if (m.role === "tool") {
351
+ const data = toolDataOf(m);
352
+ if (data.input) return <div className="msg msg-tool"><InputView input={data.input} agentLabel={agentLabel} onSubmit={handlers.onInput} onBrowser={handlers.onShowBrowser} /></div>;
353
+ 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>;
354
+ return <div className="msg msg-tool"><ToolView data={data} /></div>;
355
+ }
356
+ if (m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX)) {
357
+ return <HandoffLine text={m.content} onBrowser={handlers.onShowBrowser} onHandback={handlers.onHandback} />;
358
+ }
359
+ if (m.role === "system") {
360
+ if (LOUD_NOTICES.some((notice) => m.content.includes(notice))) {
361
+ return (
362
+ <div className="msg system overflow">
363
+ <div className="msg-body">
364
+ {m.content}
365
+ {m.content.includes(APPROVAL_BLOCKED) && handlers.onRetry && (
366
+ <div className="overflow-actions">
367
+ <button className="btn btn-sm" onClick={() => handlers.onRetry!(m.id)} disabled={running} title="重新发送失败的消息">{Icon.bolt()} 重试</button>
368
+ </div>
369
+ )}
370
+ </div>
371
+ </div>
372
+ );
373
+ }
374
+ const info = /^[✓ℹ⇄]/.test(m.content);
375
+ const glyphed = info || m.content.startsWith("⚠");
376
+ return <div className={`msg system${info ? " info" : ""}`}><div className="msg-body">{glyphed ? m.content : `⚠ ${m.content}`}</div></div>;
377
+ }
378
+ const isUser = m.role === "user";
379
+ return (
380
+ <div className={`msg ${isUser ? "user" : "assistant"} ${initial ? "initial" : ""}`}>
381
+ {!hideWho && (
382
+ <div className="who">
383
+ <Avatar who={isUser ? "user" : "cc"} agent={agent} />
384
+ {isUser ? view.userLabel : view.agentName}
385
+ {initial && <span className="badge">初始提示</span>}
386
+ {time}
387
+ </div>
388
+ )}
389
+ <div className="msg-body">
390
+ {initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
391
+ {isUser ? <UserBody content={m.content} attachmentUrl={handlers.attachmentUrl} /> : m.content && <Markdown>{m.content}</Markdown>}
392
+ </div>
393
+ </div>
394
+ );
395
+ });
396
+
397
+ export function SessionBreak({ summary }: { summary: string }) {
398
+ return (
399
+ <div className="sbreak">
400
+ <span className="ln" />
401
+ <div className="card">
402
+ <div className="cl">{Icon.clear()} 上下文已清空 · 摘要已保存</div>
403
+ <div className="ct">{summary}</div>
404
+ </div>
405
+ <span className="ln" />
406
+ </div>
407
+ );
408
+ }
@@ -0,0 +1,101 @@
1
+ // 格式化 / 推导小工具。规则与 Operator(operator-oss/app/orchestrator/format.ts)一致,
2
+ // 只保留对话任务用得到的部分。
3
+ import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
4
+
5
+ // 紧凑 token 数:1234 → "1.2k",1_200_000 → "1.2M"。
6
+ export function fmtTokens(n: number): string {
7
+ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
8
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
9
+ return String(n);
10
+ }
11
+
12
+ export function fmtCount(n: number): string {
13
+ return n.toLocaleString("zh-CN");
14
+ }
15
+
16
+ // 消息时间:当天只显示时刻,跨天带日期。绝对时间是有意的:消息行是 memo 的,相对时间会冻住。
17
+ export function clockTime(ts: number): string {
18
+ const d = new Date(ts);
19
+ const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
20
+ if (d.toDateString() === new Date().toDateString()) return time;
21
+ return `${d.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
22
+ }
23
+
24
+ export function relTime(ts: number): string {
25
+ const d = Date.now() - ts;
26
+ const m = Math.floor(d / 60000);
27
+ if (m < 1) return "刚刚";
28
+ if (m < 60) return `${m} 分钟前`;
29
+ const h = Math.floor(m / 60);
30
+ if (h < 24) return `${h} 小时前`;
31
+ return `${Math.floor(h / 24)} 天前`;
32
+ }
33
+
34
+ export const toMs = (value: string | number | null | undefined) => {
35
+ if (value === null || value === undefined) return null;
36
+ const ms = typeof value === "number" ? value : new Date(value).getTime();
37
+ return Number.isFinite(ms) ? ms : null;
38
+ };
39
+
40
+ // 用户回答 AskUserQuestion 后,组成一条普通回复发出去(对话任务里回答就是下一条消息)。
41
+ export function formatAnswersText(questions: AskQuestion[], answers: AskAnswers): string {
42
+ const lines = questions.map((q, i) => {
43
+ const picked = (answers[i] ?? []).filter((s) => s && s.trim());
44
+ return `- ${q.header || q.question}:${picked.length ? picked.join("、") : "(未选择)"}`;
45
+ });
46
+ return `回答你的问题:\n${lines.join("\n")}`;
47
+ }
48
+
49
+ export const diffCls = (sign: "+" | "-" | " ") => (sign === "+" ? "add" : sign === "-" ? "del" : "ctx");
50
+
51
+ // 按 generation 把扁平的消息分成一次次会话,抽出 /clear 的摘要。
52
+ export function buildSessions(messages: Msg[]) {
53
+ const summaryByGen: Record<number, string> = {};
54
+ for (const m of messages) if (m.role === "session_break") summaryByGen[m.generation] = m.content;
55
+ const committed = messages.filter((m) => m.role !== "queued");
56
+ const gens = Array.from(new Set(committed.filter((m) => m.role !== "session_break").map((m) => m.generation))).sort((a, b) => a - b);
57
+ return gens.map((n) => ({
58
+ n,
59
+ summaryBefore: summaryByGen[n - 1] ?? null,
60
+ messages: committed.filter((m) => m.generation === n && m.role !== "session_break"),
61
+ }));
62
+ }
63
+
64
+ // 智能体显示名:与 Operator 的驱动 id 对应。
65
+ const AGENT_LABELS: Record<string, string> = { claude: "Claude Code", codex: "Codex", grok: "Grok", pi: "Pi", mock: "Mock" };
66
+ export const agentLabel = (agent: string | null | undefined) => (agent ? AGENT_LABELS[agent] ?? agent : "智能体");
67
+
68
+ // 已解析的模型 id 的友好名("claude-opus-5" → "Opus 5";非 Claude 的原样显示)。
69
+ export function modelLabel(id: string | null | undefined): string {
70
+ if (!id) return "";
71
+ const s = id.toLowerCase();
72
+ const long = s.includes("[1m]") ? " (1M)" : "";
73
+ const fam = ["fable", "opus", "sonnet", "haiku"].find((f) => s.includes(f));
74
+ if (!fam) return id;
75
+ const cap = fam[0].toUpperCase() + fam.slice(1);
76
+ const v = s.match(new RegExp(`${fam}-(\\d+)(?:-(\\d+))?`));
77
+ return v ? `${cap} ${v[1]}${v[2] ? `.${v[2]}` : ""}${long}` : `${cap}${long}`;
78
+ }
79
+
80
+ // ── Operator 的附件标记行 ─────────────────────────────
81
+ // Operator 界面里上传的图片 / 大段粘贴,以一行「[Attached image: 路径]」「[Attached file: 路径]」写进用户消息。
82
+ // 规则与 Operator 的 splitAttachments 一致:路径形如 …/uploads/<task>/<file> 的才算附件,手打的同形文字留在正文里。
83
+ const ATTACHMENT_RE = /^\[Attached (image|file): (.+)\]$/;
84
+ export interface MsgAttachment { path: string; kind: "image" | "file"; name: string; taskId: string }
85
+
86
+ export function splitAttachments(content: string): { text: string; attachments: MsgAttachment[] } {
87
+ if (!content.includes("[Attached image: ") && !content.includes("[Attached file: ")) return { text: content, attachments: [] };
88
+ const attachments: MsgAttachment[] = [];
89
+ const kept: string[] = [];
90
+ for (const line of content.split("\n")) {
91
+ const m = ATTACHMENT_RE.exec(line.trim());
92
+ const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
93
+ if (m && parts.length >= 3 && parts[parts.length - 3] === "uploads") {
94
+ const [taskId, file] = parts.slice(-2);
95
+ attachments.push({ path: m[2], kind: m[1] === "image" ? "image" : "file", name: file, taskId });
96
+ } else {
97
+ kept.push(line);
98
+ }
99
+ }
100
+ return { text: kept.join("\n").trim(), attachments };
101
+ }