@spzxg/cli-ui 0.0.0-stage → 0.3.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.
@@ -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,46 @@
1
+ // Agent 消息里指向本地文件的链接([报告](report.md)、/Users/…/a.ts、file:///…、src/a.ts:12)。
2
+ // 识别规则移植自 Operator 的 app/orchestrator/fileLinks.ts:原样渲染的话浏览器会把它当成宿主网站根目录下的地址,打不开。
3
+ // Operator 把它改写到任务的文件接口;这里由宿主 / 后端给出地址(fileUrl),给不出就显示成不可点的文字,不留坏链接。
4
+ import { createContext } from "react";
5
+
6
+ /** 本地文件 → 能打开的地址;返回 null 表示打不开(显示成文字)。 */
7
+ export type FileUrlResolver = (path: string, kind: "link" | "image") => string | null;
8
+ export const FileLinkContext = createContext<FileUrlResolver | null>(null);
9
+
10
+ /**
11
+ * 点了本地文件链接:交给看板 / 弹窗打开,返回 true 表示接住了(链接不再按网址跳转)。
12
+ * 按住 ⌘/Ctrl/Shift 点、或中键点,仍然按网址在新窗口打开。
13
+ */
14
+ export type FileOpenHandler = (path: string) => boolean;
15
+ export const FileOpenContext = createContext<FileOpenHandler | null>(null);
16
+
17
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
18
+ const WIN_ABS = /^[a-z]:[\\/]/i;
19
+
20
+ function normalize(p: string): string {
21
+ const abs = p.startsWith("/");
22
+ const out: string[] = [];
23
+ for (const seg of p.split("/")) {
24
+ if (!seg || seg === ".") continue;
25
+ if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
26
+ else if (seg === ".." && abs) continue;
27
+ else out.push(seg);
28
+ }
29
+ return (abs ? "/" : "") + out.join("/");
30
+ }
31
+
32
+ /** 链接指向本地文件时返回路径(相对工作目录或绝对路径);网址、锚点等返回 null。规则与 Operator 一致。 */
33
+ export function localFilePath(href: string | undefined | null): string | null {
34
+ let h = String(href ?? "").trim();
35
+ if (!h || h.startsWith("#") || h.startsWith("//") || h.startsWith("/api/") || h.startsWith("/view?")) return null;
36
+ if (/^file:/i.test(h)) h = h.replace(/^file:(\/\/localhost)?(\/\/)?/i, "");
37
+ else if (SCHEME.test(h) && !WIN_ABS.test(h)) return null;
38
+ h = h.replace(/[?#].*$/, "");
39
+ try { h = decodeURI(h); } catch { /* 保持原样 */ }
40
+ h = h.replace(/\\/g, "/");
41
+ h = h.replace(/:(\d+)(:\d+)?$/, "");
42
+ if (!h) return null;
43
+ if (h === "~" || h.startsWith("~/")) return h;
44
+ if (h.startsWith("/") || WIN_ABS.test(h)) return WIN_ABS.test(h) ? h : normalize(h);
45
+ return normalize(h);
46
+ }
@@ -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
+ }