@spzxg/cli-ui 0.6.1 → 0.6.5
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 +26 -0
- package/README.md +145 -13
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +113 -53
- package/src/chat/Composer.tsx +68 -57
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +16 -6
- package/src/core/config.ts +11 -15
- package/src/core/conversation.ts +4 -4
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +463 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +51 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/icons.tsx +1 -0
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
package/src/chat/CliChat.tsx
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
// cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
|
|
2
2
|
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
3
|
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
|
-
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
|
-
import { CliErrorBoundary } from "../ui/ErrorBoundary
|
|
6
|
-
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store
|
|
7
|
-
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
4
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
|
5
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
6
|
+
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
|
|
7
|
+
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider";
|
|
8
|
+
import { formatBoardContext, withoutBoardContext } from "../board/context";
|
|
9
|
+
import { MAX_MESSAGE_CHARS, charCount, boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks";
|
|
10
|
+
import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation";
|
|
11
|
+
import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types";
|
|
12
|
+
import { Icon } from "../ui/icons";
|
|
13
|
+
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
|
|
14
|
+
import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
|
|
15
|
+
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
|
|
16
|
+
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
|
|
17
|
+
import { Composer, type ComposerOptions } from "./Composer";
|
|
18
|
+
import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
|
|
19
|
+
import { exportMarkdown } from "./export";
|
|
20
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
21
|
+
import { FileViewer } from "../board/FileViewer";
|
|
22
|
+
import { BrowserPane } from "../board/BrowserPane";
|
|
22
23
|
|
|
23
24
|
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
24
25
|
export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
26
|
+
/** Integration slots share this transcript/composer; they do not create another chat implementation. */
|
|
27
|
+
slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
|
|
28
|
+
/** Saving execution settings blocks send until persistence completes. */
|
|
29
|
+
configurationBusy?: boolean;
|
|
30
|
+
onClear?: () => void;
|
|
31
|
+
/** 每条消息自动带入看板当前文件、页签和最近浏览;默认 true。 */
|
|
32
|
+
passiveContext?: boolean;
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
|
|
27
36
|
* 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
|
|
@@ -120,7 +129,7 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
|
|
|
120
129
|
);
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
132
|
+
function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
124
133
|
useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
|
|
125
134
|
// 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
|
|
126
135
|
const configKey = JSON.stringify(config ?? {});
|
|
@@ -174,17 +183,27 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
174
183
|
})), [allMessages, cfg.transcript.tools]);
|
|
175
184
|
const running = conv ? isRunning(conv) : false;
|
|
176
185
|
const job = entry?.job;
|
|
177
|
-
//
|
|
178
|
-
// 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
|
|
186
|
+
// 一次性任务没有轮次:补充消息通过 continueTask 接在当前子任务的对话后面。
|
|
179
187
|
const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
|
|
180
188
|
const chat = !!taskId && isChatTask(conv);
|
|
181
|
-
// 普通子任务结束后(完成 / 失败 / 取消)也能接着聊:发出去 = 续派一条接着它的对话式子任务(同一个 Agent 会话、同一个工作区)。
|
|
182
|
-
// 还在跑的时候输入框照样显示、但不能发,提示结束后再接着聊(需要登录、验证码时在看板的浏览器里接管)。
|
|
183
189
|
const batchTaskStatus = batch ? conv?.batchTask?.status ?? job?.tasks?.find((t) => t.id === (taskId ?? conv?.task?.id))?.status ?? null : null;
|
|
184
|
-
const
|
|
185
|
-
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
const batchWritable = batch && !!(taskId ?? conv?.task?.id) && !!store.client.continueTask;
|
|
191
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !batchWritable);
|
|
192
|
+
const showComposer = !readOnlyProp && cfg.composer.show;
|
|
193
|
+
// 最新消息可能还在排队:沿当前对话的接续链找正在执行 / 暂停的子任务,不能误停同 Job 的其他对话。
|
|
194
|
+
const controlTask = useMemo(() => {
|
|
195
|
+
if (!batch || !conv?.batchTask) return null;
|
|
196
|
+
const tasks = new Map<string, Pick<CenterTask, "id" | "status" | "continue_from_task_id">>((job?.tasks ?? []).map((t) => [t.id, t]));
|
|
197
|
+
tasks.set(conv.batchTask.id, { ...conv.batchTask, continue_from_task_id: conv.batchTask.continue_from_task_id ?? undefined });
|
|
198
|
+
const chain = [];
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
let t = tasks.get(conv.batchTask.id);
|
|
201
|
+
while (t && !seen.has(t.id)) { chain.push(t); seen.add(t.id); t = tasks.get(t.continue_from_task_id ?? ""); }
|
|
202
|
+
return chain.find((t) => ["dispatching", "running", "verifying"].includes(t.status))
|
|
203
|
+
?? chain.find((t) => t.status === "paused")
|
|
204
|
+
?? chain.reverse().find((t) => ["queued", "blocked"].includes(t.status)) ?? null;
|
|
205
|
+
}, [batch, conv?.batchTask, job?.tasks]);
|
|
206
|
+
const batchPending = batch && !!batchTaskStatus && !SETTLED_TASK.has(batchTaskStatus);
|
|
188
207
|
// 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
|
|
189
208
|
// 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
|
|
190
209
|
const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
|
|
@@ -194,10 +213,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
194
213
|
const agentText = labelOf(agent);
|
|
195
214
|
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
196
215
|
// 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
|
|
197
|
-
//
|
|
198
|
-
const jobEnded = !chat && !
|
|
216
|
+
// 支持续派的普通子任务同理:大任务结束不挡输入框。
|
|
217
|
+
const jobEnded = !chat && !batchWritable && !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
199
218
|
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
200
|
-
const convClosed = !jobEnded && !
|
|
219
|
+
const convClosed = !jobEnded && !batchWritable && !!(conv?.task?.closed ?? info?.closed);
|
|
201
220
|
const status = convClosed ? "succeeded" : jobStatus;
|
|
202
221
|
const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
|
|
203
222
|
const ended = jobEnded || convClosed;
|
|
@@ -259,6 +278,9 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
259
278
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
260
279
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
261
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]);
|
|
262
284
|
const send = useCallback(async (text: string) => {
|
|
263
285
|
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
264
286
|
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
@@ -266,26 +288,33 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
266
288
|
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
|
|
267
289
|
const refKey = latest.current.key;
|
|
268
290
|
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
269
|
-
const
|
|
291
|
+
const passiveView=refKey?store.getState().boardContext[refKey]:null;
|
|
292
|
+
const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
|
|
293
|
+
const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
|
|
270
294
|
const blocks = [
|
|
295
|
+
...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
|
|
271
296
|
...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
|
|
272
297
|
...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
|
|
273
298
|
];
|
|
274
299
|
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
275
300
|
let target: string;
|
|
276
301
|
try {
|
|
302
|
+
if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
|
|
277
303
|
target = await store.send(id ?? null, full, sendOpts.current);
|
|
278
304
|
} catch (error) {
|
|
279
305
|
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
280
306
|
throw error;
|
|
281
307
|
}
|
|
308
|
+
if(latest.current.key===refKey)setSkipPassive(false);
|
|
282
309
|
if (target !== id) go(target);
|
|
283
|
-
}, [store, go]);
|
|
310
|
+
}, [store, go, passiveContext]);
|
|
311
|
+
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
312
|
+
const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
|
|
284
313
|
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
285
314
|
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
286
315
|
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
287
316
|
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
288
|
-
const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
317
|
+
const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
289
318
|
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
290
319
|
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
291
320
|
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
@@ -314,6 +343,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
314
343
|
return () => window.removeEventListener("keydown", onKey);
|
|
315
344
|
}, [popup]);
|
|
316
345
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
346
|
+
onQuote: readOnly ? undefined : (m,selected) => {const id=latest.current.key;if(id) store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});},
|
|
317
347
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
318
348
|
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
319
349
|
const id = latest.current.key;
|
|
@@ -351,7 +381,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
351
381
|
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
352
382
|
const list = latest.current.messages;
|
|
353
383
|
const at = list.findIndex((m) => m.id === msgId);
|
|
354
|
-
for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
|
|
384
|
+
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; }
|
|
355
385
|
},
|
|
356
386
|
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
|
|
357
387
|
const stop = useCallback(async () => {
|
|
@@ -359,7 +389,19 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
359
389
|
const turn = c ? runningTurn(c) : null;
|
|
360
390
|
if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
|
|
361
391
|
}, [store, report]);
|
|
362
|
-
const [
|
|
392
|
+
const [closeAction, setCloseAction] = useState<{ key: string; stage: "confirm" | "busy" } | null>(null);
|
|
393
|
+
const closing = closeAction?.key === key ? closeAction.stage : "idle";
|
|
394
|
+
useEffect(() => setCloseAction(null), [key]);
|
|
395
|
+
const confirmClose = () => {
|
|
396
|
+
if (!key || closeAction?.key !== key || closeAction.stage !== "confirm") return;
|
|
397
|
+
const pending = { key, stage: "busy" as const };
|
|
398
|
+
setCloseAction(pending);
|
|
399
|
+
void store.close(key).then(() => {
|
|
400
|
+
if (latest.current.key === pending.key) setActionError(null);
|
|
401
|
+
}, (error: unknown) => {
|
|
402
|
+
if (latest.current.key === pending.key) setActionError(`结束对话失败:${error instanceof Error ? error.message : String(error)}`);
|
|
403
|
+
}).finally(() => setCloseAction(current => current === pending ? null : current));
|
|
404
|
+
};
|
|
363
405
|
|
|
364
406
|
// ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
|
|
365
407
|
const lastUserTs = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null; return null; }, [messages]);
|
|
@@ -385,7 +427,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
385
427
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
386
428
|
const olderRef = useRef(older); olderRef.current = older;
|
|
387
429
|
const keyRef2 = useRef(key); keyRef2.current = key;
|
|
388
|
-
const items = useMemo(() => buildTranscriptItems(committed, older > 0)
|
|
430
|
+
const items = useMemo(() => buildTranscriptItems(committed, older > 0)
|
|
431
|
+
.filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
|
|
389
432
|
// 展开了的「续等」折叠行(按折叠行的 key)。
|
|
390
433
|
const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
|
|
391
434
|
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
@@ -541,9 +584,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
541
584
|
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
542
585
|
const disabledHint = readOnly
|
|
543
586
|
? batch && !readOnlyProp && cfg.composer.show
|
|
544
|
-
?
|
|
545
|
-
: batchTaskStatus === "blocked" || batchTaskStatus === "queued" ? "子任务还没开始,开始并结束后可以接着它聊"
|
|
546
|
-
: "子任务运行中,结束后可以接着它继续聊;需要登录等操作请在右侧浏览器里接管")
|
|
587
|
+
? "当前任务暂不支持发送消息"
|
|
547
588
|
: "只读"
|
|
548
589
|
: finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
549
590
|
|
|
@@ -573,15 +614,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
573
614
|
<div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
574
615
|
<div className="session">
|
|
575
616
|
{h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
576
|
-
{(h.crumb || h.
|
|
577
|
-
{
|
|
578
|
-
{h.crumb && <>
|
|
617
|
+
{(h.crumb || h.title) && <div className="sh-main">
|
|
618
|
+
{h.crumb && <div className="crumb">
|
|
579
619
|
<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>
|
|
580
620
|
{title} <span className="sep">/</span> 对话
|
|
581
|
-
</>}
|
|
582
|
-
{h.export && key && allMessages.length > 0 && (
|
|
583
|
-
<button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
|
|
584
|
-
)}
|
|
585
621
|
</div>}
|
|
586
622
|
{h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
|
|
587
623
|
</div>}
|
|
@@ -603,8 +639,14 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
603
639
|
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
604
640
|
<span className="cv">{statusText}</span>
|
|
605
641
|
</span>}
|
|
642
|
+
{h.export && key && <button type="button" className="btn btn-line btn-sm cli-export" disabled={!allMessages.length}
|
|
643
|
+
aria-label="导出执行过程(Markdown)"
|
|
644
|
+
title={older > 0 ? "下载已加载的执行过程(Markdown);更早的消息尚未加载" : "下载执行过程(Markdown),包含初始上下文和工具记录"}
|
|
645
|
+
onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])}>
|
|
646
|
+
{Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
|
|
647
|
+
</button>}
|
|
606
648
|
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
607
|
-
<button className="btn btn-line btn-sm" onClick={() =>
|
|
649
|
+
<button className="btn btn-line btn-sm" onClick={() => key && setCloseAction({ key, stage: "confirm" })} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
608
650
|
)}
|
|
609
651
|
</div>
|
|
610
652
|
</div>}
|
|
@@ -613,8 +655,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
613
655
|
<div className="clear-confirm">
|
|
614
656
|
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
|
|
615
657
|
<span className="spacer" />
|
|
616
|
-
<button className="btn btn-ghost btn-sm" onClick={() =>
|
|
617
|
-
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={
|
|
658
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setCloseAction(null)} disabled={closing === "busy"}>取消</button>
|
|
659
|
+
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={confirmClose}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
|
|
618
660
|
</div>
|
|
619
661
|
)}
|
|
620
662
|
{actionError && <div className="sync-banner conflict"><span className="sync-msg">⚠ {actionError}</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => setActionError(null)}>知道了</button></div>}
|
|
@@ -624,6 +666,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
624
666
|
<div className="transcript-wrap">
|
|
625
667
|
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
626
668
|
<div className="tw">
|
|
669
|
+
{slots?.beforeMessages}
|
|
627
670
|
{(!key || isNew) && cfg.empty.show && (
|
|
628
671
|
<div className="hero cli-hero">
|
|
629
672
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
@@ -688,6 +731,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
688
731
|
{ended && cfg.transcript.status && (
|
|
689
732
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
690
733
|
)}
|
|
734
|
+
{slots?.afterMessages}
|
|
691
735
|
{continuedBy && (
|
|
692
736
|
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
|
693
737
|
)}
|
|
@@ -727,16 +771,32 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
727
771
|
))}
|
|
728
772
|
</div>
|
|
729
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
|
+
{slots?.beforeComposer}
|
|
730
782
|
{showComposer && (
|
|
731
783
|
<Composer
|
|
732
784
|
key={key ?? "new"}
|
|
733
|
-
draft={key ?? `new:${instanceId}`}
|
|
785
|
+
draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
|
|
734
786
|
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
735
|
-
disabled={composerDisabled}
|
|
787
|
+
disabled={composerDisabled || !!configurationBusy}
|
|
736
788
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
737
|
-
running={running}
|
|
738
|
-
|
|
789
|
+
running={running || batchPending}
|
|
790
|
+
toolbar={slots?.composerToolbar}
|
|
791
|
+
onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
|
|
739
792
|
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
793
|
+
taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
|
|
794
|
+
paused: controlTask.status === "paused",
|
|
795
|
+
onToggle: async () => {
|
|
796
|
+
const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
|
|
797
|
+
await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
|
|
798
|
+
},
|
|
799
|
+
} : undefined}
|
|
740
800
|
options={composerOptions}
|
|
741
801
|
/>
|
|
742
802
|
)}
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
// 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
|
|
2
2
|
// 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
|
|
3
3
|
// 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
|
|
4
|
-
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
5
5
|
import {
|
|
6
6
|
ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
7
|
-
} from "../core/blocks
|
|
8
|
-
import { Icon } from "../ui/icons
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
} from "../core/blocks";
|
|
8
|
+
import { Icon } from "../ui/icons";
|
|
9
|
+
import { clientId } from "../core/id";
|
|
10
|
+
import { TextBlockCard } from "../ui/TextBlock";
|
|
11
|
+
import { fmtCount } from "../ui/format";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
const loadDraft = (key: string) => { try { return localStorage.getItem(draftKey(key)) ?? ""; } catch { return ""; } };
|
|
14
|
-
const saveDraft = (key: string, value: string) => {
|
|
15
|
-
try {
|
|
16
|
-
if (value) localStorage.setItem(draftKey(key), value);
|
|
17
|
-
else localStorage.removeItem(draftKey(key));
|
|
18
|
-
} catch { /* 隐私模式 / 配额:草稿就不保存 */ }
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
type Pending = { key: string; name: string; status: "reading" | "error"; error?: string };
|
|
13
|
+
import { clearComposerDraft, readComposerDraft, updateComposerDraft, useComposerDraft, type PendingFile } from "./draft";
|
|
22
14
|
|
|
23
15
|
/** 输入框的可配置项(来自 CliChat 的 config.composer)。 */
|
|
24
16
|
export interface ComposerOptions {
|
|
@@ -30,7 +22,7 @@ export interface ComposerOptions {
|
|
|
30
22
|
queue: boolean;
|
|
31
23
|
}
|
|
32
24
|
|
|
33
|
-
export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, options }: {
|
|
25
|
+
export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar }: {
|
|
34
26
|
/** 草稿按这个键存在 localStorage(一般是对话 id)。 */
|
|
35
27
|
draft: string;
|
|
36
28
|
placeholder: string;
|
|
@@ -39,38 +31,43 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
39
31
|
running: boolean;
|
|
40
32
|
onSend: (text: string) => Promise<void>;
|
|
41
33
|
onStop?: () => Promise<void> | void;
|
|
34
|
+
taskControl?: { paused: boolean; onToggle: () => Promise<void> };
|
|
42
35
|
options: ComposerOptions;
|
|
36
|
+
toolbar?: ReactNode;
|
|
43
37
|
}) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const [
|
|
38
|
+
const state = useComposerDraft(draft);
|
|
39
|
+
const { text: val, blocks, pending, error, sending } = state;
|
|
40
|
+
const setVal = (text: string) => updateComposerDraft(draft, s => ({ ...s, text }));
|
|
41
|
+
const setBlocks = (fn: (blocks: typeof state.blocks) => typeof state.blocks) => updateComposerDraft(draft, s => ({ ...s, blocks: fn(s.blocks) }));
|
|
42
|
+
const setPending = (fn: (pending: PendingFile[]) => PendingFile[]) => updateComposerDraft(draft, s => ({ ...s, pending: fn(s.pending) }));
|
|
43
|
+
const setError = (error: string | null) => updateComposerDraft(draft, s => ({ ...s, error }));
|
|
44
|
+
const setSending = (sending: boolean) => updateComposerDraft(draft, s => ({ ...s, sending }));
|
|
49
45
|
const [stopping, setStopping] = useState(false);
|
|
46
|
+
const [controlling, setControlling] = useState(false);
|
|
50
47
|
const [dragging, setDragging] = useState(false);
|
|
51
48
|
const dragDepth = useRef(0);
|
|
52
|
-
const
|
|
49
|
+
const stopLock = useRef(false);
|
|
50
|
+
const controlLock = useRef(false);
|
|
51
|
+
const composing = useRef(false);
|
|
53
52
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
54
53
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
55
54
|
|
|
56
|
-
useEffect(() => { if (!running) setStopping(false); }, [running]);
|
|
57
|
-
useEffect(() => { saveDraft(draft, val); }, [draft, val]);
|
|
55
|
+
useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
|
|
58
56
|
const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
|
|
59
|
-
useEffect(() => { if (ref.current) autosize(ref.current); }, [draft]);
|
|
57
|
+
useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
|
|
60
58
|
|
|
61
|
-
const nextKey = () => `blk-${
|
|
59
|
+
const nextKey = () => `blk-${clientId()}`;
|
|
62
60
|
const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
|
|
63
61
|
|
|
64
62
|
const addFiles = (files: File[]) => {
|
|
65
|
-
if (disabled || !options.fileUpload) return;
|
|
63
|
+
if (disabled || sending || !options.fileUpload) return;
|
|
66
64
|
for (const file of files) {
|
|
67
65
|
const key = nextKey();
|
|
68
66
|
setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
|
|
69
67
|
readFileText(file)
|
|
70
68
|
.then((text) => {
|
|
71
|
-
setPending((prev) => prev.filter((p) => p.key !== key));
|
|
72
69
|
if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
|
|
73
|
-
|
|
70
|
+
updateComposerDraft(draft, s => ({ ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }));
|
|
74
71
|
})
|
|
75
72
|
.catch((err: unknown) => {
|
|
76
73
|
setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
|
|
@@ -83,20 +80,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
83
80
|
const tooLong = total > MAX_MESSAGE_CHARS;
|
|
84
81
|
const reading = pending.some((p) => p.status === "reading");
|
|
85
82
|
// 运行中不允许排队时,等这一轮结束再发。
|
|
86
|
-
const canSend = !!message.trim() && !reading && !tooLong && !sending && (!running || options.queue);
|
|
83
|
+
const canSend = !!message.trim() && !pending.length && !reading && !tooLong && !sending && (!running || options.queue);
|
|
87
84
|
|
|
88
85
|
const submit = async () => {
|
|
89
|
-
if (!canSend || disabled) return;
|
|
86
|
+
if (!canSend || disabled || readComposerDraft(draft).sending) return;
|
|
87
|
+
const submitted = JSON.stringify([val, blocks]);
|
|
90
88
|
setSending(true);
|
|
91
89
|
setError(null);
|
|
92
90
|
const key = draft;
|
|
93
91
|
try {
|
|
94
92
|
await onSend(message);
|
|
95
93
|
// 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
setBlocks([]);
|
|
99
|
-
setPending([]);
|
|
94
|
+
const current = readComposerDraft(key);
|
|
95
|
+
if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
|
|
100
96
|
if (ref.current) ref.current.style.height = "auto";
|
|
101
97
|
} catch (err) {
|
|
102
98
|
// 发送失败:内容留在输入框里,可以直接再发。
|
|
@@ -108,12 +104,20 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
108
104
|
|
|
109
105
|
const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
110
106
|
const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
|
|
107
|
+
const stopButton = running && onStop && (
|
|
108
|
+
<button type="button" className="send stop" onClick={async () => {
|
|
109
|
+
if (stopLock.current) return;
|
|
110
|
+
stopLock.current = true; setStopping(true); setError(null);
|
|
111
|
+
try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
|
|
112
|
+
}}
|
|
113
|
+
disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
|
|
114
|
+
);
|
|
111
115
|
|
|
112
116
|
return (
|
|
113
117
|
<div className="composer">
|
|
114
118
|
<div className="composer-inner">
|
|
115
119
|
{(error || tooLong) && (
|
|
116
|
-
<div className="cli-comp-err">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
|
|
120
|
+
<div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
|
|
117
121
|
)}
|
|
118
122
|
<div
|
|
119
123
|
className={`comp-box${dragging ? " dropping" : ""}`}
|
|
@@ -145,10 +149,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
145
149
|
)}
|
|
146
150
|
<div className="comp-area">
|
|
147
151
|
<textarea
|
|
148
|
-
ref={ref} rows={
|
|
149
|
-
placeholder={disabled ? disabledHint ?? "暂时不能发送" : running && options.queue ? options.placeholderRunning : placeholder}
|
|
152
|
+
ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
|
|
153
|
+
placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
|
|
150
154
|
onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
|
|
151
|
-
|
|
155
|
+
onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
|
|
156
|
+
onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
|
|
152
157
|
onPaste={(e) => {
|
|
153
158
|
const files = Array.from(e.clipboardData?.files ?? []);
|
|
154
159
|
if (files.length) {
|
|
@@ -166,27 +171,33 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
166
171
|
}
|
|
167
172
|
}}
|
|
168
173
|
/>
|
|
169
|
-
{running ? (
|
|
170
|
-
<div className="send-group">
|
|
171
|
-
{canSend && <button className="send queue" onClick={() => void submit()} title="把这条后续消息排队 —— 当前轮次结束后自动发送" disabled={disabled}>{Icon.send()}</button>}
|
|
172
|
-
{onStop && <button className="send stop" onClick={() => { setStopping(true); void Promise.resolve(onStop()).catch(() => setStopping(false)); }} disabled={stopping} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>}
|
|
173
|
-
</div>
|
|
174
|
-
) : (
|
|
175
|
-
<button className="send" disabled={!canSend || disabled} onClick={() => void submit()}>{Icon.send()}</button>
|
|
176
|
-
)}
|
|
177
174
|
</div>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
175
|
+
<div className="comp-foot">
|
|
176
|
+
{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}(也可拖拽进来),内容会转成文字发送`}
|
|
179
|
+
onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
|
|
180
|
+
</>}
|
|
181
|
+
{toolbar}
|
|
182
|
+
{options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
|
|
183
|
+
<span className="spacer" />
|
|
182
184
|
{options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
|
|
183
185
|
{!options.charCount && tooLong && <span className="hint cli-count over">{fmtCount(total)} 字</span>}
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
<div className="send-group">
|
|
187
|
+
{taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
|
|
188
|
+
aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
|
|
189
|
+
title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
|
|
190
|
+
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
|
+
{taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
|
|
192
|
+
</button>}
|
|
193
|
+
{(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
|
|
194
|
+
onClick={() => void submit()} aria-label={sending ? "正在发送" : running ? "排队发送" : "发送消息"}
|
|
195
|
+
title={taskControl?.paused ? "排队发送,继续任务后依次执行" : running ? "排队发送,当前轮次结束后执行" : "发送消息(Enter)"}>
|
|
196
|
+
{Icon.send()}<span>{sending ? "发送中…" : running ? "排队发送" : "发送"}</span>
|
|
197
|
+
</button>}
|
|
198
|
+
{stopButton}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
190
201
|
</div>
|
|
191
202
|
</div>
|
|
192
203
|
</div>
|