@spzxg/cli-ui 0.1.0 → 0.3.0

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.
@@ -0,0 +1,96 @@
1
+ // 看板的两个核心,各自是一块独立的积木,放到哪里都能用:
2
+ //
3
+ // <CliFilePreview jobId path /> 任务工作区里一个文件的预览 + 下载(Markdown / 代码 / CSV / 图片 / PDF / HTML / Word)
4
+ // <CliBrowserView jobId /> 任务浏览器的实时画面 + 接管 / 交还 / 打开 / 关闭
5
+ //
6
+ // 和别的组件一样:外面有 <CliCenterProvider> 就共用它(同一个对话只连一条流);没有就用自己的连接参数
7
+ // (baseUrl / token / line / headers)建一个。不给 jobId 时跟 Provider 的选中走。
8
+ // 它们只依赖中枢的看板接口(/api/jobs/:id/files、/browser…),不依赖 cli-chat / cli-board。
9
+ import { useState, type CSSProperties } from "react";
10
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
11
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
12
+ import { FileViewer, type FileAction } from "./FileViewer.js";
13
+ import { BrowserPane } from "./BrowserPane.js";
14
+
15
+ interface BlockProps extends CliStoreOptions {
16
+ /** 哪个对话(center 的 job_id)。不给就跟 Provider 的选中走。 */
17
+ jobId?: string | null;
18
+ theme?: "light" | "dark";
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ store?: CliStore;
22
+ onEvent?: (event: CliEvent) => void;
23
+ }
24
+
25
+ export interface CliFilePreviewProps extends BlockProps {
26
+ /** 工作区里的相对路径(如 "outputs/报告.md"),或节点上的绝对路径。 */
27
+ path: string;
28
+ /** 预览里点了别的本地文件(Markdown 里的链接)。不给就在本组件里切换过去。 */
29
+ onOpenPath?: (path: string) => void;
30
+ /** 给了就显示关闭按钮。 */
31
+ onClose?: () => void;
32
+ /** 头部的自定义按钮。 */
33
+ actions?: FileAction[];
34
+ /** 变了就重读(宿主知道文件更新时递增)。 */
35
+ version?: number;
36
+ /** 不要头部(宿主自己做标题和按钮)。 */
37
+ bare?: boolean;
38
+ }
39
+
40
+ export function CliFilePreview(props: CliFilePreviewProps) {
41
+ const ctx = useCliStoreContext();
42
+ return withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx);
43
+ }
44
+
45
+ function FilePreviewInner({ jobId: jobIdProp, path, onOpenPath, onClose, actions, version, bare, theme = "light", className, style, onEvent, inProvider }: CliFilePreviewProps & { inProvider: boolean }) {
46
+ useComponentEvents(onEvent, {}, inProvider);
47
+ const store = useCliStore();
48
+ const selected = useCliState((s) => s.selected);
49
+ const jobId = jobIdProp !== undefined ? jobIdProp : selected;
50
+ const [shown, setShown] = useShownPath(path);
51
+ const backend = store.client.board;
52
+ return (
53
+ <div className={`cli-ui cli-file-preview ${className ?? ""}`} data-theme={theme} style={style}>
54
+ {!backend ? <div className="cli-fv-note">当前后端不支持文件预览(需要任务中心的 /api/jobs/:id/files 接口)。</div>
55
+ : !jobId ? <div className="cli-fv-note">没有指定对话。</div>
56
+ : !shown ? <div className="cli-fv-note">没有指定文件。</div>
57
+ : <FileViewer key={`${jobId}:${shown}`} backend={backend} jobId={jobId} path={shown} version={version} actions={actions} onClose={onClose} bare={bare}
58
+ onOpenPath={onOpenPath ?? setShown} />}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ export interface CliBrowserViewProps extends BlockProps {
64
+ /** 只看:不能打开 / 关闭 / 接管。 */
65
+ readOnly?: boolean;
66
+ }
67
+
68
+ export function CliBrowserView(props: CliBrowserViewProps) {
69
+ const ctx = useCliStoreContext();
70
+ return withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx);
71
+ }
72
+
73
+ function BrowserViewInner({ jobId: jobIdProp, readOnly, theme = "light", className, style, onEvent, inProvider }: CliBrowserViewProps & { inProvider: boolean }) {
74
+ useComponentEvents(onEvent, {}, inProvider);
75
+ const store = useCliStore();
76
+ const selected = useCliState((s) => s.selected);
77
+ const jobId = jobIdProp !== undefined ? jobIdProp : selected;
78
+ const backend = store.client.board;
79
+ return (
80
+ <div className={`cli-ui cli-browser-view ${className ?? ""}`} data-theme={theme} style={style}>
81
+ {!backend ? <div className="cli-fv-note">当前后端不支持任务浏览器(需要任务中心的 /api/jobs/:id/browser 接口)。</div>
82
+ : !jobId ? <div className="cli-fv-note">没有指定对话。</div>
83
+ : <BrowserPane key={jobId} backend={backend} jobId={jobId} readOnly={readOnly} />}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** 显示的路径:跟着 prop 走;预览里点了别的文件时在组件内切换,prop 变了再以 prop 为准。 */
89
+ function useShownPath(path: string): [string, (p: string) => void] {
90
+ const [state, setState] = useState({ prop: path, shown: path });
91
+ if (state.prop !== path) {
92
+ setState({ prop: path, shown: path });
93
+ return [path, (p) => setState({ prop: path, shown: p })];
94
+ }
95
+ return [state.shown, (p) => setState((s) => ({ ...s, shown: p }))];
96
+ }
@@ -0,0 +1,81 @@
1
+ // 看板按扩展名决定怎么预览一个文件。
2
+ export type PreviewKind = "markdown" | "code" | "text" | "csv" | "image" | "pdf" | "html" | "docx" | "binary";
3
+
4
+ const CODE_LANG: Record<string, string> = {
5
+ ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
6
+ json: "json", py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
7
+ c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp", php: "php", sh: "bash", bash: "bash", zsh: "bash",
8
+ sql: "sql", yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini", xml: "xml", css: "css", scss: "scss",
9
+ vue: "xml", svelte: "xml", dockerfile: "dockerfile", makefile: "makefile", diff: "diff", patch: "diff",
10
+ };
11
+ const TEXT_EXT = new Set(["txt", "log", "env", "gitignore", "conf", "cfg", "properties", "lock", "tsv"]);
12
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg", "avif"]);
13
+
14
+ export function extOf(path: string): string {
15
+ const name = path.split("/").pop() ?? "";
16
+ const lower = name.toLowerCase();
17
+ if (lower === "dockerfile" || lower === "makefile") return lower;
18
+ const dot = lower.lastIndexOf(".");
19
+ return dot > 0 ? lower.slice(dot + 1) : "";
20
+ }
21
+
22
+ export function previewKind(path: string, contentType = ""): PreviewKind {
23
+ const ext = extOf(path);
24
+ if (ext === "md" || ext === "markdown") return "markdown";
25
+ if (ext === "csv") return "csv";
26
+ if (IMAGE_EXT.has(ext)) return "image";
27
+ if (ext === "pdf") return "pdf";
28
+ if (ext === "html" || ext === "htm") return "html";
29
+ if (ext === "docx") return "docx";
30
+ if (CODE_LANG[ext]) return "code";
31
+ if (TEXT_EXT.has(ext)) return "text";
32
+ if (/^text\//.test(contentType) || /json|xml|javascript/.test(contentType)) return "text";
33
+ if (!ext) return "text"; // README、LICENSE 之类
34
+ return "binary";
35
+ }
36
+
37
+ export const codeLang = (path: string) => CODE_LANG[extOf(path)] ?? "";
38
+
39
+ export function fmtSize(bytes: number): string {
40
+ if (bytes < 1024) return `${bytes} B`;
41
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
42
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
43
+ }
44
+
45
+ /** 相对某个文件所在目录解析链接(Markdown 里的 ./img.png、../a.md)。 */
46
+ export function resolveRelative(fromFile: string, href: string): string {
47
+ if (href.startsWith("/") || href.startsWith("~")) return href;
48
+ const base = fromFile.split("/").slice(0, -1);
49
+ for (const seg of href.split("/")) {
50
+ if (!seg || seg === ".") continue;
51
+ if (seg === "..") base.pop();
52
+ else base.push(seg);
53
+ }
54
+ return base.join("/");
55
+ }
56
+
57
+ /** 一行 CSV(支持引号里的逗号和 "" 转义)。 */
58
+ export function parseCsv(text: string, maxRows = 500): { rows: string[][]; more: boolean } {
59
+ const rows: string[][] = [];
60
+ let row: string[] = [];
61
+ let cell = "";
62
+ let quoted = false;
63
+ const sep = text.includes("\t") && !text.split("\n")[0].includes(",") ? "\t" : ",";
64
+ for (let i = 0; i < text.length; i++) {
65
+ const ch = text[i];
66
+ if (quoted) {
67
+ if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
68
+ else if (ch === '"') quoted = false;
69
+ else cell += ch;
70
+ } else if (ch === '"' && cell === "") quoted = true;
71
+ else if (ch === sep) { row.push(cell); cell = ""; }
72
+ else if (ch === "\n" || ch === "\r") {
73
+ if (ch === "\r" && text[i + 1] === "\n") i++;
74
+ row.push(cell); cell = "";
75
+ rows.push(row); row = [];
76
+ if (rows.length > maxRows) return { rows: rows.slice(0, maxRows), more: true };
77
+ } else cell += ch;
78
+ }
79
+ if (cell || row.length) { row.push(cell); rows.push(row); }
80
+ return { rows: rows.slice(0, maxRows), more: rows.length > maxRows };
81
+ }
@@ -3,22 +3,34 @@
3
3
  // 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
4
4
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
5
5
  import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
6
- import type { ConversationCallback } from "../core/types.js";
7
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
8
- import { emptyConv, isRunning, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
6
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
7
+ import { convKey, emptyConv, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
9
8
  import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
10
9
  import { Icon } from "../ui/icons.js";
11
10
  import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
12
11
  import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
13
- import { MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
12
+ import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
14
13
  import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
15
14
  import { Composer, type ComposerOptions } from "./Composer.js";
16
15
  import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
17
16
  import { exportMarkdown } from "./export.js";
17
+ import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
18
+ import { FileViewer } from "../board/FileViewer.js";
19
+ import { BrowserPane } from "../board/BrowserPane.js";
18
20
 
19
- export interface CliChatProps extends CliStoreOptions {
20
- /** 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。 */
21
+ /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
22
+ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
23
+ /**
24
+ * 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
25
+ * 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
26
+ */
21
27
  jobId?: string | null;
28
+ /**
29
+ * 对话任务里的哪个对话(子任务 task_id),和 jobId 一起用。不给 = 对话任务的第一个对话(只有一个对话时就是它);
30
+ * "new" = 在这个对话任务里新开一个对话(同一节点、同一工作区,新的 Agent 会话),第一条消息发出后组件跟到开出来的那个。
31
+ * 结束对话时:给了 taskId 只关掉这一个对话,没给结束整个对话任务。
32
+ */
33
+ taskId?: string | null;
22
34
  /** 面包屑里的名称与色块(如业务 / 项目名)。 */
23
35
  title?: string;
24
36
  color?: string;
@@ -28,10 +40,24 @@ export interface CliChatProps extends CliStoreOptions {
28
40
  allowClose?: boolean;
29
41
  /** 界面配置:顶部栏、输入框、对话区各模块的显示与文案,见 core/config.ts。 */
30
42
  config?: CliChatConfig;
43
+ /**
44
+ * Agent 消息里本地文件链接(report.md、/abs/path、src/a.ts:12)的打开地址。缺省没有:显示成不可点的文字。
45
+ * 有文件服务时(例如以后的看板 / Operator 后端)给出地址,链接就能打开(与 Operator 一致)。
46
+ * taskId:看的是对话任务里指定的那个对话时才有。
47
+ */
48
+ fileUrl?: (path: string, jobId: string, kind: "link" | "image", taskId?: string | null) => string | null;
49
+ /**
50
+ * 点了本地文件链接 / 「查看浏览器」怎么打开:
51
+ * · "auto"(缺省)—— 同一个 Provider 里有 cli-board 在看这个对话就在看板里打开,否则在对话里弹窗预览;
52
+ * · "board" —— 只交给看板(没有看板时按 fileUrl 的地址打开);
53
+ * · "modal" —— 总是在对话里弹窗;
54
+ * · "link" —— 不拦截,按 fileUrl 的地址在新窗口打开(以前的行为)。
55
+ * 无论哪种,都会发 open 事件(onEvent),宿主可以据此展开右侧面板。
56
+ */
57
+ fileOpen?: "auto" | "board" | "modal" | "link";
31
58
  /** 一轮结束(≈ center 回调 conversation_turn)、对话结束、job 变化等。 */
32
59
  onEvent?: (event: CliEvent) => void;
33
- /** 前端回调:载荷与 center 回调 conversation_turn / conversation_closed 的请求体同名同义。 */
34
- onCallback?: (payload: ConversationCallback) => void;
60
+
35
61
  theme?: "light" | "dark";
36
62
  className?: string;
37
63
  style?: CSSProperties;
@@ -44,9 +70,9 @@ export function CliChat(props: CliChatProps) {
44
70
  }
45
71
 
46
72
  // 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
47
- const FINAL_REASONS = new Set(["closed", "cancelled"]);
73
+ const FINAL_REASONS = new Set(["closed", "cancelled", "conversation_closed"]);
48
74
  const REASON_TEXT: Record<string, string> = {
49
- closed: "对话已结束", cancelled: "对话已取消", expired: "对话空闲太久已过期",
75
+ closed: "对话已结束", cancelled: "对话已取消", conversation_closed: "这个对话已结束", expired: "对话空闲太久已过期",
50
76
  node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
51
77
  };
52
78
 
@@ -76,8 +102,8 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
76
102
  );
77
103
  }
78
104
 
79
- function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onCallback, inProvider, newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
80
- useComponentEvents(onEvent, onCallback, inProvider);
105
+ 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 }) {
106
+ useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
81
107
  // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
82
108
  const configKey = JSON.stringify(config ?? {});
83
109
  const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -87,31 +113,39 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
87
113
  // 显示哪个对话:
88
114
  // · 不传 jobId → 跟仓库的选中走(和同一 Provider 里的 cli-msglist 联动);
89
115
  // · 传了 jobId(含 null = 新对话)→ 组件自己接管:从传入的 id 开始,之后自己新建出的对话、
90
- // center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId 就以宿主为准。
91
- // 新对话的草稿按组件实例存(对话的草稿按 job_id 存,看同一个对话的组件共用一份是对的)。
116
+ // center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId / taskId 就以宿主为准。
117
+ // 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
118
+ // 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
92
119
  const instanceId = useId();
93
120
  const controlled = jobIdProp !== undefined;
94
- const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: jobIdProp, id: jobIdProp ?? null }));
121
+ // jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
122
+ const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
123
+ const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: keyProp, id: keyProp ?? null }));
95
124
  let viewId = followed.id;
96
- if (followed.prop !== jobIdProp) {
97
- viewId = jobIdProp ?? null;
98
- setShown({ prop: jobIdProp, id: viewId });
125
+ if (followed.prop !== keyProp) {
126
+ viewId = keyProp ?? null;
127
+ setShown({ prop: keyProp, id: viewId });
99
128
  }
100
- const jobId = controlled ? viewId : selected;
129
+ const key = controlled ? viewId : selected;
130
+ const ref = key ? parseConvKey(key) : null;
131
+ const jobId = ref?.jobId ?? null;
132
+ // 「在这个对话任务里新开一个对话」:还没有对话,第一条消息开出来。
133
+ const isNew = isNewInJob(key);
134
+ const taskId = ref?.taskId && !isNew ? ref.taskId : null;
101
135
  const go = useCallback((id: string | null) => {
102
136
  if (controlled) setShown((v) => (v.id === id ? v : { ...v, id }));
103
137
  else store.select(id);
104
138
  }, [controlled, store]);
105
- const conv = useCliState((s) => (jobId ? s.convs[jobId] : undefined)) ?? (jobId ? emptyConv(jobId) : null);
139
+ const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
106
140
  const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
107
141
 
108
142
  // 看这个对话:开实时流(同一对话多处看只连一次)。
109
- useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
143
+ useEffect(() => (key ? store.open(key) : undefined), [store, key]);
110
144
  // 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
111
- const jobIdRef = useRef(jobId);
112
- jobIdRef.current = jobId;
145
+ const keyRef = useRef(key);
146
+ keyRef.current = key;
113
147
  useEffect(() => store.on((event) => {
114
- if (event.type === "job_change" && event.previousJobId && event.previousJobId === jobIdRef.current) go(event.jobId);
148
+ if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
115
149
  }), [store, go]);
116
150
 
117
151
  const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
@@ -123,39 +157,54 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
123
157
  })), [allMessages, cfg.transcript.tools]);
124
158
  const running = conv ? isRunning(conv) : false;
125
159
  const job = entry?.job;
126
- const task = job?.tasks?.[0];
160
+ // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示)。
161
+ const task = (taskId ? job?.tasks?.find((t) => t.id === taskId) : undefined) ?? (taskId ? undefined : job?.tasks?.[0]);
162
+ const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
127
163
  const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
128
164
  const agentText = labelOf(agent);
129
- const status = conv?.job?.status ?? job?.status ?? null;
130
- const closedReason = conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
131
- const ended = !!status && TERMINAL_JOB.has(status);
165
+ const jobStatus = conv?.job?.status ?? job?.status ?? null;
166
+ const jobEnded = !!jobStatus && TERMINAL_JOB.has(jobStatus);
167
+ // 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
168
+ const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
169
+ const status = convClosed ? "succeeded" : jobStatus;
170
+ const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
171
+ const ended = jobEnded || convClosed;
132
172
  const finalEnd = ended && FINAL_REASONS.has(closedReason ?? "closed");
133
173
  const continuedFrom = job?.conversation?.continued_from_job_id ?? null;
134
- const continuedBy = conv?.continuedBy ?? job?.conversation?.continued_by_job_id ?? null;
135
- const convTitle = job?.title ?? conv?.job?.title ?? (jobId ? "对话" : "新对话");
174
+ // 接续自旧对话任务里的哪个对话(指定了对话时跟回那一个)。
175
+ const continuedFromKey = continuedFrom ? convKey(continuedFrom, taskId ? task?.continue_from_task_id ?? info?.continue_from_task_id ?? null : null) : null;
176
+ const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
177
+ const jobTitle = job?.title ?? conv?.job?.title;
178
+ // 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
179
+ const manyConvs = (job?.conversation?.conversations?.length ?? 0) > 1;
180
+ const convName = conv?.task?.title ?? info?.title ?? task?.title;
181
+ const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
182
+ : taskId && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
183
+ : jobTitle ?? (key ? "对话" : "新对话");
136
184
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
137
185
  const lastTurn = conv?.turns[conv.turns.length - 1];
138
186
  const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
139
- const loading = !!jobId && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
187
+ const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
140
188
 
141
- // 用量:任务详情里的累计 + 实时流里上次刷新后的增量。
142
- const fresh = (task ? Math.max(0, task.total_tokens - task.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
143
- const cacheRead = (task?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
189
+ // 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
190
+ const usageTask = isNew ? undefined : task;
191
+ const fresh = (usageTask ? Math.max(0, usageTask.total_tokens - usageTask.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
192
+ const cacheRead = (usageTask?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
144
193
  const resolvedModel = conv?.resolvedModel ?? task?.resolved_model ?? null;
145
194
 
146
195
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
147
- if (!jobId) return { dotCls: "r", statusText: "未开始" };
196
+ if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
148
197
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
149
198
  if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
150
199
  if (running) return { dotCls: "a", statusText: "运行中" };
151
200
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
152
201
  return { dotCls: "a", statusText: "进行中" };
153
- }, [jobId, ended, status, closedReason, awaiting, running]);
202
+ }, [key, isNew, ended, status, closedReason, awaiting, running]);
154
203
 
155
204
  // ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
156
205
  // 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
157
206
  const [actionError, setActionError] = useState<string | null>(null);
158
- useEffect(() => setActionError(null), [jobId]);
207
+ useEffect(() => setActionError(null), [key]);
159
208
  const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
160
209
  try {
161
210
  const result = await work;
@@ -166,46 +215,81 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
166
215
  throw error;
167
216
  }
168
217
  }, []);
169
- const latest = useRef({ jobId, messages, conv });
170
- latest.current = { jobId, messages, conv };
218
+ const latest = useRef({ key, messages, conv });
219
+ latest.current = { key, messages, conv };
171
220
  // 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
172
221
  const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
173
222
  sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
174
223
  const send = useCallback(async (text: string) => {
175
- const id = latest.current.jobId;
224
+ const id = latest.current.key;
176
225
  const target = await store.send(id ?? null, text, sendOpts.current);
177
226
  if (target !== id) go(target);
178
227
  }, [store, go]);
228
+ // AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
229
+ // 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
230
+ const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
231
+ useEffect(() => {
232
+ if (!key || !conv || fileOpen === "link" || fileOpen === "modal") return;
233
+ if (handoffSeen.current.key !== key) handoffSeen.current = { key, ids: null };
234
+ if (!conv.hasTranscript) return;
235
+ const handoffs = conv.msgs.filter((m) => m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX));
236
+ const seen = handoffSeen.current;
237
+ if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
238
+ const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
239
+ for (const m of fresh) seen.ids.add(m.id);
240
+ if (fresh.length) store.openInBoard(key, { kind: "browser" });
241
+ }, [key, conv, fileOpen, store]);
242
+
243
+ // 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
244
+ const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
245
+ useEffect(() => setPopup(null), [key]);
246
+ useEffect(() => {
247
+ if (!popup) return;
248
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPopup(null); };
249
+ window.addEventListener("keydown", onKey);
250
+ return () => window.removeEventListener("keydown", onKey);
251
+ }, [popup]);
179
252
  const handlers = useMemo<MessageHandlers>(() => ({
180
253
  // 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
181
254
  onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
182
- const id = latest.current.jobId;
255
+ const id = latest.current.key;
183
256
  if (!id) return;
184
257
  const sent = askId
185
258
  ? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
186
259
  : send(formatAnswersText(questions, answers));
187
260
  await report(sent, "回答没有发出去");
188
261
  },
189
- onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.jobId; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
262
+ onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
190
263
  // 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
191
264
  onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
192
- const id = latest.current.jobId;
265
+ const id = latest.current.key;
193
266
  if (!id) return "对话还没开始";
194
267
  try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
195
268
  },
196
- onHandback: readOnly || !store.client.handback ? undefined : async () => {
197
- const id = latest.current.jobId;
269
+ // 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
270
+ onHandback: readOnly || !(store.client.handback || store.client.board) ? undefined : async () => {
271
+ const id = latest.current.key;
198
272
  if (!id) return "对话还没开始";
199
- try { return await store.client.handback!(id); } catch (error) { return error instanceof Error ? error.message : String(error); }
273
+ try {
274
+ if (store.client.handback) return await store.client.handback(id);
275
+ await store.client.board!.browserAction(id, "handback");
276
+ return null;
277
+ } catch (error) { return error instanceof Error ? error.message : String(error); }
278
+ },
279
+ onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
280
+ const id = latest.current.key;
281
+ if (!id) return;
282
+ if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
283
+ if (fileOpen !== "board") setPopup({ kind: "browser" });
200
284
  },
201
285
  onRetry: readOnly ? undefined : (msgId: string) => {
202
286
  const list = latest.current.messages;
203
287
  const at = list.findIndex((m) => m.id === msgId);
204
288
  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; }
205
289
  },
206
- }), [readOnly, send, store, go, report]);
290
+ }), [readOnly, send, store, go, report, fileOpen]);
207
291
  const stop = useCallback(async () => {
208
- const { jobId: id, conv: c } = latest.current;
292
+ const { key: id, conv: c } = latest.current;
209
293
  const turn = c ? runningTurn(c) : null;
210
294
  if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
211
295
  }, [store, report]);
@@ -365,7 +449,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
365
449
  setAtBottom(true);
366
450
  setWin({ start: null, end: null });
367
451
  if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
368
- }, [jobId]);
452
+ }, [key]);
369
453
 
370
454
  const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
371
455
  const h = cfg.header;
@@ -377,7 +461,29 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
377
461
  const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
378
462
  const disabledHint = readOnly ? "只读" : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
379
463
 
464
+ const fileUrlRef = useRef(fileUrl);
465
+ fileUrlRef.current = fileUrl;
466
+ const board = store.client.board;
467
+ // 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
468
+ const resolveFile = useMemo(() => {
469
+ if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
470
+ if (board?.directLinks && key) return (path: string) => board.fileUrl(key, path);
471
+ return null;
472
+ }, [!!fileUrl, key, jobId, taskId, board]); // eslint-disable-line react-hooks/exhaustive-deps
473
+ // 点了本地文件:看板 → 弹窗 → 地址(见 fileOpen)。
474
+ const openFile = useMemo(() => {
475
+ if (!key || fileOpen === "link") return null;
476
+ return (path: string) => {
477
+ if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
478
+ if (fileOpen === "board" || !board) return false;
479
+ setPopup({ kind: "file", path });
480
+ return true;
481
+ };
482
+ }, [key, fileOpen, store, board]);
483
+
380
484
  return (
485
+ <FileLinkContext.Provider value={resolveFile}>
486
+ <FileOpenContext.Provider value={openFile}>
381
487
  <div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
382
488
  <div className="session">
383
489
  {h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
@@ -387,8 +493,8 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
387
493
  <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>
388
494
  {title} <span className="sep">/</span> 对话
389
495
  </>}
390
- {h.export && jobId && allMessages.length > 0 && (
391
- <button className="crumb-export" onClick={() => exportMarkdown(convTitle, jobId, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
496
+ {h.export && key && allMessages.length > 0 && (
497
+ <button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
392
498
  )}
393
499
  </div>}
394
500
  {h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
@@ -401,7 +507,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
401
507
  {cacheRead > 0 && <> <span className="usage-dot">·</span> <span className="usage-cached">{fmtTokens(cacheRead)} 缓存</span></>}
402
508
  </span>
403
509
  )}
404
- {h.model && jobId && (
510
+ {h.model && key && !isNew && (
405
511
  <span className="status-ctl" style={{ cursor: "default" }} title="模型由任务中心按产品线配置分配">
406
512
  {Icon.spark()}<span className="cv">默认</span>
407
513
  {resolvedModel && <span className="model-badge" title={`上次运行于 ${resolvedModel}`}>{modelLabel(resolvedModel)}</span>}
@@ -411,18 +517,18 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
411
517
  <StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
412
518
  <span className="cv">{statusText}</span>
413
519
  </span>}
414
- {h.close && jobId && allowClose && !readOnly && !ended && (
415
- <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title="结束这个对话:之后不再接收消息,正在跑的一轮会被中止">{Icon.stop()} 结束对话</button>
520
+ {h.close && key && !isNew && allowClose && !readOnly && !ended && (
521
+ <button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
416
522
  )}
417
523
  </div>
418
524
  </div>}
419
525
 
420
- {closing !== "idle" && jobId && (
526
+ {closing !== "idle" && key && (
421
527
  <div className="clear-confirm">
422
- <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}。</span>
528
+ <span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
423
529
  <span className="spacer" />
424
530
  <button className="btn btn-ghost btn-sm" onClick={() => setClosing("idle")} disabled={closing === "busy"}>取消</button>
425
- <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(jobId), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
531
+ <button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(key), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
426
532
  </div>
427
533
  )}
428
534
  {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>}
@@ -432,17 +538,18 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
432
538
  <div className="transcript-wrap">
433
539
  <div className="transcript" ref={scrollRef} onScroll={onScroll}>
434
540
  <div className="tw">
435
- {!jobId && cfg.empty.show && (
541
+ {(!key || isNew) && cfg.empty.show && (
436
542
  <div className="hero cli-hero">
437
543
  <div className="h-ic">{Icon.bolt()}</div>
438
544
  <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
439
545
  <h2>{cfg.empty.title}</h2>
546
+ {isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
440
547
  {cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
441
548
  </div>
442
549
  )}
443
550
  {continuedFrom && (
444
551
  <div className="msg system info"><div className="msg-body">ℹ 这个对话接续自上一个已结束的对话({continuedFrom})。工作区文件沿用,之前的对话内容没有带进来。
445
- <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFrom)}>查看上一个对话</button>
552
+ <button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFromKey)}>查看上一个对话</button>
446
553
  </div></div>
447
554
  )}
448
555
  {contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
@@ -499,18 +606,34 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
499
606
  </div>
500
607
  {!readOnly && (
501
608
  <Composer
502
- key={jobId ?? "new"}
503
- draft={jobId ?? `new:${instanceId}`}
504
- placeholder={fillTemplate(jobId ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
609
+ key={key ?? "new"}
610
+ draft={key ?? `new:${instanceId}`}
611
+ placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
505
612
  disabled={composerDisabled}
506
613
  disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
507
614
  running={running}
508
615
  onSend={send}
509
- onStop={cfg.composer.stop && jobId && conv && runningTurn(conv) ? stop : undefined}
616
+ onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
510
617
  options={composerOptions}
511
618
  />
512
619
  )}
513
620
  </div>
621
+ {popup && key && board && (
622
+ <div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
623
+ <div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
624
+ {popup.kind === "file" ? (
625
+ <FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })} />
626
+ ) : (
627
+ <>
628
+ <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>
629
+ <BrowserPane backend={board} jobId={key} readOnly={readOnly} />
630
+ </>
631
+ )}
632
+ </div>
633
+ </div>
634
+ )}
514
635
  </div>
636
+ </FileOpenContext.Provider>
637
+ </FileLinkContext.Provider>
515
638
  );
516
639
  }