@xgspz/cli-ui 0.0.0-stage → 0.6.14

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 (169) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +932 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +577 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1949 -0
  149. package/src/operator.css +1949 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,239 @@
1
+ // 输入框与 Operator 共用原件上传。上传完成只保留附件引用,用户发送时才交给 Agent。
2
+ // 没有原件上传适配器的宿主继续使用原有文档文本解析。
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import {
5
+ ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
6
+ } from "../core/blocks";
7
+ import { Icon } from "../ui/icons";
8
+ import { clientId } from "../core/id";
9
+ import { TextBlockCard } from "../ui/TextBlock";
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";
14
+
15
+ import { clearComposerDraft, readComposerDraft, updateComposerDraft, useComposerDraft, type PendingFile } from "./draft";
16
+
17
+ /** 输入框的可配置项(来自 CliChat 的 config.composer)。 */
18
+ export interface ComposerOptions {
19
+ placeholderRunning: string;
20
+ hints: boolean;
21
+ charCount: boolean;
22
+ fileUpload: boolean;
23
+ fileButtonText: string;
24
+ queue: boolean;
25
+ }
26
+
27
+ export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar, queued, context, uploadDocument }: {
28
+ /** 草稿按这个键存在 localStorage(一般是对话 id)。 */
29
+ draft: string;
30
+ placeholder: string;
31
+ disabled: boolean;
32
+ disabledHint?: string;
33
+ running: boolean;
34
+ onSend: (text: string) => Promise<void>;
35
+ onStop?: () => Promise<void> | void;
36
+ taskControl?: { paused: boolean; onToggle: () => Promise<void> };
37
+ options: ComposerOptions;
38
+ toolbar?: ReactNode;
39
+ queued?: ReactNode;
40
+ context?: ReactNode;
41
+ uploadDocument?: UploadDocument;
42
+ }) {
43
+ const state = useComposerDraft(draft);
44
+ const { text: val, blocks, pending, error, sending } = state;
45
+ const setVal = (text: string) => updateComposerDraft(draft, s => ({ ...s, text }));
46
+ const setBlocks = (fn: (blocks: typeof state.blocks) => typeof state.blocks) => updateComposerDraft(draft, s => ({ ...s, blocks: fn(s.blocks) }));
47
+ const setPending = (fn: (pending: PendingFile[]) => PendingFile[]) => updateComposerDraft(draft, s => ({ ...s, pending: fn(s.pending) }));
48
+ const setError = (error: string | null) => updateComposerDraft(draft, s => ({ ...s, error }));
49
+ const setSending = (sending: boolean) => updateComposerDraft(draft, s => ({ ...s, sending }));
50
+ const [stopping, setStopping] = useState(false);
51
+ const [controlling, setControlling] = useState(false);
52
+ const [dragging, setDragging] = useState(false);
53
+ const dragDepth = useRef(0);
54
+ const stopLock = useRef(false);
55
+ const controlLock = useRef(false);
56
+ const composing = useRef(false);
57
+ const fileRef = useRef<HTMLInputElement>(null);
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]);
71
+
72
+ useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
73
+ useComposerAutosize(ref, val);
74
+
75
+ const nextKey = () => `blk-${clientId()}`;
76
+ const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
77
+
78
+ const addFiles = (files: File[]) => {
79
+ if (disabled || sending || !options.fileUpload) return;
80
+ for (const file of files) {
81
+ const key = nextKey();
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
92
+ .then((text) => {
93
+ if (controller.signal.aborted) return;
94
+ if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
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);
99
+ })
100
+ .catch((err: unknown) => {
101
+ if (controller.signal.aborted) return;
102
+ setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
103
+ }).finally(() => uploads.current.delete(key));
104
+ }
105
+ };
106
+
107
+ const message = composeMessage(val, blocks);
108
+ const total = charCount(message);
109
+ const tooLong = total > MAX_MESSAGE_CHARS;
110
+ const reading = pending.some((p) => p.status === "reading");
111
+ // 运行中不允许排队时,等这一轮结束再发。
112
+ const canSend = !!message.trim() && !pending.length && !reading && !tooLong && !sending && !stopping && (!running || options.queue);
113
+ const showStop = running && !!onStop && !sending && (stopping || disabled || !options.queue || !message.trim());
114
+
115
+ const submit = async () => {
116
+ if (!canSend || disabled || stopLock.current || readComposerDraft(draft).sending) return;
117
+ const submitted = JSON.stringify([val, blocks]);
118
+ setSending(true);
119
+ setError(null);
120
+ const key = draft;
121
+ try {
122
+ await onSend(message);
123
+ // 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
124
+ const current = readComposerDraft(key);
125
+ if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
126
+ } catch (err) {
127
+ // 发送失败:内容留在输入框里,可以直接再发。
128
+ setError(err instanceof Error ? err.message : String(err));
129
+ } finally {
130
+ setSending(false);
131
+ }
132
+ };
133
+
134
+ const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
135
+ const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
136
+ const stopButton = running && onStop && (
137
+ <button type="button" className="send stop" onClick={async () => {
138
+ if (stopLock.current) return;
139
+ stopLock.current = true; setStopping(true); setError(null);
140
+ try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
141
+ }}
142
+ disabled={stopping} aria-busy={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>
143
+ );
144
+
145
+ return (
146
+ <div className="composer">
147
+ <div className="composer-inner">
148
+ {(error || tooLong) && (
149
+ <div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
150
+ )}
151
+ {queued}
152
+ <div
153
+ className={`comp-box${dragging ? " dropping" : ""}`}
154
+ onDragEnter={(e) => { if (!disabled && hasFileDrag(e)) { e.preventDefault(); dragDepth.current++; setDragging(true); } }}
155
+ onDragOver={(e) => { if (isFileDrag(e)) e.preventDefault(); }}
156
+ onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
157
+ onDrop={(e) => {
158
+ if (!isFileDrag(e)) return;
159
+ // 文件拖进来总要拦下:不拦的话浏览器会直接打开这个文件、离开当前页面。
160
+ e.preventDefault();
161
+ dragDepth.current = 0;
162
+ setDragging(false);
163
+ if (disabled) return;
164
+ if (!options.fileUpload) { setError("这里只能发送文字"); return; }
165
+ addFiles(Array.from(e.dataTransfer.files));
166
+ }}
167
+ >
168
+ {context}
169
+ {(blocks.length > 0 || pending.length > 0) && (
170
+ <div className="cli-blocks">
171
+ {blocks.map((b) => <TextBlockCard key={b.key} block={b} onRemove={() => setBlocks((prev) => prev.filter((x) => x.key !== b.key))} />)}
172
+ {pending.map((p) => p.phase ? <UploadProgress key={p.key} name={p.name} status={p.status === "error" ? "error" : p.phase}
173
+ bytes={p.bytes} total={p.size} error={p.error}
174
+ onCancel={() => { uploads.current.get(p.key)?.abort(); selectedFiles.current.delete(p.key); setPending(prev => prev.filter(x => x.key !== p.key)); }}
175
+ onRemove={() => { selectedFiles.current.delete(p.key); setPending(prev => prev.filter(x => x.key !== p.key)); }}
176
+ 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} /> : (
177
+ <div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
178
+ <span className="attach-file">{Icon.doc()} {p.name}</span>
179
+ <span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? p.progress ?? "解析中…" : p.error}</span>
180
+ <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>
181
+ </div>
182
+ ))}
183
+ </div>
184
+ )}
185
+ <div className="comp-area">
186
+ <textarea
187
+ ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
188
+ placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
189
+ onChange={(e) => setVal(e.target.value)}
190
+ onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
191
+ onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
192
+ onPaste={(e) => {
193
+ const files = Array.from(e.clipboardData?.files ?? []);
194
+ if (files.length) {
195
+ e.preventDefault();
196
+ if (!options.fileUpload) { setError("这里只能发送文字"); return; }
197
+ if (!uploadDocument && files.some((f) => f.type.startsWith("image/"))) setError(`不支持发送图片,只能发送文字,或上传文档:${ACCEPTED_LABEL}`);
198
+ addFiles(uploadDocument ? files : files.filter((f) => !f.type.startsWith("image/")));
199
+ return;
200
+ }
201
+ // 大段粘贴不放进输入框(几万字的 textarea 会很卡),转成可折叠的文本块。
202
+ const text = e.clipboardData?.getData("text/plain") ?? "";
203
+ if (isLongText(text)) {
204
+ e.preventDefault();
205
+ addBlock({ name: PASTE_NAME, text, kind: "paste" });
206
+ }
207
+ }}
208
+ />
209
+ </div>
210
+ <div className="comp-foot">
211
+ {options.fileUpload && !disabled && <>
212
+ <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 = ""; }} />
213
+ <button type="button" className="cli-attach" disabled={sending} aria-label={uploadDocument ? "添加文件" : "添加文档"} title={uploadDocument ? "添加文件(也可拖拽进来);HTML、Markdown 最大 100 MiB,其他支持类型最大 10 MiB" : `添加文档:${ACCEPTED_LABEL}(也可拖拽进来)`}
214
+ onClick={() => fileRef.current?.click()}>{Icon.plus()}{options.fileButtonText && <span>{options.fileButtonText}</span>}</button>
215
+ </>}
216
+ {toolbar}
217
+ {options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
218
+ <span className="spacer" />
219
+ {options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
220
+ {!options.charCount && tooLong && <span className="hint cli-count over">{fmtCount(total)} 字</span>}
221
+ <div className="send-group">
222
+ {taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
223
+ aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
224
+ title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
225
+ 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); } }}>
226
+ {taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
227
+ </button>}
228
+ {showStop ? stopButton : (!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend} aria-busy={sending}
229
+ onClick={() => void submit()} aria-label={sending ? "正在发送" : running ? "排队发送" : "发送消息"}
230
+ title={sending ? "发送中…" : taskControl?.paused ? "排队发送,继续任务后依次执行" : running ? "排队发送,当前轮次结束后执行" : "发送消息(Enter)"}>
231
+ {sending ? <span className="spinner" /> : Icon.send()}
232
+ </button>}
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ );
239
+ }
@@ -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
+ }
@@ -0,0 +1,196 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { AgentMark, Icon } from "../ui/icons";
5
+ import { Popover } from "../ui/Popover";
6
+ import { modelLabel } from "../ui/format";
7
+ import { acquireConfiguration, useConfigurationBusy } from "./configuration";
8
+ export interface RunOption { value: string; label: string; sub: string; group?: string }
9
+ export interface RunCapabilities { models: RunOption[]; reasoningOptions: RunOption[]; permissionModes: RunOption[]; supportsCustomModels?: boolean }
10
+ export interface RunTask { id: string; agent?: string; model?: string | null; reasoning?: string | null; permission_mode?: string | null; resolved_model?: string | null }
11
+ const withDefault = (options?: RunOption[]) => [{ value: null as string | null, label: "默认", sub: "沿用智能体的默认设置", group: undefined as string | undefined }, ...(options ?? [])];
12
+ const modelOptions = (caps?: RunCapabilities) => withDefault(caps?.models);
13
+ const permissionOptions = (caps?: RunCapabilities) => withDefault(caps?.permissionModes);
14
+ const 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();
16
+
17
+ export type RunControlProps = {
18
+ task: RunTask;
19
+ agents?: {id:string;label:string;disabled?:boolean}[];
20
+ onSetAgent?: (id:string)=>Promise<void>;
21
+ caps?: RunCapabilities;
22
+ defaults?: { model?: string | null; reasoning?: string | null; permission_mode?: string | null };
23
+ disabled: boolean;
24
+ showAgent?: boolean;
25
+ showPermission?: boolean;
26
+ unavailableReason?: string;
27
+ onSetModel: (value: string | null) => Promise<void>;
28
+ onSetReasoning: (value: string | null) => Promise<void>;
29
+ onSetPermission: (value: string | null) => Promise<void>;
30
+ onBusyChange?: (busy: boolean) => void;
31
+ };
32
+
33
+ // Both the pre-start view and composer use the same capability-driven controls.
34
+ // Await persistence before allowing Send so the next turn uses the chosen value.
35
+ export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, showAgent = true, showPermission = true, unavailableReason, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
36
+ const [open, setOpen] = useState<"model" | "permission" | null>(null);
37
+ const [modelPage, setModelPage] = useState<"agents" | "settings" | "models">("agents");
38
+ const [switching, setSwitching] = useState<{ agent: string; saved: boolean } | null>(null);
39
+ const [custom, setCustom] = useState("");
40
+ const busy = useConfigurationBusy(task.id);
41
+ const currentTask = useRef({ id: task.id, revision: 0 });
42
+ if (currentTask.current.id !== task.id) currentTask.current = { id: task.id, revision: currentTask.current.revision + 1 };
43
+ const agentListRef = useRef<HTMLDivElement>(null);
44
+ const modelEntryRef = useRef<HTMLButtonElement>(null);
45
+ const modelListRef = useRef<HTMLDivElement>(null);
46
+ useEffect(() => { setOpen(null); setError(""); setCustom(""); setSwitching(null); setModelPage("agents"); }, [task.id]);
47
+ const [error, setError] = useState("");
48
+ const availableAgents = agentNames.flatMap(agent => {
49
+ const available = agents?.find(item => item.id === agent.id);
50
+ return available ? [{ ...available, label: agent.label }] : [];
51
+ });
52
+ const hasAgentPicker = !!onSetAgent && availableAgents.length > 0;
53
+ const currentAgent = agentNames.find(agent => agent.id === task.agent)?.label ?? agents?.find(agent => agent.id === task.agent)?.label ?? task.agent ?? "智能体";
54
+ const models = modelOptions(caps);
55
+ const permissions = permissionOptions(caps);
56
+ const effectiveModel = task.model ?? defaults?.model ?? null;
57
+ const currentModel = effectiveModel ? models.find(option => option.value === effectiveModel)?.label ?? modelLabel(effectiveModel) : "默认模型";
58
+ const effectivePermission = task.permission_mode ?? defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
59
+ const defaultModel = defaults?.model ? models.find(option => option.value === defaults.model)?.label ?? modelLabel(defaults.model) : null;
60
+ const defaultReasoningOption = caps?.reasoningOptions.find(option => option.value === defaults?.reasoning);
61
+ const defaultReasoning = defaultReasoningOption?.label ?? defaults?.reasoning ?? null;
62
+ const currentReasoning = caps?.reasoningOptions.find(option => option.value === task.reasoning);
63
+ const reasoningLabel = task.reasoning ? currentReasoning?.label ?? task.reasoning : defaultReasoning ? `默认(${defaultReasoning})` : "默认";
64
+ const reasoningDescription = task.reasoning ? currentReasoning?.sub ?? "当前任务的推理设置" : defaultReasoningOption?.sub ?? "沿用智能体的默认设置";
65
+ const defaultPermission = defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
66
+ const defaultPermissionLabel = permissions.find(option => option.value === defaultPermission)?.label ?? defaultPermission;
67
+ const permission = permissions.find((p) => p.value === effectivePermission);
68
+ const change = async (save: () => Promise<void>, close = true) => {
69
+ if (disabled) return false;
70
+ const release = acquireConfiguration(task.id);
71
+ if (!release) return false;
72
+ const targetRevision = currentTask.current.revision;
73
+ setError("");
74
+ try {
75
+ onBusyChange?.(true);
76
+ await save();
77
+ if (currentTask.current.revision !== targetRevision) return false;
78
+ if (close) setOpen(null);
79
+ return true;
80
+ }
81
+ catch (e) { if(currentTask.current.revision === targetRevision) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); return false; }
82
+ finally { release(); onBusyChange?.(false); }
83
+ };
84
+ const locked = disabled || busy || !!switching || !caps;
85
+ const permissionAvailable = !!caps?.permissionModes.length;
86
+ useEffect(() => {
87
+ if ((open === "model" && (!showAgent || !caps)) || (open === "permission" && (!showPermission || !permissionAvailable))) setOpen(null);
88
+ }, [open, showAgent, showPermission, caps, permissionAvailable]);
89
+ const pickAgent = async (agent: string) => {
90
+ if (locked || !onSetAgent) return;
91
+ setError(""); setCustom("");
92
+ if (agent === task.agent) { setModelPage("settings"); return; }
93
+ const targetRevision = currentTask.current.revision;
94
+ setSwitching({ agent, saved: false });
95
+ const saved = await change(() => onSetAgent(agent), false);
96
+ if (currentTask.current.revision === targetRevision) setSwitching(saved ? { agent, saved: true } : null);
97
+ };
98
+ // SessionView derives caps from task.agent in the same render. Wait for that
99
+ // task update as well as the save, so the old agent's models never appear.
100
+ useEffect(() => {
101
+ if (switching?.saved && task.agent === switching.agent && caps) {
102
+ setSwitching(null); setModelPage("settings");
103
+ }
104
+ }, [switching, task.agent, caps]);
105
+ useEffect(() => {
106
+ if (open !== "model" || switching || busy) return;
107
+ if (modelPage === "settings") modelEntryRef.current?.focus({ preventScroll: true });
108
+ else if (modelPage === "models") (modelListRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]:not(:disabled)') ?? modelListRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus({ preventScroll: true });
109
+ else (agentListRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]:not(:disabled)') ?? agentListRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus({ preventScroll: true });
110
+ }, [open, modelPage, switching, busy]);
111
+ const modelGroups = models.reduce<{ label?: string; options: typeof models }[]>((groups, option) => {
112
+ const previous = groups.at(-1);
113
+ if (previous && previous.label === option.group) previous.options.push(option);
114
+ else groups.push({ label: option.group, options: [option] });
115
+ return groups;
116
+ }, []);
117
+ return <>
118
+ {showPermission && <div className="comp-control">
119
+ <button className={`comp-setting permission${effectivePermission === "bypassPermissions" ? " elevated" : ""}`} disabled={locked || !permissionAvailable} aria-label="权限模式" aria-expanded={open === "permission"} aria-haspopup="dialog" title={unavailableReason || (!permissionAvailable ? "当前智能体暂未提供执行模式设置" : disabled ? "本轮结束后可修改权限" : permission?.sub)} onClick={(e) => { e.stopPropagation(); setError(""); setOpen(open === "permission" ? null : "permission"); }}>
120
+ {Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
121
+ </button>
122
+ {open === "permission" && permissionAvailable && <Popover onClose={() => setOpen(null)} className="composer-popover composer-permission-popover" ariaLabel="权限模式" placement="top" align="start">
123
+ <div className="composer-menu-heading">权限模式</div>
124
+ <p className="composer-menu-note">保存后用于下一轮</p>
125
+ {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))}>
126
+ <span className="composer-option-icon" aria-hidden="true">{permissionIcon(p.value)}</span>
127
+ <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>
128
+ <span className="composer-option-trailing" aria-hidden="true">{(task.permission_mode ?? null) === p.value && Icon.check()}</span>
129
+ </button>)}
130
+ {busy && <div className="composer-menu-note" role="status">正在保存…</div>}
131
+ {error && <div className="control-error" role="alert">{error}</div>}
132
+ </Popover>}
133
+ </div>}
134
+ {showAgent && <div className="comp-control comp-model">
135
+ <button className="comp-setting" disabled={locked} aria-label="智能体设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={unavailableReason || (!caps ? "当前服务暂未提供智能体设置" : disabled ? "本轮结束后可修改智能体设置" : `当前智能体:${currentAgent}`)} onClick={(e) => { e.stopPropagation(); setError(""); setCustom(""); setModelPage(hasAgentPicker ? "agents" : "settings"); setOpen(open === "model" ? null : "model"); }}>
136
+ <span className="comp-model-name">{currentAgent}</span>{Icon.chevDown()}
137
+ </button>
138
+ {open === "model" && caps && <Popover onClose={() => setOpen(null)} className="composer-popover composer-agent-popover" ariaLabel={modelPage === "agents" ? "选择智能体" : modelPage === "models" ? "选择模型" : "模型与推理设置"} placement="top" align="end">
139
+ {modelPage === "agents" ? <div ref={agentListRef} className="run-agent-list">
140
+ <div className="composer-menu-heading">选择智能体</div>
141
+ {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)}>
142
+ <span className="composer-option-icon" aria-hidden="true">{AgentMark[agent.id]?.() ?? Icon.spark()}</span>
143
+ <span className="composer-option-copy"><span className="composer-option-title">{agent.label}</span></span>
144
+ <span className="composer-option-trailing run-agent-trailing" aria-hidden="true">{task.agent === agent.id && Icon.check()}{Icon.chevRight()}</span>
145
+ </button>)}
146
+ </div> : !switching && modelPage === "settings" ? <div className="run-settings">
147
+ <div className="run-settings-head">
148
+ {hasAgentPicker && <button className="pop-item run-settings-back" onClick={() => { setModelPage("agents"); setError(""); }}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 智能体</button>}
149
+ <span className="composer-menu-heading">{currentAgent}</span>
150
+ </div>
151
+ <div className="run-settings-field">
152
+ <div className="run-field-label">模型</div>
153
+ <button ref={modelEntryRef} className="composer-option run-model-entry" disabled={locked} aria-label="选择模型" onClick={() => { setModelPage("models"); setError(""); }}>
154
+ <span className="composer-option-icon" aria-hidden="true">{Icon.spark()}</span>
155
+ <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>
156
+ <span className="composer-option-trailing" aria-hidden="true">{Icon.chevRight()}</span>
157
+ </button>
158
+ </div>
159
+ {!!caps?.reasoningOptions.length && <div className="run-reasoning-card">
160
+ <div className="run-reasoning-head"><span className="run-field-label">推理强度</span><span className="run-reasoning-value">{reasoningLabel}</span></div>
161
+ <p className="run-reasoning-note">{reasoningDescription}</p>
162
+ <div className="run-reasoning-options" role="group" aria-label="推理强度档位">
163
+ {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>)}
164
+ </div>
165
+ <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>
166
+ </div>}
167
+ <p className="composer-menu-note run-settings-note">保存后用于下一轮</p>
168
+ </div> : !switching && modelPage === "models" && <div className="run-settings run-models">
169
+ <div className="run-settings-head">
170
+ <button className="pop-item run-models-back" onClick={() => { setModelPage("settings"); setError(""); }}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 返回</button>
171
+ <span className="composer-menu-heading">选择模型</span>
172
+ </div>
173
+ <div ref={modelListRef} className="run-model-list">
174
+ {modelGroups.map((group, index) => <div className="run-model-group" key={`${group.label ?? "default"}-${index}`}>
175
+ {group.label && <div className="run-model-group-title">{group.label}</div>}
176
+ {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))}>
177
+ <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>
178
+ <span className="composer-option-trailing" aria-hidden="true">{(task.model ?? null) === option.value && Icon.check()}</span>
179
+ </button>)}
180
+ </div>)}
181
+ {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!))}>
182
+ <span className="composer-option-copy"><span className="composer-option-title">{modelLabel(task.model)}(当前模型)</span><span className="composer-option-sub">当前任务使用的自定义模型</span></span>
183
+ <span className="composer-option-trailing" aria-hidden="true">{Icon.check()}</span>
184
+ </button>}
185
+ </div>
186
+ {caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
187
+ <label className="composer-menu-heading" htmlFor={`custom-model-${task.id}`}>自定义模型</label>
188
+ <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>
189
+ </form>}
190
+ </div>}
191
+ {(busy || switching) && <div className="composer-menu-note" role="status">{switching ? "正在切换智能体…" : "正在保存…"}</div>}
192
+ {error && <div className="control-error" role="alert">{error}</div>}
193
+ </Popover>}
194
+ </div>}
195
+ </>;
196
+ }
@@ -0,0 +1,15 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ // A task's setting request outlives its controls when the user changes tasks.
3
+ const pending = new Set<string>();
4
+ const listeners = new Set<() => void>();
5
+ const notify = () => listeners.forEach(listener => listener());
6
+ export const isConfigurationBusy = (taskId:string) => pending.has(taskId);
7
+ export function acquireConfiguration(taskId:string): (()=>void) | null {
8
+ if (pending.has(taskId)) return null;
9
+ pending.add(taskId); notify();
10
+ let active=true;
11
+ return () => { if(active){active=false;pending.delete(taskId);notify();} };
12
+ }
13
+ export function useConfigurationBusy(taskId:string) {
14
+ return useSyncExternalStore(listener=>{listeners.add(listener);return()=>listeners.delete(listener);},()=>isConfigurationBusy(taskId),()=>false);
15
+ }
@@ -0,0 +1,49 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import type { TextBlock } from "../core/blocks";
3
+ import type { UploadPhase } from "../core/documentUpload";
4
+ import type { ExecutionOptions } from "../core/execution";
5
+ export type PendingFile = { key: string; name: string; status: "reading" | "error"; error?: string; progress?: string; phase?: UploadPhase; bytes?: number; size?: number };
6
+ export interface ComposerDraft { text: string; blocks: Array<TextBlock & { key: string }>; pending: PendingFile[]; sending: boolean; error: string | null; revision: number; executionConfig?: ExecutionOptions; executionUncertain?: boolean }
7
+ const empty = (): ComposerDraft => ({ text: "", blocks: [], pending: [], sending: false, error: null, revision: 0 });
8
+ const server = empty();
9
+ const entries = new Map<string, { state: ComposerDraft; stamp: string }>();
10
+ const listeners = new Set<() => void>();
11
+ const textKey = (id: string) => `cli-ui:draft:${id}`;
12
+ const dataKey = (id: string) => `${textKey(id)}:data`;
13
+ const stamp = (id: string) => { try { return JSON.stringify([localStorage.getItem(textKey(id)), localStorage.getItem(dataKey(id))]); } catch { return ""; } };
14
+ export function readComposerDraft(id: string): ComposerDraft {
15
+ const stored = stamp(id), cached = entries.get(id);
16
+ if (cached?.stamp === stored) return cached.state;
17
+ let state = empty();
18
+ try {
19
+ state.text = localStorage.getItem(textKey(id)) ?? "";
20
+ const data = JSON.parse(localStorage.getItem(dataKey(id)) ?? "null");
21
+ if (data?.version === 1) {
22
+ if (data.executionUncertain === true) state.executionUncertain = true;
23
+ if (data.executionConfig && typeof data.executionConfig === "object") state.executionConfig = Object.fromEntries(
24
+ Object.entries(data.executionConfig).filter(([key, value]) => ["agent", "model", "reasoning", "permission_mode"].includes(key) && (typeof value === "string" || value === null)),
25
+ );
26
+ state.blocks = Array.isArray(data.blocks) ? data.blocks.filter((b: TextBlock & { key: string }) => typeof b?.text === "string" && typeof b?.key === "string") : [];
27
+ state.pending = Array.isArray(data.pending) ? data.pending.map((p: PendingFile) => ({ ...p, status: "error", error: p.phase
28
+ ? `${p.error || "页面重载中断了上传。"}${p.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`
29
+ : p.error || "页面重载中断了解析,请移除后重新添加" })) : [];
30
+ }
31
+ } catch { /* Old text drafts and restricted storage remain usable. */ }
32
+ entries.set(id, { state, stamp: stored });
33
+ return state;
34
+ }
35
+ export function updateComposerDraft(id: string, update: (state: ComposerDraft) => ComposerDraft) {
36
+ const previous = readComposerDraft(id);
37
+ const state = { ...update(previous), revision: previous.revision + 1 };
38
+ try {
39
+ if (state.text) localStorage.setItem(textKey(id), state.text); else localStorage.removeItem(textKey(id));
40
+ if (state.blocks.length || state.pending.length || state.executionConfig || state.executionUncertain) localStorage.setItem(dataKey(id), JSON.stringify({ version: 1, blocks: state.blocks, pending: state.pending, executionConfig: state.executionConfig, executionUncertain: state.executionUncertain })); else localStorage.removeItem(dataKey(id));
41
+ } catch { /* Keep the in-memory draft even if storage is full. */ }
42
+ entries.set(id, { state, stamp: stamp(id) });
43
+ listeners.forEach(listener => listener());
44
+ return state;
45
+ }
46
+ export function useComposerDraft(id: string) {
47
+ return useSyncExternalStore(listener => { listeners.add(listener); return () => listeners.delete(listener); }, () => readComposerDraft(id), () => server);
48
+ }
49
+ export function clearComposerDraft(id: string) { updateComposerDraft(id, () => empty()); }