@spzxg/cli-ui 0.1.0 → 0.3.1

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.
@@ -3,22 +3,34 @@
3
3
  // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
4
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
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";
6
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
7
+ import { convKey, emptyConv, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
9
8
  import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
10
9
  import { Icon } from "../ui/icons.js";
11
10
  import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
12
11
  import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
13
- import { MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
12
+ import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
14
13
  import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
15
14
  import { Composer, type ComposerOptions } from "./Composer.js";
16
15
  import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
17
16
  import { exportMarkdown } from "./export.js";
17
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
18
+ import { FileViewer } from "../board/FileViewer.js";
19
+ import { BrowserPane } from "../board/BrowserPane.js";
18
20
 
19
- export interface CliChatProps extends CliStoreOptions {
20
- /** 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。 */
21
+ /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
22
+ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
23
+ /**
24
+ * 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
25
+ * 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
26
+ */
21
27
  jobId?: string | null;
28
+ /**
29
+ * 对话任务里的哪个对话(子任务 task_id),和 jobId 一起用。不给 = 对话任务的第一个对话(只有一个对话时就是它);
30
+ * "new" = 在这个对话任务里新开一个对话(同一节点、同一工作区,新的 Agent 会话),第一条消息发出后组件跟到开出来的那个。
31
+ * 结束对话时:给了 taskId 只关掉这一个对话,没给结束整个对话任务。
32
+ */
33
+ taskId?: string | null;
22
34
  /** 面包屑里的名称与色块(如业务 / 项目名)。 */
23
35
  title?: string;
24
36
  color?: string;
@@ -28,10 +40,24 @@ export interface CliChatProps extends CliStoreOptions {
28
40
  allowClose?: boolean;
29
41
  /** 界面配置:顶部栏、输入框、对话区各模块的显示与文案,见 core/config.ts。 */
30
42
  config?: CliChatConfig;
43
+ /**
44
+ * Agent 消息里本地文件链接(report.md、/abs/path、src/a.ts:12)的打开地址。缺省没有:显示成不可点的文字。
45
+ * 有文件服务时(例如以后的看板 / Operator 后端)给出地址,链接就能打开(与 Operator 一致)。
46
+ * taskId:看的是对话任务里指定的那个对话时才有。
47
+ */
48
+ fileUrl?: (path: string, jobId: string, kind: "link" | "image", taskId?: string | null) => string | null;
49
+ /**
50
+ * 点了本地文件链接 / 「查看浏览器」怎么打开:
51
+ * · "auto"(缺省)—— 同一个 Provider 里有 cli-board 在看这个对话就在看板里打开,否则在对话里弹窗预览;
52
+ * · "board" —— 只交给看板(没有看板时按 fileUrl 的地址打开);
53
+ * · "modal" —— 总是在对话里弹窗;
54
+ * · "link" —— 不拦截,按 fileUrl 的地址在新窗口打开(以前的行为)。
55
+ * 无论哪种,都会发 open 事件(onEvent),宿主可以据此展开右侧面板。
56
+ */
57
+ fileOpen?: "auto" | "board" | "modal" | "link";
31
58
  /** 一轮结束(≈ center 回调 conversation_turn)、对话结束、job 变化等。 */
32
59
  onEvent?: (event: CliEvent) => void;
33
- /** 前端回调:载荷与 center 回调 conversation_turn / conversation_closed 的请求体同名同义。 */
34
- onCallback?: (payload: ConversationCallback) => void;
60
+
35
61
  theme?: "light" | "dark";
36
62
  className?: string;
37
63
  style?: CSSProperties;
@@ -44,12 +70,25 @@ export function CliChat(props: CliChatProps) {
44
70
  }
45
71
 
46
72
  // 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
47
- const FINAL_REASONS = new Set(["closed", "cancelled"]);
73
+ const FINAL_REASONS = new Set(["closed", "cancelled", "conversation_closed"]);
48
74
  const REASON_TEXT: Record<string, string> = {
49
- closed: "对话已结束", cancelled: "对话已取消", expired: "对话空闲太久已过期",
75
+ closed: "对话已结束", cancelled: "对话已取消", conversation_closed: "这个对话已结束", expired: "对话空闲太久已过期",
50
76
  node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
51
77
  };
52
78
 
79
+ // 一次性任务(含续派)的子任务状态(center TaskStatus)。
80
+ const BATCH_STATUS: Record<string, { dotCls: DotCls; statusText: string }> = {
81
+ succeeded: { dotCls: "g", statusText: "子任务已完成" }, failed: { dotCls: "x", statusText: "子任务失败" }, cancelled: { dotCls: "x", statusText: "子任务已取消" },
82
+ dispatching: { dotCls: "a", statusText: "正在派发" }, running: { dotCls: "a", statusText: "运行中" }, verifying: { dotCls: "a", statusText: "正在核对结果" },
83
+ queued: { dotCls: "a", statusText: "等待执行" }, blocked: { dotCls: "a", statusText: "等待执行" }, paused: { dotCls: "a", statusText: "已暂停" },
84
+ orphaned: { dotCls: "a", statusText: "执行节点暂时连不上,恢复后继续" },
85
+ };
86
+ function batchStatusView(taskStatus: string | null, jobStatus: string | null): { dotCls: DotCls; statusText: string } {
87
+ if (taskStatus && BATCH_STATUS[taskStatus]) return BATCH_STATUS[taskStatus];
88
+ if (jobStatus && TERMINAL_JOB.has(jobStatus)) return { dotCls: jobStatus === "succeeded" ? "g" : "x", statusText: jobStatus === "succeeded" ? "已完成" : jobStatus === "cancelled" ? "已取消" : "已失败" };
89
+ return { dotCls: "a", statusText: "进行中" };
90
+ }
91
+
53
92
  /** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
54
93
  const QUIET_AFTER_MS = 180_000;
55
94
  function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
@@ -76,42 +115,49 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
76
115
  );
77
116
  }
78
117
 
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);
118
+ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
119
+ useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
81
120
  // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
82
121
  const configKey = JSON.stringify(config ?? {});
83
122
  const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
84
- const readOnly = !!readOnlyProp || !cfg.composer.show;
85
123
  const store = useCliStore();
86
124
  const selected = useCliState((s) => s.selected);
87
125
  // 显示哪个对话:
88
126
  // · 不传 jobId → 跟仓库的选中走(和同一 Provider 里的 cli-msglist 联动);
89
127
  // · 传了 jobId(含 null = 新对话)→ 组件自己接管:从传入的 id 开始,之后自己新建出的对话、
90
- // center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId 就以宿主为准。
91
- // 新对话的草稿按组件实例存(对话的草稿按 job_id 存,看同一个对话的组件共用一份是对的)。
128
+ // center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId / taskId 就以宿主为准。
129
+ // 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
130
+ // 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
92
131
  const instanceId = useId();
93
132
  const controlled = jobIdProp !== undefined;
94
- const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: jobIdProp, id: jobIdProp ?? null }));
133
+ // jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
134
+ const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
135
+ const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: keyProp, id: keyProp ?? null }));
95
136
  let viewId = followed.id;
96
- if (followed.prop !== jobIdProp) {
97
- viewId = jobIdProp ?? null;
98
- setShown({ prop: jobIdProp, id: viewId });
137
+ if (followed.prop !== keyProp) {
138
+ viewId = keyProp ?? null;
139
+ setShown({ prop: keyProp, id: viewId });
99
140
  }
100
- const jobId = controlled ? viewId : selected;
141
+ const key = controlled ? viewId : selected;
142
+ const ref = key ? parseConvKey(key) : null;
143
+ const jobId = ref?.jobId ?? null;
144
+ // 「在这个对话任务里新开一个对话」:还没有对话,第一条消息开出来。
145
+ const isNew = isNewInJob(key);
146
+ const taskId = ref?.taskId && !isNew ? ref.taskId : null;
101
147
  const go = useCallback((id: string | null) => {
102
148
  if (controlled) setShown((v) => (v.id === id ? v : { ...v, id }));
103
149
  else store.select(id);
104
150
  }, [controlled, store]);
105
- const conv = useCliState((s) => (jobId ? s.convs[jobId] : undefined)) ?? (jobId ? emptyConv(jobId) : null);
151
+ const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
106
152
  const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
107
153
 
108
154
  // 看这个对话:开实时流(同一对话多处看只连一次)。
109
- useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
155
+ useEffect(() => (key ? store.open(key) : undefined), [store, key]);
110
156
  // 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
111
- const jobIdRef = useRef(jobId);
112
- jobIdRef.current = jobId;
157
+ const keyRef = useRef(key);
158
+ keyRef.current = key;
113
159
  useEffect(() => store.on((event) => {
114
- if (event.type === "job_change" && event.previousJobId && event.previousJobId === jobIdRef.current) go(event.jobId);
160
+ if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
115
161
  }), [store, go]);
116
162
 
117
163
  const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
@@ -123,39 +169,62 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
123
169
  })), [allMessages, cfg.transcript.tools]);
124
170
  const running = conv ? isRunning(conv) : false;
125
171
  const job = entry?.job;
126
- const task = job?.tasks?.[0];
172
+ // 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
173
+ const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
174
+ const readOnly = !!readOnlyProp || !cfg.composer.show || batch;
175
+ // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
176
+ // 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
177
+ const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
178
+ const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
179
+ const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
127
180
  const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
128
181
  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);
182
+ const jobStatus = conv?.job?.status ?? job?.status ?? null;
183
+ const jobEnded = !!jobStatus && TERMINAL_JOB.has(jobStatus);
184
+ // 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
185
+ const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
186
+ const status = convClosed ? "succeeded" : jobStatus;
187
+ const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
188
+ const ended = jobEnded || convClosed;
132
189
  const finalEnd = ended && FINAL_REASONS.has(closedReason ?? "closed");
133
190
  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 ? "对话" : "新对话");
191
+ // 接续自旧对话任务里的哪个对话(指定了对话时跟回那一个)。
192
+ const continuedFromKey = continuedFrom ? convKey(continuedFrom, taskId ? task?.continue_from_task_id ?? info?.continue_from_task_id ?? null : null) : null;
193
+ const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
194
+ const jobTitle = job?.title ?? conv?.job?.title;
195
+ // 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
196
+ const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
197
+ const convName = conv?.task?.title ?? info?.title ?? task?.title;
198
+ const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
199
+ : (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
200
+ : jobTitle ?? (key ? "对话" : "新对话");
136
201
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
137
202
  const lastTurn = conv?.turns[conv.turns.length - 1];
138
203
  const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
139
- const loading = !!jobId && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
204
+ // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
205
+ const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
206
+ const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
140
207
 
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);
208
+ // 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
209
+ const usageTask = isNew ? undefined : task;
210
+ const fresh = (usageTask ? Math.max(0, usageTask.total_tokens - usageTask.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
211
+ const cacheRead = (usageTask?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
144
212
  const resolvedModel = conv?.resolvedModel ?? task?.resolved_model ?? null;
145
213
 
146
214
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
147
- if (!jobId) return { dotCls: "r", statusText: "未开始" };
215
+ if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
216
+ if (batch) return batchStatusView(batchStatus, jobStatus);
148
217
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
149
218
  if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
150
219
  if (running) return { dotCls: "a", statusText: "运行中" };
151
220
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
152
221
  return { dotCls: "a", statusText: "进行中" };
153
- }, [jobId, ended, status, closedReason, awaiting, running]);
222
+ }, [key, isNew, batch, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
154
223
 
155
224
  // ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
156
225
  // 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
157
226
  const [actionError, setActionError] = useState<string | null>(null);
158
- useEffect(() => setActionError(null), [jobId]);
227
+ useEffect(() => setActionError(null), [key]);
159
228
  const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
160
229
  try {
161
230
  const result = await work;
@@ -166,46 +235,81 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
166
235
  throw error;
167
236
  }
168
237
  }, []);
169
- const latest = useRef({ jobId, messages, conv });
170
- latest.current = { jobId, messages, conv };
238
+ const latest = useRef({ key, messages, conv });
239
+ latest.current = { key, messages, conv };
171
240
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
172
241
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
173
242
  sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
174
243
  const send = useCallback(async (text: string) => {
175
- const id = latest.current.jobId;
244
+ const id = latest.current.key;
176
245
  const target = await store.send(id ?? null, text, sendOpts.current);
177
246
  if (target !== id) go(target);
178
247
  }, [store, go]);
248
+ // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
249
+ // 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
250
+ const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
251
+ useEffect(() => {
252
+ if (!key || !conv || fileOpen === "link" || fileOpen === "modal") return;
253
+ if (handoffSeen.current.key !== key) handoffSeen.current = { key, ids: null };
254
+ if (!conv.hasTranscript) return;
255
+ const handoffs = conv.msgs.filter((m) => m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX));
256
+ const seen = handoffSeen.current;
257
+ if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
258
+ const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
259
+ for (const m of fresh) seen.ids.add(m.id);
260
+ if (fresh.length) store.openInBoard(key, { kind: "browser" });
261
+ }, [key, conv, fileOpen, store]);
262
+
263
+ // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
264
+ const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
265
+ useEffect(() => setPopup(null), [key]);
266
+ useEffect(() => {
267
+ if (!popup) return;
268
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPopup(null); };
269
+ window.addEventListener("keydown", onKey);
270
+ return () => window.removeEventListener("keydown", onKey);
271
+ }, [popup]);
179
272
  const handlers = useMemo<MessageHandlers>(() => ({
180
273
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
181
274
  onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
182
- const id = latest.current.jobId;
275
+ const id = latest.current.key;
183
276
  if (!id) return;
184
277
  const sent = askId
185
278
  ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
186
279
  : send(formatAnswersText(questions, answers));
187
280
  await report(sent, "回答没有发出去");
188
281
  },
189
- onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.jobId; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
282
+ onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
190
283
  // 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
191
284
  onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
192
- const id = latest.current.jobId;
285
+ const id = latest.current.key;
193
286
  if (!id) return "对话还没开始";
194
287
  try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
195
288
  },
196
- onHandback: readOnly || !store.client.handback ? undefined : async () => {
197
- const id = latest.current.jobId;
289
+ // 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
290
+ onHandback: readOnly || !(store.client.handback || store.client.board) ? undefined : async () => {
291
+ const id = latest.current.key;
198
292
  if (!id) return "对话还没开始";
199
- try { return await store.client.handback!(id); } catch (error) { return error instanceof Error ? error.message : String(error); }
293
+ try {
294
+ if (store.client.handback) return await store.client.handback(id);
295
+ await store.client.board!.browserAction(id, "handback");
296
+ return null;
297
+ } catch (error) { return error instanceof Error ? error.message : String(error); }
298
+ },
299
+ onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
300
+ const id = latest.current.key;
301
+ if (!id) return;
302
+ if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
303
+ if (fileOpen !== "board") setPopup({ kind: "browser" });
200
304
  },
201
305
  onRetry: readOnly ? undefined : (msgId: string) => {
202
306
  const list = latest.current.messages;
203
307
  const at = list.findIndex((m) => m.id === msgId);
204
308
  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
309
  },
206
- }), [readOnly, send, store, go, report]);
310
+ }), [readOnly, send, store, go, report, fileOpen]);
207
311
  const stop = useCallback(async () => {
208
- const { jobId: id, conv: c } = latest.current;
312
+ const { key: id, conv: c } = latest.current;
209
313
  const turn = c ? runningTurn(c) : null;
210
314
  if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
211
315
  }, [store, report]);
@@ -365,7 +469,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
365
469
  setAtBottom(true);
366
470
  setWin({ start: null, end: null });
367
471
  if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
368
- }, [jobId]);
472
+ }, [key]);
369
473
 
370
474
  const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
371
475
  const h = cfg.header;
@@ -377,7 +481,29 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
377
481
  const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
378
482
  const disabledHint = readOnly ? "只读" : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
379
483
 
484
+ const fileUrlRef = useRef(fileUrl);
485
+ fileUrlRef.current = fileUrl;
486
+ const board = store.client.board;
487
+ // 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
488
+ const resolveFile = useMemo(() => {
489
+ if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
490
+ if (board?.directLinks && key) return (path: string) => board.fileUrl(key, path);
491
+ return null;
492
+ }, [!!fileUrl, key, jobId, taskId, board]); // eslint-disable-line react-hooks/exhaustive-deps
493
+ // 点了本地文件:看板 → 弹窗 → 地址(见 fileOpen)。
494
+ const openFile = useMemo(() => {
495
+ if (!key || fileOpen === "link") return null;
496
+ return (path: string) => {
497
+ if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
498
+ if (fileOpen === "board" || !board) return false;
499
+ setPopup({ kind: "file", path });
500
+ return true;
501
+ };
502
+ }, [key, fileOpen, store, board]);
503
+
380
504
  return (
505
+ <FileLinkContext.Provider value={resolveFile}>
506
+ <FileOpenContext.Provider value={openFile}>
381
507
  <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
382
508
  <div className="session">
383
509
  {h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
@@ -387,8 +513,8 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
387
513
  <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
514
  {title} <span className="sep">/</span> 对话
389
515
  </>}
390
- {h.export && jobId && allMessages.length > 0 && (
391
- <button className="crumb-export" onClick={() => exportMarkdown(convTitle, jobId, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
516
+ {h.export && key && allMessages.length > 0 && (
517
+ <button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
392
518
  )}
393
519
  </div>}
394
520
  {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
@@ -401,7 +527,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
401
527
  {cacheRead > 0 && <> <span className="usage-dot">·</span> <span className="usage-cached">{fmtTokens(cacheRead)} 缓存</span></>}
402
528
  </span>
403
529
  )}
404
- {h.model && jobId && (
530
+ {h.model && key && !isNew && (
405
531
  <span className="status-ctl" style={{ cursor: "default" }} title="模型由任务中心按产品线配置分配">
406
532
  {Icon.spark()}<span className="cv">默认</span>
407
533
  {resolvedModel && <span className="model-badge" title={`上次运行于 ${resolvedModel}`}>{modelLabel(resolvedModel)}</span>}
@@ -411,18 +537,18 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
411
537
  <StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
412
538
  <span className="cv">{statusText}</span>
413
539
  </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>
540
+ {h.close && key && !isNew && allowClose && !readOnly && !ended && (
541
+ <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
416
542
  )}
417
543
  </div>
418
544
  </div>}
419
545
 
420
- {closing !== "idle" && jobId && (
546
+ {closing !== "idle" && key && (
421
547
  <div className="clear-confirm">
422
- <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}。</span>
548
+ <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
423
549
  <span className="spacer" />
424
550
  <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>
551
+ <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(key), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
426
552
  </div>
427
553
  )}
428
554
  {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>}
@@ -432,17 +558,18 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
432
558
  <div className="transcript-wrap">
433
559
  <div className="transcript" ref={scrollRef} onScroll={onScroll}>
434
560
  <div className="tw">
435
- {!jobId && cfg.empty.show && (
561
+ {(!key || isNew) && cfg.empty.show && (
436
562
  <div className="hero cli-hero">
437
563
  <div className="h-ic">{Icon.bolt()}</div>
438
564
  <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
439
565
  <h2>{cfg.empty.title}</h2>
566
+ {isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
440
567
  {cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
441
568
  </div>
442
569
  )}
443
570
  {continuedFrom && (
444
571
  <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>
572
+ <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFromKey)}>查看上一个对话</button>
446
573
  </div></div>
447
574
  )}
448
575
  {contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
@@ -484,7 +611,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
484
611
  {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
485
612
  {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
486
613
  {ended && (
487
- <div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
614
+ <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
488
615
  )}
489
616
  {continuedBy && (
490
617
  <div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
@@ -499,18 +626,34 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
499
626
  </div>
500
627
  {!readOnly && (
501
628
  <Composer
502
- key={jobId ?? "new"}
503
- draft={jobId ?? `new:${instanceId}`}
504
- placeholder={fillTemplate(jobId ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
629
+ key={key ?? "new"}
630
+ draft={key ?? `new:${instanceId}`}
631
+ placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
505
632
  disabled={composerDisabled}
506
633
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
507
634
  running={running}
508
635
  onSend={send}
509
- onStop={cfg.composer.stop && jobId && conv && runningTurn(conv) ? stop : undefined}
636
+ onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
510
637
  options={composerOptions}
511
638
  />
512
639
  )}
513
640
  </div>
641
+ {popup && key && board && (
642
+ <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
643
+ <div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
644
+ {popup.kind === "file" ? (
645
+ <FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })} />
646
+ ) : (
647
+ <>
648
+ <div className="cli-fv-h"><span className="cli-fv-name">任务浏览器</span><span className="cli-fv-sp" /><button className="cli-fv-icon" onClick={() => setPopup(null)} title="关闭">{Icon.x()}</button></div>
649
+ <BrowserPane backend={board} jobId={key} readOnly={readOnly} />
650
+ </>
651
+ )}
652
+ </div>
653
+ </div>
654
+ )}
514
655
  </div>
656
+ </FileOpenContext.Provider>
657
+ </FileLinkContext.Provider>
515
658
  );
516
659
  }