@spzxg/cli-ui 0.6.5 → 0.6.7

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 (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +109 -30
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +11 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/CliBoard.tsx +128 -100
  13. package/src/board/FileViewer.tsx +276 -136
  14. package/src/board/FilesPane.tsx +129 -40
  15. package/src/board/HtmlReadingPreview.tsx +52 -0
  16. package/src/board/MarkdownPreview.tsx +134 -0
  17. package/src/board/PanelControls.tsx +19 -7
  18. package/src/board/fileKind.ts +138 -0
  19. package/src/board/fileOps.ts +9 -2
  20. package/src/board/fileScope.ts +29 -0
  21. package/src/board/fileSync.ts +343 -0
  22. package/src/board/fileSyncCache.ts +250 -0
  23. package/src/board/fileSyncProcessor.ts +71 -0
  24. package/src/board/fileSyncQueue.ts +56 -0
  25. package/src/board/fileSyncTransport.ts +85 -0
  26. package/src/board/fileWatch.ts +177 -70
  27. package/src/board/folderDownload.ts +39 -5
  28. package/src/board/frameQuote.ts +12 -10
  29. package/src/board/htmlReading.ts +148 -0
  30. package/src/board/textLoad.ts +18 -41
  31. package/src/board/useBoardUpload.tsx +51 -0
  32. package/src/board/useBoardVisibility.ts +35 -0
  33. package/src/board/workspace.ts +53 -11
  34. package/src/chat/CliChat.tsx +105 -55
  35. package/src/chat/Composer.tsx +55 -21
  36. package/src/chat/GroupChat.tsx +113 -0
  37. package/src/chat/RunControls.tsx +132 -42
  38. package/src/chat/draft.ts +5 -2
  39. package/src/core/blocks.ts +17 -5
  40. package/src/core/client.ts +88 -19
  41. package/src/core/conversation.ts +1 -0
  42. package/src/core/documentUpload.ts +196 -0
  43. package/src/core/provider.tsx +7 -0
  44. package/src/core/store.ts +134 -36
  45. package/src/core/types.ts +31 -10
  46. package/src/index.ts +3 -0
  47. package/src/layout/split.ts +67 -30
  48. package/src/msglist/CliMsgList.tsx +18 -5
  49. package/src/operator/node/Chat.tsx +7 -8
  50. package/src/operator/node/Composer.tsx +99 -72
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +25 -25
  54. package/src/operator/node/Transcript.tsx +28 -11
  55. package/src/operator/node/boardBackend.ts +6 -23
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/protocol/types.ts +2 -2
  63. package/src/operator/node/useTaskStream.ts +8 -5
  64. package/src/operator.base.css +148 -37
  65. package/src/operator.css +148 -37
  66. package/src/styles.css +274 -32
  67. package/src/ui/Markdown.tsx +2 -2
  68. package/src/ui/Popover.tsx +32 -14
  69. package/src/ui/QueuedMessages.tsx +69 -0
  70. package/src/ui/ReferenceCard.tsx +28 -0
  71. package/src/ui/TextBlock.tsx +6 -4
  72. package/src/ui/Transcript.tsx +27 -12
  73. package/src/ui/UploadProgress.tsx +46 -0
  74. package/src/ui/WorkspaceDrawer.tsx +64 -0
  75. package/src/ui/WorkspaceTabs.tsx +60 -0
  76. package/src/ui/format.ts +3 -1
  77. package/src/ui/reference.ts +40 -0
  78. package/src/ui/useChatScrollbar.ts +29 -0
  79. package/src/ui/useComposerAutosize.ts +54 -0
  80. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
@@ -20,9 +20,19 @@ import { exportMarkdown } from "./export";
20
20
  import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
21
21
  import { FileViewer } from "../board/FileViewer";
22
22
  import { BrowserPane } from "../board/BrowserPane";
23
+ import { CliBoard } from "../board/CliBoard";
24
+ import { WorkspaceTabs } from "../ui/WorkspaceTabs";
25
+ import { WorkspaceDrawer, useWorkspaceDrawer } from "../ui/WorkspaceDrawer";
26
+ import { useChatScrollbar } from "../ui/useChatScrollbar";
27
+ import { QueuedMessages } from "../ui/QueuedMessages";
28
+ import { ReferenceCard } from "../ui/ReferenceCard";
29
+ import type { UploadDocument } from "../core/documentUpload";
30
+ import { GroupChat, type GroupChatAdapter } from "./GroupChat";
23
31
 
24
32
  /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
25
33
  export interface CliChatProps extends CliStoreOptions, ResultHandlers {
34
+ /** 宿主提供的多发言人群聊;发送统一交给 group.onSend,不连接普通单任务转录。 */
35
+ group?: GroupChatAdapter;
26
36
  /** Integration slots share this transcript/composer; they do not create another chat implementation. */
27
37
  slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
28
38
  /** Saving execution settings blocks send until persistence completes. */
@@ -77,6 +87,7 @@ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
77
87
 
78
88
  export function CliChat(props: CliChatProps) {
79
89
  const ctx = useCliStoreContext();
90
+ if (props.group) return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <GroupChat key={props.group.scopeKey} group={props.group} theme={props.theme} className={props.className} style={props.style} readOnly={props.readOnly} />, !!ctx)}</CliErrorBoundary>;
80
91
  return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
81
92
  }
82
93
 
@@ -143,6 +154,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
143
154
  // 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
144
155
  // 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
145
156
  const instanceId = useId();
157
+ const inlineOwner = `chat-board-${instanceId}`;
146
158
  const controlled = jobIdProp !== undefined;
147
159
  // jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
148
160
  const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
@@ -164,12 +176,19 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
164
176
  }, [controlled, store]);
165
177
  const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
166
178
  const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
179
+ const externalBoardVisible = useCliState(s => !!(key && s.externalBoards[key]));
180
+ const listVisible = useCliState(s => s.listVisible);
181
+ const externalBoardOptions = useCliState(s => key ? s.externalBoardOptions[key] : undefined);
182
+ const [tabsHost, setTabsHost] = useState<HTMLDivElement | null>(null);
183
+ const [workspace, setWorkspace] = useState<{ key: string | null; active: boolean }>({ key: null, active: false });
184
+ const showChat = useCallback(() => setWorkspace({ key, active: false }), [key]);
185
+ const showWorkspace = useCallback(() => setWorkspace({ key, active: true }), [key]);
167
186
 
168
187
  // 看这个对话:开实时流(同一对话多处看只连一次)。
169
188
  useEffect(() => (key ? store.open(key) : undefined), [store, key]);
170
189
  // 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
171
190
  const keyRef = useRef(key);
172
- keyRef.current = key;
191
+ useLayoutEffect(() => { keyRef.current = key; }, [key]);
173
192
  useEffect(() => store.on((event) => {
174
193
  if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
175
194
  }), [store, go]);
@@ -235,7 +254,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
235
254
  : jobTitle ?? (key ? "对话" : "新对话");
236
255
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
237
256
  const lastTurn = conv?.turns[conv.turns.length - 1];
238
- const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
257
+ const listedAwaiting = (info ?? job?.conversation?.conversations?.find(item => item.task_id === task?.id))?.awaiting_input;
258
+ // An explicit null retires an old wait flag when its node observation expires.
259
+ const remoteAwaiting = conv?.task?.awaiting_input !== undefined ? conv.task.awaiting_input
260
+ : listedAwaiting !== undefined ? listedAwaiting : task?.awaiting_input !== undefined ? task.awaiting_input
261
+ : !manyConvs ? job?.conversation?.awaiting_input : undefined;
262
+ const awaiting = !ended && !TERMINAL_JOB.has(jobStatus ?? "") && !(batch && batchTaskStatus && SETTLED_TASK.has(batchTaskStatus))
263
+ && (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
239
264
  // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
240
265
  const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
241
266
  const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
@@ -248,9 +273,9 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
248
273
 
249
274
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
250
275
  if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
276
+ if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
251
277
  if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
252
278
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
253
- if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
254
279
  if (running) return { dotCls: "a", statusText: "运行中" };
255
280
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
256
281
  return { dotCls: "a", statusText: "进行中" };
@@ -261,12 +286,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
261
286
  const [actionError, setActionError] = useState<string | null>(null);
262
287
  useEffect(() => setActionError(null), [key]);
263
288
  const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
289
+ const sourceKey = keyRef.current;
264
290
  try {
265
291
  const result = await work;
266
- setActionError(null);
292
+ if (keyRef.current === sourceKey) setActionError(null);
267
293
  return result;
268
294
  } catch (error) {
269
- setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
295
+ if (keyRef.current === sourceKey) setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
270
296
  throw error;
271
297
  }
272
298
  }, []);
@@ -274,13 +300,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
274
300
  // (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
275
301
  const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
276
302
  const latest = useRef({ key, messages, conv, runKey });
277
- latest.current = { key, messages, conv, runKey };
303
+ // A concurrent render may suspend while the old chat remains interactive.
304
+ // Event handlers must keep the target and options of the committed view.
305
+ useLayoutEffect(() => { latest.current = { key, messages, conv, runKey }; });
278
306
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
279
307
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
280
- sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
281
- const [skipPassive,setSkipPassive]=useState(false);
282
- const skipPassiveRef=useRef(false);skipPassiveRef.current=skipPassive;
283
- useEffect(()=>setSkipPassive(false),[key]);
308
+ useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
284
309
  const send = useCallback(async (text: string) => {
285
310
  // 一次性任务:发给正在看的那个子任务(接着它聊)。
286
311
  const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
@@ -289,7 +314,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
289
314
  const refKey = latest.current.key;
290
315
  const refs = refKey ? store.takeRefs(refKey) : [];
291
316
  const passiveView=refKey?store.getState().boardContext[refKey]:null;
292
- const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
317
+ const passive=passiveContext&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
293
318
  const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
294
319
  const blocks = [
295
320
  ...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
@@ -299,14 +324,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
299
324
  const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
300
325
  let target: string;
301
326
  try {
302
- if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
327
+ if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简消息内容后重试。");
303
328
  target = await store.send(id ?? null, full, sendOpts.current);
304
329
  } catch (error) {
305
330
  if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
306
331
  throw error;
307
332
  }
308
- if(latest.current.key===refKey)setSkipPassive(false);
309
- if (target !== id) go(target);
333
+ if (target !== id && latest.current.key === refKey) go(target);
310
334
  }, [store, go, passiveContext]);
311
335
  const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
312
336
  const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
@@ -316,6 +340,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
316
340
  // 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
317
341
  const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
318
342
  const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
343
+ const chatRef = useRef<HTMLDivElement>(null);
319
344
  // 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
320
345
  const contextItems = useCliState((s) => s.context[key ?? ""]);
321
346
  // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
@@ -330,8 +355,8 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
330
355
  if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
331
356
  const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
332
357
  for (const m of fresh) seen.ids.add(m.id);
333
- if (fresh.length) store.openInBoard(key, { kind: "browser" });
334
- }, [key, conv, fileOpen, store]);
358
+ if (fresh.length) store.openInBoard(key, { kind: "browser" }, inlineOwner);
359
+ }, [key, conv, fileOpen, store, inlineOwner]);
335
360
 
336
361
  // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
337
362
  const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
@@ -343,7 +368,12 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
343
368
  return () => window.removeEventListener("keydown", onKey);
344
369
  }, [popup]);
345
370
  const handlers = useMemo<MessageHandlers>(() => ({
346
- 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)});},
371
+ onQuote: readOnly ? undefined : (m,selected) => {
372
+ const id = latest.current.key;
373
+ if (!id) return;
374
+ store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});
375
+ chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
376
+ },
347
377
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
348
378
  onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
349
379
  const id = latest.current.key;
@@ -351,7 +381,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
351
381
  // 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
352
382
  if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
353
383
  const sent = askId
354
- ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
384
+ ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id && latest.current.key === id) go(target); })
355
385
  : send(formatAnswersText(questions, answers));
356
386
  await report(sent, "回答没有发出去");
357
387
  },
@@ -375,7 +405,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
375
405
  onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
376
406
  const id = latest.current.key;
377
407
  if (!id) return;
378
- if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
408
+ if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" }, inlineOwner)) return;
379
409
  if (fileOpen !== "board") setPopup({ kind: "browser" });
380
410
  },
381
411
  onRetry: readOnly ? undefined : (msgId: string) => {
@@ -383,7 +413,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
383
413
  const at = list.findIndex((m) => m.id === msgId);
384
414
  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; }
385
415
  },
386
- }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
416
+ }), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen, inlineOwner]);
387
417
  const stop = useCallback(async () => {
388
418
  const { key: id, conv: c } = latest.current;
389
419
  const turn = c ? runningTurn(c) : null;
@@ -418,6 +448,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
418
448
 
419
449
  // ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
420
450
  const scrollRef = useRef<HTMLDivElement>(null);
451
+ useChatScrollbar(scrollRef, key);
421
452
  const pinned = useRef(true);
422
453
  const [atBottom, setAtBottom] = useState(true);
423
454
  const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
@@ -559,7 +590,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
559
590
  if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
560
591
  }
561
592
  }, [win]);
562
- useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx]);
593
+ useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
563
594
  useEffect(() => {
564
595
  const el = scrollRef.current;
565
596
  if (!el || typeof ResizeObserver === "undefined") return;
@@ -591,6 +622,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
591
622
  const fileUrlRef = useRef(fileUrl);
592
623
  fileUrlRef.current = fileUrl;
593
624
  const board = store.client.board;
625
+ const mobileWorkspace = useWorkspaceDrawer(chatRef);
626
+ const workspaceAvailable = h.show && !!key && !isNew && !!board && !externalBoardVisible;
627
+ const workspaceTabs = workspaceAvailable && !mobileWorkspace;
628
+ const workspaceActive = workspaceAvailable && workspace.key === key && workspace.active;
629
+ const showCurrentTitle = h.title || externalBoardVisible || (mobileWorkspace && workspaceAvailable);
630
+ const quoteToChat = useCallback(() => {
631
+ const sourceKey = key;
632
+ showChat();
633
+ requestAnimationFrame(() => {
634
+ if (keyRef.current === sourceKey) chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
635
+ });
636
+ }, [key, showChat]);
594
637
  // 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
595
638
  const resolveFile = useMemo(() => {
596
639
  if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
@@ -601,27 +644,44 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
601
644
  const openFile = useMemo(() => {
602
645
  if (!key || fileOpen === "link") return null;
603
646
  return (path: string) => {
604
- if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
647
+ if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path }, inlineOwner)) return true;
605
648
  if (fileOpen === "board" || !board) return false;
606
649
  setPopup({ kind: "file", path });
607
650
  return true;
608
651
  };
609
- }, [key, fileOpen, store, board]);
652
+ }, [key, fileOpen, store, board, inlineOwner]);
653
+ const uploadDocument = useMemo<UploadDocument | undefined>(() => {
654
+ const staging = store.client.uploadDocument;
655
+ if (!key || isNew || !board?.uploadFile) return staging;
656
+ return async (file, options) => {
657
+ const target = await board.info(key);
658
+ if (options.signal?.aborted) throw new DOMException("上传已取消", "AbortError");
659
+ if (!target.dispatched) {
660
+ if (staging) return staging(file, options);
661
+ throw new Error("当前会话工作区尚未就绪");
662
+ }
663
+ const receipt = await board.uploadFile!(key, file, options);
664
+ if (!receipt.path) throw new Error("文件尚未进入当前工作区,请重试");
665
+ if (!options.signal?.aborted && latest.current.key === key) openFile?.(receipt.path);
666
+ return receipt;
667
+ };
668
+ }, [store, key, isNew, board, openFile]);
610
669
 
611
670
  return (
612
671
  <FileLinkContext.Provider value={resolveFile}>
613
672
  <FileOpenContext.Provider value={openFile}>
614
- <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
673
+ <div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
615
674
  <div className="session">
616
- {h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
617
- {(h.crumb || h.title) && <div className="sh-main">
675
+ {h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
676
+ {workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
618
677
  {h.crumb && <div className="crumb">
619
678
  <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>
620
679
  {title} <span className="sep">/</span> 对话
621
680
  </div>}
622
- {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
681
+ {showCurrentTitle && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
623
682
  </div>}
624
683
  <div className="sh-tools">
684
+ {mobileWorkspace && workspaceAvailable && <button type="button" className="btn btn-ghost btn-sm workspace-open" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={workspaceActive} title="打开工作区" onClick={showWorkspace}>{Icon.board()}</button>}
625
685
  {h.agent && agent && <AgentBadge label={agentText} />}
626
686
  {h.usage && (fresh > 0 || cacheRead > 0) && (
627
687
  <span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
@@ -651,6 +711,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
651
711
  </div>
652
712
  </div>}
653
713
 
714
+ <div className="workspace-chat-content" data-chat-layout={listVisible || externalBoardVisible ? "wide" : "centered"} hidden={workspaceActive && !mobileWorkspace} style={workspaceActive && !mobileWorkspace ? { display: "none" } : undefined}>
654
715
  {closing !== "idle" && key && (
655
716
  <div className="clear-confirm">
656
717
  <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
@@ -726,8 +787,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
726
787
  <button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
727
788
  </div>
728
789
  )}
729
- {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
730
- {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
790
+ {cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
731
791
  {ended && cfg.transcript.status && (
732
792
  <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
733
793
  )}
@@ -753,42 +813,24 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
753
813
  ))}
754
814
  </div>
755
815
  )}
756
- {showComposer && (!!shownRefs?.length || !!viewing) && (
757
- <div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
758
- <span className="cli-refs-t">{Icon.board()} 引用自看板</span>
759
- {viewing && (
760
- <span className="cli-ref auto" title={`${viewing.text}\n(看板上正在看,下一条消息缺省带上;点 × 这次不带)`}>
761
- <span className="cli-ref-k">正在看</span>
762
- <span className="cli-ref-l">{viewing.label}</span>
763
- <button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
764
- </span>
765
- )}
766
- {(shownRefs ?? []).map((r) => (
767
- <span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
768
- <span className="cli-ref-l">{r.label}</span>
769
- <button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
770
- </span>
771
- ))}
772
- </div>
773
- )}
774
- {showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
775
- <details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
776
- <div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
777
- <ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
778
- </details>
779
- <label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
780
- </div>}
781
816
  {slots?.beforeComposer}
817
+ {!showComposer && <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
782
818
  {showComposer && (
783
819
  <Composer
820
+ uploadDocument={uploadDocument}
784
821
  key={key ?? "new"}
785
822
  draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
823
+ context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
824
+ {viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />}
825
+ {(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />)}
826
+ </div>}
786
827
  placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
787
828
  disabled={composerDisabled || !!configurationBusy}
788
829
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
789
830
  running={running || batchPending}
790
831
  toolbar={slots?.composerToolbar}
791
- onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
832
+ queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} attachmentUrl={handlers.attachmentUrl} />}
833
+ onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } const sentFrom = key; await send(text); if (!running && !batchPending && latest.current.key === sentFrom) scrollToBottom("auto"); }}
792
834
  onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
793
835
  taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
794
836
  paused: controlTask.status === "paused",
@@ -800,6 +842,14 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
800
842
  options={composerOptions}
801
843
  />
802
844
  )}
845
+ </div>
846
+ <WorkspaceDrawer enabled={mobileWorkspace} open={workspaceActive} onClose={showChat}>
847
+ {workspaceAvailable && <CliBoard jobId={key} inlineOwner={inlineOwner} quoteToChat={!readOnly} autoQuoteOpenFile={passiveContext}
848
+ readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
849
+ acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
850
+ visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
851
+ inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
852
+ </WorkspaceDrawer>
803
853
  </div>
804
854
  {popup && key && board && (
805
855
  <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
@@ -1,6 +1,5 @@
1
- // 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
2
- // 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
3
- // 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
1
+ // 输入框与 Operator 共用原件上传。上传完成只保留附件引用,用户发送时才交给 Agent。
2
+ // 没有原件上传适配器的宿主继续使用原有文档文本解析。
4
3
  import { useEffect, useRef, useState, type ReactNode } from "react";
5
4
  import {
6
5
  ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
@@ -9,6 +8,9 @@ import { Icon } from "../ui/icons";
9
8
  import { clientId } from "../core/id";
10
9
  import { TextBlockCard } from "../ui/TextBlock";
11
10
  import { fmtCount } from "../ui/format";
11
+ import { useComposerAutosize } from "../ui/useComposerAutosize";
12
+ import { documentMarker, FILE_UPLOAD_ACCEPT, type UploadDocument } from "../core/documentUpload";
13
+ import { UploadProgress } from "../ui/UploadProgress";
12
14
 
13
15
  import { clearComposerDraft, readComposerDraft, updateComposerDraft, useComposerDraft, type PendingFile } from "./draft";
14
16
 
@@ -22,7 +24,7 @@ export interface ComposerOptions {
22
24
  queue: boolean;
23
25
  }
24
26
 
25
- export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar }: {
27
+ export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar, queued, context, uploadDocument }: {
26
28
  /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
27
29
  draft: string;
28
30
  placeholder: string;
@@ -34,6 +36,9 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
34
36
  taskControl?: { paused: boolean; onToggle: () => Promise<void> };
35
37
  options: ComposerOptions;
36
38
  toolbar?: ReactNode;
39
+ queued?: ReactNode;
40
+ context?: ReactNode;
41
+ uploadDocument?: UploadDocument;
37
42
  }) {
38
43
  const state = useComposerDraft(draft);
39
44
  const { text: val, blocks, pending, error, sending } = state;
@@ -51,10 +56,21 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
51
56
  const composing = useRef(false);
52
57
  const fileRef = useRef<HTMLInputElement>(null);
53
58
  const ref = useRef<HTMLTextAreaElement>(null);
59
+ const uploads = useRef(new Map<string, AbortController>());
60
+ const selectedFiles = useRef(new Map<string, File>());
61
+
62
+ useEffect(() => () => {
63
+ const owned = new Set(uploads.current.keys());
64
+ for (const controller of uploads.current.values()) controller.abort();
65
+ uploads.current.clear(); selectedFiles.current.clear();
66
+ if (readComposerDraft(draft).pending.some(file => owned.has(file.key) && file.status === "reading")) {
67
+ updateComposerDraft(draft, current => ({ ...current, pending: current.pending.map(file => owned.has(file.key) && file.status === "reading"
68
+ ? { ...file, status: "error", error: file.phase ? "上传已中断,请重新选择文件。" : "解析已中断,请重新选择文件。" } : file) }));
69
+ }
70
+ }, [draft]);
54
71
 
55
72
  useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
56
- const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
57
- useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
73
+ useComposerAutosize(ref, val);
58
74
 
59
75
  const nextKey = () => `blk-${clientId()}`;
60
76
  const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
@@ -63,15 +79,28 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
63
79
  if (disabled || sending || !options.fileUpload) return;
64
80
  for (const file of files) {
65
81
  const key = nextKey();
66
- setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
67
- readFileText(file)
82
+ const original = !!uploadDocument;
83
+ const controller = new AbortController();
84
+ uploads.current.set(key, controller);
85
+ selectedFiles.current.set(key, file);
86
+ setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading", ...(original ? { phase: "queued" as const, bytes: 0, size: file.size } : {}) }]);
87
+ const content = original ? Promise.resolve().then(() => uploadDocument!(file, { draftId: clientId(), signal: controller.signal,
88
+ onPhase: phase => { if (!controller.signal.aborted) setPending(prev => prev.map(p => p.key === key ? { ...p, phase } : p)); },
89
+ onBytes: bytes => { if (!controller.signal.aborted) setPending(prev => prev.map(p => p.key === key ? { ...p, bytes } : p)); },
90
+ })).then(documentMarker) : readFileText(file);
91
+ content
68
92
  .then((text) => {
93
+ if (controller.signal.aborted) return;
69
94
  if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
70
- updateComposerDraft(draft, s => ({ ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }));
95
+ updateComposerDraft(draft, s => s.pending.some(p => p.key === key && p.status === "reading")
96
+ ? { ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }
97
+ : s);
98
+ selectedFiles.current.delete(key);
71
99
  })
72
100
  .catch((err: unknown) => {
101
+ if (controller.signal.aborted) return;
73
102
  setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
74
- });
103
+ }).finally(() => uploads.current.delete(key));
75
104
  }
76
105
  };
77
106
 
@@ -93,7 +122,6 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
93
122
  // 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
94
123
  const current = readComposerDraft(key);
95
124
  if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
96
- if (ref.current) ref.current.style.height = "auto";
97
125
  } catch (err) {
98
126
  // 发送失败:内容留在输入框里,可以直接再发。
99
127
  setError(err instanceof Error ? err.message : String(err));
@@ -110,7 +138,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
110
138
  stopLock.current = true; setStopping(true); setError(null);
111
139
  try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
112
140
  }}
113
- disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
141
+ disabled={stopping} aria-busy={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
114
142
  );
115
143
 
116
144
  return (
@@ -119,6 +147,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
119
147
  {(error || tooLong) && (
120
148
  <div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
121
149
  )}
150
+ {queued}
122
151
  <div
123
152
  className={`comp-box${dragging ? " dropping" : ""}`}
124
153
  onDragEnter={(e) => { if (!disabled && hasFileDrag(e)) { e.preventDefault(); dragDepth.current++; setDragging(true); } }}
@@ -135,14 +164,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
135
164
  addFiles(Array.from(e.dataTransfer.files));
136
165
  }}
137
166
  >
167
+ {context}
138
168
  {(blocks.length > 0 || pending.length > 0) && (
139
169
  <div className="cli-blocks">
140
170
  {blocks.map((b) => <TextBlockCard key={b.key} block={b} onRemove={() => setBlocks((prev) => prev.filter((x) => x.key !== b.key))} />)}
141
- {pending.map((p) => (
171
+ {pending.map((p) => p.phase ? <UploadProgress key={p.key} name={p.name} status={p.status === "error" ? "error" : p.phase}
172
+ bytes={p.bytes} total={p.size} error={p.error}
173
+ onCancel={() => { uploads.current.get(p.key)?.abort(); selectedFiles.current.delete(p.key); setPending(prev => prev.filter(x => x.key !== p.key)); }}
174
+ onRemove={() => { selectedFiles.current.delete(p.key); setPending(prev => prev.filter(x => x.key !== p.key)); }}
175
+ onRetry={selectedFiles.current.has(p.key) ? () => { if (disabled || sending) return; const file = selectedFiles.current.get(p.key)!; selectedFiles.current.delete(p.key); setPending(prev => prev.filter(x => x.key !== p.key)); addFiles([file]); } : undefined} /> : (
142
176
  <div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
143
177
  <span className="attach-file">{Icon.doc()} {p.name}</span>
144
- <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
145
- {p.status === "error" && <button className="attach-x" title="移除" aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>}
178
+ <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? p.progress ?? "解析中…" : p.error}</span>
179
+ <button type="button" className="attach-x" title={p.status === "reading" ? "取消添加" : "移除"} aria-label={`移除 ${p.name}`} onClick={() => { uploads.current.get(p.key)?.abort(); setPending((prev) => prev.filter((x) => x.key !== p.key)); }}>×</button>
146
180
  </div>
147
181
  ))}
148
182
  </div>
@@ -151,7 +185,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
151
185
  <textarea
152
186
  ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
153
187
  placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
154
- onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
188
+ onChange={(e) => setVal(e.target.value)}
155
189
  onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
156
190
  onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
157
191
  onPaste={(e) => {
@@ -159,8 +193,8 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
159
193
  if (files.length) {
160
194
  e.preventDefault();
161
195
  if (!options.fileUpload) { setError("这里只能发送文字"); return; }
162
- if (files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
163
- addFiles(files.filter((f) => !f.type.startsWith("image/")));
196
+ if (!uploadDocument && files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
197
+ addFiles(uploadDocument ? files : files.filter((f) => !f.type.startsWith("image/")));
164
198
  return;
165
199
  }
166
200
  // 大段粘贴不放进输入框(几万字的 textarea 会很卡),转成可折叠的文本块。
@@ -174,8 +208,8 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
174
208
  </div>
175
209
  <div className="comp-foot">
176
210
  {options.fileUpload && !disabled && <>
177
- <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
178
- <button type="button" className="cli-attach" disabled={sending} aria-label="添加文档" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
211
+ <input ref={fileRef} type="file" accept={uploadDocument ? FILE_UPLOAD_ACCEPT : ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
212
+ <button type="button" className="cli-attach" disabled={sending} aria-label={uploadDocument ? "添加文件" : "添加文档"} title={uploadDocument ? "添加文件(也可拖拽进来);HTML、Markdown 最大 100 MiB,其他支持类型最大 10 MiB" : `添加文档:${ACCEPTED_LABEL}(也可拖拽进来)`}
179
213
  onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
180
214
  </>}
181
215
  {toolbar}
@@ -190,7 +224,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
190
224
  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
225
  {taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
192
226
  </button>}
193
- {(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
227
+ {(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend} aria-busy={sending}
194
228
  onClick={() => void submit()} aria-label={sending ? "正在发送" : running ? "排队发送" : "发送消息"}
195
229
  title={taskControl?.paused ? "排队发送,继续任务后依次执行" : running ? "排队发送,当前轮次结束后执行" : "发送消息(Enter)"}>
196
230
  {Icon.send()}<span>{sending ? "发送中…" : running ? "排队发送" : "发送"}</span>