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