@spzxg/cli-ui 0.6.2 → 0.6.6

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -1,27 +1,36 @@
1
1
  // cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
2
2
  // 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
3
3
  // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
- import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
- import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
6
- import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
7
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
- import { boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks.js";
9
- import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
10
- import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types.js";
11
- import { Icon } from "../ui/icons.js";
12
- import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
13
- import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
14
- import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
15
- import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
16
- import { Composer, type ComposerOptions } from "./Composer.js";
17
- import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config.js";
18
- import { exportMarkdown } from "./export.js";
19
- import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
20
- import { FileViewer } from "../board/FileViewer.js";
21
- import { BrowserPane } from "../board/BrowserPane.js";
4
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
5
+ import { CliErrorBoundary } from "../ui/ErrorBoundary";
6
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
7
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider";
8
+ import { formatBoardContext, withoutBoardContext } from "../board/context";
9
+ import { MAX_MESSAGE_CHARS, charCount, boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks";
10
+ import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation";
11
+ import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types";
12
+ import { Icon } from "../ui/icons";
13
+ import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
14
+ import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
15
+ import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
16
+ import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
17
+ import { Composer, type ComposerOptions } from "./Composer";
18
+ import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
19
+ import { exportMarkdown } from "./export";
20
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
21
+ import { FileViewer } from "../board/FileViewer";
22
+ import { BrowserPane } from "../board/BrowserPane";
22
23
 
23
24
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
24
25
  export interface CliChatProps extends CliStoreOptions, ResultHandlers {
26
+ /** Integration slots share this transcript/composer; they do not create another chat implementation. */
27
+ slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
28
+ /** Saving execution settings blocks send until persistence completes. */
29
+ configurationBusy?: boolean;
30
+ onClear?: () => void;
31
+ /** 每条消息自动带入看板当前文件、页签和最近浏览;默认 true。 */
32
+ passiveContext?: boolean;
33
+
25
34
  /**
26
35
  * 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
27
36
  * 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
@@ -120,7 +129,7 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
120
129
  );
121
130
  }
122
131
 
123
- 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 }) {
132
+ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, 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 }) {
124
133
  useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
125
134
  // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
126
135
  const configKey = JSON.stringify(config ?? {});
@@ -160,7 +169,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
160
169
  useEffect(() => (key ? store.open(key) : undefined), [store, key]);
161
170
  // 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
162
171
  const keyRef = useRef(key);
163
- keyRef.current = key;
172
+ useLayoutEffect(() => { keyRef.current = key; }, [key]);
164
173
  useEffect(() => store.on((event) => {
165
174
  if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
166
175
  }), [store, go]);
@@ -226,7 +235,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
226
235
  : jobTitle ?? (key ? "对话" : "新对话");
227
236
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
228
237
  const lastTurn = conv?.turns[conv.turns.length - 1];
229
- const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
238
+ const listedAwaiting = (info ?? job?.conversation?.conversations?.find(item => item.task_id === task?.id))?.awaiting_input;
239
+ // An explicit null retires an old wait flag when its node observation expires.
240
+ const remoteAwaiting = conv?.task?.awaiting_input !== undefined ? conv.task.awaiting_input
241
+ : listedAwaiting !== undefined ? listedAwaiting : task?.awaiting_input !== undefined ? task.awaiting_input
242
+ : !manyConvs ? job?.conversation?.awaiting_input : undefined;
243
+ const awaiting = !ended && !TERMINAL_JOB.has(jobStatus ?? "") && !(batch && batchTaskStatus && SETTLED_TASK.has(batchTaskStatus))
244
+ && (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
230
245
  // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
231
246
  const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
232
247
  const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
@@ -239,9 +254,9 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
239
254
 
240
255
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
241
256
  if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
257
+ if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
242
258
  if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
243
259
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
244
- if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
245
260
  if (running) return { dotCls: "a", statusText: "运行中" };
246
261
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
247
262
  return { dotCls: "a", statusText: "进行中" };
@@ -252,12 +267,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
252
267
  const [actionError, setActionError] = useState<string | null>(null);
253
268
  useEffect(() => setActionError(null), [key]);
254
269
  const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
270
+ const sourceKey = keyRef.current;
255
271
  try {
256
272
  const result = await work;
257
- setActionError(null);
273
+ if (keyRef.current === sourceKey) setActionError(null);
258
274
  return result;
259
275
  } catch (error) {
260
- setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
276
+ if (keyRef.current === sourceKey) setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
261
277
  throw error;
262
278
  }
263
279
  }, []);
@@ -265,10 +281,16 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
265
281
  // (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
266
282
  const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
267
283
  const latest = useRef({ key, messages, conv, runKey });
268
- latest.current = { key, messages, conv, runKey };
284
+ // A concurrent render may suspend while the old chat remains interactive.
285
+ // Event handlers must keep the target and options of the committed view.
286
+ useLayoutEffect(() => { latest.current = { key, messages, conv, runKey }; });
269
287
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
270
288
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
271
- sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
289
+ useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
290
+ const [skipPassive,setSkipPassive]=useState(false);
291
+ const skipPassiveRef=useRef(false);
292
+ useLayoutEffect(()=>{skipPassiveRef.current=skipPassive;},[skipPassive]);
293
+ useEffect(()=>setSkipPassive(false),[key]);
272
294
  const send = useCallback(async (text: string) => {
273
295
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
274
296
  const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
@@ -276,26 +298,33 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
276
298
  // 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
277
299
  const refKey = latest.current.key;
278
300
  const refs = refKey ? store.takeRefs(refKey) : [];
279
- const viewing = refKey ? store.takeViewing(refKey) : null;
301
+ const passiveView=refKey?store.getState().boardContext[refKey]:null;
302
+ const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
303
+ const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
280
304
  const blocks = [
305
+ ...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
281
306
  ...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
282
307
  ...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
283
308
  ];
284
309
  const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
285
310
  let target: string;
286
311
  try {
312
+ if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
287
313
  target = await store.send(id ?? null, full, sendOpts.current);
288
314
  } catch (error) {
289
315
  if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
290
316
  throw error;
291
317
  }
292
- if (target !== id) go(target);
293
- }, [store, go]);
318
+ if(latest.current.key===refKey)setSkipPassive(false);
319
+ if (target !== id && latest.current.key === refKey) go(target);
320
+ }, [store, go, passiveContext]);
321
+ const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
322
+ const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
294
323
  const refs = useCliState((s) => (key ? s.refs[key] : undefined));
295
324
  const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
296
325
  const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
297
326
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
298
- const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
327
+ const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
299
328
  const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
300
329
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
301
330
  const contextItems = useCliState((s) => s.context[key ?? ""]);
@@ -324,6 +353,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
324
353
  return () => window.removeEventListener("keydown", onKey);
325
354
  }, [popup]);
326
355
  const handlers = useMemo<MessageHandlers>(() => ({
356
+ onQuote: readOnly ? undefined : (m,selected) => {const id=latest.current.key;if(id) store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});},
327
357
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
328
358
  onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
329
359
  const id = latest.current.key;
@@ -331,7 +361,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
331
361
  // 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
332
362
  if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
333
363
  const sent = askId
334
- ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
364
+ ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id && latest.current.key === id) go(target); })
335
365
  : send(formatAnswersText(questions, answers));
336
366
  await report(sent, "回答没有发出去");
337
367
  },
@@ -361,7 +391,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
361
391
  onRetry: readOnly ? undefined : (msgId: string) => {
362
392
  const list = latest.current.messages;
363
393
  const at = list.findIndex((m) => m.id === msgId);
364
- 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; }
394
+ for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(withoutBoardContext(list[j].content)), "重试没有发出去").catch(() => undefined); return; }
365
395
  },
366
396
  }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
367
397
  const stop = useCallback(async () => {
@@ -369,7 +399,19 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
369
399
  const turn = c ? runningTurn(c) : null;
370
400
  if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
371
401
  }, [store, report]);
372
- const [closing, setClosing] = useState<"idle" | "confirm" | "busy">("idle");
402
+ const [closeAction, setCloseAction] = useState<{ key: string; stage: "confirm" | "busy" } | null>(null);
403
+ const closing = closeAction?.key === key ? closeAction.stage : "idle";
404
+ useEffect(() => setCloseAction(null), [key]);
405
+ const confirmClose = () => {
406
+ if (!key || closeAction?.key !== key || closeAction.stage !== "confirm") return;
407
+ const pending = { key, stage: "busy" as const };
408
+ setCloseAction(pending);
409
+ void store.close(key).then(() => {
410
+ if (latest.current.key === pending.key) setActionError(null);
411
+ }, (error: unknown) => {
412
+ if (latest.current.key === pending.key) setActionError(`结束对话失败:${error instanceof Error ? error.message : String(error)}`);
413
+ }).finally(() => setCloseAction(current => current === pending ? null : current));
414
+ };
373
415
 
374
416
  // ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
375
417
  const lastUserTs = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null; return null; }, [messages]);
@@ -614,7 +656,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
614
656
  {Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
615
657
  </button>}
616
658
  {h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
617
- <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
659
+ <button className="btn btn-line btn-sm" onClick={() => key && setCloseAction({ key, stage: "confirm" })} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
618
660
  )}
619
661
  </div>
620
662
  </div>}
@@ -623,8 +665,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
623
665
  <div className="clear-confirm">
624
666
  <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
625
667
  <span className="spacer" />
626
- <button className="btn btn-ghost btn-sm" onClick={() => setClosing("idle")} disabled={closing === "busy"}>取消</button>
627
- <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>
668
+ <button className="btn btn-ghost btn-sm" onClick={() => setCloseAction(null)} disabled={closing === "busy"}>取消</button>
669
+ <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={confirmClose}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
628
670
  </div>
629
671
  )}
630
672
  {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>}
@@ -634,6 +676,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
634
676
  <div className="transcript-wrap">
635
677
  <div className="transcript" ref={scrollRef} onScroll={onScroll}>
636
678
  <div className="tw">
679
+ {slots?.beforeMessages}
637
680
  {(!key || isNew) && cfg.empty.show && (
638
681
  <div className="hero cli-hero">
639
682
  <div className="h-ic">{Icon.bolt()}</div>
@@ -693,11 +736,12 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
693
736
  <button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
694
737
  </div>
695
738
  )}
696
- {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
739
+ {cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
697
740
  {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
698
741
  {ended && cfg.transcript.status && (
699
742
  <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
700
743
  )}
744
+ {slots?.afterMessages}
701
745
  {continuedBy && (
702
746
  <div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
703
747
  )}
@@ -737,15 +781,24 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
737
781
  ))}
738
782
  </div>
739
783
  )}
784
+ {showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
785
+ <details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
786
+ <div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
787
+ <ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
788
+ </details>
789
+ <label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
790
+ </div>}
791
+ {slots?.beforeComposer}
740
792
  {showComposer && (
741
793
  <Composer
742
794
  key={key ?? "new"}
743
- draft={key ?? `new:${instanceId}`}
795
+ draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
744
796
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
745
- disabled={composerDisabled}
797
+ disabled={composerDisabled || !!configurationBusy}
746
798
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
747
799
  running={running || batchPending}
748
- onSend={send}
800
+ toolbar={slots?.composerToolbar}
801
+ onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
749
802
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
750
803
  taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
751
804
  paused: controlTask.status === "paused",
@@ -1,24 +1,16 @@
1
1
  // 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
2
2
  // 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
3
3
  // 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
4
- import { useEffect, useRef, useState } from "react";
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
5
  import {
6
6
  ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
7
- } from "../core/blocks.js";
8
- import { Icon } from "../ui/icons.js";
9
- import { TextBlockCard } from "../ui/TextBlock.js";
10
- import { fmtCount } from "../ui/format.js";
7
+ } from "../core/blocks";
8
+ import { Icon } from "../ui/icons";
9
+ import { clientId } from "../core/id";
10
+ import { TextBlockCard } from "../ui/TextBlock";
11
+ import { fmtCount } from "../ui/format";
11
12
 
12
- const draftKey = (key: string) => `cli-ui:draft:${key}`;
13
- const loadDraft = (key: string) => { try { return localStorage.getItem(draftKey(key)) ?? ""; } catch { return ""; } };
14
- const saveDraft = (key: string, value: string) => {
15
- try {
16
- if (value) localStorage.setItem(draftKey(key), value);
17
- else localStorage.removeItem(draftKey(key));
18
- } catch { /* 隐私模式 / 配额:草稿就不保存 */ }
19
- };
20
-
21
- type Pending = { key: string; name: string; status: "reading" | "error"; error?: string };
13
+ import { clearComposerDraft, readComposerDraft, updateComposerDraft, useComposerDraft, type PendingFile } from "./draft";
22
14
 
23
15
  /** 输入框的可配置项(来自 CliChat 的 config.composer)。 */
24
16
  export interface ComposerOptions {
@@ -30,7 +22,7 @@ export interface ComposerOptions {
30
22
  queue: boolean;
31
23
  }
32
24
 
33
- export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options }: {
25
+ export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar }: {
34
26
  /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
35
27
  draft: string;
36
28
  placeholder: string;
@@ -41,38 +33,43 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
41
33
  onStop?: () => Promise<void> | void;
42
34
  taskControl?: { paused: boolean; onToggle: () => Promise<void> };
43
35
  options: ComposerOptions;
36
+ toolbar?: ReactNode;
44
37
  }) {
45
- const [val, setVal] = useState(() => loadDraft(draft));
46
- const [blocks, setBlocks] = useState<Array<TextBlock & { key: string }>>([]);
47
- const [pending, setPending] = useState<Pending[]>([]);
48
- const [error, setError] = useState<string | null>(null);
49
- const [sending, setSending] = useState(false);
38
+ const state = useComposerDraft(draft);
39
+ const { text: val, blocks, pending, error, sending } = state;
40
+ const setVal = (text: string) => updateComposerDraft(draft, s => ({ ...s, text }));
41
+ const setBlocks = (fn: (blocks: typeof state.blocks) => typeof state.blocks) => updateComposerDraft(draft, s => ({ ...s, blocks: fn(s.blocks) }));
42
+ const setPending = (fn: (pending: PendingFile[]) => PendingFile[]) => updateComposerDraft(draft, s => ({ ...s, pending: fn(s.pending) }));
43
+ const setError = (error: string | null) => updateComposerDraft(draft, s => ({ ...s, error }));
44
+ const setSending = (sending: boolean) => updateComposerDraft(draft, s => ({ ...s, sending }));
50
45
  const [stopping, setStopping] = useState(false);
51
46
  const [controlling, setControlling] = useState(false);
52
47
  const [dragging, setDragging] = useState(false);
53
48
  const dragDepth = useRef(0);
54
- const seq = useRef(0);
49
+ const stopLock = useRef(false);
50
+ const controlLock = useRef(false);
51
+ const composing = useRef(false);
55
52
  const fileRef = useRef<HTMLInputElement>(null);
56
53
  const ref = useRef<HTMLTextAreaElement>(null);
57
54
 
58
- useEffect(() => { if (!running) setStopping(false); }, [running]);
59
- useEffect(() => { saveDraft(draft, val); }, [draft, val]);
55
+ useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
60
56
  const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
61
57
  useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
62
58
 
63
- const nextKey = () => `blk-${++seq.current}`;
59
+ const nextKey = () => `blk-${clientId()}`;
64
60
  const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
65
61
 
66
62
  const addFiles = (files: File[]) => {
67
- if (disabled || !options.fileUpload) return;
63
+ if (disabled || sending || !options.fileUpload) return;
68
64
  for (const file of files) {
69
65
  const key = nextKey();
70
66
  setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
71
67
  readFileText(file)
72
68
  .then((text) => {
73
- setPending((prev) => prev.filter((p) => p.key !== key));
74
69
  if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
75
- addBlock({ name: file.name, text, kind: "file" });
70
+ updateComposerDraft(draft, s => s.pending.some(p => p.key === key && p.status === "reading")
71
+ ? { ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }
72
+ : s);
76
73
  })
77
74
  .catch((err: unknown) => {
78
75
  setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
@@ -85,20 +82,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
85
82
  const tooLong = total > MAX_MESSAGE_CHARS;
86
83
  const reading = pending.some((p) => p.status === "reading");
87
84
  // 运行中不允许排队时,等这一轮结束再发。
88
- const canSend = !!message.trim() && !reading && !tooLong && !sending && (!running || options.queue);
85
+ const canSend = !!message.trim() && !pending.length && !reading && !tooLong && !sending && (!running || options.queue);
89
86
 
90
87
  const submit = async () => {
91
- if (!canSend || disabled) return;
88
+ if (!canSend || disabled || readComposerDraft(draft).sending) return;
89
+ const submitted = JSON.stringify([val, blocks]);
92
90
  setSending(true);
93
91
  setError(null);
94
92
  const key = draft;
95
93
  try {
96
94
  await onSend(message);
97
95
  // 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
98
- saveDraft(key, "");
99
- setVal("");
100
- setBlocks([]);
101
- setPending([]);
96
+ const current = readComposerDraft(key);
97
+ if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
102
98
  if (ref.current) ref.current.style.height = "auto";
103
99
  } catch (err) {
104
100
  // 发送失败:内容留在输入框里,可以直接再发。
@@ -111,7 +107,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
111
107
  const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
112
108
  const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
113
109
  const stopButton = running && onStop && (
114
- <button type="button" className="send stop" onClick={() => { setStopping(true); void Promise.resolve(onStop()).catch(() => setStopping(false)); }}
110
+ <button type="button" className="send stop" onClick={async () => {
111
+ if (stopLock.current) return;
112
+ stopLock.current = true; setStopping(true); setError(null);
113
+ try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
114
+ }}
115
115
  disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
116
116
  );
117
117
 
@@ -119,7 +119,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
119
119
  <div className="composer">
120
120
  <div className="composer-inner">
121
121
  {(error || tooLong) && (
122
- <div className="cli-comp-err">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
122
+ <div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
123
123
  )}
124
124
  <div
125
125
  className={`comp-box${dragging ? " dropping" : ""}`}
@@ -144,17 +144,18 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
144
144
  <div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
145
145
  <span className="attach-file">{Icon.doc()} {p.name}</span>
146
146
  <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
147
- {p.status === "error" && <button className="attach-x" title="移除" aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>}
147
+ <button type="button" className="attach-x" title={p.status === "reading" ? "取消添加,忽略尚未完成的解析结果" : "移除"} aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>
148
148
  </div>
149
149
  ))}
150
150
  </div>
151
151
  )}
152
152
  <div className="comp-area">
153
153
  <textarea
154
- ref={ref} rows={2} value={val} disabled={disabled} aria-label="消息内容"
154
+ ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
155
155
  placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
156
156
  onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
157
- onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void submit(); } }}
157
+ onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
158
+ onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
158
159
  onPaste={(e) => {
159
160
  const files = Array.from(e.clipboardData?.files ?? []);
160
161
  if (files.length) {
@@ -176,9 +177,10 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
176
177
  <div className="comp-foot">
177
178
  {options.fileUpload && !disabled && <>
178
179
  <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
179
- <button type="button" className="cli-attach" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
180
- onClick={() => fileRef.current?.click()}>{Icon.clip()} {options.fileButtonText}</button>
180
+ <button type="button" className="cli-attach" disabled={sending} aria-label="添加文档" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
181
+ onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
181
182
  </>}
183
+ {toolbar}
182
184
  {options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
183
185
  <span className="spacer" />
184
186
  {options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
@@ -187,7 +189,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
187
189
  {taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
188
190
  aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
189
191
  title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
190
- onClick={async () => { setControlling(true); try { await taskControl.onToggle(); } catch { /* 错误由调用方显示,保留草稿和当前状态 */ } finally { setControlling(false); } }}>
192
+ onClick={async () => { if (controlLock.current) return; controlLock.current = true; setControlling(true); try { await taskControl.onToggle(); } catch (error) { setError(error instanceof Error ? error.message : "执行控制失败,请重试"); } finally { controlLock.current = false; setControlling(false); } }}>
191
193
  {taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
192
194
  </button>}
193
195
  {(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { Fragment, useEffect, useRef, useState } from "react";
4
+ import { Icon } from "../ui/icons";
5
+ import { Popover } from "../ui/Popover";
6
+ import { modelLabel } from "../ui/format";
7
+ import { acquireConfiguration, useConfigurationBusy } from "./configuration";
8
+ export interface RunOption { value: string; label: string; sub: string; group?: string }
9
+ export interface RunCapabilities { models: RunOption[]; reasoningOptions: RunOption[]; permissionModes: RunOption[]; supportsCustomModels?: boolean }
10
+ export interface RunTask { id: string; agent?: string; model?: string | null; reasoning?: string | null; permission_mode?: string | null; resolved_model?: string | null }
11
+ const withDefault = (options?: RunOption[]) => [{ value: null as string | null, label: "默认", sub: "沿用智能体的默认设置", group: undefined as string | undefined }, ...(options ?? [])];
12
+ const modelOptions = (caps?: RunCapabilities) => withDefault(caps?.models);
13
+ const permissionOptions = (caps?: RunCapabilities) => withDefault(caps?.permissionModes);
14
+ const reasoningOptions = (caps?: RunCapabilities) => withDefault(caps?.reasoningOptions);
15
+
16
+ export type RunControlProps = {
17
+ task: RunTask;
18
+ agents?: {id:string;label:string;disabled?:boolean}[];
19
+ onSetAgent?: (id:string)=>Promise<void>;
20
+ caps?: RunCapabilities;
21
+ defaults?: { model?: string | null; reasoning?: string | null; permission_mode?: string | null };
22
+ disabled: boolean;
23
+ onSetModel: (value: string | null) => Promise<void>;
24
+ onSetReasoning: (value: string | null) => Promise<void>;
25
+ onSetPermission: (value: string | null) => Promise<void>;
26
+ onBusyChange?: (busy: boolean) => void;
27
+ };
28
+
29
+ // Both the pre-start view and composer use the same capability-driven controls.
30
+ // Await persistence before allowing Send so the next turn uses the chosen value.
31
+ export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
32
+ const [open, setOpen] = useState<"model" | "permission" | null>(null);
33
+ const [custom, setCustom] = useState("");
34
+ const busy = useConfigurationBusy(task.id);
35
+ const currentTask = useRef(task.id); currentTask.current = task.id;
36
+ useEffect(() => { setOpen(null); setError(""); }, [task.id]);
37
+ const [error, setError] = useState("");
38
+ const models = modelOptions(caps);
39
+ const reasoning = reasoningOptions(caps);
40
+ const permissions = permissionOptions(caps);
41
+ const effectiveModel = task.model ?? defaults?.model ?? null;
42
+ const effectiveReasoning = task.reasoning ?? defaults?.reasoning ?? null;
43
+ const effectivePermission = task.permission_mode ?? defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
44
+ const model = models.find((m) => m.value === effectiveModel)?.label ?? modelLabel(effectiveModel ?? "");
45
+ const permission = permissions.find((p) => p.value === effectivePermission);
46
+ const change = async (save: () => Promise<void>) => {
47
+ if (disabled) return;
48
+ const release = acquireConfiguration(task.id);
49
+ if (!release) return;
50
+ const targetId = task.id;
51
+ setError("");
52
+ try { onBusyChange?.(true); await save(); if(currentTask.current === targetId) setOpen(null); }
53
+ catch (e) { if(currentTask.current === targetId) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); }
54
+ finally { release(); onBusyChange?.(false); }
55
+ };
56
+ const locked = disabled || busy || !caps;
57
+ return <>
58
+ {!!caps?.permissionModes.length && <div className="comp-control">
59
+ <button className={`comp-setting permission${effectivePermission === "bypassPermissions" ? " elevated" : ""}`} disabled={locked} aria-label="权限模式" aria-expanded={open === "permission"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改权限" : permission?.sub} onClick={(e) => { e.stopPropagation(); setOpen(open === "permission" ? null : "permission"); }}>
60
+ {Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
61
+ </button>
62
+ {open === "permission" && <Popover onClose={() => setOpen(null)}>
63
+ <div className="pop-sec">权限模式 · 保存后用于下一轮</div>
64
+ {permissions.map((p) => <button key={p.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.permission_mode ?? null) === p.value} onClick={() => void change(() => onSetPermission(p.value))}>
65
+ <span><span>{p.label}</span><span className="pi-sub">{p.sub}</span></span>{(task.permission_mode ?? null) === p.value && Icon.check()}
66
+ </button>)}
67
+ {error && <div className="control-error" role="alert">{error}</div>}
68
+ </Popover>}
69
+ </div>}
70
+ <div className="comp-control comp-model">
71
+ <button className="comp-setting" disabled={locked} aria-label="模型与推理设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改模型与推理" : `模型:${model}${task.resolved_model ? ` · 上次运行:${task.resolved_model}` : ""}`} onClick={(e) => { e.stopPropagation(); setOpen(open === "model" ? null : "model"); }}>
72
+ <span className="comp-model-name">{model === "默认" ? "默认模型" : model}</span>
73
+ {!!caps?.reasoningOptions.length && <span className="comp-reasoning">{reasoning.find((r) => r.value === effectiveReasoning)?.label ?? "默认"}</span>}{Icon.chevDown()}
74
+ </button>
75
+ {open === "model" && <Popover onClose={() => setOpen(null)}>
76
+ {agents && agents.length>1 && onSetAgent && <><div className="pop-sec">智能体 · 接手当前任务的历史</div>{agents.map(a=><button key={a.id} className="pop-item" disabled={locked||a.disabled} aria-pressed={task.agent===a.id} onClick={()=>void change(()=>onSetAgent(a.id))}>{a.label}{task.agent===a.id&&Icon.check()}</button>)}<div className="divider"/></>}
77
+ <div className="pop-sec">模型 · 保存后用于下一轮</div>
78
+ {models.map((m, i) => <Fragment key={m.value ?? "default"}>
79
+ {m.group && m.group !== models[i - 1]?.group && <div className="pop-sec">{m.group}</div>}
80
+ <button className="pop-item" disabled={locked} aria-pressed={(task.model ?? null) === m.value} onClick={() => void change(() => onSetModel(m.value))}>
81
+ <span><span>{m.label}</span><span className="pi-sub">{m.sub}</span></span>{(task.model ?? null) === m.value && Icon.check()}
82
+ </button>
83
+ </Fragment>)}
84
+ {caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
85
+ <label className="pop-sec" htmlFor={`model-${task.id}`}>自定义模型</label>
86
+ <div className="pop-custom-row"><input id={`model-${task.id}`} value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="模型 ID" disabled={locked} /><button className="btn btn-sm" disabled={locked || !custom.trim()}>使用</button></div>
87
+ </form>}
88
+ {!!caps?.reasoningOptions.length && <>
89
+ <div className="divider" /><div className="pop-sec">推理强度</div>
90
+ {reasoning.map((r) => <button key={r.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.reasoning ?? null) === r.value} onClick={() => void change(() => onSetReasoning(r.value))}>
91
+ <span><span>{r.label}</span><span className="pi-sub">{r.sub}</span></span>{(task.reasoning ?? null) === r.value && Icon.check()}
92
+ </button>)}
93
+ </>}
94
+ {busy && <div className="pop-sec" role="status">正在保存…</div>}
95
+ {error && <div className="control-error" role="alert">{error}</div>}
96
+ </Popover>}
97
+ </div>
98
+ </>;
99
+ }
@@ -0,0 +1,15 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ // A task's setting request outlives its controls when the user changes tasks.
3
+ const pending = new Set<string>();
4
+ const listeners = new Set<() => void>();
5
+ const notify = () => listeners.forEach(listener => listener());
6
+ export const isConfigurationBusy = (taskId:string) => pending.has(taskId);
7
+ export function acquireConfiguration(taskId:string): (()=>void) | null {
8
+ if (pending.has(taskId)) return null;
9
+ pending.add(taskId); notify();
10
+ let active=true;
11
+ return () => { if(active){active=false;pending.delete(taskId);notify();} };
12
+ }
13
+ export function useConfigurationBusy(taskId:string) {
14
+ return useSyncExternalStore(listener=>{listeners.add(listener);return()=>listeners.delete(listener);},()=>isConfigurationBusy(taskId),()=>false);
15
+ }