@spzxg/cli-ui 0.0.0-stage → 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/README.md +163 -2
- package/dist/cli-ui-embed-hutDQhkT.js +37841 -0
- package/dist/cli-ui-index-C8TOk7M3.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +49 -4
- package/src/board/BrowserPane.tsx +418 -0
- package/src/board/ChangesPane.tsx +142 -0
- package/src/board/CliBoard.tsx +246 -0
- package/src/board/FileViewer.tsx +257 -0
- package/src/board/FilesPane.tsx +109 -0
- package/src/board/blocks.tsx +96 -0
- package/src/board/fileKind.ts +81 -0
- package/src/chat/CliChat.tsx +639 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +441 -0
- package/src/core/config.ts +222 -0
- package/src/core/conversation.ts +439 -0
- package/src/core/provider.tsx +103 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +575 -0
- package/src/core/types.ts +385 -0
- package/src/elements.tsx +166 -0
- package/src/embed.ts +58 -0
- package/src/index.ts +40 -0
- package/src/msglist/CliMsgList.tsx +342 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +174 -0
- package/src/ui/Markdown.tsx +55 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/fileLinks.ts +46 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +94 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// cli-board:右侧看板。界面取自 Operator 的会话右栏(SessionRail):文件 / 改动 / 浏览器三个页签。
|
|
2
|
+
// 数据经 center 的 /api/jobs/:id/board、files、diff、browser 转发自执行节点(浏览器不直连节点)。
|
|
3
|
+
//
|
|
4
|
+
// · 文件:工作区目录浏览、预览(Markdown / 代码 / CSV / 图片 / PDF / HTML / Word)、下载;
|
|
5
|
+
// · 改动:分支上的 diff(只读);
|
|
6
|
+
// · 浏览器:任务浏览器的实时画面、接管 / 交还、打开 / 关闭。
|
|
7
|
+
//
|
|
8
|
+
// 同步:任务每有进展(实时流里的轮次 / 输出变化)就刷新,运行中再定时刷新;打开着的文件改了会自动重读。
|
|
9
|
+
// 和 cli-chat 放在同一个 Provider 里时,对话里点本地文件 / 「查看浏览器」会在这里打开(store.openInBoard)。
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
11
|
+
import type { CliEvent, CliStore, CliStoreOptions, OpenTarget } from "../core/store.js";
|
|
12
|
+
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
|
|
13
|
+
import { convKey, parseConvKey, isRunning } from "../core/conversation.js";
|
|
14
|
+
import type { BoardInfo, FileEntry } from "../core/types.js";
|
|
15
|
+
import { Icon } from "../ui/icons.js";
|
|
16
|
+
import { CenterError } from "../core/client.js";
|
|
17
|
+
import { FileViewer, type FileAction } from "./FileViewer.js";
|
|
18
|
+
import { FilesPane } from "./FilesPane.js";
|
|
19
|
+
import { ChangesPane } from "./ChangesPane.js";
|
|
20
|
+
import { BrowserPane } from "./BrowserPane.js";
|
|
21
|
+
|
|
22
|
+
export type BoardTab = "files" | "changes" | "browser";
|
|
23
|
+
const TAB_LABEL: Record<BoardTab, string> = { files: "文件", changes: "改动", browser: "浏览器" };
|
|
24
|
+
|
|
25
|
+
export interface CliBoardProps extends CliStoreOptions {
|
|
26
|
+
/** 看哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist / cli-chat 联动)。 */
|
|
27
|
+
jobId?: string | null;
|
|
28
|
+
/** 对话任务里的哪个对话(和 jobId 一起用,同 cli-chat 的 taskId)。不给 = 缺省那个(最近派发过的)。 */
|
|
29
|
+
taskId?: string | null;
|
|
30
|
+
/** 显示哪些页签、什么顺序。缺省三个都有(节点没开浏览器功能时不显示「浏览器」)。 */
|
|
31
|
+
tabs?: BoardTab[];
|
|
32
|
+
defaultTab?: BoardTab;
|
|
33
|
+
/** 只看:不能接管 / 打开 / 关闭浏览器。 */
|
|
34
|
+
readOnly?: boolean;
|
|
35
|
+
/** 文件上的自定义操作(预览头部的按钮)。 */
|
|
36
|
+
fileActions?: FileAction[];
|
|
37
|
+
/** 运行中定时刷新文件列表的间隔,缺省 4000ms;0 = 只在实时流有变化时刷新。改动(diff)不轮询,只在有进展时刷新。 */
|
|
38
|
+
pollMs?: number;
|
|
39
|
+
/** 接不接对话里的「打开文件 / 查看浏览器」,缺省接。 */
|
|
40
|
+
acceptOpen?: boolean;
|
|
41
|
+
/** 给了就在页签栏右侧显示「隐藏面板」按钮。 */
|
|
42
|
+
onCollapse?: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* 收起状态:只显示一条窄栏(点一下展开)。收起时仍然接对话里的打开请求——接到就记下要打开的东西并调 onExpand,
|
|
45
|
+
* 宿主把 collapsed 改回 false 后直接显示。
|
|
46
|
+
*/
|
|
47
|
+
collapsed?: boolean;
|
|
48
|
+
onExpand?: () => void;
|
|
49
|
+
/** 选中 / 打开文件 / 切页签时通知宿主(例如记到地址栏)。 */
|
|
50
|
+
onNavigate?: (state: { tab: BoardTab; dir: string; file: string | null }) => void;
|
|
51
|
+
onEvent?: (event: CliEvent) => void;
|
|
52
|
+
theme?: "light" | "dark";
|
|
53
|
+
className?: string;
|
|
54
|
+
style?: CSSProperties;
|
|
55
|
+
store?: CliStore;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function CliBoard(props: CliBoardProps) {
|
|
59
|
+
const ctx = useCliStoreContext();
|
|
60
|
+
return withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OpenFile { path: string; size?: number; mtime?: number; version: number }
|
|
64
|
+
|
|
65
|
+
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, readOnly, fileActions, pollMs = 4000, acceptOpen = true, onCollapse, collapsed, onExpand, onNavigate, onEvent, theme = "light", className, style, inProvider }: CliBoardProps & { inProvider: boolean }) {
|
|
66
|
+
useComponentEvents(onEvent, {}, inProvider);
|
|
67
|
+
const store = useCliStore();
|
|
68
|
+
const backend = store.client.board;
|
|
69
|
+
const selected = useCliState((s) => s.selected);
|
|
70
|
+
// 内部用对话键(见 core/conversation.ts):看板接口按它找对话任务里的那个对话。
|
|
71
|
+
const jobId = jobIdProp !== undefined ? (jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null) : selected;
|
|
72
|
+
|
|
73
|
+
const [info, setInfo] = useState<BoardInfo | null>(null);
|
|
74
|
+
const [infoError, setInfoError] = useState<string | null>(null);
|
|
75
|
+
const [tab, setTab] = useState<BoardTab>(defaultTab ?? tabs?.[0] ?? "files");
|
|
76
|
+
const [dir, setDir] = useState("");
|
|
77
|
+
const [file, setFile] = useState<OpenFile | null>(null);
|
|
78
|
+
// 刷新信号,按代价分开:
|
|
79
|
+
// tick 进展(实时流有新输出 / 轮次变化,节流)、切回页面、手动刷新 —— 文件、改动、打开的文件都跟着刷新;
|
|
80
|
+
// pollTick 运行中的定时刷新 —— 只刷文件列表和打开的文件(改动要在节点上跑 git diff,不轮询);
|
|
81
|
+
// statusKey 轮次 / 对话状态变化 —— 才重新查看板概况(它会探测节点的浏览器接口,不随输出刷新)。
|
|
82
|
+
const [tick, setTick] = useState(0);
|
|
83
|
+
const [pollTick, setPollTick] = useState(0);
|
|
84
|
+
|
|
85
|
+
// 换了对话:回到初始状态。
|
|
86
|
+
const lastJob = useRef(jobId);
|
|
87
|
+
if (lastJob.current !== jobId) {
|
|
88
|
+
lastJob.current = jobId;
|
|
89
|
+
setInfo(null); setInfoError(null); setDir(""); setFile(null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 实时流:看板单独用时也要知道任务在不在跑(同一对话只连一次)。
|
|
93
|
+
useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
|
|
94
|
+
const conv = useCliState((s) => (jobId ? s.convs[jobId] : undefined));
|
|
95
|
+
const running = conv ? isRunning(conv) : false;
|
|
96
|
+
// 进展签名:轮次状态 + 转录条数。变了就刷新(节流)。
|
|
97
|
+
const statusKey = conv ? `${conv.turns.map((t) => `${t.turn_id}:${t.status}`).join(",")}|${conv.job?.status ?? ""}` : "";
|
|
98
|
+
const activity = conv ? `${statusKey}|${conv.msgs.length}` : "";
|
|
99
|
+
|
|
100
|
+
const bump = useCallback(() => setTick((n) => n + 1), []);
|
|
101
|
+
// 手动刷新(页签栏的 ↻):连看板概况一起重查。
|
|
102
|
+
const [manual, setManual] = useState(0);
|
|
103
|
+
const refreshAll = useCallback(() => { setManual((n) => n + 1); bump(); }, [bump]);
|
|
104
|
+
const lastBump = useRef(0);
|
|
105
|
+
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!activity) return;
|
|
108
|
+
const wait = Math.max(0, 1500 - (Date.now() - lastBump.current));
|
|
109
|
+
if (pending.current) clearTimeout(pending.current);
|
|
110
|
+
pending.current = setTimeout(() => { lastBump.current = Date.now(); bump(); }, wait);
|
|
111
|
+
return () => { if (pending.current) clearTimeout(pending.current); };
|
|
112
|
+
}, [activity, bump]);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!running || !pollMs) return;
|
|
115
|
+
const t = setInterval(() => { if (document.visibilityState === "visible") setPollTick((n) => n + 1); }, pollMs);
|
|
116
|
+
return () => clearInterval(t);
|
|
117
|
+
}, [running, pollMs, bump]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const on = () => { if (document.visibilityState === "visible") bump(); };
|
|
120
|
+
document.addEventListener("visibilitychange", on);
|
|
121
|
+
return () => document.removeEventListener("visibilitychange", on);
|
|
122
|
+
}, [bump]);
|
|
123
|
+
|
|
124
|
+
// 看板信息:还没派发到节点时每 3 秒再问一次。
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!backend || !jobId) return;
|
|
127
|
+
let alive = true;
|
|
128
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
const load = () => backend.info(jobId).then((i) => {
|
|
130
|
+
if (!alive) return;
|
|
131
|
+
setInfo((old) => (old && old.dispatched === i.dispatched && old.remote_task_id === i.remote_task_id && old.browser === i.browser && old.node?.health === i.node?.health ? old : i));
|
|
132
|
+
setInfoError(null);
|
|
133
|
+
if (!i.dispatched) timer = setTimeout(load, 3000);
|
|
134
|
+
}, (e: unknown) => {
|
|
135
|
+
if (!alive) return;
|
|
136
|
+
setInfoError(e instanceof Error ? e.message : String(e));
|
|
137
|
+
timer = setTimeout(load, 5000);
|
|
138
|
+
});
|
|
139
|
+
void load();
|
|
140
|
+
return () => { alive = false; if (timer) clearTimeout(timer); };
|
|
141
|
+
}, [backend, jobId, statusKey, manual]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
142
|
+
|
|
143
|
+
const shownTabs = useMemo(() => (tabs ?? ["files", "changes", "browser"]).filter((t) => t !== "browser" || !info || info.browser), [tabs, info]);
|
|
144
|
+
const activeTab = shownTabs.includes(tab) ? tab : shownTabs[0] ?? "files";
|
|
145
|
+
|
|
146
|
+
// 打开着的文件:每次刷新查一下它的修改时间,变了就重读。
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!backend || !jobId || !file || !(tick + pollTick) || !info?.dispatched || collapsed) return;
|
|
149
|
+
let alive = true;
|
|
150
|
+
backend.stat(jobId, file.path).then((s) => {
|
|
151
|
+
if (!alive || s.type !== "file") return;
|
|
152
|
+
setFile((f) => {
|
|
153
|
+
if (!f || f.path !== file.path || f.mtime === s.mtime) return f;
|
|
154
|
+
// 第一次拿到修改时间(从对话里打开的)只记下来;之后变了才重读。
|
|
155
|
+
return { ...f, size: s.size, mtime: s.mtime, version: f.mtime === undefined ? f.version : f.version + 1 };
|
|
156
|
+
});
|
|
157
|
+
}, (e: unknown) => {
|
|
158
|
+
// 打开着的文件被删了:重读一次,预览里显示「找不到文件」,不继续显示旧内容。
|
|
159
|
+
if (alive && e instanceof CenterError && e.status === 404) setFile((f) => (f && f.path === file.path && f.mtime !== -1 ? { ...f, mtime: -1, version: f.version + 1 } : f));
|
|
160
|
+
});
|
|
161
|
+
return () => { alive = false; };
|
|
162
|
+
}, [tick, pollTick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
163
|
+
|
|
164
|
+
const openPath = useCallback((path: string, entry?: Partial<FileEntry>) => {
|
|
165
|
+
const clean = path.replace(/^\.\//, "");
|
|
166
|
+
setTab("files");
|
|
167
|
+
setFile((f) => ({ path: clean, size: entry?.size, mtime: entry?.mtime, version: (f?.version ?? 0) + 1 }));
|
|
168
|
+
const parent = clean.includes("/") && !clean.startsWith("/") ? clean.split("/").slice(0, -1).join("/") : "";
|
|
169
|
+
setDir(parent);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
// 接对话里的打开请求(只接自己正在看的对话)。
|
|
173
|
+
const jobRef = useRef(jobId);
|
|
174
|
+
jobRef.current = jobId;
|
|
175
|
+
const expandRef = useRef(onExpand);
|
|
176
|
+
expandRef.current = onExpand;
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!acceptOpen || !backend) return;
|
|
179
|
+
return store.registerBoard((target: string, what: OpenTarget) => {
|
|
180
|
+
if (!jobRef.current || target !== jobRef.current) return false;
|
|
181
|
+
if (what.kind === "file") openPath(what.path);
|
|
182
|
+
else if (what.kind === "files") { setTab("files"); setFile(null); setDir(what.path ?? ""); }
|
|
183
|
+
else setTab(what.kind);
|
|
184
|
+
expandRef.current?.();
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}, [store, backend, acceptOpen, openPath]);
|
|
188
|
+
|
|
189
|
+
const navRef = useRef(onNavigate);
|
|
190
|
+
navRef.current = onNavigate;
|
|
191
|
+
useEffect(() => { navRef.current?.({ tab: activeTab, dir, file: file?.path ?? null }); }, [activeTab, dir, file?.path]);
|
|
192
|
+
|
|
193
|
+
const body = () => {
|
|
194
|
+
if (!backend) return <div className="tc-note">当前后端不支持看板(需要任务中心提供 /api/jobs/:id/board)。</div>;
|
|
195
|
+
if (!jobId) return <div className="tc-note">选择或开始一个对话后,这里显示它的文件、改动和浏览器。</div>;
|
|
196
|
+
if (!info) return <div className="tc-note">{infoError ? `读取看板失败:${infoError}(稍后自动重试)` : "读取中…"}</div>;
|
|
197
|
+
if (!info.dispatched) return <div className="tc-note">任务还没开始执行({info.detail ?? "等待分配执行节点"})。开始后这里会显示工作区文件。</div>;
|
|
198
|
+
if (activeTab === "changes") return <ChangesPane key={jobId} backend={backend} jobId={jobId} refreshKey={tick} onOpen={(p) => openPath(p)} />;
|
|
199
|
+
if (activeTab === "browser") return <BrowserPane key={`${jobId}:${info.remote_task_id}`} backend={backend} jobId={jobId} readOnly={readOnly} />;
|
|
200
|
+
if (file) {
|
|
201
|
+
return (
|
|
202
|
+
<FileViewer
|
|
203
|
+
key={file.path}
|
|
204
|
+
backend={backend}
|
|
205
|
+
jobId={jobId}
|
|
206
|
+
path={file.path}
|
|
207
|
+
size={file.size}
|
|
208
|
+
mtime={file.mtime}
|
|
209
|
+
version={file.version}
|
|
210
|
+
actions={fileActions}
|
|
211
|
+
onOpenPath={(p) => openPath(p)}
|
|
212
|
+
lead={<button className="cli-fv-back" onClick={() => setFile(null)} title="返回文件列表">‹</button>}
|
|
213
|
+
/>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return <FilesPane key={jobId} backend={backend} jobId={jobId} dir={dir} onDir={setDir} onOpen={(e) => openPath(e.path, e)} refreshKey={tick + pollTick} />;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (collapsed) {
|
|
220
|
+
return (
|
|
221
|
+
<aside className={`cli-ui cli-board collapsed ${className ?? ""}`} data-theme={theme} style={style}>
|
|
222
|
+
<button className="cli-board-strip" onClick={onExpand} title="显示看板">
|
|
223
|
+
{Icon.chevRight()}
|
|
224
|
+
{shownTabs.map((t) => <span key={t} className="cli-board-strip-t">{TAB_LABEL[t]}</span>)}
|
|
225
|
+
{running && <span className="cli-sync-dot on" />}
|
|
226
|
+
</button>
|
|
227
|
+
</aside>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<aside className={`cli-ui cli-board sess-rail ${className ?? ""}`} data-theme={theme} style={style}>
|
|
233
|
+
<div className="rail-tabs">
|
|
234
|
+
{shownTabs.map((t) => (
|
|
235
|
+
<button key={t} className={`rail-tab ${activeTab === t ? "on" : ""}`} onClick={() => { setTab(t); if (t === "files" && activeTab === "files") setFile(null); }}>{TAB_LABEL[t]}</button>
|
|
236
|
+
))}
|
|
237
|
+
<span style={{ flex: 1 }} />
|
|
238
|
+
{info?.dispatched && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 自动同步" : "刷新"}>
|
|
239
|
+
<span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
|
|
240
|
+
</button>}
|
|
241
|
+
{onCollapse && <button className="rail-collapse" onClick={onCollapse} title="隐藏面板">{Icon.chevRight()}</button>}
|
|
242
|
+
</div>
|
|
243
|
+
<div className="rail-scroll">{body()}</div>
|
|
244
|
+
</aside>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// 文件预览:Markdown / 代码 / 文本 / CSV / 图片 / PDF / HTML(沙箱)/ Word,其余给下载。
|
|
2
|
+
// 看板和对话里的弹窗共用。文件经 center 转发读取(浏览器不直连执行节点)。
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
4
|
+
import type { BoardBackend } from "../core/client.js";
|
|
5
|
+
import { Icon } from "../ui/icons.js";
|
|
6
|
+
import { Markdown } from "../ui/Markdown.js";
|
|
7
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
8
|
+
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, type PreviewKind } from "./fileKind.js";
|
|
9
|
+
import { relTime } from "../ui/format.js";
|
|
10
|
+
|
|
11
|
+
/** 看板 / 预览里给文件加的自定义操作(例如「在对话里引用」「发到工作协同」)。 */
|
|
12
|
+
export interface FileAction {
|
|
13
|
+
label: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
/** 只对某些文件显示。 */
|
|
16
|
+
when?: (file: { path: string; name: string }) => boolean;
|
|
17
|
+
onClick: (file: { path: string; name: string; url: string; downloadUrl: string }, jobId: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TEXT_MAX = 512 * 1024;
|
|
21
|
+
const HIGHLIGHT_MAX = 120 * 1024;
|
|
22
|
+
|
|
23
|
+
const MIME: Record<string, string> = {
|
|
24
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
25
|
+
bmp: "image/bmp", ico: "image/x-icon", avif: "image/avif", pdf: "application/pdf", html: "text/html", htm: "text/html",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** 能直接用的地址;需要请求头时先 fetch 成 blob 地址(离开时释放)。 */
|
|
29
|
+
function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled: boolean, version: number) {
|
|
30
|
+
const [state, setState] = useState<{ url: string | null; error: string | null }>({ url: null, error: null });
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!enabled) return;
|
|
33
|
+
if (backend.directLinks) {
|
|
34
|
+
const url = backend.fileUrl(jobId, path);
|
|
35
|
+
setState({ url: version ? `${url}${url.includes("?") ? "&" : "?"}v=${version}` : url, error: null });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let alive = true;
|
|
39
|
+
let objectUrl: string | null = null;
|
|
40
|
+
backend.readBuffer(jobId, path).then((buffer) => {
|
|
41
|
+
if (!alive) return;
|
|
42
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
43
|
+
objectUrl = URL.createObjectURL(new Blob([buffer], { type: MIME[ext] ?? "application/octet-stream" }));
|
|
44
|
+
setState({ url: objectUrl, error: null });
|
|
45
|
+
}, (error: unknown) => { if (alive) setState({ url: null, error: error instanceof Error ? error.message : String(error) }); });
|
|
46
|
+
return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
|
47
|
+
}, [backend, jobId, path, enabled, version]);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 下载:能直接用地址就用(不占内存),否则 fetch 成 blob 再存。 */
|
|
52
|
+
export async function downloadFile(backend: BoardBackend, jobId: string, path: string) {
|
|
53
|
+
const name = path.split("/").pop() || "file";
|
|
54
|
+
const a = document.createElement("a");
|
|
55
|
+
a.download = name;
|
|
56
|
+
let objectUrl: string | null = null;
|
|
57
|
+
if (backend.directLinks) a.href = backend.fileUrl(jobId, path, { download: true });
|
|
58
|
+
else {
|
|
59
|
+
objectUrl = URL.createObjectURL(new Blob([await backend.readBuffer(jobId, path)]));
|
|
60
|
+
a.href = objectUrl;
|
|
61
|
+
}
|
|
62
|
+
document.body.appendChild(a);
|
|
63
|
+
a.click();
|
|
64
|
+
a.remove();
|
|
65
|
+
if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl!), 30_000);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Loaded =
|
|
69
|
+
| { kind: "text"; text: string; truncated: boolean; size: number }
|
|
70
|
+
| { kind: "docx"; html: string }
|
|
71
|
+
| { kind: "none" };
|
|
72
|
+
|
|
73
|
+
export interface FileViewerProps {
|
|
74
|
+
backend: BoardBackend;
|
|
75
|
+
jobId: string;
|
|
76
|
+
path: string;
|
|
77
|
+
/** 文件信息(列表里已有的话省一次请求)。 */
|
|
78
|
+
size?: number;
|
|
79
|
+
mtime?: number;
|
|
80
|
+
/** 变了就重新读(看板在文件更新时递增)。 */
|
|
81
|
+
version?: number;
|
|
82
|
+
/** 预览里点了别的本地文件(Markdown 里的链接)。 */
|
|
83
|
+
onOpenPath?: (path: string) => void;
|
|
84
|
+
onClose?: () => void;
|
|
85
|
+
/** 头部左侧额外内容(看板放「返回」按钮)。 */
|
|
86
|
+
lead?: ReactNode;
|
|
87
|
+
actions?: FileAction[];
|
|
88
|
+
/** 隐藏头部(宿主自己做)。 */
|
|
89
|
+
bare?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onOpenPath, onClose, lead, actions = [], bare }: FileViewerProps) {
|
|
93
|
+
const name = path.split("/").pop() || path;
|
|
94
|
+
const kind: PreviewKind = previewKind(path);
|
|
95
|
+
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
96
|
+
const [error, setError] = useState<string | null>(null);
|
|
97
|
+
const [copied, setCopied] = useState(false);
|
|
98
|
+
const [busy, setBusy] = useState(false);
|
|
99
|
+
const [mode, setMode] = useState<"preview" | "source">("preview");
|
|
100
|
+
const needsSrc = kind === "image" || kind === "pdf" || kind === "html";
|
|
101
|
+
const src = useFileSrc(backend, jobId, path, needsSrc, version);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
let alive = true;
|
|
105
|
+
setError(null);
|
|
106
|
+
if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
|
|
107
|
+
if (kind === "docx") {
|
|
108
|
+
backend.readBuffer(jobId, path).then(async (buffer) => {
|
|
109
|
+
const mammoth = await import("mammoth");
|
|
110
|
+
const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
|
|
111
|
+
?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
|
|
112
|
+
const input: Record<string, unknown> = { arrayBuffer: buffer };
|
|
113
|
+
const B = (globalThis as { Buffer?: { from(b: ArrayBuffer): unknown } }).Buffer;
|
|
114
|
+
if (B) input.buffer = B.from(buffer);
|
|
115
|
+
const out = await convert(input as unknown as Parameters<typeof convert>[0]);
|
|
116
|
+
if (alive) setLoaded({ kind: "docx", html: out.value });
|
|
117
|
+
}).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
118
|
+
} else {
|
|
119
|
+
backend.readText(jobId, path, TEXT_MAX).then(
|
|
120
|
+
(r) => { if (alive) setLoaded({ kind: "text", text: r.text, truncated: r.truncated, size: r.size }); },
|
|
121
|
+
(e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return () => { alive = false; };
|
|
125
|
+
}, [backend, jobId, path, kind, needsSrc, version]);
|
|
126
|
+
|
|
127
|
+
const fileInfo = useCallback(() => ({
|
|
128
|
+
path, name, url: backend.fileUrl(jobId, path), downloadUrl: backend.fileUrl(jobId, path, { download: true }),
|
|
129
|
+
}), [backend, jobId, path, name]);
|
|
130
|
+
|
|
131
|
+
const download = async () => {
|
|
132
|
+
setBusy(true);
|
|
133
|
+
try { await downloadFile(backend, jobId, path); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
134
|
+
};
|
|
135
|
+
const openNew = () => {
|
|
136
|
+
if (backend.directLinks) window.open(backend.fileUrl(jobId, path), "_blank", "noopener");
|
|
137
|
+
else if (src.url) window.open(src.url, "_blank", "noopener");
|
|
138
|
+
};
|
|
139
|
+
const copyPath = () => {
|
|
140
|
+
void navigator.clipboard?.writeText(path).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }, () => undefined);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Markdown 里的相对链接 / 图片:相对这个文件所在目录。
|
|
144
|
+
const mdResolve = useMemo(() => (href: string) => (backend.directLinks ? backend.fileUrl(jobId, resolveRelative(path, href)) : null), [backend, jobId, path]);
|
|
145
|
+
const mdOpen = useMemo(() => (onOpenPath ? (href: string) => { onOpenPath(resolveRelative(path, href)); return true; } : null), [onOpenPath, path]);
|
|
146
|
+
|
|
147
|
+
const canSource = kind === "markdown" || kind === "html" || kind === "csv";
|
|
148
|
+
const shownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div className="cli-fv">
|
|
152
|
+
{!bare && (
|
|
153
|
+
<div className="cli-fv-h">
|
|
154
|
+
{lead}
|
|
155
|
+
<span className="cli-fv-ic">{Icon.doc()}</span>
|
|
156
|
+
<span className="cli-fv-name" title={path}>{name}</span>
|
|
157
|
+
<span className="cli-fv-meta">
|
|
158
|
+
{shownSize !== undefined && fmtSize(shownSize)}
|
|
159
|
+
{mtime && mtime > 0 ? ` · ${relTime(mtime)}更新` : ""}
|
|
160
|
+
</span>
|
|
161
|
+
<span className="cli-fv-sp" />
|
|
162
|
+
{canSource && (
|
|
163
|
+
<button className="cli-fv-btn" onClick={() => setMode(mode === "preview" ? "source" : "preview")} title={mode === "preview" ? "查看源文件" : "查看预览"}>
|
|
164
|
+
{mode === "preview" ? "源文件" : "预览"}
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
{actions.filter((a) => !a.when || a.when({ path, name })).map((a) => (
|
|
168
|
+
<button key={a.label} className="cli-fv-btn" title={a.title} onClick={() => a.onClick(fileInfo(), jobId)}>{a.label}</button>
|
|
169
|
+
))}
|
|
170
|
+
<button className="cli-fv-icon" onClick={copyPath} title={copied ? "已复制" : "复制路径"}>{copied ? Icon.check() : Icon.copy()}</button>
|
|
171
|
+
<button className="cli-fv-icon" onClick={openNew} title="在新窗口打开" disabled={!backend.directLinks && !src.url}>{Icon.external()}</button>
|
|
172
|
+
<button className="cli-fv-icon" onClick={() => void download()} title="下载" disabled={busy}>{Icon.download()}</button>
|
|
173
|
+
{onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
<div className={`cli-fv-b k-${kind}`}>
|
|
177
|
+
{error || src.error ? (
|
|
178
|
+
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
179
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()}>{Icon.download()} 下载</button>
|
|
180
|
+
</div>
|
|
181
|
+
) : kind === "binary" ? (
|
|
182
|
+
<div className="cli-fv-note">这种文件不能在这里预览。
|
|
183
|
+
<button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
184
|
+
</div>
|
|
185
|
+
) : needsSrc ? (
|
|
186
|
+
!src.url ? <div className="cli-fv-note">加载中…</div>
|
|
187
|
+
: kind === "image" ? <div className="cli-fv-img"><img src={src.url} alt={name} /></div>
|
|
188
|
+
: kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
|
|
189
|
+
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={version} />
|
|
190
|
+
// HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
|
|
191
|
+
: <iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
192
|
+
) : !loaded ? (
|
|
193
|
+
<div className="cli-fv-note">加载中…</div>
|
|
194
|
+
) : loaded.kind === "docx" ? (
|
|
195
|
+
<iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
|
|
196
|
+
) : loaded.kind === "text" ? (
|
|
197
|
+
<>
|
|
198
|
+
{loaded.truncated && <div className="cli-fv-trunc">文件较大,只显示前 {fmtSize(TEXT_MAX)};完整内容请下载。</div>}
|
|
199
|
+
{kind === "markdown" && mode === "preview" ? (
|
|
200
|
+
<FileLinkContext.Provider value={mdResolve}>
|
|
201
|
+
<FileOpenContext.Provider value={mdOpen}>
|
|
202
|
+
<div className="cli-fv-md"><Markdown>{loaded.text}</Markdown></div>
|
|
203
|
+
</FileOpenContext.Provider>
|
|
204
|
+
</FileLinkContext.Provider>
|
|
205
|
+
) : kind === "csv" && mode === "preview" ? (
|
|
206
|
+
<CsvTable text={loaded.text} />
|
|
207
|
+
) : kind === "code" && loaded.text.length <= HIGHLIGHT_MAX ? (
|
|
208
|
+
<div className="cli-fv-code"><Markdown>{fenced(loaded.text, codeLang(path))}</Markdown></div>
|
|
209
|
+
) : (
|
|
210
|
+
<pre className="cli-fv-pre">{loaded.text}</pre>
|
|
211
|
+
)}
|
|
212
|
+
</>
|
|
213
|
+
) : null}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function SourceView({ backend, jobId, path, version }: { backend: BoardBackend; jobId: string; path: string; version: number }) {
|
|
220
|
+
const [text, setText] = useState<string | null>(null);
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
let alive = true;
|
|
223
|
+
backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive) setText(r.text); }, (e: unknown) => { if (alive) setText(`读取失败:${e instanceof Error ? e.message : String(e)}`); });
|
|
224
|
+
return () => { alive = false; };
|
|
225
|
+
}, [backend, jobId, path, version]);
|
|
226
|
+
return text === null ? <div className="cli-fv-note">加载中…</div> : <pre className="cli-fv-pre">{text}</pre>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function CsvTable({ text }: { text: string }) {
|
|
230
|
+
const { rows, more } = useMemo(() => parseCsv(text), [text]);
|
|
231
|
+
if (!rows.length) return <div className="cli-fv-note">空文件。</div>;
|
|
232
|
+
const [head, ...body] = rows;
|
|
233
|
+
return (
|
|
234
|
+
<div className="cli-fv-csv">
|
|
235
|
+
<table>
|
|
236
|
+
<thead><tr>{head.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
|
|
237
|
+
<tbody>{body.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
|
|
238
|
+
</table>
|
|
239
|
+
{more && <div className="cli-fv-trunc">只显示前 500 行;完整内容请下载。</div>}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fenced(text: string, lang: string) {
|
|
245
|
+
const runs = text.match(/`{3,}/g) ?? [];
|
|
246
|
+
const fence = "`".repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
247
|
+
return `${fence}${lang}\n${text}\n${fence}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Word 转出来的 HTML 放进无脚本的沙箱 iframe,配一份阅读样式。
|
|
251
|
+
function docxPage(body: string) {
|
|
252
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
253
|
+
body{font:14px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#1f2328;max-width:820px;margin:0 auto;padding:24px 28px;}
|
|
254
|
+
h1,h2,h3{line-height:1.35}table{border-collapse:collapse}td,th{border:1px solid #d0d7de;padding:4px 8px}img{max-width:100%}
|
|
255
|
+
</style>${body}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 工作区文件:面包屑 + 列表。目录点进去,文件点开预览;行尾可直接下载。
|
|
2
|
+
// 列表随任务进展自动刷新(CliBoard 给 refreshKey),这次刷新里新出现 / 改过的文件标出来。
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { CenterError, type BoardBackend } from "../core/client.js";
|
|
5
|
+
import type { FileEntry } from "../core/types.js";
|
|
6
|
+
import { Icon } from "../ui/icons.js";
|
|
7
|
+
import { relTime } from "../ui/format.js";
|
|
8
|
+
import { downloadFile } from "./FileViewer.js";
|
|
9
|
+
import { fmtSize } from "./fileKind.js";
|
|
10
|
+
|
|
11
|
+
export interface FilesPaneProps {
|
|
12
|
+
backend: BoardBackend;
|
|
13
|
+
jobId: string;
|
|
14
|
+
dir: string;
|
|
15
|
+
onDir: (dir: string) => void;
|
|
16
|
+
onOpen: (entry: FileEntry) => void;
|
|
17
|
+
refreshKey: number;
|
|
18
|
+
/** 列表读到了(看板用来判断打开着的文件有没有更新)。 */
|
|
19
|
+
onListed?: (dir: string, entries: FileEntry[]) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onListed }: FilesPaneProps) {
|
|
23
|
+
const [entries, setEntries] = useState<FileEntry[] | null>(null);
|
|
24
|
+
const [truncated, setTruncated] = useState(false);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const [loading, setLoading] = useState(false);
|
|
27
|
+
// 这个目录上一次看到的 mtime:用来标「新」。第一次打开目录不标。
|
|
28
|
+
const seen = useRef<{ dir: string; mtimes: Map<string, number> | null }>({ dir, mtimes: null });
|
|
29
|
+
const [fresh, setFresh] = useState<Set<string>>(new Set());
|
|
30
|
+
const onListedRef = useRef(onListed);
|
|
31
|
+
onListedRef.current = onListed;
|
|
32
|
+
const onDirRef = useRef(onDir);
|
|
33
|
+
onDirRef.current = onDir;
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let alive = true;
|
|
37
|
+
setLoading(true);
|
|
38
|
+
if (seen.current.dir !== dir) { seen.current = { dir, mtimes: null }; setEntries(null); setFresh(new Set()); }
|
|
39
|
+
backend.stat(jobId, dir || ".").then((res) => {
|
|
40
|
+
if (!alive) return;
|
|
41
|
+
setLoading(false);
|
|
42
|
+
if (res.type !== "dir") { setError("不是目录"); return; }
|
|
43
|
+
setError(null);
|
|
44
|
+
const prev = seen.current.mtimes;
|
|
45
|
+
if (prev) {
|
|
46
|
+
const changed = res.entries.filter((e) => e.type === "file" && prev.get(e.path) !== e.mtime).map((e) => e.path);
|
|
47
|
+
if (changed.length) setFresh((old) => new Set([...old, ...changed]));
|
|
48
|
+
}
|
|
49
|
+
seen.current.mtimes = new Map(res.entries.map((e) => [e.path, e.mtime]));
|
|
50
|
+
setEntries(res.entries);
|
|
51
|
+
setTruncated(res.truncated);
|
|
52
|
+
onListedRef.current?.(dir, res.entries);
|
|
53
|
+
}, (e: unknown) => {
|
|
54
|
+
if (!alive) return;
|
|
55
|
+
setLoading(false);
|
|
56
|
+
// 正在看的目录被删了(任务清理了输出):回到上一级。
|
|
57
|
+
if (dir && e instanceof CenterError && e.status === 404) { onDirRef.current(dir.split("/").slice(0, -1).join("/")); return; }
|
|
58
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
59
|
+
});
|
|
60
|
+
return () => { alive = false; };
|
|
61
|
+
}, [backend, jobId, dir, refreshKey]);
|
|
62
|
+
|
|
63
|
+
const segs = dir ? dir.split("/").filter(Boolean) : [];
|
|
64
|
+
return (
|
|
65
|
+
<div className="cli-files">
|
|
66
|
+
<div className="cli-crumbs">
|
|
67
|
+
<button className={`cli-crumb ${segs.length ? "" : "on"}`} onClick={() => onDir("")}>{Icon.folder()} 工作区</button>
|
|
68
|
+
{segs.map((s, i) => (
|
|
69
|
+
<span key={i} className="cli-crumb-w">
|
|
70
|
+
<span className="cli-crumb-sep">/</span>
|
|
71
|
+
<button className={`cli-crumb ${i === segs.length - 1 ? "on" : ""}`} onClick={() => onDir(segs.slice(0, i + 1).join("/"))}>{s}</button>
|
|
72
|
+
</span>
|
|
73
|
+
))}
|
|
74
|
+
<span className="cli-fv-sp" />
|
|
75
|
+
{loading && entries && <span className="cli-files-sync" title="正在同步">同步中…</span>}
|
|
76
|
+
</div>
|
|
77
|
+
{error && entries && <div className="cli-fv-trunc">同步失败:{error}(下面是上次读到的列表,有进展时会自动再试,也可以点右上角 ↻)</div>}
|
|
78
|
+
{error && !entries ? (
|
|
79
|
+
<div className="cli-fv-note err">{error}<br /><span style={{ color: "var(--ink-3)" }}>有进展时会自动再试,也可以点右上角 ↻ 重试。</span></div>
|
|
80
|
+
) : !entries ? (
|
|
81
|
+
<div className="cli-fv-note">读取文件…</div>
|
|
82
|
+
) : entries.length === 0 ? (
|
|
83
|
+
<div className="cli-fv-note">{dir ? "这个目录是空的。" : "工作区里还没有文件。任务生成的文件会出现在这里。"}</div>
|
|
84
|
+
) : (
|
|
85
|
+
<div className="cli-files-list" role="list">
|
|
86
|
+
{dir && (
|
|
87
|
+
<button className="cli-frow" onClick={() => onDir(segs.slice(0, -1).join("/"))}>
|
|
88
|
+
<span className="cli-frow-ic">{Icon.folder()}</span><span className="cli-frow-name">..</span>
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
{entries.map((e) => (
|
|
92
|
+
<div key={e.path} className={`cli-frow ${e.type} ${fresh.has(e.path) ? "fresh" : ""}`} role="listitem">
|
|
93
|
+
<button className="cli-frow-main" onClick={() => (e.type === "dir" ? onDir(e.path) : onOpen(e))} title={e.path}>
|
|
94
|
+
<span className="cli-frow-ic">{e.type === "dir" ? Icon.folder() : Icon.doc()}</span>
|
|
95
|
+
<span className="cli-frow-name">{e.name}</span>
|
|
96
|
+
{fresh.has(e.path) && <span className="cli-frow-new">有更新</span>}
|
|
97
|
+
<span className="cli-frow-meta">{e.type === "file" ? `${fmtSize(e.size)} · ` : ""}{relTime(e.mtime)}</span>
|
|
98
|
+
</button>
|
|
99
|
+
{e.type === "file" && (
|
|
100
|
+
<button className="cli-frow-dl" title="下载" onClick={() => void downloadFile(backend, jobId, e.path).catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))}>{Icon.download()}</button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
{truncated && <div className="cli-fv-trunc">文件太多,只列出前 2000 个。</div>}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|