@spzxg/cli-ui 0.6.2 → 0.6.6
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 +49 -0
- package/README.md +165 -19
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +17 -10
- package/src/board/BrowserPane.tsx +346 -115
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +54 -31
- 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 +94 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/frameQuote.ts +2 -2
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +9 -7
- package/src/board/workspace.ts +53 -14
- package/src/chat/CliChat.tsx +93 -40
- package/src/chat/Composer.tsx +44 -42
- 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 +17 -6
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +103 -19
- package/src/core/conversation.ts +4 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +31 -14
- package/src/core/store.ts +151 -53
- package/src/core/types.ts +64 -9
- 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/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +19 -13
- 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 +468 -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 +82 -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 +280 -0
- package/src/operator.base.css +1806 -0
- package/src/operator.css +194 -16
- package/src/styles.css +49 -1
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +82 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +17 -10
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
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 ?? {});
|
|
@@ -160,7 +169,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
160
169
|
useEffect(() => (key ? store.open(key) : undefined), [store, key]);
|
|
161
170
|
// 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
|
|
162
171
|
const keyRef = useRef(key);
|
|
163
|
-
keyRef.current = key;
|
|
172
|
+
useLayoutEffect(() => { keyRef.current = key; }, [key]);
|
|
164
173
|
useEffect(() => store.on((event) => {
|
|
165
174
|
if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
|
|
166
175
|
}), [store, go]);
|
|
@@ -226,7 +235,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
226
235
|
: jobTitle ?? (key ? "对话" : "新对话");
|
|
227
236
|
const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
|
|
228
237
|
const lastTurn = conv?.turns[conv.turns.length - 1];
|
|
229
|
-
const
|
|
238
|
+
const listedAwaiting = (info ?? job?.conversation?.conversations?.find(item => item.task_id === task?.id))?.awaiting_input;
|
|
239
|
+
// An explicit null retires an old wait flag when its node observation expires.
|
|
240
|
+
const remoteAwaiting = conv?.task?.awaiting_input !== undefined ? conv.task.awaiting_input
|
|
241
|
+
: listedAwaiting !== undefined ? listedAwaiting : task?.awaiting_input !== undefined ? task.awaiting_input
|
|
242
|
+
: !manyConvs ? job?.conversation?.awaiting_input : undefined;
|
|
243
|
+
const awaiting = !ended && !TERMINAL_JOB.has(jobStatus ?? "") && !(batch && batchTaskStatus && SETTLED_TASK.has(batchTaskStatus))
|
|
244
|
+
&& (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
|
|
230
245
|
// 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
|
|
231
246
|
const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
|
|
232
247
|
const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
|
|
@@ -239,9 +254,9 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
239
254
|
|
|
240
255
|
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
241
256
|
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
257
|
+
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
242
258
|
if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
|
|
243
259
|
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
244
|
-
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
245
260
|
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
246
261
|
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
247
262
|
return { dotCls: "a", statusText: "进行中" };
|
|
@@ -252,12 +267,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
252
267
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
253
268
|
useEffect(() => setActionError(null), [key]);
|
|
254
269
|
const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
|
|
270
|
+
const sourceKey = keyRef.current;
|
|
255
271
|
try {
|
|
256
272
|
const result = await work;
|
|
257
|
-
setActionError(null);
|
|
273
|
+
if (keyRef.current === sourceKey) setActionError(null);
|
|
258
274
|
return result;
|
|
259
275
|
} catch (error) {
|
|
260
|
-
setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
|
|
276
|
+
if (keyRef.current === sourceKey) setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
|
|
261
277
|
throw error;
|
|
262
278
|
}
|
|
263
279
|
}, []);
|
|
@@ -265,10 +281,16 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
265
281
|
// (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
|
|
266
282
|
const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
|
|
267
283
|
const latest = useRef({ key, messages, conv, runKey });
|
|
268
|
-
|
|
284
|
+
// A concurrent render may suspend while the old chat remains interactive.
|
|
285
|
+
// Event handlers must keep the target and options of the committed view.
|
|
286
|
+
useLayoutEffect(() => { latest.current = { key, messages, conv, runKey }; });
|
|
269
287
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
270
288
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
271
|
-
sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
|
|
289
|
+
useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
|
|
290
|
+
const [skipPassive,setSkipPassive]=useState(false);
|
|
291
|
+
const skipPassiveRef=useRef(false);
|
|
292
|
+
useLayoutEffect(()=>{skipPassiveRef.current=skipPassive;},[skipPassive]);
|
|
293
|
+
useEffect(()=>setSkipPassive(false),[key]);
|
|
272
294
|
const send = useCallback(async (text: string) => {
|
|
273
295
|
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
274
296
|
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
@@ -276,26 +298,33 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
276
298
|
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
|
|
277
299
|
const refKey = latest.current.key;
|
|
278
300
|
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
279
|
-
const
|
|
301
|
+
const passiveView=refKey?store.getState().boardContext[refKey]:null;
|
|
302
|
+
const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
|
|
303
|
+
const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
|
|
280
304
|
const blocks = [
|
|
305
|
+
...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
|
|
281
306
|
...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
|
|
282
307
|
...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
|
|
283
308
|
];
|
|
284
309
|
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
285
310
|
let target: string;
|
|
286
311
|
try {
|
|
312
|
+
if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
|
|
287
313
|
target = await store.send(id ?? null, full, sendOpts.current);
|
|
288
314
|
} catch (error) {
|
|
289
315
|
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
290
316
|
throw error;
|
|
291
317
|
}
|
|
292
|
-
if
|
|
293
|
-
|
|
318
|
+
if(latest.current.key===refKey)setSkipPassive(false);
|
|
319
|
+
if (target !== id && latest.current.key === refKey) go(target);
|
|
320
|
+
}, [store, go, passiveContext]);
|
|
321
|
+
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
322
|
+
const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
|
|
294
323
|
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
295
324
|
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
296
325
|
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
297
326
|
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
298
|
-
const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
327
|
+
const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
299
328
|
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
300
329
|
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
301
330
|
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
@@ -324,6 +353,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
324
353
|
return () => window.removeEventListener("keydown", onKey);
|
|
325
354
|
}, [popup]);
|
|
326
355
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
356
|
+
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)});},
|
|
327
357
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
328
358
|
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
329
359
|
const id = latest.current.key;
|
|
@@ -331,7 +361,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
331
361
|
// 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
|
|
332
362
|
if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
|
|
333
363
|
const sent = askId
|
|
334
|
-
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
|
|
364
|
+
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id && latest.current.key === id) go(target); })
|
|
335
365
|
: send(formatAnswersText(questions, answers));
|
|
336
366
|
await report(sent, "回答没有发出去");
|
|
337
367
|
},
|
|
@@ -361,7 +391,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
361
391
|
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
362
392
|
const list = latest.current.messages;
|
|
363
393
|
const at = list.findIndex((m) => m.id === msgId);
|
|
364
|
-
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; }
|
|
394
|
+
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; }
|
|
365
395
|
},
|
|
366
396
|
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
|
|
367
397
|
const stop = useCallback(async () => {
|
|
@@ -369,7 +399,19 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
369
399
|
const turn = c ? runningTurn(c) : null;
|
|
370
400
|
if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
|
|
371
401
|
}, [store, report]);
|
|
372
|
-
const [
|
|
402
|
+
const [closeAction, setCloseAction] = useState<{ key: string; stage: "confirm" | "busy" } | null>(null);
|
|
403
|
+
const closing = closeAction?.key === key ? closeAction.stage : "idle";
|
|
404
|
+
useEffect(() => setCloseAction(null), [key]);
|
|
405
|
+
const confirmClose = () => {
|
|
406
|
+
if (!key || closeAction?.key !== key || closeAction.stage !== "confirm") return;
|
|
407
|
+
const pending = { key, stage: "busy" as const };
|
|
408
|
+
setCloseAction(pending);
|
|
409
|
+
void store.close(key).then(() => {
|
|
410
|
+
if (latest.current.key === pending.key) setActionError(null);
|
|
411
|
+
}, (error: unknown) => {
|
|
412
|
+
if (latest.current.key === pending.key) setActionError(`结束对话失败:${error instanceof Error ? error.message : String(error)}`);
|
|
413
|
+
}).finally(() => setCloseAction(current => current === pending ? null : current));
|
|
414
|
+
};
|
|
373
415
|
|
|
374
416
|
// ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
|
|
375
417
|
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]);
|
|
@@ -614,7 +656,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
614
656
|
{Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
|
|
615
657
|
</button>}
|
|
616
658
|
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
617
|
-
<button className="btn btn-line btn-sm" onClick={() =>
|
|
659
|
+
<button className="btn btn-line btn-sm" onClick={() => key && setCloseAction({ key, stage: "confirm" })} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
618
660
|
)}
|
|
619
661
|
</div>
|
|
620
662
|
</div>}
|
|
@@ -623,8 +665,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
623
665
|
<div className="clear-confirm">
|
|
624
666
|
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
|
|
625
667
|
<span className="spacer" />
|
|
626
|
-
<button className="btn btn-ghost btn-sm" onClick={() =>
|
|
627
|
-
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={
|
|
668
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setCloseAction(null)} disabled={closing === "busy"}>取消</button>
|
|
669
|
+
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={confirmClose}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
|
|
628
670
|
</div>
|
|
629
671
|
)}
|
|
630
672
|
{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>}
|
|
@@ -634,6 +676,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
634
676
|
<div className="transcript-wrap">
|
|
635
677
|
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
636
678
|
<div className="tw">
|
|
679
|
+
{slots?.beforeMessages}
|
|
637
680
|
{(!key || isNew) && cfg.empty.show && (
|
|
638
681
|
<div className="hero cli-hero">
|
|
639
682
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
@@ -693,11 +736,12 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
693
736
|
<button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
|
|
694
737
|
</div>
|
|
695
738
|
)}
|
|
696
|
-
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
739
|
+
{cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
697
740
|
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
698
741
|
{ended && cfg.transcript.status && (
|
|
699
742
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
700
743
|
)}
|
|
744
|
+
{slots?.afterMessages}
|
|
701
745
|
{continuedBy && (
|
|
702
746
|
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
|
703
747
|
)}
|
|
@@ -737,15 +781,24 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
737
781
|
))}
|
|
738
782
|
</div>
|
|
739
783
|
)}
|
|
784
|
+
{showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
|
|
785
|
+
<details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
|
|
786
|
+
<div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
|
|
787
|
+
<ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
|
|
788
|
+
</details>
|
|
789
|
+
<label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
|
|
790
|
+
</div>}
|
|
791
|
+
{slots?.beforeComposer}
|
|
740
792
|
{showComposer && (
|
|
741
793
|
<Composer
|
|
742
794
|
key={key ?? "new"}
|
|
743
|
-
draft={key ?? `new:${instanceId}`}
|
|
795
|
+
draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
|
|
744
796
|
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
745
|
-
disabled={composerDisabled}
|
|
797
|
+
disabled={composerDisabled || !!configurationBusy}
|
|
746
798
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
747
799
|
running={running || batchPending}
|
|
748
|
-
|
|
800
|
+
toolbar={slots?.composerToolbar}
|
|
801
|
+
onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
|
|
749
802
|
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
750
803
|
taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
|
|
751
804
|
paused: controlTask.status === "paused",
|
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, taskControl, 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;
|
|
@@ -41,38 +33,43 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
41
33
|
onStop?: () => Promise<void> | void;
|
|
42
34
|
taskControl?: { paused: boolean; onToggle: () => Promise<void> };
|
|
43
35
|
options: ComposerOptions;
|
|
36
|
+
toolbar?: ReactNode;
|
|
44
37
|
}) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
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 }));
|
|
50
45
|
const [stopping, setStopping] = useState(false);
|
|
51
46
|
const [controlling, setControlling] = useState(false);
|
|
52
47
|
const [dragging, setDragging] = useState(false);
|
|
53
48
|
const dragDepth = useRef(0);
|
|
54
|
-
const
|
|
49
|
+
const stopLock = useRef(false);
|
|
50
|
+
const controlLock = useRef(false);
|
|
51
|
+
const composing = useRef(false);
|
|
55
52
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
56
53
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
57
54
|
|
|
58
|
-
useEffect(() => { if (!running) setStopping(false); }, [running]);
|
|
59
|
-
useEffect(() => { saveDraft(draft, val); }, [draft, val]);
|
|
55
|
+
useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
|
|
60
56
|
const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
|
|
61
57
|
useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
|
|
62
58
|
|
|
63
|
-
const nextKey = () => `blk-${
|
|
59
|
+
const nextKey = () => `blk-${clientId()}`;
|
|
64
60
|
const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
|
|
65
61
|
|
|
66
62
|
const addFiles = (files: File[]) => {
|
|
67
|
-
if (disabled || !options.fileUpload) return;
|
|
63
|
+
if (disabled || sending || !options.fileUpload) return;
|
|
68
64
|
for (const file of files) {
|
|
69
65
|
const key = nextKey();
|
|
70
66
|
setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
|
|
71
67
|
readFileText(file)
|
|
72
68
|
.then((text) => {
|
|
73
|
-
setPending((prev) => prev.filter((p) => p.key !== key));
|
|
74
69
|
if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
|
|
75
|
-
|
|
70
|
+
updateComposerDraft(draft, s => s.pending.some(p => p.key === key && p.status === "reading")
|
|
71
|
+
? { ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }
|
|
72
|
+
: s);
|
|
76
73
|
})
|
|
77
74
|
.catch((err: unknown) => {
|
|
78
75
|
setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
|
|
@@ -85,20 +82,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
85
82
|
const tooLong = total > MAX_MESSAGE_CHARS;
|
|
86
83
|
const reading = pending.some((p) => p.status === "reading");
|
|
87
84
|
// 运行中不允许排队时,等这一轮结束再发。
|
|
88
|
-
const canSend = !!message.trim() && !reading && !tooLong && !sending && (!running || options.queue);
|
|
85
|
+
const canSend = !!message.trim() && !pending.length && !reading && !tooLong && !sending && (!running || options.queue);
|
|
89
86
|
|
|
90
87
|
const submit = async () => {
|
|
91
|
-
if (!canSend || disabled) return;
|
|
88
|
+
if (!canSend || disabled || readComposerDraft(draft).sending) return;
|
|
89
|
+
const submitted = JSON.stringify([val, blocks]);
|
|
92
90
|
setSending(true);
|
|
93
91
|
setError(null);
|
|
94
92
|
const key = draft;
|
|
95
93
|
try {
|
|
96
94
|
await onSend(message);
|
|
97
95
|
// 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
setBlocks([]);
|
|
101
|
-
setPending([]);
|
|
96
|
+
const current = readComposerDraft(key);
|
|
97
|
+
if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
|
|
102
98
|
if (ref.current) ref.current.style.height = "auto";
|
|
103
99
|
} catch (err) {
|
|
104
100
|
// 发送失败:内容留在输入框里,可以直接再发。
|
|
@@ -111,7 +107,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
111
107
|
const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
112
108
|
const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
|
|
113
109
|
const stopButton = running && onStop && (
|
|
114
|
-
<button type="button" className="send stop" onClick={() => {
|
|
110
|
+
<button type="button" className="send stop" onClick={async () => {
|
|
111
|
+
if (stopLock.current) return;
|
|
112
|
+
stopLock.current = true; setStopping(true); setError(null);
|
|
113
|
+
try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
|
|
114
|
+
}}
|
|
115
115
|
disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
|
|
116
116
|
);
|
|
117
117
|
|
|
@@ -119,7 +119,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
119
119
|
<div className="composer">
|
|
120
120
|
<div className="composer-inner">
|
|
121
121
|
{(error || tooLong) && (
|
|
122
|
-
<div className="cli-comp-err">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
|
|
122
|
+
<div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
|
|
123
123
|
)}
|
|
124
124
|
<div
|
|
125
125
|
className={`comp-box${dragging ? " dropping" : ""}`}
|
|
@@ -144,17 +144,18 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
144
144
|
<div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
|
|
145
145
|
<span className="attach-file">{Icon.doc()} {p.name}</span>
|
|
146
146
|
<span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
|
|
147
|
-
|
|
147
|
+
<button type="button" className="attach-x" title={p.status === "reading" ? "取消添加,忽略尚未完成的解析结果" : "移除"} aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>
|
|
148
148
|
</div>
|
|
149
149
|
))}
|
|
150
150
|
</div>
|
|
151
151
|
)}
|
|
152
152
|
<div className="comp-area">
|
|
153
153
|
<textarea
|
|
154
|
-
ref={ref} rows={2} value={val} disabled={disabled} aria-label="消息内容"
|
|
154
|
+
ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
|
|
155
155
|
placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
|
|
156
156
|
onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
|
|
157
|
-
|
|
157
|
+
onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
|
|
158
|
+
onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
|
|
158
159
|
onPaste={(e) => {
|
|
159
160
|
const files = Array.from(e.clipboardData?.files ?? []);
|
|
160
161
|
if (files.length) {
|
|
@@ -176,9 +177,10 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
176
177
|
<div className="comp-foot">
|
|
177
178
|
{options.fileUpload && !disabled && <>
|
|
178
179
|
<input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
|
|
179
|
-
<button type="button" className="cli-attach" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
|
|
180
|
-
onClick={() => fileRef.current?.click()}>{Icon.
|
|
180
|
+
<button type="button" className="cli-attach" disabled={sending} aria-label="添加文档" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
|
|
181
|
+
onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
|
|
181
182
|
</>}
|
|
183
|
+
{toolbar}
|
|
182
184
|
{options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
|
|
183
185
|
<span className="spacer" />
|
|
184
186
|
{options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
|
|
@@ -187,7 +189,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
187
189
|
{taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
|
|
188
190
|
aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
|
|
189
191
|
title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
|
|
190
|
-
onClick={async () => { setControlling(true); try { await taskControl.onToggle(); } catch {
|
|
192
|
+
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
193
|
{taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
|
|
192
194
|
</button>}
|
|
193
195
|
{(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { 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 reasoningOptions = (caps?: RunCapabilities) => withDefault(caps?.reasoningOptions);
|
|
15
|
+
|
|
16
|
+
export type RunControlProps = {
|
|
17
|
+
task: RunTask;
|
|
18
|
+
agents?: {id:string;label:string;disabled?:boolean}[];
|
|
19
|
+
onSetAgent?: (id:string)=>Promise<void>;
|
|
20
|
+
caps?: RunCapabilities;
|
|
21
|
+
defaults?: { model?: string | null; reasoning?: string | null; permission_mode?: string | null };
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
onSetModel: (value: string | null) => Promise<void>;
|
|
24
|
+
onSetReasoning: (value: string | null) => Promise<void>;
|
|
25
|
+
onSetPermission: (value: string | null) => Promise<void>;
|
|
26
|
+
onBusyChange?: (busy: boolean) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Both the pre-start view and composer use the same capability-driven controls.
|
|
30
|
+
// Await persistence before allowing Send so the next turn uses the chosen value.
|
|
31
|
+
export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
|
|
32
|
+
const [open, setOpen] = useState<"model" | "permission" | null>(null);
|
|
33
|
+
const [custom, setCustom] = useState("");
|
|
34
|
+
const busy = useConfigurationBusy(task.id);
|
|
35
|
+
const currentTask = useRef(task.id); currentTask.current = task.id;
|
|
36
|
+
useEffect(() => { setOpen(null); setError(""); }, [task.id]);
|
|
37
|
+
const [error, setError] = useState("");
|
|
38
|
+
const models = modelOptions(caps);
|
|
39
|
+
const reasoning = reasoningOptions(caps);
|
|
40
|
+
const permissions = permissionOptions(caps);
|
|
41
|
+
const effectiveModel = task.model ?? defaults?.model ?? null;
|
|
42
|
+
const effectiveReasoning = task.reasoning ?? defaults?.reasoning ?? null;
|
|
43
|
+
const effectivePermission = task.permission_mode ?? defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
|
|
44
|
+
const model = models.find((m) => m.value === effectiveModel)?.label ?? modelLabel(effectiveModel ?? "");
|
|
45
|
+
const permission = permissions.find((p) => p.value === effectivePermission);
|
|
46
|
+
const change = async (save: () => Promise<void>) => {
|
|
47
|
+
if (disabled) return;
|
|
48
|
+
const release = acquireConfiguration(task.id);
|
|
49
|
+
if (!release) return;
|
|
50
|
+
const targetId = task.id;
|
|
51
|
+
setError("");
|
|
52
|
+
try { onBusyChange?.(true); await save(); if(currentTask.current === targetId) setOpen(null); }
|
|
53
|
+
catch (e) { if(currentTask.current === targetId) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); }
|
|
54
|
+
finally { release(); onBusyChange?.(false); }
|
|
55
|
+
};
|
|
56
|
+
const locked = disabled || busy || !caps;
|
|
57
|
+
return <>
|
|
58
|
+
{!!caps?.permissionModes.length && <div className="comp-control">
|
|
59
|
+
<button className={`comp-setting permission${effectivePermission === "bypassPermissions" ? " elevated" : ""}`} disabled={locked} aria-label="权限模式" aria-expanded={open === "permission"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改权限" : permission?.sub} onClick={(e) => { e.stopPropagation(); setOpen(open === "permission" ? null : "permission"); }}>
|
|
60
|
+
{Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
|
|
61
|
+
</button>
|
|
62
|
+
{open === "permission" && <Popover onClose={() => setOpen(null)}>
|
|
63
|
+
<div className="pop-sec">权限模式 · 保存后用于下一轮</div>
|
|
64
|
+
{permissions.map((p) => <button key={p.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.permission_mode ?? null) === p.value} onClick={() => void change(() => onSetPermission(p.value))}>
|
|
65
|
+
<span><span>{p.label}</span><span className="pi-sub">{p.sub}</span></span>{(task.permission_mode ?? null) === p.value && Icon.check()}
|
|
66
|
+
</button>)}
|
|
67
|
+
{error && <div className="control-error" role="alert">{error}</div>}
|
|
68
|
+
</Popover>}
|
|
69
|
+
</div>}
|
|
70
|
+
<div className="comp-control comp-model">
|
|
71
|
+
<button className="comp-setting" disabled={locked} aria-label="模型与推理设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改模型与推理" : `模型:${model}${task.resolved_model ? ` · 上次运行:${task.resolved_model}` : ""}`} onClick={(e) => { e.stopPropagation(); setOpen(open === "model" ? null : "model"); }}>
|
|
72
|
+
<span className="comp-model-name">{model === "默认" ? "默认模型" : model}</span>
|
|
73
|
+
{!!caps?.reasoningOptions.length && <span className="comp-reasoning">{reasoning.find((r) => r.value === effectiveReasoning)?.label ?? "默认"}</span>}{Icon.chevDown()}
|
|
74
|
+
</button>
|
|
75
|
+
{open === "model" && <Popover onClose={() => setOpen(null)}>
|
|
76
|
+
{agents && agents.length>1 && onSetAgent && <><div className="pop-sec">智能体 · 接手当前任务的历史</div>{agents.map(a=><button key={a.id} className="pop-item" disabled={locked||a.disabled} aria-pressed={task.agent===a.id} onClick={()=>void change(()=>onSetAgent(a.id))}>{a.label}{task.agent===a.id&&Icon.check()}</button>)}<div className="divider"/></>}
|
|
77
|
+
<div className="pop-sec">模型 · 保存后用于下一轮</div>
|
|
78
|
+
{models.map((m, i) => <Fragment key={m.value ?? "default"}>
|
|
79
|
+
{m.group && m.group !== models[i - 1]?.group && <div className="pop-sec">{m.group}</div>}
|
|
80
|
+
<button className="pop-item" disabled={locked} aria-pressed={(task.model ?? null) === m.value} onClick={() => void change(() => onSetModel(m.value))}>
|
|
81
|
+
<span><span>{m.label}</span><span className="pi-sub">{m.sub}</span></span>{(task.model ?? null) === m.value && Icon.check()}
|
|
82
|
+
</button>
|
|
83
|
+
</Fragment>)}
|
|
84
|
+
{caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
|
|
85
|
+
<label className="pop-sec" htmlFor={`model-${task.id}`}>自定义模型</label>
|
|
86
|
+
<div className="pop-custom-row"><input id={`model-${task.id}`} value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="模型 ID" disabled={locked} /><button className="btn btn-sm" disabled={locked || !custom.trim()}>使用</button></div>
|
|
87
|
+
</form>}
|
|
88
|
+
{!!caps?.reasoningOptions.length && <>
|
|
89
|
+
<div className="divider" /><div className="pop-sec">推理强度</div>
|
|
90
|
+
{reasoning.map((r) => <button key={r.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.reasoning ?? null) === r.value} onClick={() => void change(() => onSetReasoning(r.value))}>
|
|
91
|
+
<span><span>{r.label}</span><span className="pi-sub">{r.sub}</span></span>{(task.reasoning ?? null) === r.value && Icon.check()}
|
|
92
|
+
</button>)}
|
|
93
|
+
</>}
|
|
94
|
+
{busy && <div className="pop-sec" role="status">正在保存…</div>}
|
|
95
|
+
{error && <div className="control-error" role="alert">{error}</div>}
|
|
96
|
+
</Popover>}
|
|
97
|
+
</div>
|
|
98
|
+
</>;
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|