@spzxg/cli-ui 0.3.0 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { g as yu, c as ft, a as xu } from "./cli-ui-embed-hutDQhkT.js";
1
+ import { g as yu, c as ft, a as xu } from "./cli-ui-embed-D7xtcvGG.js";
2
2
  function vu(e, t) {
3
3
  for (var n = 0; n < t.length; n++) {
4
4
  const i = t[n];
package/dist/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-hutDQhkT.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-D7xtcvGG.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
6
6
  "main": "src/index.ts",
@@ -4,7 +4,7 @@
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
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";
7
+ import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
8
8
  import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
9
9
  import { Icon } from "../ui/icons.js";
10
10
  import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
@@ -76,6 +76,19 @@ const REASON_TEXT: Record<string, string> = {
76
76
  node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
77
77
  };
78
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
+
79
92
  /** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
80
93
  const QUIET_AFTER_MS = 180_000;
81
94
  function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
@@ -107,7 +120,6 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
107
120
  // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
108
121
  const configKey = JSON.stringify(config ?? {});
109
122
  const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
110
- const readOnly = !!readOnlyProp || !cfg.composer.show;
111
123
  const store = useCliStore();
112
124
  const selected = useCliState((s) => s.selected);
113
125
  // 显示哪个对话:
@@ -157,13 +169,21 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
157
169
  })), [allMessages, cfg.transcript.tools]);
158
170
  const running = conv ? isRunning(conv) : false;
159
171
  const job = entry?.job;
160
- // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示)。
161
- const task = (taskId ? job?.tasks?.find((t) => t.id === taskId) : undefined) ?? (taskId ? undefined : job?.tasks?.[0]);
172
+ // 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
173
+ // 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
174
+ const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
175
+ const chat = !!taskId && isChatTask(conv);
176
+ const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat);
177
+ // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
178
+ // 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
179
+ const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
180
+ const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
162
181
  const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
163
182
  const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
164
183
  const agentText = labelOf(agent);
165
184
  const jobStatus = conv?.job?.status ?? job?.status ?? null;
166
- const jobEnded = !!jobStatus && TERMINAL_JOB.has(jobStatus);
185
+ // 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
186
+ const jobEnded = !chat && !!jobStatus && TERMINAL_JOB.has(jobStatus);
167
187
  // 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
168
188
  const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
169
189
  const status = convClosed ? "succeeded" : jobStatus;
@@ -176,15 +196,18 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
176
196
  const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
177
197
  const jobTitle = job?.title ?? conv?.job?.title;
178
198
  // 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
179
- const manyConvs = (job?.conversation?.conversations?.length ?? 0) > 1;
180
- const convName = conv?.task?.title ?? info?.title ?? task?.title;
199
+ const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
200
+ // 对话式子任务:流里的是这个对话最新的那条消息,名称取打开的那个子任务。
201
+ const convName = chat ? task?.title ?? conv?.task?.title : conv?.task?.title ?? info?.title ?? task?.title;
181
202
  const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
182
- : taskId && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
203
+ : (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
183
204
  : jobTitle ?? (key ? "对话" : "新对话");
184
205
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
185
206
  const lastTurn = conv?.turns[conv.turns.length - 1];
186
207
  const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
187
- const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
208
+ // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
209
+ const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
210
+ const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
188
211
 
189
212
  // 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
190
213
  const usageTask = isNew ? undefined : task;
@@ -194,12 +217,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
194
217
 
195
218
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
196
219
  if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
220
+ if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
197
221
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
198
222
  if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
199
223
  if (running) return { dotCls: "a", statusText: "运行中" };
200
224
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
201
225
  return { dotCls: "a", statusText: "进行中" };
202
- }, [key, isNew, ended, status, closedReason, awaiting, running]);
226
+ }, [key, isNew, batch, chat, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
203
227
 
204
228
  // ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
205
229
  // 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
@@ -259,7 +283,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
259
283
  : send(formatAnswersText(questions, answers));
260
284
  await report(sent, "回答没有发出去");
261
285
  },
262
- onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
286
+ onCancelQueued: readOnly || batch ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
263
287
  // 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
264
288
  onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
265
289
  const id = latest.current.key;
@@ -287,7 +311,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
287
311
  const at = list.findIndex((m) => m.id === msgId);
288
312
  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; }
289
313
  },
290
- }), [readOnly, send, store, go, report, fileOpen]);
314
+ }), [readOnly, batch, send, store, go, report, fileOpen]);
291
315
  const stop = useCallback(async () => {
292
316
  const { key: id, conv: c } = latest.current;
293
317
  const turn = c ? runningTurn(c) : null;
@@ -517,7 +541,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
517
541
  <StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
518
542
  <span className="cv">{statusText}</span>
519
543
  </span>}
520
- {h.close && key && !isNew && allowClose && !readOnly && !ended && (
544
+ {h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
521
545
  <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
522
546
  )}
523
547
  </div>
@@ -541,7 +565,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
541
565
  {(!key || isNew) && cfg.empty.show && (
542
566
  <div className="hero cli-hero">
543
567
  <div className="h-ic">{Icon.bolt()}</div>
544
- <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
568
+ {cfg.transcript.status && <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>}
545
569
  <h2>{cfg.empty.title}</h2>
546
570
  {isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
547
571
  {cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
@@ -590,8 +614,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
590
614
  )}
591
615
  {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
592
616
  {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
593
- {ended && (
594
- <div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
617
+ {ended && cfg.transcript.status && (
618
+ <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
595
619
  )}
596
620
  {continuedBy && (
597
621
  <div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
@@ -2,7 +2,7 @@ import { createSseParser } from "./sse.js";
2
2
  import { NEW_TASK, parseConvKey } from "./conversation.js";
3
3
  import type {
4
4
  BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo,
5
- NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
5
+ BatchStreamSnapshot, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
6
6
  } from "./types.js";
7
7
 
8
8
  /**
@@ -80,6 +80,11 @@ export interface ConversationBackend {
80
80
  createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
81
81
  /** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
82
82
  sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string }): Promise<SendResult>;
83
+ /**
84
+ * 一次性任务里的对话式子任务接着聊:续派一条 mode=conversation、continue_from 这个子任务的子任务(接在同一个 Agent 会话里)。
85
+ * 返回新子任务的 id。
86
+ */
87
+ continueTask?(jobId: string, taskId: string, text: string, opts?: { title?: string; businessId?: string }): Promise<{ task_id: string }>;
83
88
  /** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
84
89
  openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
85
90
  cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
@@ -197,6 +202,13 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
197
202
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
198
203
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
199
204
  }),
205
+ continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
206
+ const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
207
+ job_id: jobId, mode: "conversation",
208
+ tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
209
+ });
210
+ return { task_id: result.task_ids[0] };
211
+ },
200
212
  /** 同一个 business_id 重发只开一个对话。 */
201
213
  openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
202
214
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
@@ -246,7 +258,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
246
258
  const parser = createSseParser(({ event, data }) => {
247
259
  let payload: unknown;
248
260
  try { payload = JSON.parse(data); } catch { return; }
249
- if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot);
261
+ if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot | BatchStreamSnapshot);
250
262
  else if (event === "chunk") handlers.onChunk?.((payload as { events?: StreamItem[] }).events ?? []);
251
263
  else if (event === "end") handlers.onEnd?.(payload as StreamEnd);
252
264
  });
@@ -278,7 +290,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
278
290
 
279
291
  export interface StreamHandlers {
280
292
  onOpen?: () => void;
281
- onSnapshot?: (snapshot: StreamSnapshot) => void;
293
+ /** 对话任务是 StreamSnapshot;一次性任务(含续派)是 BatchStreamSnapshot(见 isBatchSnapshot)。 */
294
+ onSnapshot?: (snapshot: StreamSnapshot | BatchStreamSnapshot) => void;
282
295
  onChunk?: (events: StreamItem[]) => void;
283
296
  onEnd?: (end: StreamEnd) => void;
284
297
  /** 流断了(error 为 null = 服务端正常关闭)。onEnd 之后也会触发一次。 */
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(全部显示、默认文案)。
2
+ * 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(默认文案;cli-chat 的顶部栏缺省隐藏,其余全部显示)。
3
3
  *
4
- * <CliChat config={{ header: { export: false, close: false }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
4
+ * <CliChat config={{ header: { title: true, status: true }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
5
5
  * <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
6
6
  *
7
7
  * 开关类的模块写 false 就整块隐藏,写对象就逐项开关;文案里可以用 {title}(对话标题)、{agent}(智能体名)。
@@ -10,7 +10,7 @@
10
10
  // ── cli-chat ─────────────────────────────────────────
11
11
 
12
12
  export interface CliChatConfig {
13
- /** 顶部栏。false = 整栏隐藏;对象 = 逐项开关。 */
13
+ /** 顶部栏。缺省隐藏;true = 每一项都显示;对象 = 只显示写了 true 的项。 */
14
14
  header?: boolean | {
15
15
  /** 面包屑:色块 + 名称 / 对话。 */
16
16
  crumb?: boolean;
@@ -66,6 +66,8 @@ export interface CliChatConfig {
66
66
  tools?: boolean;
67
67
  /** 「智能体 · 运行中 0:12」那一行。 */
68
68
  runningIndicator?: boolean;
69
+ /** 状态说明行:新对话的「尚未开始」、结束时的「对话已结束」「子任务已完成」等。缺省不显示。 */
70
+ status?: boolean;
69
71
  };
70
72
  /** 消息头上的称呼。 */
71
73
  labels?: { user?: string; agent?: string };
@@ -79,13 +81,14 @@ export interface ResolvedChatConfig {
79
81
  show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
80
82
  hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
81
83
  };
82
- transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean };
84
+ transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
83
85
  labels: { user: string; agent: string };
84
86
  empty: { show: boolean; title: string; description: string };
85
87
  }
86
88
 
87
89
  export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
88
- header: { show: true, crumb: true, export: true, title: true, agent: true, usage: true, model: true, status: true, close: true },
90
+ // 顶部栏缺省整栏隐藏(模型、状态、用量这些对使用者是干扰),需要的项在 config.header 里打开。
91
+ header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
89
92
  composer: {
90
93
  show: true,
91
94
  placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
@@ -94,14 +97,14 @@ export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
94
97
  placeholderDisabled: null,
95
98
  hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
96
99
  },
97
- transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true },
100
+ transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
98
101
  labels: { user: "你", agent: "智能体" },
99
102
  empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
100
103
  };
101
104
 
102
105
  export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
103
106
  const d = DEFAULT_CHAT_CONFIG;
104
- const header = toggle(config.header, d.header);
107
+ const header = config.header === true ? allOn(d.header) : toggle(config.header, d.header);
105
108
  const composer = toggle(config.composer, d.composer);
106
109
  const empty = toggle(config.empty, d.empty);
107
110
  // 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
@@ -138,22 +141,26 @@ export interface CliMsgListConfig {
138
141
  /** 搜索框:true 总是显示,false 不显示,"auto"(缺省)对话数 ≥ 6 时显示。 */
139
142
  search?: boolean | "auto";
140
143
  searchPlaceholder?: string;
144
+ /**
145
+ * 按状态分组(需要你处理 / 进行中 / 已结束 / 已取消),配合 groups 改组名。缺省不分组:一个列表,最近活动的在前。
146
+ */
147
+ grouped?: boolean;
141
148
  /** 卡片上显示哪些内容。 */
142
149
  card?: {
143
- /** 状态点和状态文字。 */
150
+ /** 状态点、状态文字,以及底部的「运行中 / 等你处理 / 已完成」等字样。缺省不显示(底部只有时间)。 */
144
151
  status?: boolean;
145
152
  /** 智能体标签。 */
146
153
  agent?: boolean;
147
154
  /** 第一条消息摘要。 */
148
155
  description?: boolean;
149
- /** 底部活动(运行中 · 3 分钟前……)。 */
156
+ /** 底部活动(3 分钟前;打开 status 时带上运行中、排队等状态)。 */
150
157
  activity?: boolean;
151
158
  /** 「N 轮对话」。 */
152
159
  turns?: boolean;
153
160
  /** 「+」按钮:在这个任务(对话任务)里新开一个对话,共用同一个工作区。 */
154
161
  newInJob?: boolean;
155
162
  };
156
- /** 分组标题;写 false 隐藏这一组。 */
163
+ /** 分组标题(grouped 时);写 false 隐藏这一组。 */
157
164
  groups?: { needs?: string | false; active?: string | false; done?: string | false; cancelled?: string | false };
158
165
  /** 没有对话时的提示。 */
159
166
  emptyTitle?: string;
@@ -164,6 +171,7 @@ export interface ResolvedMsgListConfig {
164
171
  header: { show: boolean; title: boolean; newButton: boolean; newButtonText: string; refresh: boolean; context: boolean };
165
172
  search: boolean | "auto";
166
173
  searchPlaceholder: string;
174
+ grouped: boolean;
167
175
  card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean; newInJob: boolean };
168
176
  groups: { needs: string | false; active: string | false; done: string | false; cancelled: string | false };
169
177
  emptyTitle: string;
@@ -174,7 +182,9 @@ export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
174
182
  header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
175
183
  search: "auto",
176
184
  searchPlaceholder: "搜索对话…",
177
- card: { status: true, agent: true, description: true, activity: true, turns: true, newInJob: true },
185
+ grouped: false,
186
+ // 状态缺省不显示(未开始、进行中、已完成……对使用者是干扰),需要时打开 card.status / grouped。
187
+ card: { status: false, agent: true, description: true, activity: true, turns: true, newInJob: true },
178
188
  groups: { needs: "需要你处理", active: "进行中", done: "已结束", cancelled: "已取消" },
179
189
  emptyTitle: "还没有对话",
180
190
  emptyDescription: "点「对话」开始一个新的对话。",
@@ -186,6 +196,7 @@ export function resolveMsgListConfig(config: CliMsgListConfig = {}): ResolvedMsg
186
196
  header: toggle(config.header, d.header),
187
197
  search: config.search ?? d.search,
188
198
  searchPlaceholder: config.searchPlaceholder ?? d.searchPlaceholder,
199
+ grouped: config.grouped ?? d.grouped,
189
200
  card: { ...d.card, ...defined(config.card) },
190
201
  groups: { ...d.groups, ...defined(config.groups) },
191
202
  emptyTitle: config.emptyTitle ?? d.emptyTitle,
@@ -211,6 +222,12 @@ function defined<T extends object>(value: T | undefined): Partial<T> {
211
222
  /**
212
223
  * 开关型模块:false → 全关(show=false 且布尔项全 false,文案保留);true / 不给 → 缺省;对象 → 合并缺省。
213
224
  */
225
+ function allOn<T extends { show: boolean }>(defaults: T): T {
226
+ const on = { ...defaults } as Record<string, unknown>;
227
+ for (const key of Object.keys(on)) if (typeof on[key] === "boolean") on[key] = true;
228
+ return on as T;
229
+ }
230
+
214
231
  function toggle<T extends { show: boolean }>(value: boolean | object | undefined, defaults: T): T {
215
232
  if (value === false) {
216
233
  const off = { ...defaults, show: false } as Record<string, unknown>;
@@ -6,13 +6,16 @@
6
6
  * · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
7
7
  * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
8
  */
9
- import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, StreamTask, ToolData, TurnUsage } from "./types.js";
9
+ import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types.js";
10
10
  import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
11
11
  import { formatAnswersText } from "../ui/format.js";
12
12
 
13
13
  export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
14
14
  export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
15
15
  export const TERMINAL_JOB = new Set(["succeeded", "failed", "cancelled"]);
16
+ /** 一次性任务的子任务:结束了的、正在节点上跑的。 */
17
+ export const TERMINAL_TASK = TERMINAL_JOB;
18
+ const ACTIVE_TASK = new Set(["dispatching", "running", "verifying"]);
16
19
 
17
20
  // ── 对话键 ─────────────────────────────────────────────
18
21
  //
@@ -76,11 +79,19 @@ export interface ConvState {
76
79
  continuedBy: string | null;
77
80
  /** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
78
81
  localAnswers: Record<string, AskAnswers>;
82
+ /**
83
+ * 一次性任务(含续派,不是对话任务):只读查看其中一个子任务。没有轮次,task 是那个子任务,
84
+ * batchTask 是它的原样(状态、错误、原文),remoteTaskId 是它在节点上的对话(流里只认这个对话的转录)。
85
+ */
86
+ batch: boolean;
87
+ batchTask: BatchStreamTask | null;
88
+ remoteTaskId: string | null;
79
89
  }
80
90
 
81
91
  export const emptyConv = (key: string): ConvState => ({
82
92
  key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
83
93
  liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
94
+ batch: false, batchTask: null, remoteTaskId: null,
84
95
  });
85
96
 
86
97
  export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
@@ -101,23 +112,76 @@ export function addTurnsIfAbsent(turns: CenterTurn[], incoming: CenterTurn[]): C
101
112
  return fresh.length ? upsertTurns(turns, fresh) : turns;
102
113
  }
103
114
 
104
- export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot): ConvState {
115
+ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot | BatchStreamSnapshot): ConvState {
116
+ if (isBatchSnapshot(snapshot)) return applyBatchSnapshot(conv, snapshot);
105
117
  const { job } = snapshot;
106
118
  return {
107
119
  ...conv,
108
120
  job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
109
121
  task: snapshot.task ?? conv.task,
110
- turns: upsertTurns([], snapshot.turns),
122
+ turns: upsertTurns([], Array.isArray(snapshot.turns) ? snapshot.turns : []),
111
123
  resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
112
124
  };
113
125
  }
114
126
 
127
+ // ── 一次性任务(含续派):只读查看其中一个子任务 ─────────
128
+ //
129
+ // 指定了子任务就看它;没指定看最近一个派发过的(续派开出新的子任务时跟过去)。
130
+ // 流里可能同时有几个节点对话的事件(带 remote_task_id),只认看的这个子任务所在的那个。
131
+
132
+ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
133
+ // 对话式子任务跑完一条不算结束:还能接着聊。
134
+ id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
135
+ remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
136
+ });
137
+
138
+ /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
139
+ export const latestBatchTask = (tasks: BatchStreamTask[]) =>
140
+ [...tasks].sort((a, b) => a.seq - b.seq).reverse().find((task) => task.remote_task_id) ?? tasks.reduce<BatchStreamTask | null>((last, task) => (!last || task.seq > last.seq ? task : last), null);
141
+
142
+ /** 对话式子任务(续派时 mode=conversation):可以接着聊,发消息 = 续派一条接着它的对话。 */
143
+ export const isChatTask = (conv: Pick<ConvState, "batch" | "batchTask"> | null | undefined) => !!conv?.batch && !!conv.batchTask?.conversation;
144
+
145
+ /** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
146
+ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
147
+ // 对话式的对话里最新那条可能还没派发:节点对话用它们共用的那个。
148
+ const remote = task?.conversation_remote_task_id ?? task?.remote_task_id ?? null;
149
+ const sameRemote = remote === conv.remoteTaskId;
150
+ // 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
151
+ const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
152
+ const batchTask = task ? { ...kept, ...task } : null;
153
+ return {
154
+ ...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
155
+ ...(sameRemote ? {} : { msgs: [], hasTranscript: false, nodeRunning: false }),
156
+ };
157
+ }
158
+
159
+ function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
160
+ const { job } = snapshot;
161
+ const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
162
+ // 流给的那个优先:指定了子任务就是它(对话式的是它所在对话里最新的那条);没指定是最近一个派发过的。旧版中枢不给时自己挑。
163
+ const shown = snapshot.task ?? (conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : latestBatchTask(tasks));
164
+ return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
165
+ }
166
+
167
+ /** 一次性任务里的子任务状态变化。 */
168
+ function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
169
+ if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
170
+ // 对话式的对话:接着聊的新消息成了最新那条(流只发这个对话的)。
171
+ if (conv.taskId && task.conversation) return focusBatchTask(conv, task);
172
+ // 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
173
+ if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
174
+ return conv;
175
+ }
176
+
115
177
  // 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
116
178
  const toMsg = (m: Message): Msg => ({ id: m.id, role: m.role, content: m.content, generation: typeof m.generation === "number" ? m.generation : 1, ...(typeof m.created_at === "number" ? { ts: m.created_at } : {}) });
117
179
 
118
180
  export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
119
181
  let next = conv;
120
182
  for (const item of items) {
183
+ // 一次性任务:只认看的这个子任务所在的节点对话。
184
+ if (next.batch && (item.type === "transcript" || item.type === "node") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
121
185
  if (item.type === "transcript") {
122
186
  // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
123
187
  const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
@@ -131,9 +195,9 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
131
195
  const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
132
196
  next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
133
197
  } else if (item.type === "job") {
134
- next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason, node_id: item.job.node_id } };
198
+ next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason ?? null, node_id: item.job.node_id } };
135
199
  } else if (item.type === "task") {
136
- next = { ...next, task: item.task };
200
+ next = next.batch ? applyBatchTask(next, item.task as BatchStreamTask) : { ...next, task: item.task as StreamTask };
137
201
  }
138
202
  }
139
203
  return next;
@@ -227,7 +291,7 @@ export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?
227
291
  /** 正在执行的那一轮(派发中或执行中)。 */
228
292
  export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
229
293
 
230
- export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv);
294
+ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv) || (conv.batch && ACTIVE_TASK.has(conv.batchTask?.status ?? ""));
231
295
 
232
296
  /**
233
297
  * 界面上的转录:
@@ -236,6 +300,7 @@ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(
236
300
  * 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
237
301
  */
238
302
  export function viewMessages(conv: ConvState): Msg[] {
303
+ if (conv.batch) return batchViewMessages(conv);
239
304
  let out: Msg[] = [];
240
305
  // 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
241
306
  if (conv.msgs.length) {
@@ -296,6 +361,55 @@ export function viewMessages(conv: ConvState): Msg[] {
296
361
  return out;
297
362
  }
298
363
 
364
+ // 中枢派发一次性任务时加在正文前的标记行,和完成后的自动核对(见 center scheduler.ts)。
365
+ const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
366
+ const CONFIRM_MARK = "[CENTER-CONFIRM]";
367
+ const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
368
+ const CHAT_TASK_ERROR: Record<string, string> = { failed: "这一轮没有完成", cancelled: "这条消息已取消" };
369
+
370
+ /**
371
+ * 一次性任务(含续派)的转录,只读:
372
+ * 1. 第一条用户消息是 Operator 固定的启动语句,换成开出这个节点对话的子任务原文(流里给了才换);
373
+ * 2. 去掉中枢加在正文前的标记行([CENTER-ATTEMPT:…]、[CENTER-TERMINAL-REVISION:…]);
374
+ * 3. 中枢的完成核对([CENTER-CONFIRM] 追问 + Agent 回的 TASK_COMPLETE / TASK_INCOMPLETE)合成一行说明;
375
+ * 4. 还没派发、拿不到执行记录、失败 / 取消时补一行说明。
376
+ */
377
+ function batchViewMessages(conv: ConvState): Msg[] {
378
+ const out: Msg[] = [];
379
+ const task = conv.batchTask;
380
+ const opener = task?.opener_prompt ?? null;
381
+ const firstUserId = conv.msgs.find((m) => m.role === "user")?.id;
382
+ for (let i = 0; i < conv.msgs.length; i++) {
383
+ const m = conv.msgs[i];
384
+ if (m.role === "user" && m.content.includes(CONFIRM_MARK)) {
385
+ const reply = conv.msgs[i + 1]?.role === "assistant" ? conv.msgs[++i] : null;
386
+ const content = !reply ? "中枢正在核对任务是否完成…" : /^\s*TASK_COMPLETE\b/.test(reply.content) ? "✓ 中枢核对:任务已完成" : "⚠ 中枢核对:任务没有完成";
387
+ out.push({ id: `confirm-${m.id}`, role: "system", content, generation: m.generation, ...(m.ts !== undefined ? { ts: m.ts } : {}) });
388
+ continue;
389
+ }
390
+ if (m.role !== "user") { out.push(m); continue; }
391
+ const content = m.id === firstUserId && opener ? opener : m.content.replace(CENTER_MARKER_LINES, "");
392
+ out.push(content === m.content ? m : { ...m, content });
393
+ }
394
+ const generation = out.length ? out[out.length - 1].generation : 1;
395
+ const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
396
+ if (task?.conversation) {
397
+ // 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
398
+ if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
399
+ for (const q of task.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
400
+ return out;
401
+ }
402
+ if (!task) {
403
+ if (conv.job) note("none", "这个任务里还没有子任务");
404
+ } else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
405
+ note("waiting", "子任务还没派发到执行节点,派发后这里会实时显示执行过程");
406
+ } else if (!conv.msgs.length && conv.stream === "ended") {
407
+ note("empty", "没有拿到这个子任务的执行记录(执行节点上的会话可能已经清理)");
408
+ }
409
+ if (task && BATCH_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${BATCH_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
410
+ return out;
411
+ }
412
+
299
413
  /** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
300
414
  function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
301
415
  if (!notices.length) return msgs;
@@ -1,16 +1,20 @@
1
- import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
1
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
2
  import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
3
3
  import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types.js";
4
4
 
5
5
  /**
6
- * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库:
7
- * 列表点哪个对话,对话组件就显示哪个;同一个对话只连一条实时流。
6
+ * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库,切换时一起变:
7
+ * 列表点哪个对话,对话和看板就显示哪个;对话里新建 / 接续出新对话,列表跟着选中、看板跟着切;
8
+ * 对话里点文件在看板里打开。同一个对话只连一条实时流。
8
9
  *
9
- * <CliCenterProvider baseUrl="/api/center">
10
+ * <CliCenterProvider baseUrl="/api/center" selectedId={key} onEvent={(e) => e.type === "select" && setKey(e.jobId)}>
10
11
  * <CliMsgList />
11
12
  * <CliChat />
13
+ * <CliBoard />
12
14
  * </CliCenterProvider>
13
15
  *
16
+ * 要一起切换,里面的组件就不要再传 jobId(传了的组件只看自己那个,不跟着切);从外面打开某个对话用 selectedId。
17
+ *
14
18
  * 组件单独使用时不需要它:组件发现外面没有 Provider,会用自己的 props 建一个。
15
19
  */
16
20
  const StoreContext = createContext<CliStore | null>(null);
@@ -18,6 +22,11 @@ const StoreContext = createContext<CliStore | null>(null);
18
22
  export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers {
19
23
  /** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
20
24
  store?: CliStore;
25
+ /**
26
+ * 里面这几个组件一起看哪个对话(对话键:"job_x" 或 "job_x#task_y";null = 新对话)。不给就由列表的点击决定。
27
+ * 给了之后改它,列表、对话、看板一起切过去;列表点击、对话里新建出的对话照样会切,并发 select / job_change 事件。
28
+ */
29
+ selectedId?: string | null;
21
30
  /** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
22
31
  onEvent?: (event: CliEvent) => void;
23
32
  children: ReactNode;
@@ -41,10 +50,22 @@ export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
41
50
  else if (event.type === "conversation_closed") { handlers.onConversationEnd?.(event.payload); handlers.onCallback?.(event.payload); }
42
51
  }
43
52
 
44
- export function CliCenterProvider({ store: external, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
53
+ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
45
54
  // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
46
55
  const optionsRef = useRef(options);
47
- const store = useMemo(() => external ?? createCliStore(optionsRef.current), [external]);
56
+ const applied = useRef<string | null | undefined>(undefined);
57
+ const store = useMemo(() => {
58
+ const created = external ?? createCliStore(optionsRef.current);
59
+ // 第一次渲染前就选好:里面的组件一上来就看这个对话,不会先打开别的。
60
+ if (selectedId !== undefined) { created.select(selectedId); applied.current = selectedId; }
61
+ return created;
62
+ }, [external]); // eslint-disable-line react-hooks/exhaustive-deps
63
+ // 之后宿主改了 selectedId:一起切过去(在绘制前,免得闪一下旧的)。
64
+ useLayoutEffect(() => {
65
+ if (selectedId === undefined || selectedId === applied.current) return;
66
+ applied.current = selectedId;
67
+ store.select(selectedId);
68
+ }, [store, selectedId]);
48
69
  const onEventRef = useRef(onEvent);
49
70
  onEventRef.current = onEvent;
50
71
  const resultRef = useRef<ResultHandlers>({});