@spzxg/cli-ui 0.6.6 → 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 (75) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +79 -20
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.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 +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +86 -46
  34. package/src/chat/Composer.tsx +52 -20
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +132 -42
  37. package/src/chat/draft.ts +5 -2
  38. package/src/core/blocks.ts +2 -1
  39. package/src/core/client.ts +44 -57
  40. package/src/core/documentUpload.ts +196 -0
  41. package/src/core/store.ts +71 -14
  42. package/src/core/types.ts +1 -1
  43. package/src/index.ts +3 -0
  44. package/src/msglist/CliMsgList.tsx +9 -2
  45. package/src/operator/node/Chat.tsx +7 -8
  46. package/src/operator/node/Composer.tsx +99 -72
  47. package/src/operator/node/Orchestrator.tsx +1 -1
  48. package/src/operator/node/SessionRail.tsx +71 -32
  49. package/src/operator/node/SessionView.tsx +25 -25
  50. package/src/operator/node/Transcript.tsx +21 -9
  51. package/src/operator/node/boardBackend.ts +0 -19
  52. package/src/operator/node/boardEvents.ts +21 -3
  53. package/src/operator/node/boardState.ts +18 -1
  54. package/src/operator/node/boardWorkspace.ts +4 -1
  55. package/src/operator/node/composerDraft.ts +6 -2
  56. package/src/operator/node/format.ts +4 -2
  57. package/src/operator/node/liveStream.ts +14 -2
  58. package/src/operator/node/useTaskStream.ts +3 -1
  59. package/src/operator.base.css +141 -38
  60. package/src/operator.css +141 -38
  61. package/src/styles.css +248 -34
  62. package/src/ui/Markdown.tsx +2 -2
  63. package/src/ui/Popover.tsx +25 -12
  64. package/src/ui/QueuedMessages.tsx +69 -0
  65. package/src/ui/ReferenceCard.tsx +28 -0
  66. package/src/ui/TextBlock.tsx +6 -4
  67. package/src/ui/Transcript.tsx +20 -10
  68. package/src/ui/UploadProgress.tsx +46 -0
  69. package/src/ui/WorkspaceDrawer.tsx +64 -0
  70. package/src/ui/WorkspaceTabs.tsx +60 -0
  71. package/src/ui/format.ts +3 -1
  72. package/src/ui/reference.ts +40 -0
  73. package/src/ui/useChatScrollbar.ts +29 -0
  74. package/src/ui/useComposerAutosize.ts +54 -0
  75. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -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,17 +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
95
  updateComposerDraft(draft, s => s.pending.some(p => p.key === key && p.status === "reading")
71
96
  ? { ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }
72
97
  : s);
98
+ selectedFiles.current.delete(key);
73
99
  })
74
100
  .catch((err: unknown) => {
101
+ if (controller.signal.aborted) return;
75
102
  setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
76
- });
103
+ }).finally(() => uploads.current.delete(key));
77
104
  }
78
105
  };
79
106
 
@@ -95,7 +122,6 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
95
122
  // 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
96
123
  const current = readComposerDraft(key);
97
124
  if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
98
- if (ref.current) ref.current.style.height = "auto";
99
125
  } catch (err) {
100
126
  // 发送失败:内容留在输入框里,可以直接再发。
101
127
  setError(err instanceof Error ? err.message : String(err));
@@ -112,7 +138,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
112
138
  stopLock.current = true; setStopping(true); setError(null);
113
139
  try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
114
140
  }}
115
- disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
141
+ disabled={stopping} aria-busy={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
116
142
  );
117
143
 
118
144
  return (
@@ -121,6 +147,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
121
147
  {(error || tooLong) && (
122
148
  <div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
123
149
  )}
150
+ {queued}
124
151
  <div
125
152
  className={`comp-box${dragging ? " dropping" : ""}`}
126
153
  onDragEnter={(e) => { if (!disabled && hasFileDrag(e)) { e.preventDefault(); dragDepth.current++; setDragging(true); } }}
@@ -137,14 +164,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
137
164
  addFiles(Array.from(e.dataTransfer.files));
138
165
  }}
139
166
  >
167
+ {context}
140
168
  {(blocks.length > 0 || pending.length > 0) && (
141
169
  <div className="cli-blocks">
142
170
  {blocks.map((b) => <TextBlockCard key={b.key} block={b} onRemove={() => setBlocks((prev) => prev.filter((x) => x.key !== b.key))} />)}
143
- {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} /> : (
144
176
  <div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
145
177
  <span className="attach-file">{Icon.doc()} {p.name}</span>
146
- <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
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>
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>
148
180
  </div>
149
181
  ))}
150
182
  </div>
@@ -153,7 +185,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
153
185
  <textarea
154
186
  ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
155
187
  placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
156
- onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
188
+ onChange={(e) => setVal(e.target.value)}
157
189
  onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
158
190
  onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
159
191
  onPaste={(e) => {
@@ -161,8 +193,8 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
161
193
  if (files.length) {
162
194
  e.preventDefault();
163
195
  if (!options.fileUpload) { setError("这里只能发送文字"); return; }
164
- if (files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
165
- 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/")));
166
198
  return;
167
199
  }
168
200
  // 大段粘贴不放进输入框(几万字的 textarea 会很卡),转成可折叠的文本块。
@@ -176,8 +208,8 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
176
208
  </div>
177
209
  <div className="comp-foot">
178
210
  {options.fileUpload && !disabled && <>
179
- <input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
180
- <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}(也可拖拽进来)`}
181
213
  onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
182
214
  </>}
183
215
  {toolbar}
@@ -192,7 +224,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
192
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); } }}>
193
225
  {taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
194
226
  </button>}
195
- {(!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}
196
228
  onClick={() => void submit()} aria-label={sending ? "正在发送" : running ? "排队发送" : "发送消息"}
197
229
  title={taskControl?.paused ? "排队发送,继续任务后依次执行" : running ? "排队发送,当前轮次结束后执行" : "发送消息(Enter)"}>
198
230
  {Icon.send()}<span>{sending ? "发送中…" : running ? "排队发送" : "发送"}</span>
@@ -0,0 +1,113 @@
1
+ import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import type { Msg } from "../core/types";
3
+ import { useCliState, useCliStore } from "../core/provider";
4
+ import { boardContextBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "../core/blocks";
5
+ import { MessageView } from "../ui/Transcript";
6
+ import { ReferenceCard } from "../ui/ReferenceCard";
7
+ import { useChatScrollbar } from "../ui/useChatScrollbar";
8
+ import { Icon } from "../ui/icons";
9
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
10
+ import { Composer, type ComposerOptions } from "./Composer";
11
+
12
+ export interface GroupChatReference { id: string; label: string; text?: string }
13
+ export interface GroupChatMessage {
14
+ id: string;
15
+ role: "user" | "assistant" | "system";
16
+ content: string;
17
+ createdAt?: string;
18
+ actor?: { id: string; name: string };
19
+ recipientLabel?: string;
20
+ status?: string;
21
+ error?: string | null;
22
+ references?: GroupChatReference[];
23
+ }
24
+ /** 群消息由宿主提供;所有输入始终经过同一个 onSend,不按发言人建立独立发送通道。 */
25
+ export interface GroupChatAdapter {
26
+ /** 包含业务与用户身份的稳定键,隔离草稿和引用。 */
27
+ scopeKey: string;
28
+ messages: GroupChatMessage[];
29
+ onSend: (text: string) => Promise<void>;
30
+ userName?: string;
31
+ placeholder?: string;
32
+ toolbar?: ReactNode;
33
+ beforeMessages?: ReactNode;
34
+ onSource?: (reference: GroupChatReference, message: GroupChatMessage) => void;
35
+ /** 本地文件沿用共享 Markdown 解析;宿主按实际发言人的授权工作区打开。 */
36
+ onOpenFile?: (path: string, message: GroupChatMessage) => void;
37
+ /** 仅消费宿主明确授权给此群的看板引用键(如当前 Job 的成员 job#task)。 */
38
+ referenceKeys?: string[];
39
+ }
40
+
41
+ const OPTIONS: ComposerOptions = { placeholderRunning: "继续补充…", hints: false, charCount: false, fileUpload: false, fileButtonText: "文件", queue: true };
42
+ type Quote = { id: string; author: string; text: string };
43
+
44
+ /** CliChat 的群消息适配;沿用共享消息、输入框和引用组件,不连接单任务转录。 */
45
+ export function GroupChat({ group, theme = "light", className, style, readOnly }: {
46
+ group: GroupChatAdapter; theme?: "light" | "dark"; className?: string; style?: CSSProperties; readOnly?: boolean;
47
+ }) {
48
+ const [quote, setQuote] = useState<Quote | null>(null);
49
+ const [visibleCount, setVisibleCount] = useState(120);
50
+ const store = useCliStore();
51
+ const references = useCliState(state => state.refs);
52
+ const referenceKeys = [...new Set(group.referenceKeys ?? [])];
53
+ const boardRefs = referenceKeys.flatMap(key => (references[key] ?? []).map(ref => ({ key, ref })));
54
+ const scroll = useRef<HTMLDivElement>(null);
55
+ const pinned = useRef(true);
56
+ useChatScrollbar(scroll, group.scopeKey);
57
+ useLayoutEffect(() => {
58
+ if (pinned.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
59
+ }, [group.messages]);
60
+ const send = async (text: string) => {
61
+ const submittedQuote = quote;
62
+ const message = submittedQuote
63
+ ? `引用 ${submittedQuote.author}:\n${submittedQuote.text.split("\n").map(line => "> " + line).join("\n")}\n\n${text}` : text;
64
+ const taken = referenceKeys.map(key => ({ key, refs: store.takeRefs(key) }));
65
+ const refs = taken.flatMap(item => item.refs);
66
+ const content = refs.length ? `${message}\n\n${serializeBlock(boardContextBlock(refs.map(ref => ref.text)))}` : message;
67
+ try {
68
+ if (charCount(content) > MAX_MESSAGE_CHARS) throw new Error("加上引用后消息过长,请精简消息内容后重试。");
69
+ await group.onSend(content);
70
+ } catch (error) {
71
+ for (const item of taken) store.restoreRefs(item.key, item.refs);
72
+ throw error;
73
+ }
74
+ setQuote(current => current === submittedQuote ? null : current);
75
+ pinned.current = true;
76
+ if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
77
+ };
78
+ return <div className={`cli-ui cli-chat cli-group-chat ${className ?? ""}`} data-theme={theme} style={style}>
79
+ <div className="session"><div className="workspace-chat-content" data-chat-layout="centered">
80
+ <div className="transcript-wrap"><div className="transcript" ref={scroll} role="log" aria-label="群组对话" aria-live="polite" onScroll={() => {
81
+ const element = scroll.current;
82
+ if (element) pinned.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
83
+ }}><div className="tw">
84
+ {group.beforeMessages}
85
+ {group.messages.length > visibleCount && <div className="load-older"><button type="button" className="btn btn-ghost btn-sm" onClick={() => { pinned.current = false; setVisibleCount(value => value + 80); }}>查看更早的消息</button></div>}
86
+ {group.messages.slice(-visibleCount).map(message => {
87
+ const name = message.actor?.name || (message.role === "user" ? group.userName || "你" : "智能体");
88
+ const ts = message.createdAt ? Date.parse(message.createdAt) : NaN;
89
+ const msg: Msg = { id: message.id, role: message.role, content: message.content, generation: 0, ...(Number.isFinite(ts) ? { ts } : {}) };
90
+ return <div key={message.id} data-msg="" data-message-id={message.id} data-actor-id={message.actor?.id}>
91
+ {message.recipientLabel && <div className="cli-group-meta">{message.recipientLabel}</div>}
92
+ <FileLinkContext.Provider value={null}><FileOpenContext.Provider value={message.role === "assistant" && message.actor && group.onOpenFile
93
+ ? path => { group.onOpenFile?.(path, message); return true; } : null}>
94
+ <MessageView m={msg} initial={false} hideWho={false} view={{ timestamps: true, initialBadge: false, userLabel: name, agentName: name }} handlers={{
95
+ onQuote: readOnly ? undefined : (value, selected) => setQuote({ id: value.id, author: name, text: selected || value.content }),
96
+ }} />
97
+ </FileOpenContext.Provider></FileLinkContext.Provider>
98
+ {message.status && <div className="cli-group-meta">{message.status}</div>}
99
+ {message.error && <div className="cli-comp-err" role="alert">{message.error}</div>}
100
+ {!!message.references?.length && <div className="cli-group-references">{message.references.map(reference => group.onSource
101
+ ? <button key={reference.id} type="button" className="btn btn-ghost btn-sm" onClick={() => group.onSource?.(reference, message)}>{Icon.doc()}{reference.label}</button>
102
+ : <ReferenceCard key={reference.id} title={reference.label} text={reference.text || "引用内容未提供"} />)}</div>}
103
+ </div>;
104
+ })}
105
+ </div></div></div>
106
+ {!readOnly && <Composer draft={`group:${group.scopeKey}`} placeholder={group.placeholder || "发送消息…"} disabled={false} running={false} onSend={send} options={OPTIONS} toolbar={group.toolbar}
107
+ context={(quote || boardRefs.length > 0) && <div className="cli-refs composer-references">
108
+ {quote && <ReferenceCard title={quote.author} text={quote.text} removeLabel="取消引用" onRemove={() => setQuote(null)} />}
109
+ {boardRefs.map(({ key, ref }) => <ReferenceCard key={`${key}:${ref.id}`} title={ref.label} text={ref.text} removeLabel={`不带上 ${ref.label}`} onRemove={() => store.removeRef(key, ref.id)} />)}
110
+ </div>} />}
111
+ </div></div>
112
+ </div>;
113
+ }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Fragment, useEffect, useRef, useState } from "react";
4
- import { Icon } from "../ui/icons";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { AgentMark, Icon } from "../ui/icons";
5
5
  import { Popover } from "../ui/Popover";
6
6
  import { modelLabel } from "../ui/format";
7
7
  import { acquireConfiguration, useConfigurationBusy } from "./configuration";
@@ -11,7 +11,8 @@ export interface RunTask { id: string; agent?: string; model?: string | null; re
11
11
  const withDefault = (options?: RunOption[]) => [{ value: null as string | null, label: "默认", sub: "沿用智能体的默认设置", group: undefined as string | undefined }, ...(options ?? [])];
12
12
  const modelOptions = (caps?: RunCapabilities) => withDefault(caps?.models);
13
13
  const permissionOptions = (caps?: RunCapabilities) => withDefault(caps?.permissionModes);
14
- const reasoningOptions = (caps?: RunCapabilities) => withDefault(caps?.reasoningOptions);
14
+ const agentNames = [{ id: "codex", label: "Codex" }, { id: "claude", label: "Claude" }, { id: "grok", label: "Grok" }, { id: "pi", label: "PI" }];
15
+ const permissionIcon = (value: string | null) => value === null ? Icon.clear() : value === "bypassPermissions" ? Icon.bolt() : value === "plan" ? Icon.list() : value === "acceptEdits" ? Icon.edit() : Icon.lock();
15
16
 
16
17
  export type RunControlProps = {
17
18
  task: RunTask;
@@ -30,68 +31,157 @@ export type RunControlProps = {
30
31
  // Await persistence before allowing Send so the next turn uses the chosen value.
31
32
  export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
32
33
  const [open, setOpen] = useState<"model" | "permission" | null>(null);
34
+ const [modelPage, setModelPage] = useState<"agents" | "settings" | "models">("agents");
35
+ const [switching, setSwitching] = useState<{ agent: string; saved: boolean } | null>(null);
33
36
  const [custom, setCustom] = useState("");
34
37
  const busy = useConfigurationBusy(task.id);
35
- const currentTask = useRef(task.id); currentTask.current = task.id;
36
- useEffect(() => { setOpen(null); setError(""); }, [task.id]);
38
+ const currentTask = useRef({ id: task.id, revision: 0 });
39
+ if (currentTask.current.id !== task.id) currentTask.current = { id: task.id, revision: currentTask.current.revision + 1 };
40
+ const agentListRef = useRef<HTMLDivElement>(null);
41
+ const modelEntryRef = useRef<HTMLButtonElement>(null);
42
+ const modelListRef = useRef<HTMLDivElement>(null);
43
+ useEffect(() => { setOpen(null); setError(""); setCustom(""); setSwitching(null); setModelPage("agents"); }, [task.id]);
37
44
  const [error, setError] = useState("");
45
+ const availableAgents = agentNames.flatMap(agent => {
46
+ const available = agents?.find(item => item.id === agent.id);
47
+ return available ? [{ ...available, label: agent.label }] : [];
48
+ });
49
+ const hasAgentPicker = !!onSetAgent && availableAgents.length > 0;
50
+ const currentAgent = agentNames.find(agent => agent.id === task.agent)?.label ?? agents?.find(agent => agent.id === task.agent)?.label ?? task.agent ?? "智能体";
38
51
  const models = modelOptions(caps);
39
- const reasoning = reasoningOptions(caps);
40
52
  const permissions = permissionOptions(caps);
41
53
  const effectiveModel = task.model ?? defaults?.model ?? null;
42
- const effectiveReasoning = task.reasoning ?? defaults?.reasoning ?? null;
54
+ const currentModel = effectiveModel ? models.find(option => option.value === effectiveModel)?.label ?? modelLabel(effectiveModel) : "默认模型";
43
55
  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 ?? "");
56
+ const defaultModel = defaults?.model ? models.find(option => option.value === defaults.model)?.label ?? modelLabel(defaults.model) : null;
57
+ const defaultReasoningOption = caps?.reasoningOptions.find(option => option.value === defaults?.reasoning);
58
+ const defaultReasoning = defaultReasoningOption?.label ?? defaults?.reasoning ?? null;
59
+ const currentReasoning = caps?.reasoningOptions.find(option => option.value === task.reasoning);
60
+ const reasoningLabel = task.reasoning ? currentReasoning?.label ?? task.reasoning : defaultReasoning ? `默认(${defaultReasoning})` : "默认";
61
+ const reasoningDescription = task.reasoning ? currentReasoning?.sub ?? "当前任务的推理设置" : defaultReasoningOption?.sub ?? "沿用智能体的默认设置";
62
+ const defaultPermission = defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
63
+ const defaultPermissionLabel = permissions.find(option => option.value === defaultPermission)?.label ?? defaultPermission;
45
64
  const permission = permissions.find((p) => p.value === effectivePermission);
46
- const change = async (save: () => Promise<void>) => {
47
- if (disabled) return;
65
+ const change = async (save: () => Promise<void>, close = true) => {
66
+ if (disabled) return false;
48
67
  const release = acquireConfiguration(task.id);
49
- if (!release) return;
50
- const targetId = task.id;
68
+ if (!release) return false;
69
+ const targetRevision = currentTask.current.revision;
51
70
  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 : "设置保存失败,请重试。"); }
71
+ try {
72
+ onBusyChange?.(true);
73
+ await save();
74
+ if (currentTask.current.revision !== targetRevision) return false;
75
+ if (close) setOpen(null);
76
+ return true;
77
+ }
78
+ catch (e) { if(currentTask.current.revision === targetRevision) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); return false; }
54
79
  finally { release(); onBusyChange?.(false); }
55
80
  };
56
- const locked = disabled || busy || !caps;
81
+ const locked = disabled || busy || !!switching || !caps;
82
+ const pickAgent = async (agent: string) => {
83
+ if (locked || !onSetAgent) return;
84
+ setError(""); setCustom("");
85
+ if (agent === task.agent) { setModelPage("settings"); return; }
86
+ const targetRevision = currentTask.current.revision;
87
+ setSwitching({ agent, saved: false });
88
+ const saved = await change(() => onSetAgent(agent), false);
89
+ if (currentTask.current.revision === targetRevision) setSwitching(saved ? { agent, saved: true } : null);
90
+ };
91
+ // SessionView derives caps from task.agent in the same render. Wait for that
92
+ // task update as well as the save, so the old agent's models never appear.
93
+ useEffect(() => {
94
+ if (switching?.saved && task.agent === switching.agent && caps) {
95
+ setSwitching(null); setModelPage("settings");
96
+ }
97
+ }, [switching, task.agent, caps]);
98
+ useEffect(() => {
99
+ if (open !== "model" || switching || busy) return;
100
+ if (modelPage === "settings") modelEntryRef.current?.focus({ preventScroll: true });
101
+ else if (modelPage === "models") (modelListRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]:not(:disabled)') ?? modelListRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus({ preventScroll: true });
102
+ else (agentListRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]:not(:disabled)') ?? agentListRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus({ preventScroll: true });
103
+ }, [open, modelPage, switching, busy]);
104
+ const modelGroups = models.reduce<{ label?: string; options: typeof models }[]>((groups, option) => {
105
+ const previous = groups.at(-1);
106
+ if (previous && previous.label === option.group) previous.options.push(option);
107
+ else groups.push({ label: option.group, options: [option] });
108
+ return groups;
109
+ }, []);
57
110
  return <>
58
111
  {!!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"); }}>
112
+ <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(); setError(""); setOpen(open === "permission" ? null : "permission"); }}>
60
113
  {Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
61
114
  </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()}
115
+ {open === "permission" && <Popover onClose={() => setOpen(null)} className="composer-popover composer-permission-popover" ariaLabel="权限模式" placement="top" align="start">
116
+ <div className="composer-menu-heading">权限模式</div>
117
+ <p className="composer-menu-note">保存后用于下一轮</p>
118
+ {permissions.map((p) => <button key={p.value ?? "default"} className="pop-item composer-option run-permission-option" disabled={locked} aria-pressed={(task.permission_mode ?? null) === p.value} onClick={() => void change(() => onSetPermission(p.value))}>
119
+ <span className="composer-option-icon" aria-hidden="true">{permissionIcon(p.value)}</span>
120
+ <span className="composer-option-copy"><span className="composer-option-title">{p.label}</span><span className="composer-option-sub">{p.value === null && defaultPermissionLabel ? `沿用智能体默认设置:${defaultPermissionLabel}` : p.sub}</span></span>
121
+ <span className="composer-option-trailing" aria-hidden="true">{(task.permission_mode ?? null) === p.value && Icon.check()}</span>
66
122
  </button>)}
123
+ {busy && <div className="composer-menu-note" role="status">正在保存…</div>}
67
124
  {error && <div className="control-error" role="alert">{error}</div>}
68
125
  </Popover>}
69
126
  </div>}
70
127
  <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()}
128
+ <button className="comp-setting" disabled={locked} aria-label="智能体设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改智能体设置" : `当前智能体:${currentAgent}`} onClick={(e) => { e.stopPropagation(); setError(""); setCustom(""); setModelPage(hasAgentPicker ? "agents" : "settings"); setOpen(open === "model" ? null : "model"); }}>
129
+ <span className="comp-model-name">{currentAgent}</span>{Icon.chevDown()}
74
130
  </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()}
131
+ {open === "model" && <Popover onClose={() => setOpen(null)} className="composer-popover composer-agent-popover" ariaLabel={modelPage === "agents" ? "选择智能体" : modelPage === "models" ? "选择模型" : "模型与推理设置"} placement="top" align="end">
132
+ {modelPage === "agents" ? <div ref={agentListRef} className="run-agent-list">
133
+ <div className="composer-menu-heading">选择智能体</div>
134
+ {availableAgents.map(agent => <button key={agent.id} className="pop-item composer-option run-agent-option" disabled={locked || agent.disabled} aria-pressed={task.agent === agent.id} onClick={() => void pickAgent(agent.id)}>
135
+ <span className="composer-option-icon" aria-hidden="true">{AgentMark[agent.id]?.() ?? Icon.spark()}</span>
136
+ <span className="composer-option-copy"><span className="composer-option-title">{agent.label}</span></span>
137
+ <span className="composer-option-trailing run-agent-trailing" aria-hidden="true">{task.agent === agent.id && Icon.check()}{Icon.chevRight()}</span>
92
138
  </button>)}
93
- </>}
94
- {busy && <div className="pop-sec" role="status">正在保存…</div>}
139
+ </div> : !switching && modelPage === "settings" ? <div className="run-settings">
140
+ <div className="run-settings-head">
141
+ {hasAgentPicker && <button className="pop-item run-settings-back" onClick={() => { setModelPage("agents"); setError(""); }}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 智能体</button>}
142
+ <span className="composer-menu-heading">{currentAgent}</span>
143
+ </div>
144
+ <div className="run-settings-field">
145
+ <div className="run-field-label">模型</div>
146
+ <button ref={modelEntryRef} className="composer-option run-model-entry" disabled={locked} aria-label="选择模型" onClick={() => { setModelPage("models"); setError(""); }}>
147
+ <span className="composer-option-icon" aria-hidden="true">{Icon.spark()}</span>
148
+ <span className="composer-option-copy"><span className="composer-option-title run-model-value">{currentModel}</span><span className="composer-option-sub">{task.model ? models.find(option => option.value === task.model)?.sub ?? "当前使用自定义模型" : "沿用智能体的默认设置"}</span></span>
149
+ <span className="composer-option-trailing" aria-hidden="true">{Icon.chevRight()}</span>
150
+ </button>
151
+ </div>
152
+ {!!caps?.reasoningOptions.length && <div className="run-reasoning-card">
153
+ <div className="run-reasoning-head"><span className="run-field-label">推理强度</span><span className="run-reasoning-value">{reasoningLabel}</span></div>
154
+ <p className="run-reasoning-note">{reasoningDescription}</p>
155
+ <div className="run-reasoning-options" role="group" aria-label="推理强度档位">
156
+ {caps.reasoningOptions.map(option => <button key={option.value} className="run-reasoning-option" disabled={locked} aria-pressed={task.reasoning === option.value} title={option.sub} onClick={() => void change(() => onSetReasoning(option.value))}>{option.label}</button>)}
157
+ </div>
158
+ <button className="run-reasoning-reset" disabled={locked} aria-label="使用默认推理强度" aria-pressed={task.reasoning == null} onClick={() => void change(() => onSetReasoning(null))}>{Icon.clear()} {defaultReasoning ? `默认(${defaultReasoning})` : "使用默认设置"}{task.reasoning == null && Icon.check()}</button>
159
+ </div>}
160
+ <p className="composer-menu-note run-settings-note">保存后用于下一轮</p>
161
+ </div> : !switching && modelPage === "models" && <div className="run-settings run-models">
162
+ <div className="run-settings-head">
163
+ <button className="pop-item run-models-back" onClick={() => { setModelPage("settings"); setError(""); }}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 返回</button>
164
+ <span className="composer-menu-heading">选择模型</span>
165
+ </div>
166
+ <div ref={modelListRef} className="run-model-list">
167
+ {modelGroups.map((group, index) => <div className="run-model-group" key={`${group.label ?? "default"}-${index}`}>
168
+ {group.label && <div className="run-model-group-title">{group.label}</div>}
169
+ {group.options.map(option => <button key={option.value ?? "default"} className="pop-item composer-option run-model-option" data-model-value={option.value ?? ""} disabled={locked} aria-pressed={(task.model ?? null) === option.value} onClick={() => void change(() => onSetModel(option.value))}>
170
+ <span className="composer-option-copy"><span className="composer-option-title">{option.value === null && defaultModel ? `默认(${defaultModel})` : option.label}</span><span className="composer-option-sub">{option.value === null && defaultModel ? `沿用智能体默认设置:${defaultModel}` : option.sub}</span></span>
171
+ <span className="composer-option-trailing" aria-hidden="true">{(task.model ?? null) === option.value && Icon.check()}</span>
172
+ </button>)}
173
+ </div>)}
174
+ {task.model && !models.some(option => option.value === task.model) && <button className="pop-item composer-option run-model-option" data-model-value={task.model} disabled={locked} aria-pressed onClick={() => void change(() => onSetModel(task.model!))}>
175
+ <span className="composer-option-copy"><span className="composer-option-title">{modelLabel(task.model)}(当前模型)</span><span className="composer-option-sub">当前任务使用的自定义模型</span></span>
176
+ <span className="composer-option-trailing" aria-hidden="true">{Icon.check()}</span>
177
+ </button>}
178
+ </div>
179
+ {caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
180
+ <label className="composer-menu-heading" htmlFor={`custom-model-${task.id}`}>自定义模型</label>
181
+ <div className="pop-custom-row"><input id={`custom-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>
182
+ </form>}
183
+ </div>}
184
+ {(busy || switching) && <div className="composer-menu-note" role="status">{switching ? "正在切换智能体…" : "正在保存…"}</div>}
95
185
  {error && <div className="control-error" role="alert">{error}</div>}
96
186
  </Popover>}
97
187
  </div>
package/src/chat/draft.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
  import type { TextBlock } from "../core/blocks";
3
- export type PendingFile = { key: string; name: string; status: "reading" | "error"; error?: string };
3
+ import type { UploadPhase } from "../core/documentUpload";
4
+ export type PendingFile = { key: string; name: string; status: "reading" | "error"; error?: string; progress?: string; phase?: UploadPhase; bytes?: number; size?: number };
4
5
  export interface ComposerDraft { text: string; blocks: Array<TextBlock & { key: string }>; pending: PendingFile[]; sending: boolean; error: string | null; revision: number }
5
6
  const empty = (): ComposerDraft => ({ text: "", blocks: [], pending: [], sending: false, error: null, revision: 0 });
6
7
  const server = empty();
@@ -18,7 +19,9 @@ export function readComposerDraft(id: string): ComposerDraft {
18
19
  const data = JSON.parse(localStorage.getItem(dataKey(id)) ?? "null");
19
20
  if (data?.version === 1) {
20
21
  state.blocks = Array.isArray(data.blocks) ? data.blocks.filter((b: TextBlock & { key: string }) => typeof b?.text === "string" && typeof b?.key === "string") : [];
21
- state.pending = Array.isArray(data.pending) ? data.pending.map((p: PendingFile) => ({ ...p, status: "error", error: p.error || "页面重载中断了解析,请移除后重新添加" })) : [];
22
+ state.pending = Array.isArray(data.pending) ? data.pending.map((p: PendingFile) => ({ ...p, status: "error", error: p.phase
23
+ ? `${p.error || "页面重载中断了上传。"}${p.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`
24
+ : p.error || "页面重载中断了解析,请移除后重新添加" })) : [];
22
25
  }
23
26
  } catch { /* Old text drafts and restricted storage remain usable. */ }
24
27
  entries.set(id, { state, stamp: stored });
@@ -88,7 +88,8 @@ export function serializeBlock(block: TextBlock): string {
88
88
 
89
89
  /** 输入框里的文字 + 文本块 → 发给 center 的正文。 */
90
90
  export function composeMessage(typed: string, blocks: TextBlock[]): string {
91
- return [typed.trim(), ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
91
+ const originals = blocks.some(block => block.kind === "file" && (/^\[Attached (?:file|image): /.test(block.text) || /^\[文件原件:.*\]\(cli-upload:\/\//.test(block.text)));
92
+ return [typed.trim(), originals ? "请先读取以下附件原件,再处理本条消息。" : "", ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
92
93
  }
93
94
 
94
95
  export type MessagePart = { kind: "text"; text: string } | { kind: "block"; block: TextBlock };