@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.
package/src/elements.tsx CHANGED
@@ -7,18 +7,27 @@
7
7
  *
8
8
  * <cli-msglist group="a" base-url="/api/center" title="战略"></cli-msglist>
9
9
  * <cli-chat group="a"></cli-chat>
10
+ * <cli-board group="a"></cli-board> (右侧看板:文件 / 改动 / 浏览器;对话里点文件会在这里打开)
11
+ * <cli-file-preview job-id="job_x" path="outputs/报告.md"></cli-file-preview> (单独的文件预览 + 下载)
12
+ * <cli-browser job-id="job_x"></cli-browser> (单独的浏览器实时画面)
10
13
  *
11
14
  * 同一个 group 的元素共享一个仓库:列表点哪个,对话就显示哪个;不写 group 各自独立。
12
- * 属性:base-url、token、line、namespace、group、job-id、title、color、theme、context、read-only、
15
+ * 属性:base-url、token、line、namespace、group、job-id、task-id(对话任务里的哪个对话,"new" = 新开一个;对话 / 看板)、
16
+ * title、color、theme、context、read-only、
17
+ * tabs(看板页签,逗号分隔:files,changes,browser)、default-tab、file-open(对话:auto / board / modal / link)、
13
18
  * config(界面配置 JSON)、new-conversation(新建对话参数 JSON:title / businessId / workspace / callback / agent …)。
14
19
  * 界面配置也可以直接赋对象:document.querySelector("cli-chat").config = { header: { export: false } }。
15
- * 事件:cli-event(detail 是 CliEvent:select / conversation_turn / conversation_closed / job_change / error);
16
- * cli-callback(detail 与 center 回调 conversation_turn / conversation_closed 的请求体同名同义)。
20
+ * 事件(全在浏览器里从实时流算出,和 center 的 callback.url 无关):
21
+ * cli-turn-end(一轮结束)、cli-conversation-end(对话结束),detail 字段与 center 回调体一致;
22
+ * cli-event(全部事件:select / conversation_turn / conversation_closed / job_change / open / error);
23
+ * cli-callback 是旧名字,等同于前两个合在一起,保留兼容。
17
24
  * 渲染在 light DOM 里(不用 Shadow DOM),样式靠页面引入 styles.css,作用域都在 .cli-ui 内。
18
25
  */
19
26
  import { createRoot, type Root } from "react-dom/client";
20
27
  import { CliMsgList } from "./msglist/CliMsgList.js";
21
28
  import { CliChat } from "./chat/CliChat.js";
29
+ import { CliBoard, type BoardTab } from "./board/CliBoard.js";
30
+ import { CliBrowserView, CliFilePreview } from "./board/blocks.js";
22
31
  import { createCliStore, type CliStore } from "./core/store.js";
23
32
 
24
33
  const groups = new Map<string, CliStore>();
@@ -36,7 +45,7 @@ function storeFor(el: HTMLElement): CliStore | undefined {
36
45
  return store;
37
46
  }
38
47
 
39
- const ATTRS = ["base-url", "token", "line", "namespace", "group", "job-id", "title", "color", "theme", "context", "read-only", "config", "new-conversation"];
48
+ const ATTRS = ["base-url", "token", "line", "namespace", "group", "job-id", "task-id", "title", "color", "theme", "context", "read-only", "config", "new-conversation", "tabs", "default-tab", "file-open", "path"];
40
49
 
41
50
  function jsonAttr(el: HTMLElement, name: string): object | undefined {
42
51
  const raw = el.getAttribute(name);
@@ -91,6 +100,8 @@ abstract class CliElement extends HTMLElement {
91
100
  color: this.getAttribute("color") ?? undefined,
92
101
  theme: (this.getAttribute("theme") as "light" | "dark" | null) ?? undefined,
93
102
  onEvent: (event: unknown) => this.dispatchEvent(new CustomEvent("cli-event", { detail: event, bubbles: true })),
103
+ onTurnEnd: (payload: unknown) => this.dispatchEvent(new CustomEvent("cli-turn-end", { detail: payload, bubbles: true })),
104
+ onConversationEnd: (payload: unknown) => this.dispatchEvent(new CustomEvent("cli-conversation-end", { detail: payload, bubbles: true })),
94
105
  onCallback: (payload: unknown) => this.dispatchEvent(new CustomEvent("cli-callback", { detail: payload, bubbles: true })),
95
106
  style: { height: "100%" },
96
107
  config: this.config as never,
@@ -103,8 +114,8 @@ abstract class CliElement extends HTMLElement {
103
114
  class CliMsgListElement extends CliElement {
104
115
  protected element() {
105
116
  const context = this.getAttribute("context");
106
- // 回调类事件只由 <cli-chat> 发:同一个 group 里列表和对话共用一个仓库,两边都发的话 document 上会收到两遍。
107
- const { onCallback: _chatOnly, ...common } = this.common();
117
+ // 结果事件只由 <cli-chat> 发:同一个 group 里列表和对话共用一个仓库,两边都发的话 document 上会收到两遍。
118
+ const { onTurnEnd: _a, onConversationEnd: _b, onCallback: _c, ...common } = this.common();
108
119
  return <CliMsgList {...common} {...(context !== null ? { context } : {})} />;
109
120
  }
110
121
  }
@@ -112,11 +123,44 @@ class CliMsgListElement extends CliElement {
112
123
  class CliChatElement extends CliElement {
113
124
  protected element() {
114
125
  const jobId = this.getAttribute("job-id");
115
- return <CliChat {...this.common()} {...(jobId !== null ? { jobId: jobId || null } : {})} readOnly={this.hasAttribute("read-only")} />;
126
+ const taskId = this.getAttribute("task-id");
127
+ const fileOpen = this.getAttribute("file-open") as "auto" | "board" | "modal" | "link" | null;
128
+ return <CliChat {...this.common()} {...(jobId !== null ? { jobId: jobId || null } : {})} {...(taskId ? { taskId } : {})} readOnly={this.hasAttribute("read-only")} {...(fileOpen ? { fileOpen } : {})} />;
129
+ }
130
+ }
131
+
132
+ class CliBoardElement extends CliElement {
133
+ protected element() {
134
+ const jobId = this.getAttribute("job-id");
135
+ const tabs = this.getAttribute("tabs")?.split(",").map((t) => t.trim()).filter(Boolean) as BoardTab[] | undefined;
136
+ const defaultTab = (this.getAttribute("default-tab") as BoardTab | null) ?? undefined;
137
+ // 结果事件只由 <cli-chat> 发(同上);看板只发 cli-event。
138
+ const { onTurnEnd: _a, onConversationEnd: _b, onCallback: _c, title: _t, color: _k, config: _g, ...common } = this.common();
139
+ const taskId = this.getAttribute("task-id");
140
+ return <CliBoard {...common} {...(jobId !== null ? { jobId: jobId || null } : {})} {...(taskId ? { taskId } : {})} tabs={tabs?.length ? tabs : undefined} defaultTab={defaultTab} readOnly={this.hasAttribute("read-only")} />;
141
+ }
142
+ }
143
+
144
+ class CliFilePreviewElement extends CliElement {
145
+ protected element() {
146
+ const jobId = this.getAttribute("job-id");
147
+ const { onTurnEnd: _a, onConversationEnd: _b, onCallback: _c, title: _t, color: _k, config: _g, ...common } = this.common();
148
+ return <CliFilePreview {...common} {...(jobId !== null ? { jobId: jobId || null } : {})} path={this.getAttribute("path") ?? ""} />;
149
+ }
150
+ }
151
+
152
+ class CliBrowserElement extends CliElement {
153
+ protected element() {
154
+ const jobId = this.getAttribute("job-id");
155
+ const { onTurnEnd: _a, onConversationEnd: _b, onCallback: _c, title: _t, color: _k, config: _g, ...common } = this.common();
156
+ return <CliBrowserView {...common} {...(jobId !== null ? { jobId: jobId || null } : {})} readOnly={this.hasAttribute("read-only")} />;
116
157
  }
117
158
  }
118
159
 
119
160
  export function defineCliElements() {
120
161
  if (!customElements.get("cli-msglist")) customElements.define("cli-msglist", CliMsgListElement);
121
162
  if (!customElements.get("cli-chat")) customElements.define("cli-chat", CliChatElement);
163
+ if (!customElements.get("cli-board")) customElements.define("cli-board", CliBoardElement);
164
+ if (!customElements.get("cli-file-preview")) customElements.define("cli-file-preview", CliFilePreviewElement);
165
+ if (!customElements.get("cli-browser")) customElements.define("cli-browser", CliBrowserElement);
122
166
  }
package/src/embed.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * <script type="module" src="/static/cli-ui/cli-ui.js"></script>
6
6
  * <cli-chat base-url="/api/center" job-id="job_xxx"></cli-chat>
7
7
  *
8
- * 引入后自动注册 <cli-chat> / <cli-msglist>;想用 JS 控制(传回调函数、随时改参数)就用 window.CliUI.mountChat / mountMsgList。
8
+ * 引入后自动注册 <cli-chat> / <cli-msglist> / <cli-board>;想用 JS 控制(传回调函数、随时改参数)就用
9
+ * window.CliUI.mountChat / mountMsgList / mountBoard。几个 mount 要联动(对话里点文件在看板打开)时传同一个 store:
10
+ * const store = CliUI.createCliStore({ baseUrl: "/api/center" });
11
+ * CliUI.mountChat(a, { store }); CliUI.mountBoard(b, { store });
9
12
  * 产物由 `npm run build:embed -w @spzxg/cli-ui` 生成在 dist/。
10
13
  */
11
14
  import "./styles.css";
@@ -14,6 +17,8 @@ import { createRoot } from "react-dom/client";
14
17
  import { defineCliElements } from "./elements.js";
15
18
  import { CliChat, type CliChatProps } from "./chat/CliChat.js";
16
19
  import { CliMsgList, type CliMsgListProps } from "./msglist/CliMsgList.js";
20
+ import { CliBoard, type CliBoardProps } from "./board/CliBoard.js";
21
+ import { CliBrowserView, CliFilePreview, type CliBrowserViewProps, type CliFilePreviewProps } from "./board/blocks.js";
17
22
  import { CliCenterProvider } from "./core/provider.js";
18
23
  import { createCliStore } from "./core/store.js";
19
24
  import { createCenterClient } from "./core/client.js";
@@ -35,13 +40,19 @@ function mount<P extends object>(Component: (props: P) => unknown, el: Element,
35
40
  };
36
41
  }
37
42
 
38
- /** 在一个元素里挂对话组件。参数与 React 的 <CliChat> 相同(jobId、newConversation、onCallback、config……)。 */
43
+ /** 在一个元素里挂对话组件。参数与 React 的 <CliChat> 相同(jobId、newConversation、onTurnEnd、config……)。 */
39
44
  export const mountChat = (el: Element, props: CliChatProps = {}) => mount(CliChat, el, props);
40
45
  /** 在一个元素里挂列表组件。参数与 React 的 <CliMsgList> 相同。 */
41
46
  export const mountMsgList = (el: Element, props: CliMsgListProps = {}) => mount(CliMsgList, el, props);
47
+ /** 在一个元素里挂右侧看板。参数与 React 的 <CliBoard> 相同(jobId、tabs、fileActions……)。 */
48
+ export const mountBoard = (el: Element, props: CliBoardProps = {}) => mount(CliBoard, el, props);
49
+ /** 单独的文件预览 + 下载:{ jobId, path, baseUrl… }。 */
50
+ export const mountFilePreview = (el: Element, props: CliFilePreviewProps) => mount(CliFilePreview, el, props);
51
+ /** 单独的浏览器实时画面:{ jobId, readOnly, baseUrl… }。 */
52
+ export const mountBrowser = (el: Element, props: CliBrowserViewProps = {}) => mount(CliBrowserView, el, props);
42
53
 
43
- export { defineCliElements, CliChat, CliMsgList, CliCenterProvider, createCliStore, createCenterClient };
54
+ export { defineCliElements, CliChat, CliMsgList, CliBoard, CliFilePreview, CliBrowserView, CliCenterProvider, createCliStore, createCenterClient };
44
55
 
45
56
  defineCliElements();
46
- const api = { mountChat, mountMsgList, createCliStore, createCenterClient, defineCliElements };
57
+ const api = { mountChat, mountMsgList, mountBoard, mountFilePreview, mountBrowser, createCliStore, createCenterClient, defineCliElements };
47
58
  (globalThis as { CliUI?: typeof api }).CliUI = api;
package/src/index.ts CHANGED
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * cli-msglist 消息对话列表(CliMsgList)
5
5
  * cli-chat 对话(CliChat)
6
- * cli-board 右侧看板 —— 规划中,见 PLAN.md
6
+ * cli-board 右侧看板:工作区文件(预览 / 下载)、改动、任务浏览器(CliBoard)
7
+ * 其中两个核心也能单独用:CliFilePreview(文件预览 + 下载)、CliBrowserView(浏览器实时画面)
7
8
  *
8
9
  * 单独用:
9
10
  * <CliChat baseUrl="/api/center" jobId="job_xxx" />
11
+ * <CliChat baseUrl="/api/center" jobId="job_xxx" taskId="task_yyy" /> (对话任务里的某个对话;taskId="new" 新开一个)
10
12
  * 组合用(同一个 Provider 里共享选中和实时流):
11
13
  * <CliCenterProvider baseUrl="/api/center" namespace="strategy">
12
14
  * <CliMsgList title="战略" />
@@ -18,15 +20,19 @@
18
20
  */
19
21
  export { CliMsgList, type CliMsgListProps } from "./msglist/CliMsgList.js";
20
22
  export { CliChat, type CliChatProps } from "./chat/CliChat.js";
21
- export { CliCenterProvider, useCliStore, useCliState, type CliCenterProviderProps } from "./core/provider.js";
23
+ export { CliBoard, type CliBoardProps, type BoardTab } from "./board/CliBoard.js";
24
+ export { CliFilePreview, CliBrowserView, type CliFilePreviewProps, type CliBrowserViewProps } from "./board/blocks.js";
25
+ export { FileViewer, downloadFile, type FileViewerProps, type FileAction } from "./board/FileViewer.js";
26
+ export { BrowserPane } from "./board/BrowserPane.js";
27
+ export { CliCenterProvider, useCliStore, useCliState, type CliCenterProviderProps, type ResultHandlers } from "./core/provider.js";
22
28
 
23
29
  export {
24
30
  resolveChatConfig, resolveMsgListConfig, DEFAULT_CHAT_CONFIG, DEFAULT_MSGLIST_CONFIG,
25
31
  type CliChatConfig, type CliMsgListConfig, type ResolvedChatConfig, type ResolvedMsgListConfig,
26
32
  } from "./core/config.js";
27
- export { createCliStore, type CliStore, type CliStoreOptions, type CliState, type CliEvent, type JobEntry } from "./core/store.js";
28
- export { createCenterClient, CenterError, type CenterClient, type CenterClientOptions, type StreamHandlers } from "./core/client.js";
29
- export { viewMessages, isRunning, runningTurn, type ConvState } from "./core/conversation.js";
33
+ export { createCliStore, type CliStore, type CliStoreOptions, type CliState, type CliEvent, type JobEntry, type OpenTarget } from "./core/store.js";
34
+ export { createCenterClient, CenterError, boardFileUrl, type CenterClient, type CenterClientOptions, type StreamHandlers, type BoardBackend, type ConversationBackend } from "./core/client.js";
35
+ export { viewMessages, isRunning, runningTurn, convKey, parseConvKey, NEW_TASK, type ConvState } from "./core/conversation.js";
30
36
  export {
31
37
  composeMessage, splitMessage, serializeBlock, readFileText, isLongText, ACCEPTED_EXTENSIONS, MAX_MESSAGE_CHARS,
32
38
  type TextBlock, type MessagePart,
@@ -1,24 +1,30 @@
1
1
  // cli-msglist:消息对话列表。界面取自 Operator 的任务栏(TasksColumn 的列表视图,不做看板),
2
- // 一张卡片是 center 里的一个对话任务(mode=conversation)。
2
+ // 一张卡片是 center 里的一个对话任务(mode=conversation)。对话任务里开了几个对话时,卡片下面按对话展开,
3
+ // 点哪个对话选哪个(选中值是对话键 "job#task");卡片上的「+」在这个对话任务里新开一个对话(同一个工作区)。
3
4
  import { useEffect, useMemo, useState, type CSSProperties } from "react";
4
5
  import type { CliEvent, JobEntry } from "../core/store.js";
5
- import type { ConversationCallback } from "../core/types.js";
6
6
  import type { CliStoreOptions, CliStore } from "../core/store.js";
7
- import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
8
- import { TERMINAL_JOB } from "../core/conversation.js";
7
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
8
+ import { convKey, NEW_TASK, parseConvKey, TERMINAL_JOB } from "../core/conversation.js";
9
+ import type { ConversationInfo } from "../core/types.js";
9
10
  import { Icon } from "../ui/icons.js";
10
11
  import { agentLabel, relTime, toMs } from "../ui/format.js";
11
12
  import { AgentBadge, ErrNote, Skel, StatusDot, type DotCls } from "../ui/shared.js";
12
13
  import { resolveMsgListConfig, type CliMsgListConfig, type ResolvedMsgListConfig } from "../core/config.js";
13
14
 
14
- export interface CliMsgListProps extends CliStoreOptions {
15
+ /** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
16
+ export interface CliMsgListProps extends CliStoreOptions, ResultHandlers {
15
17
  /** 列表显示哪些对话(center 的 job_id)。不给就用本地记住的 + (discover 时)从 center 发现的。 */
16
18
  jobIds?: string[];
17
19
  /** 没给 jobIds 时,挂载后从 center 发现本产品线的对话。缺省 true。 */
18
20
  discover?: boolean;
19
- /** 受控的选中项;不给就用共享仓库里的选中(和 cli-chat 联动)。 */
21
+ /** 受控的选中项(对话键:job_id,或 "job_id#task_id");不给就用共享仓库里的选中(和 cli-chat 联动)。 */
20
22
  selectedId?: string | null;
21
- onSelect?: (jobId: string) => void;
23
+ /**
24
+ * 选了一个对话。id 是对话键:只有一个对话的对话任务是 job_id;对话任务里的某个对话是 "job_id#task_id";
25
+ * 点了「+」是 "job_id#new"(在这个对话任务里新开对话)。ref 是拆开的 jobId / taskId(新开时 taskId 为 "new")。
26
+ */
27
+ onSelect?: (id: string, ref: { jobId: string; taskId: string | null }) => void;
22
28
  /** 点「新对话」:缺省清空选中,cli-chat 会显示空白的新对话。 */
23
29
  onNew?: () => void;
24
30
  /** 顶部名称与色块(如业务 / 项目名)。 */
@@ -37,8 +43,7 @@ export interface CliMsgListProps extends CliStoreOptions {
37
43
  /** 界面配置:顶部栏、搜索、卡片内容、分组标题与空状态文案,见 core/config.ts。 */
38
44
  config?: CliMsgListConfig;
39
45
  onEvent?: (event: CliEvent) => void;
40
- /** 前端回调:载荷与 center 回调 conversation_turn / conversation_closed 的请求体同名同义。 */
41
- onCallback?: (payload: ConversationCallback) => void;
46
+
42
47
  store?: CliStore;
43
48
  }
44
49
 
@@ -78,19 +83,50 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
78
83
  const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
79
84
  return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
80
85
  }
81
- const running = !!conv?.running_turn;
86
+ // 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
87
+ const open = (conv?.conversations ?? []).filter((c) => !c.closed);
88
+ const many = (conv?.conversations?.length ?? 0) > 1;
89
+ const running = many ? open.some((c) => c.running_turn) : !!conv?.running_turn;
82
90
  // 上一轮以提问结束、后面没有新消息:等你回答。
83
- const awaiting = !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
91
+ const awaiting = many ? open.some((c) => !c.running_turn && !c.queued_turns && !!c.last_turn?.asked) : !running && !conv?.queued_turns && !!conv?.last_turn?.asked;
84
92
  if (awaiting) return { ...base, cls: "a", label: "需要你处理", running, awaiting, group: "needs", activity: updatedAt ? `等你处理 · ${relTime(updatedAt)}` : "等你处理" };
85
93
  if (running) return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: "运行中 · 工作中" };
86
94
  const waiting = conv?.queued_turns ? `${conv.queued_turns} 条排队中` : status === "queued" ? "等待执行节点" : updatedAt ? relTime(updatedAt) : "未开始";
87
95
  return { ...base, cls: "a", label: "进行中", running, awaiting, group: "active", activity: waiting };
88
96
  }
89
97
 
90
- function MsgCard({ v, selected, onSelect, show }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
98
+ /** 对话任务里的一个对话(对话任务开了几个对话时,卡片下面的一行)。 */
99
+ interface ConvRowView {
100
+ key: string;
101
+ title: string;
102
+ cls: DotCls;
103
+ label: string;
104
+ running: boolean;
105
+ awaiting: boolean;
106
+ activity: string;
107
+ turns: number;
108
+ }
109
+
110
+ /** 对话任务里的对话:只有一个时返回 [](整张卡片就是它)。 */
111
+ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowView[] {
112
+ const list = entry?.job?.conversation?.conversations ?? [];
113
+ if (list.length <= 1) return [];
114
+ const jobEnded = TERMINAL_JOB.has(entry?.job?.status ?? "");
115
+ return list.map((c: ConversationInfo) => {
116
+ const base = { key: convKey(jobId, c.task_id), title: c.title, turns: c.turns };
117
+ if (c.closed || jobEnded) return { ...base, cls: "g" as DotCls, label: "已结束", running: false, awaiting: false, activity: "已结束" };
118
+ const running = c.running_turn;
119
+ const awaiting = !running && !c.queued_turns && !!c.last_turn?.asked;
120
+ if (awaiting) return { ...base, cls: "a" as DotCls, label: "需要你处理", running, awaiting, activity: "等你处理" };
121
+ if (running) return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: "运行中" };
122
+ return { ...base, cls: "a" as DotCls, label: "进行中", running, awaiting, activity: c.queued_turns ? `${c.queued_turns} 条排队中` : "空闲" };
123
+ });
124
+ }
125
+
126
+ function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"]; onNewInJob?: () => void }) {
91
127
  if (v.loading) return <CardSkeleton i={v.id.length} />;
92
128
  const foot = show.activity || (show.turns && v.turns > 0);
93
- return (
129
+ const card = (
94
130
  <button className={`task ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect} title={v.error ?? undefined}>
95
131
  <div className="task-top">
96
132
  {show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
@@ -103,10 +139,29 @@ function MsgCard({ v, selected, onSelect, show }: { v: CardView; selected: boole
103
139
  {foot && <div className="task-foot">
104
140
  {show.activity && <span className="activity">{v.awaiting ? <span style={{ color: "var(--blue)" }}>●</span> : v.running ? <span style={{ color: "var(--amber)" }}>●</span> : null}{v.activity}</span>}
105
141
  <span className="spacer" />
106
- {show.turns && v.turns > 0 && <span className="activity">{v.turns} 轮对话</span>}
142
+ {show.turns && v.turns > 0 && <span className="activity">{v.turns} 条消息</span>}
107
143
  </div>}
108
144
  </button>
109
145
  );
146
+ if (!onNewInJob) return card;
147
+ // 「+」不能放进卡片按钮里(按钮不能嵌套):叠在卡片右下角。
148
+ return (
149
+ <div className="cli-job-card">
150
+ {card}
151
+ <button className="icon-btn cli-new-in-job" onClick={onNewInJob} title="在这个任务里新开一个对话(共用同一个工作区)" aria-label="在这个任务里新开一个对话">{Icon.plus()}</button>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function ConvRow({ v, selected, onSelect, show }: { v: ConvRowView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"] }) {
157
+ return (
158
+ <button className={`cli-conv-row ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}>
159
+ {show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
160
+ <span className="cli-conv-title">{v.title}</span>
161
+ {show.activity && <span className="cli-conv-act">{v.activity}</span>}
162
+ {show.turns && v.turns > 0 && <span className="cli-conv-act">{v.turns} 条</span>}
163
+ </button>
164
+ );
110
165
  }
111
166
 
112
167
  function CardSkeleton({ i = 0 }: { i?: number }) {
@@ -119,9 +174,40 @@ function CardSkeleton({ i = 0 }: { i?: number }) {
119
174
  );
120
175
  }
121
176
 
122
- function Group({ label, cards, selected, onSelect, show, accent, collapsible, collapsed, onToggle }: { label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"]; accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void }) {
177
+ type GroupProps = {
178
+ label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"];
179
+ rows: Record<string, ConvRowView[]>; firstTask: Record<string, string | undefined>; onNewInJob?: (jobId: string) => void;
180
+ accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void;
181
+ };
182
+
183
+ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle }: GroupProps) {
123
184
  if (!cards.length || label === false) return null;
124
- const list = cards.map((v) => <MsgCard key={v.id} v={v} selected={v.id === selected} onSelect={() => onSelect(v.id)} show={show} />);
185
+ const sel = selected ? parseConvKey(selected) : null;
186
+ const list = cards.map((v) => {
187
+ const convs = rows[v.id] ?? [];
188
+ // 选中的是这个对话任务:只有 job_id 指它的第一个对话。
189
+ const selectedKey = sel?.jobId === v.id ? convKey(v.id, sel.taskId ?? firstTask[v.id]) : null;
190
+ const newing = sel?.jobId === v.id && sel.taskId === NEW_TASK;
191
+ const card = (
192
+ <MsgCard
193
+ key={v.id} v={v} show={show}
194
+ // 只有一个对话:整张卡片就是它;有几个对话:卡片是标题,选中落在下面的行上。
195
+ selected={!convs.length && !newing && sel?.jobId === v.id}
196
+ onSelect={() => onSelect(convs.length ? convs.find((c) => c.key === selectedKey)?.key ?? convs[0].key : v.id)}
197
+ onNewInJob={onNewInJob && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
198
+ />
199
+ );
200
+ if (!convs.length && !newing) return card;
201
+ return (
202
+ <div key={v.id} className="cli-job-group">
203
+ {card}
204
+ <div className="cli-conv-rows">
205
+ {convs.map((c) => <ConvRow key={c.key} v={c} selected={c.key === selectedKey} onSelect={() => onSelect(c.key)} show={show} />)}
206
+ {newing && <div className="cli-conv-row sel"><span className="cli-conv-title">新对话 · 发第一条消息后开始</span></div>}
207
+ </div>
208
+ </div>
209
+ );
210
+ });
125
211
  if (collapsible) {
126
212
  return (
127
213
  <>
@@ -141,8 +227,8 @@ function Group({ label, cards, selected, onSelect, show, accent, collapsible, co
141
227
  );
142
228
  }
143
229
 
144
- function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, title = "对话", color = "#b8613f", context, onEditContext, onCollapse, pollMs = 10_000, config, theme = "light", className, style, width, onEvent, onCallback, inProvider }: CliMsgListProps & { inProvider: boolean }) {
145
- useComponentEvents(onEvent, onCallback, inProvider);
230
+ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, title = "对话", color = "#b8613f", context, onEditContext, onCollapse, pollMs = 10_000, config, theme = "light", className, style, width, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider }: CliMsgListProps & { inProvider: boolean }) {
231
+ useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
146
232
  const configKey = JSON.stringify(config ?? {});
147
233
  const cfg = useMemo(() => resolveMsgListConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
148
234
  const h = cfg.header;
@@ -162,7 +248,10 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
162
248
  useEffect(() => { if (jobIds) store.setJobIds(jobIds); }, [store, idsKey]); // eslint-disable-line react-hooks/exhaustive-deps
163
249
  // 宿主选中了列表里还没有的对话(例如旁边独立的 cli-chat 刚新建出来的):加进列表,不用两个组件共享任何东西。
164
250
  // 放在 setJobIds 之后,宿主的 jobIds 更新后也会再补一次。
165
- useEffect(() => { if (selected && !ids.includes(selected)) store.addJob(selected); }, [selected, ids, store]);
251
+ useEffect(() => {
252
+ const jobId = selected ? parseConvKey(selected).jobId : null;
253
+ if (jobId && !ids.includes(jobId)) store.addJob(jobId);
254
+ }, [selected, ids, store]);
166
255
 
167
256
  // 首次:刷新已知的;没给 jobIds 时再去 center 发现。
168
257
  useEffect(() => {
@@ -183,7 +272,8 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
183
272
 
184
273
  const cards = useMemo(() => ids.map((id) => cardView(id, jobs[id])), [ids, jobs]);
185
274
  const q = query.trim().toLowerCase();
186
- const shown = q ? cards.filter((c) => c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)) : cards;
275
+ const shown = q ? cards.filter((c) => c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
276
+ || (jobs[c.id]?.job?.conversation?.conversations ?? []).some((conv) => conv.title.toLowerCase().includes(q))) : cards;
187
277
  const byRecent = (a: CardView, b: CardView) => b.updatedAt - a.updatedAt;
188
278
  const groups = {
189
279
  needs: shown.filter((c) => c.group === "needs").sort(byRecent),
@@ -191,7 +281,11 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
191
281
  done: shown.filter((c) => c.group === "done").sort(byRecent),
192
282
  cancelled: shown.filter((c) => c.group === "cancelled").sort(byRecent),
193
283
  };
194
- const select = (id: string) => { if (selectedId === undefined) store.select(id); onSelect?.(id); };
284
+ const select = (id: string) => { if (selectedId === undefined) store.select(id); onSelect?.(id, parseConvKey(id)); };
285
+ const newInJob = cfg.card.newInJob ? (jobId: string) => select(convKey(jobId, NEW_TASK)) : undefined;
286
+ const rows = useMemo(() => Object.fromEntries(ids.map((id) => [id, convRows(id, jobs[id])])), [ids, jobs]);
287
+ const firstTask = useMemo(() => Object.fromEntries(ids.map((id) => [id, jobs[id]?.job?.conversation?.conversations?.[0]?.task_id ?? jobs[id]?.job?.tasks?.[0]?.id])), [ids, jobs]);
288
+ const groupProps = { selected, onSelect: select, show: cfg.card, rows, firstTask, onNewInJob: newInJob };
195
289
  const startNew = () => { if (onNew) onNew(); else store.select(null); };
196
290
  const firstLoad = !discovered && !cards.length;
197
291
  const showRow = h.show && (h.title || h.newButton || h.refresh || !!onCollapse);
@@ -234,10 +328,10 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
234
328
  <>
235
329
  {cards.length === 0 && <div className="empty" style={{ padding: "30px 16px" }}><div className="e-t">{cfg.emptyTitle}</div>{cfg.emptyDescription && <div className="e-s">{cfg.emptyDescription}</div>}</div>}
236
330
  {q && shown.length === 0 && <div className="search-empty">没有匹配“{query.trim()}”的对话。</div>}
237
- <Group label={cfg.groups.needs} cards={groups.needs} selected={selected} onSelect={select} show={cfg.card} accent />
238
- <Group label={cfg.groups.active} cards={groups.active} selected={selected} onSelect={select} show={cfg.card} />
239
- <Group label={cfg.groups.done} cards={groups.done} selected={selected} onSelect={select} show={cfg.card} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
240
- <Group label={cfg.groups.cancelled} cards={groups.cancelled} selected={selected} onSelect={select} show={cfg.card} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
331
+ <Group label={cfg.groups.needs} cards={groups.needs} {...groupProps} accent />
332
+ <Group label={cfg.groups.active} cards={groups.active} {...groupProps} />
333
+ <Group label={cfg.groups.done} cards={groups.done} {...groupProps} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
334
+ <Group label={cfg.groups.cancelled} cards={groups.cancelled} {...groupProps} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
241
335
  </>
242
336
  )}
243
337
  </div>
package/src/styles.css CHANGED
@@ -48,3 +48,127 @@
48
48
 
49
49
  /* 排队气泡下的等待原因(中枢给的:节点连不上、没有可用节点……) */
50
50
  .cli-ui .cli-queued-note{margin-top:6px;font-size:12px;color:var(--amber);}
51
+
52
+ /* Agent 消息里打不开的本地文件链接:显示成文字(路径在悬停提示里) */
53
+ .cli-ui .md .cli-local-link{color:var(--ink-2);border-bottom:1px dashed var(--line-strong);cursor:help;}
54
+
55
+ /* ── cli-board:右侧看板(Operator 右栏的样子 + 文件浏览 / 预览) ── */
56
+ .cli-ui.cli-file-preview,
57
+ .cli-ui.cli-browser-view{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;background:var(--panel);}
58
+ .cli-ui.cli-browser-view .rail-pad{flex:1;}
59
+ .cli-ui.cli-board{display:flex;flex-direction:column;min-height:0;min-width:0;height:100%;overflow:hidden;border-left:1px solid var(--line);}
60
+ .cli-ui.cli-board .rail-scroll{overflow:hidden;}
61
+ .cli-ui.cli-board.collapsed{background:var(--bg-2);}
62
+ .cli-ui .cli-board-strip{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;height:100%;padding:14px 0;color:var(--ink-3);}
63
+ .cli-ui .cli-board-strip:hover{background:var(--row-sel);color:var(--ink);}
64
+ .cli-ui .cli-board-strip svg{width:14px;height:14px;transform:rotate(180deg);}
65
+ .cli-ui .cli-board-strip-t{writing-mode:vertical-rl;font-family:var(--mono);font-size:11px;letter-spacing:.1em;}
66
+ .cli-ui.cli-board .tc-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.6;}
67
+ .cli-ui .cli-sync-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ink-4);margin-right:5px;vertical-align:middle;}
68
+ .cli-ui .cli-sync-dot.on{background:var(--green);animation:cli-pulse 1.6s ease-in-out infinite;}
69
+ @keyframes cli-pulse{50%{opacity:.35;}}
70
+ .cli-ui .cli-tc-open{font-size:11px;color:var(--accent);padding:1px 6px;border-radius:5px;margin-left:6px;cursor:pointer;}
71
+ .cli-ui .cli-tc-open:hover{background:var(--accent-soft);}
72
+
73
+ .cli-ui .cli-files{display:flex;flex-direction:column;min-height:0;flex:1;}
74
+ .cli-ui .cli-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 12px;border-bottom:1px solid var(--line-2);font-size:12.5px;}
75
+ .cli-ui .cli-crumb{display:inline-flex;align-items:center;gap:5px;padding:3px 6px;border-radius:6px;color:var(--ink-3);}
76
+ .cli-ui .cli-crumb svg{width:13px;height:13px;}
77
+ .cli-ui .cli-crumb.on{color:var(--ink);font-weight:600;}
78
+ .cli-ui .cli-crumb:hover{background:var(--row-sel);}
79
+ .cli-ui .cli-crumb-sep{color:var(--ink-4);}
80
+ .cli-ui .cli-files-sync{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);}
81
+ .cli-ui .cli-files-list{flex:1;min-height:0;overflow-y:auto;padding:4px 0;}
82
+ .cli-ui .cli-frow{display:flex;align-items:center;width:100%;}
83
+ .cli-ui .cli-frow:hover{background:var(--row-sel);}
84
+ .cli-ui .cli-frow.fresh .cli-frow-name{color:var(--accent-ink);}
85
+ .cli-ui button.cli-frow,.cli-ui .cli-frow-main{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:7px 12px;font-size:13px;text-align:left;color:var(--ink);}
86
+ .cli-ui .cli-frow-ic{display:inline-flex;color:var(--ink-3);}
87
+ .cli-ui .cli-frow.dir .cli-frow-ic{color:var(--accent);}
88
+ .cli-ui .cli-frow-ic svg{width:14px;height:14px;}
89
+ .cli-ui .cli-frow-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
90
+ .cli-ui .cli-frow-new{font-size:10.5px;color:var(--accent);background:var(--accent-soft);padding:1px 6px;border-radius:9px;}
91
+ .cli-ui .cli-frow-meta{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);white-space:nowrap;}
92
+ .cli-ui .cli-frow-dl{display:inline-grid;place-items:center;width:28px;height:28px;margin-right:6px;border-radius:6px;color:var(--ink-3);opacity:0;}
93
+ .cli-ui .cli-frow:hover .cli-frow-dl,.cli-ui .cli-frow-dl:focus-visible{opacity:1;}
94
+ .cli-ui .cli-frow-dl:hover{background:var(--surface-2);color:var(--ink);}
95
+ .cli-ui .cli-frow-dl svg{width:14px;height:14px;}
96
+ @media (hover:none){.cli-ui .cli-frow-dl{opacity:1;}}
97
+
98
+ .cli-ui .cli-fv{display:flex;flex-direction:column;min-height:0;flex:1;background:var(--panel);}
99
+ .cli-ui .cli-fv-h{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line-2);font-size:12.5px;min-width:0;}
100
+ .cli-ui .cli-fv-ic{display:inline-flex;color:var(--ink-3);}
101
+ .cli-ui .cli-fv-ic svg{width:14px;height:14px;}
102
+ .cli-ui .cli-fv-name{font-weight:600;color:var(--ink);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
103
+ .cli-ui .cli-fv-meta{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);white-space:nowrap;}
104
+ .cli-ui .cli-fv-sp{flex:1;}
105
+ .cli-ui .cli-fv-back{font-size:18px;line-height:1;width:26px;height:26px;border-radius:6px;color:var(--ink-3);}
106
+ .cli-ui .cli-fv-back:hover{background:var(--row-sel);color:var(--ink);}
107
+ .cli-ui .cli-fv-btn{font-size:12px;color:var(--accent);padding:3px 8px;border-radius:6px;white-space:nowrap;}
108
+ .cli-ui .cli-fv-btn:hover{background:var(--accent-soft);}
109
+ .cli-ui .cli-fv-icon{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:6px;color:var(--ink-3);flex:none;}
110
+ .cli-ui .cli-fv-icon:hover:not(:disabled){background:var(--surface-2);color:var(--ink);}
111
+ .cli-ui .cli-fv-icon:disabled{opacity:.4;}
112
+ .cli-ui .cli-fv-icon svg{width:14px;height:14px;}
113
+ .cli-ui .cli-fv-b{flex:1;min-height:0;overflow:auto;}
114
+ .cli-ui .cli-fv-b.k-pdf,.cli-ui .cli-fv-b.k-html,.cli-ui .cli-fv-b.k-docx{overflow:hidden;display:flex;}
115
+ .cli-ui .cli-fv-frame{flex:1;width:100%;height:100%;min-height:360px;border:0;background:#fff;}
116
+ .cli-ui .cli-fv-md{padding:16px 18px;}
117
+ .cli-ui .cli-fv-code .md pre{margin:0;border:0;border-radius:0;min-height:100%;}
118
+ .cli-ui .cli-fv-pre{margin:0;padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-word;}
119
+ .cli-ui .cli-fv-img{display:grid;place-items:center;padding:16px;min-height:100%;background:repeating-conic-gradient(var(--surface-2) 0 25%,transparent 0 50%) 0 0/16px 16px;}
120
+ .cli-ui .cli-fv-img img{max-width:100%;height:auto;box-shadow:var(--sh-1);}
121
+ .cli-ui .cli-fv-csv{overflow:auto;}
122
+ .cli-ui .cli-fv-csv table{border-collapse:collapse;font-size:12px;font-family:var(--mono);}
123
+ .cli-ui .cli-fv-csv th,.cli-ui .cli-fv-csv td{border:1px solid var(--line-2);padding:4px 8px;white-space:nowrap;text-align:left;}
124
+ .cli-ui .cli-fv-csv th{position:sticky;top:0;background:var(--surface-2);font-weight:600;}
125
+ .cli-ui .cli-fv-note{padding:18px 16px;font-size:13px;color:var(--ink-3);line-height:1.7;}
126
+ .cli-ui .cli-fv-note.err{color:var(--red);}
127
+ .cli-ui .cli-fv-trunc{padding:6px 14px;font-size:12px;color:var(--amber);background:var(--surface-2);border-bottom:1px solid var(--line-2);}
128
+
129
+ /* 对话里弹窗打开的文件 / 浏览器 */
130
+ .cli-ui .modal.cli-popup{width:min(960px,calc(100vw - 40px));height:min(82vh,900px);display:flex;flex-direction:column;overflow:hidden;}
131
+ .cli-ui .modal.cli-popup.browser .rail-pad{flex:1;}
132
+ .cli-ui .md a[href="#"]{cursor:pointer;}
133
+
134
+ /* ── 浏览器实时画面(canvas):按比例适应容器;接管时高亮 ── */
135
+ .cli-ui .bp-screen{display:flex;align-items:center;justify-content:center;}
136
+ /* 宽高都用 auto + max-*:替换元素按自身比例缩小,点击坐标和显示区域一致。 */
137
+ .cli-ui .bp-frame{display:block;max-width:100%;max-height:var(--cli-browser-max-h,70vh);width:auto;height:auto;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;}
138
+ .cli-ui .bp-frame.live{cursor:pointer;outline:2px solid var(--coral);outline-offset:-2px;}
139
+ /* 键盘 / 输入法接收框:不放到屏幕外(focus 时会把宿主的滚动容器拖过去),透明盖在左上角,不挡点击。 */
140
+ .cli-ui .bp-keys{position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;pointer-events:none;resize:none;border:0;padding:0;}
141
+
142
+ /* ── 窄容器(手机、很窄的侧栏):预览工具栏换行,次要信息收起 ── */
143
+ .cli-ui .cli-fv{container-type:inline-size;}
144
+ .cli-ui .cli-fv-h{flex-wrap:wrap;row-gap:4px;}
145
+ @container (max-width:380px){
146
+ .cli-ui .cli-fv-meta{display:none;}
147
+ .cli-ui .cli-fv-btn{padding:3px 6px;}
148
+ }
149
+ .cli-ui.cli-board{container-type:inline-size;}
150
+ @container (max-width:340px){
151
+ .cli-ui .cli-frow-meta{display:none;}
152
+ .cli-ui .rail-tab{padding:12px 9px 10px;}
153
+ }
154
+ .cli-ui .scrim{z-index:var(--cli-popup-z,200);}
155
+
156
+ /* ── 对话列表:一个任务里的几个对话 ── */
157
+ .cli-ui .cli-job-card{position:relative;margin-bottom:8px;}
158
+ .cli-ui .cli-job-card .task{width:100%;margin-bottom:0;}
159
+ .cli-ui .cli-job-card .task .task-foot{padding-right:26px;}
160
+ .cli-ui .cli-new-in-job{position:absolute;right:8px;bottom:8px;width:22px;height:22px;opacity:0;transition:opacity .12s;}
161
+ .cli-ui .cli-job-card:hover .cli-new-in-job,
162
+ .cli-ui .cli-new-in-job:focus-visible{opacity:1;}
163
+ @media (hover:none){.cli-ui .cli-new-in-job{opacity:1;}}
164
+ .cli-ui .cli-job-group{margin-bottom:8px;}
165
+ .cli-ui .cli-job-group .cli-job-card,
166
+ .cli-ui .cli-job-group .task{margin-bottom:0;}
167
+ .cli-ui .cli-conv-rows{display:flex;flex-direction:column;gap:2px;margin:4px 0 0 14px;padding-left:8px;border-left:1px solid var(--line);}
168
+ .cli-ui .cli-conv-row{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:1px solid transparent;border-radius:var(--r);background:none;color:inherit;text-align:left;cursor:pointer;font-size:12.5px;}
169
+ .cli-ui div.cli-conv-row{cursor:default;color:var(--ink-3);}
170
+ .cli-ui .cli-conv-row:hover{background:var(--surface-2);}
171
+ .cli-ui .cli-conv-row.sel{border-color:var(--accent);background:var(--surface);}
172
+ .cli-ui .cli-conv-row.awaiting{box-shadow:inset 2px 0 0 var(--blue);}
173
+ .cli-ui .cli-conv-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
174
+ .cli-ui .cli-conv-act{font-family:var(--mono);font-size:10px;color:var(--ink-3);white-space:nowrap;}
@@ -1,23 +1,52 @@
1
- import { memo, type ComponentProps } from "react";
2
- import ReactMarkdown from "react-markdown";
1
+ import { memo, useContext, type ComponentProps, type MouseEvent } from "react";
2
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import rehypeHighlight from "rehype-highlight";
5
+ import { FileLinkContext, FileOpenContext, localFilePath } from "./fileLinks.js";
5
6
 
6
7
  // 渲染 Agent 的 markdown 输出:标题、列表、表格、带语言的代码块(高亮)、行内代码、链接。
7
8
  // 与 Operator 的 Markdown.tsx 一致:只高亮写明语言的代码块(自动识别在流式输出时很耗),
8
9
  // 并 memo,文字没变的消息重渲染时跳过整个解析。
9
- // Operator 里本地文件链接会改走任务的文件接口;这里没有文件接口(见 PLAN.md 的看板组件),链接原样打开。
10
- function MdLink(props: ComponentProps<"a">) {
11
- return <a {...props} target="_blank" rel="noreferrer" />;
10
+ // 本地文件链接按 Operator 的规则识别:有地址(FileLinkContext)就改写过去,没有就显示成不可点的文字。
11
+ function MdLink({ href, children, ...props }: ComponentProps<"a">) {
12
+ const resolve = useContext(FileLinkContext);
13
+ const open = useContext(FileOpenContext);
14
+ const local = localFilePath(href);
15
+ if (local) {
16
+ const url = resolve?.(local, "link") ?? null;
17
+ if (!url && !open) return <span className="cli-local-link" title={`本地文件:${local}`}>{children}</span>;
18
+ const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
19
+ if (!open || (url && (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0))) return;
20
+ if (open(local) || !url) e.preventDefault();
21
+ };
22
+ return <a {...props} href={url ?? "#"} target="_blank" rel="noreferrer" title={`打开文件:${local}`} onClick={onClick}>{children}</a>;
23
+ }
24
+ return <a {...props} href={href} target="_blank" rel="noreferrer">{children}</a>;
12
25
  }
13
26
 
27
+ function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
28
+ const resolve = useContext(FileLinkContext);
29
+ const local = typeof src === "string" ? localFilePath(src) : null;
30
+ if (local) {
31
+ const url = resolve?.(local, "image") ?? null;
32
+ if (!url) return <span className="cli-local-link" title={`本地文件:${local}`}>{alt || local}</span>;
33
+ // eslint-disable-next-line jsx-a11y/alt-text
34
+ return <img {...props} alt={alt ?? ""} src={url} />;
35
+ }
36
+ return <img {...props} alt={alt ?? ""} src={src} />;
37
+ }
38
+
39
+ // 缺省的地址处理会把 file: 等非网页协议清空;放行 file: 和 Windows 路径,交给上面按本地文件处理(与 Operator 一致)。
40
+ const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
41
+
14
42
  export const Markdown = memo(function Markdown({ children }: { children: string }) {
15
43
  return (
16
44
  <div className="md">
17
45
  <ReactMarkdown
18
46
  remarkPlugins={[remarkGfm]}
19
47
  rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
20
- components={{ a: MdLink }}
48
+ components={{ a: MdLink, img: MdImg }}
49
+ urlTransform={urlTransform}
21
50
  >
22
51
  {children}
23
52
  </ReactMarkdown>