@xgspz/cli-ui 0.0.0-stage → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +512 -2
- package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
- package/dist/cli-ui-index-C34qFb4u.js +11209 -0
- package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
- package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
- package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +932 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +577 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1103 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +505 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- 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 +619 -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 +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -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 +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -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 +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -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 +485 -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 +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -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 +335 -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 +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1949 -0
- package/src/operator.css +1949 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
// cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
|
|
2
|
+
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
|
+
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
|
+
import { SubagentChatScope } from "../ui/SubagentChat";
|
|
5
|
+
import { parentMessages } from "../core/subagents";
|
|
6
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
|
7
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
8
|
+
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
|
|
9
|
+
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider";
|
|
10
|
+
import { formatBoardContext, withoutBoardContext } from "../board/context";
|
|
11
|
+
import { MAX_MESSAGE_CHARS, charCount, boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks";
|
|
12
|
+
import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation";
|
|
13
|
+
import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types";
|
|
14
|
+
import { Icon } from "../ui/icons";
|
|
15
|
+
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
|
|
16
|
+
import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
|
|
17
|
+
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
|
|
18
|
+
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
|
|
19
|
+
import { Composer, type ComposerOptions } from "./Composer";
|
|
20
|
+
import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
|
|
21
|
+
import { exportMarkdown } from "./export";
|
|
22
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
23
|
+
import { FileViewer } from "../board/FileViewer";
|
|
24
|
+
import { BrowserPane } from "../board/BrowserPane";
|
|
25
|
+
import { CliBoard } from "../board/CliBoard";
|
|
26
|
+
import { WorkspaceTabs } from "../ui/WorkspaceTabs";
|
|
27
|
+
import { WorkspaceDrawer, useWorkspaceDrawer } from "../ui/WorkspaceDrawer";
|
|
28
|
+
import { useChatScrollbar } from "../ui/useChatScrollbar";
|
|
29
|
+
import { QueuedMessages } from "../ui/QueuedMessages";
|
|
30
|
+
import { restoreQueuedToDraft } from "./queueEdit";
|
|
31
|
+
import { ReferenceCard } from "../ui/ReferenceCard";
|
|
32
|
+
import type { UploadDocument } from "../core/documentUpload";
|
|
33
|
+
import { GroupChat, type GroupChatAdapter } from "./GroupChat";
|
|
34
|
+
import { RunControls } from "./RunControls";
|
|
35
|
+
import { useExecutionConfig } from "./useExecutionConfig";
|
|
36
|
+
import { CenterError } from "../core/client";
|
|
37
|
+
|
|
38
|
+
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
39
|
+
export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
40
|
+
/** 宿主提供的多发言人群聊;发送统一交给 group.onSend,不连接普通单任务转录。 */
|
|
41
|
+
group?: GroupChatAdapter;
|
|
42
|
+
/** Integration slots share this transcript/composer; they do not create another chat implementation. */
|
|
43
|
+
slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
|
|
44
|
+
/** Saving execution settings blocks send until persistence completes. */
|
|
45
|
+
configurationBusy?: boolean;
|
|
46
|
+
onClear?: () => void;
|
|
47
|
+
/** 每条消息自动带入看板当前文件、页签和最近浏览;默认 true。 */
|
|
48
|
+
passiveContext?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
|
|
52
|
+
* 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
|
|
53
|
+
*/
|
|
54
|
+
jobId?: string | null;
|
|
55
|
+
/**
|
|
56
|
+
* 对话任务里的哪个对话(子任务 task_id),和 jobId 一起用。不给 = 对话任务的第一个对话(只有一个对话时就是它);
|
|
57
|
+
* "new" = 在这个对话任务里新开一个对话(同一节点、同一工作区,新的 Agent 会话),第一条消息发出后组件跟到开出来的那个。
|
|
58
|
+
* 结束对话时:给了 taskId 只关掉这一个对话,没给结束整个对话任务。
|
|
59
|
+
*/
|
|
60
|
+
taskId?: string | null;
|
|
61
|
+
/** 面包屑里的名称与色块(如业务 / 项目名)。 */
|
|
62
|
+
title?: string;
|
|
63
|
+
color?: string;
|
|
64
|
+
/** 只看不发。 */
|
|
65
|
+
readOnly?: boolean;
|
|
66
|
+
/** 是否显示「结束对话」按钮,缺省 true(等同 config.header.close)。 */
|
|
67
|
+
allowClose?: boolean;
|
|
68
|
+
/** 界面配置:顶部栏、输入框、对话区各模块的显示与文案,见 core/config.ts。 */
|
|
69
|
+
config?: CliChatConfig;
|
|
70
|
+
/**
|
|
71
|
+
* Agent 消息里本地文件链接(report.md、/abs/path、src/a.ts:12)的打开地址。缺省没有:显示成不可点的文字。
|
|
72
|
+
* 有文件服务时(例如以后的看板 / Operator 后端)给出地址,链接就能打开(与 Operator 一致)。
|
|
73
|
+
* taskId:看的是对话任务里指定的那个对话时才有。
|
|
74
|
+
*/
|
|
75
|
+
fileUrl?: (path: string, jobId: string, kind: "link" | "image", taskId?: string | null) => string | null;
|
|
76
|
+
/**
|
|
77
|
+
* 点了本地文件链接 / 「查看浏览器」怎么打开:
|
|
78
|
+
* · "auto"(缺省)—— 同一个 Provider 里有 cli-board 在看这个对话就在看板里打开,否则在对话里弹窗预览;
|
|
79
|
+
* · "board" —— 只交给看板(没有看板时按 fileUrl 的地址打开);
|
|
80
|
+
* · "modal" —— 总是在对话里弹窗;
|
|
81
|
+
* · "link" —— 不拦截,按 fileUrl 的地址在新窗口打开(以前的行为)。
|
|
82
|
+
* 无论哪种,都会发 open 事件(onEvent),宿主可以据此展开右侧面板。
|
|
83
|
+
*/
|
|
84
|
+
fileOpen?: "auto" | "board" | "modal" | "link";
|
|
85
|
+
/** 一轮结束(≈ center 回调 conversation_turn)、对话结束、job 变化等。 */
|
|
86
|
+
onEvent?: (event: CliEvent) => void;
|
|
87
|
+
|
|
88
|
+
theme?: "light" | "dark";
|
|
89
|
+
className?: string;
|
|
90
|
+
style?: CSSProperties;
|
|
91
|
+
store?: CliStore;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function CliChat(props: CliChatProps) {
|
|
95
|
+
const ctx = useCliStoreContext();
|
|
96
|
+
if (props.group) return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <GroupChat key={props.group.scopeKey} group={props.group} theme={props.theme} className={props.className} style={props.style} readOnly={props.readOnly} />, !!ctx)}</CliErrorBoundary>;
|
|
97
|
+
return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
|
|
101
|
+
const FINAL_REASONS = new Set(["closed", "cancelled", "conversation_closed"]);
|
|
102
|
+
const REASON_TEXT: Record<string, string> = {
|
|
103
|
+
closed: "对话已结束", cancelled: "对话已取消", conversation_closed: "这个对话已结束", expired: "对话空闲太久已过期",
|
|
104
|
+
node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** 子任务结束了的状态:可以接着它聊。 */
|
|
108
|
+
const SETTLED_TASK = new Set(["succeeded", "failed", "cancelled"]);
|
|
109
|
+
|
|
110
|
+
// 一次性任务(含续派)的子任务状态(center TaskStatus)。
|
|
111
|
+
const BATCH_STATUS: Record<string, { dotCls: DotCls; statusText: string }> = {
|
|
112
|
+
succeeded: { dotCls: "g", statusText: "子任务已完成" }, failed: { dotCls: "x", statusText: "子任务失败" }, cancelled: { dotCls: "x", statusText: "子任务已取消" },
|
|
113
|
+
dispatching: { dotCls: "a", statusText: "正在派发" }, running: { dotCls: "a", statusText: "运行中" }, verifying: { dotCls: "a", statusText: "正在核对结果" },
|
|
114
|
+
queued: { dotCls: "a", statusText: "等待执行" }, blocked: { dotCls: "a", statusText: "等待执行" }, paused: { dotCls: "a", statusText: "已暂停" },
|
|
115
|
+
orphaned: { dotCls: "a", statusText: "执行节点暂时连不上,恢复后继续" },
|
|
116
|
+
};
|
|
117
|
+
function batchStatusView(taskStatus: string | null, jobStatus: string | null): { dotCls: DotCls; statusText: string } {
|
|
118
|
+
if (taskStatus && BATCH_STATUS[taskStatus]) return BATCH_STATUS[taskStatus];
|
|
119
|
+
if (jobStatus && TERMINAL_JOB.has(jobStatus)) return { dotCls: jobStatus === "succeeded" ? "g" : "x", statusText: jobStatus === "succeeded" ? "已完成" : jobStatus === "cancelled" ? "已取消" : "已失败" };
|
|
120
|
+
return { dotCls: "a", statusText: "进行中" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
|
|
124
|
+
const QUIET_AFTER_MS = 180_000;
|
|
125
|
+
function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
|
|
126
|
+
const [now, setNow] = useState(() => Date.now());
|
|
127
|
+
useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []);
|
|
128
|
+
const elapsed = startedAt ? Math.max(0, now - startedAt) : null;
|
|
129
|
+
const quiet = now - lastActivityRef.current;
|
|
130
|
+
const clock = (ms: number) => {
|
|
131
|
+
const s = Math.floor(ms / 1000);
|
|
132
|
+
const h = Math.floor(s / 3600);
|
|
133
|
+
const m = Math.floor((s % 3600) / 60);
|
|
134
|
+
const ss = String(s % 60).padStart(2, "0");
|
|
135
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
|
|
136
|
+
};
|
|
137
|
+
return (
|
|
138
|
+
<div className="msg assistant">
|
|
139
|
+
<div className="who"><Avatar who="cc" agent={agent} /> {agentName}</div>
|
|
140
|
+
<div className="msg-body run-ind">
|
|
141
|
+
<span className="typing"><i /><i /><i /></span>
|
|
142
|
+
{elapsed !== null && <span className="run-ind-t">运行中 · {clock(elapsed)}</span>}
|
|
143
|
+
{quiet >= QUIET_AFTER_MS && <span className="run-ind-quiet">已 {Math.floor(quiet / 60_000)} 分钟没有新输出(可能在执行耗时操作);确认卡住时可点输入框右侧的停止按钮</span>}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
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 }) {
|
|
150
|
+
useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
|
|
151
|
+
// 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
|
|
152
|
+
const configKey = JSON.stringify(config ?? {});
|
|
153
|
+
const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
154
|
+
const store = useCliStore();
|
|
155
|
+
const selected = useCliState((s) => s.selected);
|
|
156
|
+
// 显示哪个对话:
|
|
157
|
+
// · 不传 jobId → 跟仓库的选中走(和同一 Provider 里的 cli-msglist 联动);
|
|
158
|
+
// · 传了 jobId(含 null = 新对话)→ 组件自己接管:从传入的 id 开始,之后自己新建出的对话、
|
|
159
|
+
// center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId / taskId 就以宿主为准。
|
|
160
|
+
// 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
|
|
161
|
+
// 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
|
|
162
|
+
const instanceId = useId();
|
|
163
|
+
const inlineOwner = `chat-board-${instanceId}`;
|
|
164
|
+
const controlled = jobIdProp !== undefined;
|
|
165
|
+
// jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
|
|
166
|
+
const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
|
|
167
|
+
const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: keyProp, id: keyProp ?? null }));
|
|
168
|
+
let viewId = followed.id;
|
|
169
|
+
if (followed.prop !== keyProp) {
|
|
170
|
+
viewId = keyProp ?? null;
|
|
171
|
+
setShown({ prop: keyProp, id: viewId });
|
|
172
|
+
}
|
|
173
|
+
const key = controlled ? viewId : selected;
|
|
174
|
+
const ref = key ? parseConvKey(key) : null;
|
|
175
|
+
const jobId = ref?.jobId ?? null;
|
|
176
|
+
// 「在这个对话任务里新开一个对话」:还没有对话,第一条消息开出来。
|
|
177
|
+
const isNew = isNewInJob(key);
|
|
178
|
+
const taskId = ref?.taskId && !isNew ? ref.taskId : null;
|
|
179
|
+
const go = useCallback((id: string | null) => {
|
|
180
|
+
if (controlled) setShown((v) => (v.id === id ? v : { ...v, id }));
|
|
181
|
+
else store.select(id);
|
|
182
|
+
}, [controlled, store]);
|
|
183
|
+
const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
|
|
184
|
+
const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
|
|
185
|
+
const externalBoardVisible = useCliState(s => !!(key && s.externalBoards[key]));
|
|
186
|
+
const listVisible = useCliState(s => s.listVisible);
|
|
187
|
+
const externalBoardOptions = useCliState(s => key ? s.externalBoardOptions[key] : undefined);
|
|
188
|
+
const [tabsHost, setTabsHost] = useState<HTMLDivElement | null>(null);
|
|
189
|
+
const [workspace, setWorkspace] = useState<{ key: string | null; active: boolean }>({ key: null, active: false });
|
|
190
|
+
const showChat = useCallback(() => setWorkspace({ key, active: false }), [key]);
|
|
191
|
+
const showWorkspace = useCallback(() => setWorkspace({ key, active: true }), [key]);
|
|
192
|
+
|
|
193
|
+
// 看这个对话:开实时流(同一对话多处看只连一次)。
|
|
194
|
+
useEffect(() => (key ? store.open(key) : undefined), [store, key]);
|
|
195
|
+
// 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
|
|
196
|
+
const keyRef = useRef(key);
|
|
197
|
+
useLayoutEffect(() => { keyRef.current = key; }, [key]);
|
|
198
|
+
useEffect(() => store.on((event) => {
|
|
199
|
+
if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
|
|
200
|
+
}), [store, go]);
|
|
201
|
+
|
|
202
|
+
const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
|
|
203
|
+
// config.transcript.tools=false:去掉工具卡片,只留文字往来(提问 / 输入卡片要用户操作,照常显示)。
|
|
204
|
+
const parent = useMemo(() => parentMessages(allMessages), [allMessages]);
|
|
205
|
+
const messages = useMemo(() => (cfg.transcript.tools ? parent : parent.filter((m) => {
|
|
206
|
+
if (m.role !== "tool") return true;
|
|
207
|
+
const data = toolDataOf(m);
|
|
208
|
+
return !!(data.ask || data.input);
|
|
209
|
+
})), [parent, cfg.transcript.tools]);
|
|
210
|
+
const remoteTaskId = conv?.remoteTaskId ?? conv?.task?.remote_task_id ?? null;
|
|
211
|
+
const loadSubagent = useCallback(async (id: string, generation: number, before?: string) => {
|
|
212
|
+
if (!key || !remoteTaskId || !store.client.subagent) throw new Error("当前接入未提供子代理历史接口");
|
|
213
|
+
return store.client.subagent(key, remoteTaskId, id, generation, before);
|
|
214
|
+
}, [store, key, remoteTaskId]);
|
|
215
|
+
const renderSubagentMessage = useCallback((message: Msg, name: string) => <MessageView m={message} initial={false} hideWho={false} handlers={{}} agentLabel={name} view={{ timestamps: true, initialBadge: false, userLabel: "收到的任务", agentName: name }} />, []);
|
|
216
|
+
const running = conv ? isRunning(conv) : false;
|
|
217
|
+
const job = entry?.job;
|
|
218
|
+
// 一次性任务没有轮次:补充消息通过 continueTask 接在当前子任务的对话后面。
|
|
219
|
+
const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
|
|
220
|
+
const chat = !!taskId && isChatTask(conv);
|
|
221
|
+
const batchTaskStatus = batch ? conv?.batchTask?.status ?? job?.tasks?.find((t) => t.id === (taskId ?? conv?.task?.id))?.status ?? null : null;
|
|
222
|
+
const batchWritable = batch && !!(taskId ?? conv?.task?.id) && !!store.client.continueTask;
|
|
223
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !batchWritable);
|
|
224
|
+
const showComposer = !readOnlyProp && cfg.composer.show;
|
|
225
|
+
// 最新消息可能还在排队:沿当前对话的接续链找正在执行 / 暂停的子任务,不能误停同 Job 的其他对话。
|
|
226
|
+
const controlTask = useMemo(() => {
|
|
227
|
+
if (!batch || !conv?.batchTask) return null;
|
|
228
|
+
const tasks = new Map<string, Pick<CenterTask, "id" | "status" | "continue_from_task_id">>((job?.tasks ?? []).map((t) => [t.id, t]));
|
|
229
|
+
tasks.set(conv.batchTask.id, { ...conv.batchTask, continue_from_task_id: conv.batchTask.continue_from_task_id ?? undefined });
|
|
230
|
+
const chain = [];
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
let t = tasks.get(conv.batchTask.id);
|
|
233
|
+
while (t && !seen.has(t.id)) { chain.push(t); seen.add(t.id); t = tasks.get(t.continue_from_task_id ?? ""); }
|
|
234
|
+
return chain.find((t) => ["dispatching", "running", "verifying"].includes(t.status))
|
|
235
|
+
?? chain.find((t) => t.status === "paused")
|
|
236
|
+
?? chain.reverse().find((t) => ["queued", "blocked"].includes(t.status)) ?? null;
|
|
237
|
+
}, [batch, conv?.batchTask, job?.tasks]);
|
|
238
|
+
const batchPending = batch && !!batchTaskStatus && !SETTLED_TASK.has(batchTaskStatus);
|
|
239
|
+
// 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
|
|
240
|
+
// 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
|
|
241
|
+
const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
|
|
242
|
+
const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
|
|
243
|
+
const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
|
|
244
|
+
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
245
|
+
const draftScope = store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`;
|
|
246
|
+
const initialOptions = newConversation ?? store.getNewConversationOptions();
|
|
247
|
+
const executionReady = !batch && (!key || !!job);
|
|
248
|
+
const executionEnabled = showComposer && executionReady && (cfg.composer.agentSelector || cfg.composer.executionMode);
|
|
249
|
+
const execution = useExecutionConfig({ client: store.client, scope: draftScope,
|
|
250
|
+
target: key && !isNew ? key : null, running: running || batchPending,
|
|
251
|
+
enabled: executionEnabled, ready: executionReady,
|
|
252
|
+
initial: { ...(initialOptions?.agent ? { agent: initialOptions.agent } : {}), ...initialOptions?.executionConfig },
|
|
253
|
+
});
|
|
254
|
+
const agentText = labelOf(execution.config?.agent ?? agent);
|
|
255
|
+
const executionAgent = execution.config?.agents.find(item => item.id === execution.config?.agent);
|
|
256
|
+
const executionReadOnly = !!key && !isNew && !store.client.setExecutionConfig;
|
|
257
|
+
const settingsBusy = !!configurationBusy || execution.blocked;
|
|
258
|
+
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
259
|
+
// 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
|
|
260
|
+
// 支持续派的普通子任务同理:大任务结束不挡输入框。
|
|
261
|
+
const jobEnded = !chat && !batchWritable && !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
262
|
+
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
263
|
+
const convClosed = !jobEnded && !batchWritable && !!(conv?.task?.closed ?? info?.closed);
|
|
264
|
+
const status = convClosed ? "succeeded" : jobStatus;
|
|
265
|
+
const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
|
|
266
|
+
const ended = jobEnded || convClosed;
|
|
267
|
+
const finalEnd = ended && FINAL_REASONS.has(closedReason ?? "closed");
|
|
268
|
+
const continuedFrom = job?.conversation?.continued_from_job_id ?? null;
|
|
269
|
+
// 接续自旧对话任务里的哪个对话(指定了对话时跟回那一个)。
|
|
270
|
+
const continuedFromKey = continuedFrom ? convKey(continuedFrom, taskId ? task?.continue_from_task_id ?? info?.continue_from_task_id ?? null : null) : null;
|
|
271
|
+
const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
|
|
272
|
+
const jobTitle = job?.title ?? conv?.job?.title;
|
|
273
|
+
// 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
|
|
274
|
+
const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
|
|
275
|
+
// 对话式子任务:流里的是这个对话最新的那条消息,名称取打开的那个子任务。
|
|
276
|
+
const convName = chat ? task?.title ?? conv?.task?.title : conv?.task?.title ?? info?.title ?? task?.title;
|
|
277
|
+
const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
|
|
278
|
+
: (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
|
|
279
|
+
: jobTitle ?? (key ? "对话" : "新对话");
|
|
280
|
+
const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
|
|
281
|
+
const lastTurn = conv?.turns[conv.turns.length - 1];
|
|
282
|
+
const listedAwaiting = (info ?? job?.conversation?.conversations?.find(item => item.task_id === task?.id))?.awaiting_input;
|
|
283
|
+
// An explicit null retires an old wait flag when its node observation expires.
|
|
284
|
+
const remoteAwaiting = conv?.task?.awaiting_input !== undefined ? conv.task.awaiting_input
|
|
285
|
+
: listedAwaiting !== undefined ? listedAwaiting : task?.awaiting_input !== undefined ? task.awaiting_input
|
|
286
|
+
: !manyConvs ? job?.conversation?.awaiting_input : undefined;
|
|
287
|
+
const awaiting = !ended && !TERMINAL_JOB.has(jobStatus ?? "") && !(batch && batchTaskStatus && SETTLED_TASK.has(batchTaskStatus))
|
|
288
|
+
&& (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
|
|
289
|
+
// 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
|
|
290
|
+
const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
|
|
291
|
+
const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
|
|
292
|
+
|
|
293
|
+
// 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
|
|
294
|
+
const usageTask = isNew ? undefined : task;
|
|
295
|
+
const fresh = (usageTask ? Math.max(0, usageTask.total_tokens - usageTask.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
|
|
296
|
+
const cacheRead = (usageTask?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
|
|
297
|
+
const resolvedModel = conv?.resolvedModel ?? task?.resolved_model ?? null;
|
|
298
|
+
|
|
299
|
+
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
300
|
+
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
301
|
+
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
302
|
+
if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
|
|
303
|
+
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
304
|
+
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
305
|
+
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
306
|
+
return { dotCls: "a", statusText: "进行中" };
|
|
307
|
+
}, [key, isNew, batch, chat, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
308
|
+
|
|
309
|
+
// ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
|
|
310
|
+
// 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
|
|
311
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
312
|
+
useEffect(() => setActionError(null), [key]);
|
|
313
|
+
const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
|
|
314
|
+
const sourceKey = keyRef.current;
|
|
315
|
+
try {
|
|
316
|
+
const result = await work;
|
|
317
|
+
if (keyRef.current === sourceKey) setActionError(null);
|
|
318
|
+
return result;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (keyRef.current === sourceKey) setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}, []);
|
|
324
|
+
// 操作这次运行(回答提问、填浏览器输入卡片、交还浏览器)落在哪个子任务上:一次性任务没指定子任务时是正在看的那个
|
|
325
|
+
// (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
|
|
326
|
+
const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
|
|
327
|
+
const latest = useRef({ key, messages, conv, runKey });
|
|
328
|
+
// A concurrent render may suspend while the old chat remains interactive.
|
|
329
|
+
// Event handlers must keep the target and options of the committed view.
|
|
330
|
+
useLayoutEffect(() => { latest.current = { key, messages, conv, runKey }; });
|
|
331
|
+
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
332
|
+
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
333
|
+
useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
|
|
334
|
+
const prepareExecution = useRef(execution.prepareSend);
|
|
335
|
+
const reloadExecution = useRef(execution.reload);
|
|
336
|
+
useLayoutEffect(() => { prepareExecution.current = execution.prepareSend; reloadExecution.current = execution.reload; });
|
|
337
|
+
const send = useCallback(async (text: string) => {
|
|
338
|
+
const executionOptions = prepareExecution.current();
|
|
339
|
+
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
340
|
+
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
341
|
+
// 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
|
|
342
|
+
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
|
|
343
|
+
const refKey = latest.current.key;
|
|
344
|
+
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
345
|
+
const passiveView=refKey?store.getState().boardContext[refKey]:null;
|
|
346
|
+
const passive=passiveContext&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
|
|
347
|
+
const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
|
|
348
|
+
const blocks = [
|
|
349
|
+
...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
|
|
350
|
+
...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
|
|
351
|
+
...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
|
|
352
|
+
];
|
|
353
|
+
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
354
|
+
let target: string;
|
|
355
|
+
try {
|
|
356
|
+
if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简消息内容后重试。");
|
|
357
|
+
target = await store.send(id ?? null, full, { ...sendOpts.current, ...executionOptions });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
360
|
+
if (error instanceof CenterError && error.code === "execution_config_conflict" && latest.current.key === refKey) await reloadExecution.current();
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
if (target !== id && latest.current.key === refKey) go(target);
|
|
364
|
+
}, [store, go, passiveContext]);
|
|
365
|
+
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
366
|
+
const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
|
|
367
|
+
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
368
|
+
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
369
|
+
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
370
|
+
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
371
|
+
const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
372
|
+
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
373
|
+
const chatRef = useRef<HTMLDivElement>(null);
|
|
374
|
+
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
375
|
+
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
376
|
+
// AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
|
|
377
|
+
// 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
|
|
378
|
+
const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (!key || !conv || fileOpen === "link" || fileOpen === "modal") return;
|
|
381
|
+
if (handoffSeen.current.key !== key) handoffSeen.current = { key, ids: null };
|
|
382
|
+
if (!conv.hasTranscript) return;
|
|
383
|
+
const handoffs = conv.msgs.filter((m) => m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX));
|
|
384
|
+
const seen = handoffSeen.current;
|
|
385
|
+
if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
|
|
386
|
+
const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
|
|
387
|
+
for (const m of fresh) seen.ids.add(m.id);
|
|
388
|
+
if (fresh.length) store.openInBoard(key, { kind: "browser" }, inlineOwner);
|
|
389
|
+
}, [key, conv, fileOpen, store, inlineOwner]);
|
|
390
|
+
|
|
391
|
+
// 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
|
|
392
|
+
const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
|
|
393
|
+
useEffect(() => setPopup(null), [key]);
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
if (!popup) return;
|
|
396
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPopup(null); };
|
|
397
|
+
window.addEventListener("keydown", onKey);
|
|
398
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
399
|
+
}, [popup]);
|
|
400
|
+
const handlers = useMemo<MessageHandlers>(() => ({
|
|
401
|
+
onQuote: readOnly ? undefined : (m,selected) => {
|
|
402
|
+
const id = latest.current.key;
|
|
403
|
+
if (!id) return;
|
|
404
|
+
store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});
|
|
405
|
+
chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
|
|
406
|
+
},
|
|
407
|
+
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
408
|
+
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
409
|
+
const id = latest.current.key;
|
|
410
|
+
if (!id) return;
|
|
411
|
+
// 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
|
|
412
|
+
if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
|
|
413
|
+
const sent = askId
|
|
414
|
+
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id && latest.current.key === id) go(target); })
|
|
415
|
+
: send(formatAnswersText(questions, answers));
|
|
416
|
+
await report(sent, "回答没有发出去");
|
|
417
|
+
},
|
|
418
|
+
// 错误由 report 显示在对话顶部;这里返回 promise 让排队行在撤回期间显示忙碌。
|
|
419
|
+
onCancelQueued: readOnly || batch ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) return report(store.cancelTurn(id, m.turnId), "撤回失败").then(() => undefined, () => undefined); },
|
|
420
|
+
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
421
|
+
onInput: readOnlyProp || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
422
|
+
const id = latest.current.runKey;
|
|
423
|
+
if (!id) return "对话还没开始";
|
|
424
|
+
try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
425
|
+
},
|
|
426
|
+
// 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
|
|
427
|
+
onHandback: readOnlyProp || !(store.client.handback || store.client.board) ? undefined : async () => {
|
|
428
|
+
const id = latest.current.runKey;
|
|
429
|
+
if (!id) return "对话还没开始";
|
|
430
|
+
try {
|
|
431
|
+
if (store.client.handback) return await store.client.handback(id);
|
|
432
|
+
await store.client.board!.browserAction(id, "handback");
|
|
433
|
+
return null;
|
|
434
|
+
} catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
435
|
+
},
|
|
436
|
+
onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
|
|
437
|
+
const id = latest.current.key;
|
|
438
|
+
if (!id) return;
|
|
439
|
+
if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" }, inlineOwner)) return;
|
|
440
|
+
if (fileOpen !== "board") setPopup({ kind: "browser" });
|
|
441
|
+
},
|
|
442
|
+
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
443
|
+
const list = latest.current.messages;
|
|
444
|
+
const at = list.findIndex((m) => m.id === msgId);
|
|
445
|
+
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; }
|
|
446
|
+
},
|
|
447
|
+
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen, inlineOwner]);
|
|
448
|
+
const stop = useCallback(async () => {
|
|
449
|
+
const { key: id, conv: c } = latest.current;
|
|
450
|
+
const turn = c ? runningTurn(c) : null;
|
|
451
|
+
if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
|
|
452
|
+
}, [store, report]);
|
|
453
|
+
// 排队消息「立即发送」:停掉正在执行的这一轮,中枢随即按顺序发出排队的消息(同一对话里排着的会合并成一轮)。
|
|
454
|
+
const sendQueuedNow = useCallback(async () => { await stop().catch(() => undefined); }, [stop]);
|
|
455
|
+
// 排队消息「编辑」:先撤回,撤回成功才放回输入框(失败时排队消息原样保留,不会出现两份)。
|
|
456
|
+
// 放回发出它的那个对话的草稿,期间切走了也不会放错地方。
|
|
457
|
+
const editQueued = useCallback(async (m: Msg) => {
|
|
458
|
+
const id = latest.current.key;
|
|
459
|
+
if (!id || !m.turnId) return;
|
|
460
|
+
try { await report(store.cancelTurn(id, m.turnId), "撤回失败,没有放回输入框"); } catch { return; }
|
|
461
|
+
restoreQueuedToDraft(store.draftScope ? `${store.draftScope}:${id}` : id, m.content);
|
|
462
|
+
if (latest.current.key === id) chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
|
|
463
|
+
}, [store, report]);
|
|
464
|
+
const [closeAction, setCloseAction] = useState<{ key: string; stage: "confirm" | "busy" } | null>(null);
|
|
465
|
+
const closing = closeAction?.key === key ? closeAction.stage : "idle";
|
|
466
|
+
useEffect(() => setCloseAction(null), [key]);
|
|
467
|
+
const confirmClose = () => {
|
|
468
|
+
if (!key || closeAction?.key !== key || closeAction.stage !== "confirm") return;
|
|
469
|
+
const pending = { key, stage: "busy" as const };
|
|
470
|
+
setCloseAction(pending);
|
|
471
|
+
void store.close(key).then(() => {
|
|
472
|
+
if (latest.current.key === pending.key) setActionError(null);
|
|
473
|
+
}, (error: unknown) => {
|
|
474
|
+
if (latest.current.key === pending.key) setActionError(`结束对话失败:${error instanceof Error ? error.message : String(error)}`);
|
|
475
|
+
}).finally(() => setCloseAction(current => current === pending ? null : current));
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
|
|
479
|
+
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]);
|
|
480
|
+
const [runSeenAt, setRunSeenAt] = useState<number | null>(null);
|
|
481
|
+
const lastActivityRef = useRef(Date.now());
|
|
482
|
+
const wasRunningRef = useRef(running);
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
if (running && !wasRunningRef.current) { setRunSeenAt(Date.now()); lastActivityRef.current = Date.now(); }
|
|
485
|
+
else if (!running) setRunSeenAt(null);
|
|
486
|
+
wasRunningRef.current = running;
|
|
487
|
+
}, [running]);
|
|
488
|
+
useEffect(() => { lastActivityRef.current = Date.now(); }, [messages]);
|
|
489
|
+
const turnStartedAt = runSeenAt ?? lastUserTs;
|
|
490
|
+
|
|
491
|
+
// ── 分窗渲染(移植自 Operator 的 SessionView):DOM 里最多 RENDER_MAX 行,几千条消息也照样流畅 ──
|
|
492
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
493
|
+
useChatScrollbar(scrollRef, key);
|
|
494
|
+
const pinned = useRef(true);
|
|
495
|
+
const [atBottom, setAtBottom] = useState(true);
|
|
496
|
+
const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
|
|
497
|
+
const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
|
|
498
|
+
// 「立即发送」只在有一轮正在执行、且接入开放了停止时可用。
|
|
499
|
+
const queueSendNow = !readOnly && !batch && cfg.composer.stop && key && conv && runningTurn(conv) ? sendQueuedNow : undefined;
|
|
500
|
+
const queueSendNowHint = !cfg.composer.stop && conv && runningTurn(conv) ? "当前接入没有开放停止,排队消息会在这一轮结束后发送" : undefined;
|
|
501
|
+
// 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
|
|
502
|
+
const older = conv?.older ?? 0;
|
|
503
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
504
|
+
const olderRef = useRef(older); olderRef.current = older;
|
|
505
|
+
const keyRef2 = useRef(key); keyRef2.current = key;
|
|
506
|
+
const items = useMemo(() => buildTranscriptItems(committed, older > 0)
|
|
507
|
+
.filter((it) => cfg.transcript.initialPrompt || it.kind !== "msg" || !it.initial), [committed, older, cfg.transcript.initialPrompt]);
|
|
508
|
+
// 展开了的「续等」折叠行(按折叠行的 key)。
|
|
509
|
+
const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
|
|
510
|
+
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
511
|
+
const { startIdx, endIdx } = useMemo(() => {
|
|
512
|
+
const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
|
|
513
|
+
const e = find(win.end);
|
|
514
|
+
const last = e >= 0 ? e : items.length - 1;
|
|
515
|
+
let s = find(win.start);
|
|
516
|
+
if (s >= 0) while (s > 0 && items[s - 1].kind !== "msg") s--;
|
|
517
|
+
return { startIdx: s >= 0 && s <= last ? s : Math.max(0, last + 1 - RENDER_WINDOW), endIdx: last };
|
|
518
|
+
}, [items, win]);
|
|
519
|
+
const shown = useMemo(() => items.slice(startIdx, endIdx + 1), [items, startIdx, endIdx]);
|
|
520
|
+
const hiddenAbove = useMemo(() => { let n = 0; for (let i = 0; i < startIdx; i++) if (items[i].kind === "msg") n++; return n; }, [items, startIdx]);
|
|
521
|
+
const hiddenBelow = useMemo(() => { let n = 0; for (let i = endIdx + 1; i < items.length; i++) if (items[i].kind === "msg") n++; return n; }, [items, endIdx]);
|
|
522
|
+
const itemsRef = useRef(items); itemsRef.current = items;
|
|
523
|
+
const rangeRef = useRef({ startIdx, endIdx }); rangeRef.current = { startIdx, endIdx };
|
|
524
|
+
const winRef = useRef(win); winRef.current = win;
|
|
525
|
+
type Anchor = { key: string; top: number };
|
|
526
|
+
const anchorRef = useRef<(Anchor & { win: typeof win }) | null>(null);
|
|
527
|
+
|
|
528
|
+
const measure = useCallback((key?: string): Anchor | null => {
|
|
529
|
+
const el = scrollRef.current;
|
|
530
|
+
const node = key ? el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(key)}"]`) : el?.querySelector<HTMLElement>("[data-tkey][data-msg]");
|
|
531
|
+
return node?.dataset.tkey ? { key: node.dataset.tkey, top: node.getBoundingClientRect().top } : null;
|
|
532
|
+
}, []);
|
|
533
|
+
const setRange = useCallback((s: number, e: number, anchor?: Anchor | null) => {
|
|
534
|
+
const its = itemsRef.current;
|
|
535
|
+
let a = s; while (a < its.length && its[a].kind !== "msg") a++;
|
|
536
|
+
let b = e; while (b > 0 && its[b].kind !== "msg") b--;
|
|
537
|
+
const next = { start: its[a]?.key ?? null, end: e >= its.length - 1 ? null : its[b]?.key ?? null };
|
|
538
|
+
if (winRef.current.start === next.start && winRef.current.end === next.end) return;
|
|
539
|
+
if (anchor) anchorRef.current = { ...anchor, win: next };
|
|
540
|
+
setWin(next);
|
|
541
|
+
}, []);
|
|
542
|
+
const revealEarlier = useCallback(() => {
|
|
543
|
+
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
544
|
+
if (s <= 0) {
|
|
545
|
+
// 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
|
|
546
|
+
const k = keyRef2.current;
|
|
547
|
+
if (!k || olderRef.current <= 0) return;
|
|
548
|
+
setLoadingOlder(true);
|
|
549
|
+
void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const ns = Math.max(0, s - RENDER_STEP);
|
|
553
|
+
setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
|
|
554
|
+
}, [measure, setRange, store]);
|
|
555
|
+
const revealLater = useCallback(() => {
|
|
556
|
+
const its = itemsRef.current;
|
|
557
|
+
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
558
|
+
if (e >= its.length - 1) return;
|
|
559
|
+
const ne = Math.min(its.length - 1, e + RENDER_STEP);
|
|
560
|
+
const ns = Math.max(s, ne - RENDER_MAX + 1);
|
|
561
|
+
setRange(ns, ne, ns > s ? measure(its[ns].key) : null);
|
|
562
|
+
}, [measure, setRange]);
|
|
563
|
+
const revealRef = useRef({ earlier: revealEarlier, later: revealLater });
|
|
564
|
+
revealRef.current = { earlier: revealEarlier, later: revealLater };
|
|
565
|
+
|
|
566
|
+
const onScroll = useCallback(() => {
|
|
567
|
+
const el = scrollRef.current;
|
|
568
|
+
if (!el) return;
|
|
569
|
+
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60 && winRef.current.end === null;
|
|
570
|
+
pinned.current = bottom;
|
|
571
|
+
setAtBottom(bottom);
|
|
572
|
+
if (!bottom && winRef.current.start === null) {
|
|
573
|
+
const first = itemsRef.current.slice(rangeRef.current.startIdx).find((it) => it.kind === "msg");
|
|
574
|
+
if (first) setWin((w) => ({ ...w, start: first.key }));
|
|
575
|
+
} else if (bottom && winRef.current.start !== null) {
|
|
576
|
+
setWin({ start: null, end: null });
|
|
577
|
+
}
|
|
578
|
+
}, []);
|
|
579
|
+
|
|
580
|
+
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
581
|
+
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
|
582
|
+
const hasAbove = hiddenAbove > 0 || older > 0;
|
|
583
|
+
const hasBelow = hiddenBelow > 0;
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
const root = scrollRef.current;
|
|
586
|
+
if (!root || typeof IntersectionObserver === "undefined") return;
|
|
587
|
+
const watch = (target: HTMLElement | null, margin: string, go: () => void) => {
|
|
588
|
+
if (!target) return null;
|
|
589
|
+
const io = new IntersectionObserver((entries) => { if (!pinned.current && entries.some((e) => e.isIntersecting)) go(); }, { root, rootMargin: margin });
|
|
590
|
+
io.observe(target);
|
|
591
|
+
return io;
|
|
592
|
+
};
|
|
593
|
+
const up = hasAbove ? watch(topSentinelRef.current, "1800px 0px 0px 0px", () => revealRef.current.earlier()) : null;
|
|
594
|
+
const down = hasBelow ? watch(bottomSentinelRef.current, "0px 0px 1800px 0px", () => revealRef.current.later()) : null;
|
|
595
|
+
return () => { up?.disconnect(); down?.disconnect(); };
|
|
596
|
+
}, [hasAbove, hasBelow]);
|
|
597
|
+
|
|
598
|
+
useLayoutEffect(() => {
|
|
599
|
+
if (win.start === null || win.end !== null || endIdx - startIdx + 1 <= RENDER_MAX + 2) return;
|
|
600
|
+
const el = scrollRef.current;
|
|
601
|
+
if (!el) return;
|
|
602
|
+
if (el.scrollTop < (el.scrollHeight - el.clientHeight) / 2) setRange(startIdx, startIdx + RENDER_MAX - 1);
|
|
603
|
+
else { const ns = endIdx - RENDER_MAX + 1; setRange(ns, endIdx, measure(itemsRef.current[ns].key)); }
|
|
604
|
+
}, [win, startIdx, endIdx, setRange, measure]);
|
|
605
|
+
|
|
606
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
607
|
+
const el = scrollRef.current;
|
|
608
|
+
if (!el) return;
|
|
609
|
+
pinned.current = true;
|
|
610
|
+
setAtBottom(true);
|
|
611
|
+
if (winRef.current.start !== null || winRef.current.end !== null) { setWin({ start: null, end: null }); return; }
|
|
612
|
+
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
613
|
+
}, []);
|
|
614
|
+
|
|
615
|
+
// 在「你」的消息之间跳。
|
|
616
|
+
const scrollToUserMsg = useCallback((dir: -1 | 1) => {
|
|
617
|
+
const el = scrollRef.current;
|
|
618
|
+
if (!el) return;
|
|
619
|
+
const base = el.getBoundingClientRect().top;
|
|
620
|
+
const tops = Array.from(el.querySelectorAll<HTMLElement>(".msg.user:not(.queued)")).map((n) => n.getBoundingClientRect().top - base + el.scrollTop);
|
|
621
|
+
const cur = el.scrollTop;
|
|
622
|
+
const candidates = dir < 0 ? tops.filter((t) => t < cur - 8) : tops.filter((t) => t > cur + 8);
|
|
623
|
+
if (!candidates.length) { if (dir < 0) revealRef.current.earlier(); else revealRef.current.later(); }
|
|
624
|
+
if (!tops.length) return;
|
|
625
|
+
const target = candidates.length ? (dir < 0 ? candidates[candidates.length - 1] : candidates[0]) : dir < 0 ? tops[0] : tops[tops.length - 1];
|
|
626
|
+
el.scrollTo({ top: Math.max(0, target - 12), behavior: "smooth" });
|
|
627
|
+
}, []);
|
|
628
|
+
|
|
629
|
+
useLayoutEffect(() => {
|
|
630
|
+
const a = anchorRef.current;
|
|
631
|
+
const el = scrollRef.current;
|
|
632
|
+
if (a && a.win === win) {
|
|
633
|
+
anchorRef.current = null;
|
|
634
|
+
const node = el?.querySelector<HTMLElement>(`[data-tkey="${CSS.escape(a.key)}"]`);
|
|
635
|
+
if (el && node) { const delta = node.getBoundingClientRect().top - a.top; if (Math.abs(delta) > 1) el.scrollTop += delta; }
|
|
636
|
+
}
|
|
637
|
+
}, [win]);
|
|
638
|
+
useLayoutEffect(() => { if (pinned.current && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, running, startIdx, endIdx, win]);
|
|
639
|
+
useEffect(() => {
|
|
640
|
+
const el = scrollRef.current;
|
|
641
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
642
|
+
const ro = new ResizeObserver(() => { if (pinned.current) el.scrollTop = el.scrollHeight; });
|
|
643
|
+
ro.observe(el);
|
|
644
|
+
return () => ro.disconnect();
|
|
645
|
+
}, []);
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
pinned.current = true;
|
|
648
|
+
setAtBottom(true);
|
|
649
|
+
setWin({ start: null, end: null });
|
|
650
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
651
|
+
}, [key]);
|
|
652
|
+
|
|
653
|
+
const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
|
|
654
|
+
const h = cfg.header;
|
|
655
|
+
const view = useMemo(() => ({ timestamps: cfg.transcript.timestamps, initialBadge: cfg.transcript.initialBadge, userLabel: cfg.labels.user, agentName: cfg.labels.agent, viewingBlock: refShown(cfg, "viewing") }), [cfg]);
|
|
656
|
+
const composerOptions = useMemo<ComposerOptions>(() => ({
|
|
657
|
+
placeholderRunning: cfg.composer.placeholderRunning, hints: cfg.composer.hints, charCount: cfg.composer.charCount,
|
|
658
|
+
fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
|
|
659
|
+
}), [cfg]);
|
|
660
|
+
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
661
|
+
const disabledHint = readOnly
|
|
662
|
+
? batch && !readOnlyProp && cfg.composer.show
|
|
663
|
+
? "当前任务暂不支持发送消息"
|
|
664
|
+
: "只读"
|
|
665
|
+
: finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
666
|
+
|
|
667
|
+
const fileUrlRef = useRef(fileUrl);
|
|
668
|
+
fileUrlRef.current = fileUrl;
|
|
669
|
+
const board = store.client.board;
|
|
670
|
+
const mobileWorkspace = useWorkspaceDrawer(chatRef);
|
|
671
|
+
const workspaceAvailable = h.show && !!key && !isNew && !!board && !externalBoardVisible;
|
|
672
|
+
const workspaceTabs = workspaceAvailable && !mobileWorkspace;
|
|
673
|
+
const workspaceActive = workspaceAvailable && workspace.key === key && workspace.active;
|
|
674
|
+
const showCurrentTitle = h.title || externalBoardVisible || (mobileWorkspace && workspaceAvailable);
|
|
675
|
+
const quoteToChat = useCallback(() => {
|
|
676
|
+
const sourceKey = key;
|
|
677
|
+
showChat();
|
|
678
|
+
requestAnimationFrame(() => {
|
|
679
|
+
if (keyRef.current === sourceKey) chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true });
|
|
680
|
+
});
|
|
681
|
+
}, [key, showChat]);
|
|
682
|
+
// 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
|
|
683
|
+
const resolveFile = useMemo(() => {
|
|
684
|
+
if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
|
|
685
|
+
if (board?.directLinks && key) return (path: string) => board.fileUrl(key, path);
|
|
686
|
+
return null;
|
|
687
|
+
}, [!!fileUrl, key, jobId, taskId, board]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
688
|
+
// 点了本地文件:看板 → 弹窗 → 地址(见 fileOpen)。
|
|
689
|
+
const openFile = useMemo(() => {
|
|
690
|
+
if (!key || fileOpen === "link") return null;
|
|
691
|
+
return (path: string) => {
|
|
692
|
+
if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path }, inlineOwner)) return true;
|
|
693
|
+
if (fileOpen === "board" || !board) return false;
|
|
694
|
+
setPopup({ kind: "file", path });
|
|
695
|
+
return true;
|
|
696
|
+
};
|
|
697
|
+
}, [key, fileOpen, store, board, inlineOwner]);
|
|
698
|
+
const uploadDocument = useMemo<UploadDocument | undefined>(() => {
|
|
699
|
+
const staging = store.client.uploadDocument;
|
|
700
|
+
if (!key || isNew || !board?.uploadFile) return staging;
|
|
701
|
+
return async (file, options) => {
|
|
702
|
+
const target = await board.info(key);
|
|
703
|
+
if (options.signal?.aborted) throw new DOMException("上传已取消", "AbortError");
|
|
704
|
+
if (!target.dispatched) {
|
|
705
|
+
if (staging) return staging(file, options);
|
|
706
|
+
throw new Error("当前会话工作区尚未就绪");
|
|
707
|
+
}
|
|
708
|
+
const receipt = await board.uploadFile!(key, file, options);
|
|
709
|
+
if (!receipt.path) throw new Error("文件尚未进入当前工作区,请重试");
|
|
710
|
+
if (!options.signal?.aborted && latest.current.key === key) openFile?.(receipt.path);
|
|
711
|
+
return receipt;
|
|
712
|
+
};
|
|
713
|
+
}, [store, key, isNew, board, openFile]);
|
|
714
|
+
|
|
715
|
+
return (
|
|
716
|
+
<FileLinkContext.Provider value={resolveFile}>
|
|
717
|
+
<FileOpenContext.Provider value={openFile}>
|
|
718
|
+
<div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
719
|
+
<SubagentChatScope scopeKey={`${key ?? "new"}:${remoteTaskId ?? ""}`} messages={allMessages} load={store.client.subagent ? loadSubagent : undefined} renderMessage={renderSubagentMessage}>
|
|
720
|
+
<div className="session">
|
|
721
|
+
{h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
722
|
+
{workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
|
|
723
|
+
{h.crumb && <div className="crumb">
|
|
724
|
+
<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>
|
|
725
|
+
{title} <span className="sep">/</span> 对话
|
|
726
|
+
</div>}
|
|
727
|
+
{showCurrentTitle && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
|
|
728
|
+
</div>}
|
|
729
|
+
<div className="sh-tools">
|
|
730
|
+
{mobileWorkspace && workspaceAvailable && <button type="button" className="btn btn-ghost btn-sm workspace-open" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={workspaceActive} title="打开工作区" onClick={showWorkspace}>{Icon.board()}</button>}
|
|
731
|
+
{h.agent && agent && <AgentBadge label={agentText} />}
|
|
732
|
+
{h.usage && (fresh > 0 || cacheRead > 0) && (
|
|
733
|
+
<span className="usage-chip" title={`新增 ${fresh.toLocaleString()} tokens${cacheRead ? `\n缓存读取 ${cacheRead.toLocaleString()}(每轮重复读取的上下文)` : ""}`}>
|
|
734
|
+
{fmtTokens(fresh)} tok
|
|
735
|
+
{cacheRead > 0 && <> <span className="usage-dot">·</span> <span className="usage-cached">{fmtTokens(cacheRead)} 缓存</span></>}
|
|
736
|
+
</span>
|
|
737
|
+
)}
|
|
738
|
+
{h.model && key && !isNew && (
|
|
739
|
+
<span className="status-ctl" style={{ cursor: "default" }} title="模型由任务中心按产品线配置分配">
|
|
740
|
+
{Icon.spark()}<span className="cv">默认</span>
|
|
741
|
+
{resolvedModel && <span className="model-badge" title={`上次运行于 ${resolvedModel}`}>{modelLabel(resolvedModel)}</span>}
|
|
742
|
+
</span>
|
|
743
|
+
)}
|
|
744
|
+
{h.status && <span className={`status-ctl ${awaiting ? "awaiting" : ""}`} style={{ cursor: "default" }}>
|
|
745
|
+
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
746
|
+
<span className="cv">{statusText}</span>
|
|
747
|
+
</span>}
|
|
748
|
+
{h.export && key && <button type="button" className="btn btn-line btn-sm cli-export" disabled={!allMessages.length}
|
|
749
|
+
aria-label="导出执行过程(Markdown)"
|
|
750
|
+
title={older > 0 ? "下载已加载的执行过程(Markdown);更早的消息尚未加载" : "下载执行过程(Markdown),包含初始上下文和工具记录"}
|
|
751
|
+
onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])}>
|
|
752
|
+
{Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
|
|
753
|
+
</button>}
|
|
754
|
+
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
755
|
+
<button className="btn btn-line btn-sm" onClick={() => key && setCloseAction({ key, stage: "confirm" })} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
756
|
+
)}
|
|
757
|
+
</div>
|
|
758
|
+
</div>}
|
|
759
|
+
|
|
760
|
+
<div className="workspace-chat-content" data-chat-layout={listVisible || externalBoardVisible ? "wide" : "centered"} hidden={workspaceActive && !mobileWorkspace} style={workspaceActive && !mobileWorkspace ? { display: "none" } : undefined}>
|
|
761
|
+
{closing !== "idle" && key && (
|
|
762
|
+
<div className="clear-confirm">
|
|
763
|
+
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
|
|
764
|
+
<span className="spacer" />
|
|
765
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setCloseAction(null)} disabled={closing === "busy"}>取消</button>
|
|
766
|
+
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={confirmClose}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
|
|
767
|
+
</div>
|
|
768
|
+
)}
|
|
769
|
+
{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>}
|
|
770
|
+
{conv?.stream === "reconnecting" && <div className="sync-banner"><span className="sync-msg">实时连接中断,正在重连…{conv.streamError ? `(${conv.streamError})` : ""}</span></div>}
|
|
771
|
+
{conv?.stream === "error" && <div className="sync-banner conflict"><span className="sync-msg">⚠ {conv.streamError}</span></div>}
|
|
772
|
+
{conv?.stream === "offline" && <div className="sync-banner conflict" role="status"><span className="sync-msg">⚠ 实时连接已中断{conv.streamError ? `(${conv.streamError})` : ""},已停止自动重试;网络恢复、回到页面或发出消息时会再连。</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => { if (key) store.reconnect(key); }}>重新连接</button></div>}
|
|
773
|
+
|
|
774
|
+
<div className="transcript-wrap">
|
|
775
|
+
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
776
|
+
<div className="tw">
|
|
777
|
+
{slots?.beforeMessages}
|
|
778
|
+
{(!key || isNew) && cfg.empty.show && (
|
|
779
|
+
<div className="hero cli-hero">
|
|
780
|
+
<div className="h-ic">{Icon.bolt()}</div>
|
|
781
|
+
{cfg.transcript.status && <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>}
|
|
782
|
+
<h2>{cfg.empty.title}</h2>
|
|
783
|
+
{isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
|
|
784
|
+
{cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
|
|
785
|
+
</div>
|
|
786
|
+
)}
|
|
787
|
+
{continuedFrom && (
|
|
788
|
+
<div className="msg system info"><div className="msg-body">ℹ 这个对话接续自上一个已结束的对话({continuedFrom})。工作区文件沿用,之前的对话内容没有带进来。
|
|
789
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFromKey)}>查看上一个对话</button>
|
|
790
|
+
</div></div>
|
|
791
|
+
)}
|
|
792
|
+
{contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
|
|
793
|
+
{loading && (
|
|
794
|
+
<div aria-hidden>
|
|
795
|
+
<div className="session-label"><span className="ln" />正在加载会话<span className="ln" /></div>
|
|
796
|
+
<div className="msg user" style={{ opacity: .7 }}>
|
|
797
|
+
<div className="who"><Skel w={18} h={18} r={5} /><Skel w={34} h={9} /></div>
|
|
798
|
+
<div className="msg-body"><Skel w="72%" h={12} /><Skel w="46%" h={12} style={{ marginTop: 9 }} /></div>
|
|
799
|
+
</div>
|
|
800
|
+
<div className="msg" style={{ opacity: .7 }}>
|
|
801
|
+
<div className="who"><Skel w={18} h={18} r={5} /><Skel w={70} h={9} /></div>
|
|
802
|
+
<Skel w="90%" h={11} /><Skel w="83%" h={11} style={{ marginTop: 8 }} /><Skel w="58%" h={11} style={{ marginTop: 8 }} />
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
805
|
+
)}
|
|
806
|
+
{hasAbove && (
|
|
807
|
+
<div ref={topSentinelRef} className="load-older">
|
|
808
|
+
<button className="btn btn-ghost btn-sm" onClick={revealEarlier} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove + older} 条)`}</button>
|
|
809
|
+
</div>
|
|
810
|
+
)}
|
|
811
|
+
{shown.map((it) =>
|
|
812
|
+
it.kind === "label" ? (
|
|
813
|
+
cfg.transcript.sessionLabel ? <div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div> : null
|
|
814
|
+
) : it.kind === "break" ? (
|
|
815
|
+
<div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
|
|
816
|
+
) : it.kind === "fold" ? (
|
|
817
|
+
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
818
|
+
<button className="cli-wait-fold" onClick={() => setOpenFolds((o) => { const n = new Set(o); if (n.has(it.key)) n.delete(it.key); else n.add(it.key); return n; })} aria-expanded={openFolds.has(it.key)}>
|
|
819
|
+
<span className="cli-wait-ic">⏳</span>
|
|
820
|
+
<span>等你在浏览器里操作 · AI 已续等 {it.waits} 次</span>
|
|
821
|
+
<span className="cli-wait-more">{openFolds.has(it.key) ? "收起" : "展开"}</span>
|
|
822
|
+
</button>
|
|
823
|
+
{openFolds.has(it.key) && it.msgs.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />)}
|
|
824
|
+
</div>
|
|
825
|
+
) : (
|
|
826
|
+
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
827
|
+
<MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />
|
|
828
|
+
</div>
|
|
829
|
+
),
|
|
830
|
+
)}
|
|
831
|
+
{hasBelow && (
|
|
832
|
+
<div ref={bottomSentinelRef} className="load-older">
|
|
833
|
+
<button className="btn btn-ghost btn-sm" onClick={revealLater}>显示后面的消息(还有 {hiddenBelow} 条)</button>
|
|
834
|
+
<button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
{cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
838
|
+
{ended && cfg.transcript.status && (
|
|
839
|
+
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
840
|
+
)}
|
|
841
|
+
{slots?.afterMessages}
|
|
842
|
+
{continuedBy && (
|
|
843
|
+
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
|
844
|
+
)}
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
{cfg.transcript.nav && <div className="msg-nav">
|
|
848
|
+
<button className="msg-nav-btn" onClick={() => scrollToUserMsg(-1)} title="上一条消息" aria-label="滚动到上一条消息">{Icon.chevUp()}</button>
|
|
849
|
+
<button className="msg-nav-btn" onClick={() => scrollToUserMsg(1)} title="下一条消息" aria-label="滚动到下一条消息">{Icon.chevDown()}</button>
|
|
850
|
+
{!atBottom && <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">{Icon.toBottom()}</button>}
|
|
851
|
+
</div>}
|
|
852
|
+
</div>
|
|
853
|
+
{showComposer && !!contextItems?.length && (
|
|
854
|
+
<div className="cli-refs cli-ctxs" aria-label="补充背景,下一条消息会带上">
|
|
855
|
+
<span className="cli-refs-t">{Icon.flag()} 补充背景</span>
|
|
856
|
+
{contextItems.map((c) => (
|
|
857
|
+
<span key={c.id} className="cli-ref ctx" title={`${c.text.slice(0, 500)}${c.text.length > 500 ? "…" : ""}\n(下一条消息会带上)`}>
|
|
858
|
+
<span className="cli-ref-l">{c.title || c.text.replace(/\s+/g, " ").slice(0, 20)}</span>
|
|
859
|
+
</span>
|
|
860
|
+
))}
|
|
861
|
+
</div>
|
|
862
|
+
)}
|
|
863
|
+
{slots?.beforeComposer}
|
|
864
|
+
{showComposer && execution.error && <div className="control-error" role="status">{execution.error} {store.client.getExecutionConfig && <button type="button" className="btn btn-ghost btn-sm" disabled={execution.loading || execution.busy} onClick={() => void execution.reload()}>重新读取</button>}</div>}
|
|
865
|
+
{!showComposer && <QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} attachmentUrl={handlers.attachmentUrl} />}
|
|
866
|
+
{showComposer && (
|
|
867
|
+
<Composer
|
|
868
|
+
uploadDocument={uploadDocument}
|
|
869
|
+
key={key ?? "new"}
|
|
870
|
+
draft={draftScope}
|
|
871
|
+
context={(!!shownRefs?.length || !!viewing) && <div className="cli-refs composer-references" aria-label="下一条消息的引用">
|
|
872
|
+
{viewing && <ReferenceCard key={`${key}:viewing:${viewing.id}`} className="reference-viewing" title={`正在看${viewing.label}`} text={viewing.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${viewing.label}`} onRemove={() => { if (key) store.dismissViewing(key); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />}
|
|
873
|
+
{(shownRefs ?? []).map(ref => <ReferenceCard key={`${key}:${ref.id}`} title={ref.kind === "message" && ref.label === "引用你的消息" ? "你" : ref.kind === "message" && ref.label === "引用智能体消息" ? "智能体" : ref.label} text={ref.text} disabled={composerDisabled || !!configurationBusy} removeLabel={`不带上 ${ref.label}`} onRemove={() => { if (key) store.removeRef(key, ref.id); chatRef.current?.querySelector<HTMLTextAreaElement>(".composer textarea")?.focus({ preventScroll: true }); }} />)}
|
|
874
|
+
</div>}
|
|
875
|
+
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
876
|
+
disabled={composerDisabled || settingsBusy}
|
|
877
|
+
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
878
|
+
running={running || batchPending}
|
|
879
|
+
toolbar={<>
|
|
880
|
+
<RunControls task={{ id: draftScope, agent: execution.config?.agent ?? agent ?? initialOptions?.agent ?? undefined,
|
|
881
|
+
model: execution.config?.model, reasoning: execution.config?.reasoning, permission_mode: execution.config?.permission_mode }}
|
|
882
|
+
agents={execution.config?.agents} caps={executionAgent?.capabilities} defaults={executionAgent?.defaults}
|
|
883
|
+
showAgent={cfg.composer.agentSelector} showPermission={cfg.composer.executionMode}
|
|
884
|
+
unavailableReason={batch ? "一次性任务沿用任务执行设置,不支持在对话中修改。" : executionReadOnly ? "当前接入仅支持读取执行设置。" : execution.error ?? (!executionEnabled || execution.loading ? "正在读取执行设置…" : undefined)}
|
|
885
|
+
disabled={!executionEnabled || composerDisabled || running || batchPending || !!configurationBusy || execution.loading || executionReadOnly}
|
|
886
|
+
onSetAgent={agent => execution.save({ agent, model: null, reasoning: null, permission_mode: null })}
|
|
887
|
+
onSetModel={model => execution.save({ model })} onSetReasoning={reasoning => execution.save({ reasoning })}
|
|
888
|
+
onSetPermission={permission_mode => execution.save({ permission_mode })} />
|
|
889
|
+
{slots?.composerToolbar}
|
|
890
|
+
</>}
|
|
891
|
+
queued={<QueuedMessages scopeKey={key ?? "new"} messages={queued} onCancel={handlers.onCancelQueued} onSendNow={queueSendNow} sendNowUnavailable={queueSendNowHint} onEdit={handlers.onCancelQueued ? editQueued : undefined} attachmentUrl={handlers.attachmentUrl} />}
|
|
892
|
+
onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } const sentFrom = key; await send(text); if (!running && !batchPending && latest.current.key === sentFrom) scrollToBottom("auto"); }}
|
|
893
|
+
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
894
|
+
taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
|
|
895
|
+
paused: controlTask.status === "paused",
|
|
896
|
+
onToggle: async () => {
|
|
897
|
+
const action = controlTask.status === "paused" ? store.client.resumeTask! : store.client.pauseTask!;
|
|
898
|
+
await report(action(controlTask.id).then(() => store.refreshJob(jobId, true)), controlTask.status === "paused" ? "继续任务失败" : "暂停任务失败");
|
|
899
|
+
},
|
|
900
|
+
} : undefined}
|
|
901
|
+
options={composerOptions}
|
|
902
|
+
/>
|
|
903
|
+
)}
|
|
904
|
+
</div>
|
|
905
|
+
<WorkspaceDrawer enabled={mobileWorkspace} open={workspaceActive} onClose={showChat}>
|
|
906
|
+
{workspaceAvailable && <CliBoard jobId={key} inlineOwner={inlineOwner} quoteToChat={!readOnly} autoQuoteOpenFile={passiveContext}
|
|
907
|
+
readOnly={readOnly || externalBoardOptions?.readOnly} availableTabs={externalBoardOptions?.availableTabs}
|
|
908
|
+
acceptOpen={fileOpen !== "modal" && fileOpen !== "link"} theme={theme}
|
|
909
|
+
visible={workspaceActive} onExpand={showWorkspace} onQuoteToChat={quoteToChat}
|
|
910
|
+
inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
|
|
911
|
+
</WorkspaceDrawer>
|
|
912
|
+
</div>
|
|
913
|
+
</SubagentChatScope>
|
|
914
|
+
{popup && key && board && (
|
|
915
|
+
<div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
|
|
916
|
+
<div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
|
|
917
|
+
{popup.kind === "file" ? (
|
|
918
|
+
<FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })} />
|
|
919
|
+
) : (
|
|
920
|
+
<>
|
|
921
|
+
<div className="cli-fv-h"><span className="cli-fv-name">任务浏览器</span><span className="cli-fv-sp" /><button className="cli-fv-icon" onClick={() => setPopup(null)} title="关闭">{Icon.x()}</button></div>
|
|
922
|
+
<BrowserPane backend={board} jobId={key} readOnly={readOnly} />
|
|
923
|
+
</>
|
|
924
|
+
)}
|
|
925
|
+
</div>
|
|
926
|
+
</div>
|
|
927
|
+
)}
|
|
928
|
+
</div>
|
|
929
|
+
</FileOpenContext.Provider>
|
|
930
|
+
</FileLinkContext.Provider>
|
|
931
|
+
);
|
|
932
|
+
}
|