@spzxg/cli-ui 0.4.0 → 0.6.1
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 +84 -10
- package/dist/cli-ui-embed-Dk2QGHxn.js +41146 -0
- package/dist/cli-ui-index-BFpRZXn4.js +11209 -0
- package/dist/cli-ui-jszip.min-B9tqMA-e.js +2324 -0
- package/dist/cli-ui-jszip.min-DIavJzUG.js +26 -0
- package/dist/cli-ui-xlsx-BKHhqcoa.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +387 -73
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileKind.ts +31 -0
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +122 -23
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +69 -11
- package/src/core/client.ts +285 -41
- package/src/core/config.ts +18 -5
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
package/src/board/FilesPane.tsx
CHANGED
|
@@ -5,8 +5,9 @@ import { CenterError, type BoardBackend } from "../core/client.js";
|
|
|
5
5
|
import type { FileEntry } from "../core/types.js";
|
|
6
6
|
import { Icon } from "../ui/icons.js";
|
|
7
7
|
import { relTime } from "../ui/format.js";
|
|
8
|
-
import { downloadFile } from "./
|
|
8
|
+
import { downloadFile } from "./fileOps.js";
|
|
9
9
|
import { fmtSize } from "./fileKind.js";
|
|
10
|
+
import { downloadFolder, FAILED_LIST_NAME, type FolderProgress } from "./folderDownload.js";
|
|
10
11
|
|
|
11
12
|
export interface FilesPaneProps {
|
|
12
13
|
backend: BoardBackend;
|
|
@@ -22,6 +23,7 @@ export interface FilesPaneProps {
|
|
|
22
23
|
export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onListed }: FilesPaneProps) {
|
|
23
24
|
const [entries, setEntries] = useState<FileEntry[] | null>(null);
|
|
24
25
|
const [truncated, setTruncated] = useState(false);
|
|
26
|
+
const [total, setTotal] = useState<number | undefined>(undefined);
|
|
25
27
|
const [error, setError] = useState<string | null>(null);
|
|
26
28
|
const [loading, setLoading] = useState(false);
|
|
27
29
|
// 这个目录上一次看到的 mtime:用来标「新」。第一次打开目录不标。
|
|
@@ -31,6 +33,27 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
31
33
|
onListedRef.current = onListed;
|
|
32
34
|
const onDirRef = useRef(onDir);
|
|
33
35
|
onDirRef.current = onDir;
|
|
36
|
+
// 打包下载文件夹:同一时间只打一个,可以取消。
|
|
37
|
+
const [zip, setZip] = useState<{ dir: string; progress: FolderProgress; abort: AbortController } | null>(null);
|
|
38
|
+
// 下载的结果 / 失败(和「同步失败」分开:下载出错不代表列表过期)。
|
|
39
|
+
const [notice, setNotice] = useState<{ text: string; err: boolean } | null>(null);
|
|
40
|
+
const zipDir = (target: string) => {
|
|
41
|
+
if (zip) return;
|
|
42
|
+
const abort = new AbortController();
|
|
43
|
+
setNotice(null);
|
|
44
|
+
setZip({ dir: target, progress: { phase: "listing", files: 0, total: 0, bytes: 0 }, abort });
|
|
45
|
+
downloadFolder(backend, jobId, target, { signal: abort.signal, onProgress: (progress) => setZip((z) => (z && z.abort === abort ? { ...z, progress } : z)) })
|
|
46
|
+
.then(({ files, failed }) => {
|
|
47
|
+
if (failed.length) setNotice({ err: true, text: `已打包 ${files} 个文件;${failed.length} 个没读到(${failed[0].reason}${failed.length > 1 ? " 等" : ""}),清单在压缩包里的 ${FAILED_LIST_NAME}` });
|
|
48
|
+
})
|
|
49
|
+
.catch((err: unknown) => { if (!(err instanceof DOMException && err.name === "AbortError")) setNotice({ err: true, text: `打包下载失败:${err instanceof Error ? err.message : String(err)}` }); })
|
|
50
|
+
.finally(() => setZip((z) => (z && z.abort === abort ? null : z)));
|
|
51
|
+
};
|
|
52
|
+
const downloadOne = (path: string, size: number) => {
|
|
53
|
+
setNotice(null);
|
|
54
|
+
void downloadFile(backend, jobId, path, size).catch((err: unknown) => setNotice({ err: true, text: `下载失败:${err instanceof Error ? err.message : String(err)}` }));
|
|
55
|
+
};
|
|
56
|
+
useEffect(() => () => zip?.abort.abort(), [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
34
57
|
|
|
35
58
|
useEffect(() => {
|
|
36
59
|
let alive = true;
|
|
@@ -49,6 +72,7 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
49
72
|
seen.current.mtimes = new Map(res.entries.map((e) => [e.path, e.mtime]));
|
|
50
73
|
setEntries(res.entries);
|
|
51
74
|
setTruncated(res.truncated);
|
|
75
|
+
setTotal(res.total);
|
|
52
76
|
onListedRef.current?.(dir, res.entries);
|
|
53
77
|
}, (e: unknown) => {
|
|
54
78
|
if (!alive) return;
|
|
@@ -73,7 +97,28 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
73
97
|
))}
|
|
74
98
|
<span className="cli-fv-sp" />
|
|
75
99
|
{loading && entries && <span className="cli-files-sync" title="正在同步">同步中…</span>}
|
|
100
|
+
{entries && entries.length > 0 && !zip && (
|
|
101
|
+
<button className="cli-fv-icon" title={`把「${dir || "工作区"}」打包成 ZIP 下载`} onClick={() => zipDir(dir)}>{Icon.download()}</button>
|
|
102
|
+
)}
|
|
76
103
|
</div>
|
|
104
|
+
{zip && (
|
|
105
|
+
<div className="cli-fv-trunc cli-zip-bar">
|
|
106
|
+
{zip.progress.phase === "listing" ? `正在列出「${zip.dir || "工作区"}」里的文件…`
|
|
107
|
+
: zip.progress.phase === "reading" ? `正在读取 ${zip.progress.files} / ${zip.progress.total} 个文件(${fmtSize(zip.progress.bytes)}${zip.progress.totalBytes ? ` / ${fmtSize(zip.progress.totalBytes)}` : ""})`
|
|
108
|
+
+ (zip.progress.retrying ? ` · 网络不稳,正在重试 ${zip.progress.retrying.split("/").pop()}` : "")
|
|
109
|
+
+ (zip.progress.failed ? ` · ${zip.progress.failed} 个没读到` : "") + "…"
|
|
110
|
+
: "正在打包…"}
|
|
111
|
+
{zip.progress.phase === "reading" && zip.progress.totalBytes ? (
|
|
112
|
+
<span className="cli-zip-meter"><span style={{ width: `${Math.min(100, (zip.progress.bytes / Math.max(1, zip.progress.totalBytes)) * 100)}%` }} /></span>
|
|
113
|
+
) : null}
|
|
114
|
+
<button className="cli-fv-btn" onClick={() => zip.abort.abort()}>取消</button>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
{notice && (
|
|
118
|
+
<div className={`cli-fv-trunc ${notice.err ? "cli-files-err" : ""}`}>{notice.text}
|
|
119
|
+
<button className="cli-fv-btn" onClick={() => setNotice(null)}>知道了</button>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
77
122
|
{error && entries && <div className="cli-fv-trunc">同步失败:{error}(下面是上次读到的列表,有进展时会自动再试,也可以点右上角 ↻)</div>}
|
|
78
123
|
{error && !entries ? (
|
|
79
124
|
<div className="cli-fv-note err">{error}<br /><span style={{ color: "var(--ink-3)" }}>有进展时会自动再试,也可以点右上角 ↻ 重试。</span></div>
|
|
@@ -96,12 +141,14 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
96
141
|
{fresh.has(e.path) && <span className="cli-frow-new">有更新</span>}
|
|
97
142
|
<span className="cli-frow-meta">{e.type === "file" ? `${fmtSize(e.size)} · ` : ""}{relTime(e.mtime)}</span>
|
|
98
143
|
</button>
|
|
99
|
-
{e.type === "file"
|
|
100
|
-
<button className="cli-frow-dl" title="下载" onClick={() =>
|
|
144
|
+
{e.type === "file" ? (
|
|
145
|
+
<button className="cli-frow-dl" title="下载" onClick={() => downloadOne(e.path, e.size)}>{Icon.download()}</button>
|
|
146
|
+
) : (
|
|
147
|
+
<button className="cli-frow-dl" title="打包成 ZIP 下载" disabled={!!zip} onClick={() => zipDir(e.path)}>{Icon.download()}</button>
|
|
101
148
|
)}
|
|
102
149
|
</div>
|
|
103
150
|
))}
|
|
104
|
-
{truncated && <div className="cli-fv-trunc"
|
|
151
|
+
{truncated && <div className="cli-fv-trunc">文件太多{total ? `(共 ${total.toLocaleString()} 个)` : ""},只列出前 {entries.length.toLocaleString()} 个。</div>}
|
|
105
152
|
</div>
|
|
106
153
|
)}
|
|
107
154
|
</div>
|
package/src/board/blocks.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (baseUrl / token / line / headers)建一个。不给 jobId 时跟 Provider 的选中走。
|
|
8
8
|
// 它们只依赖中枢的看板接口(/api/jobs/:id/files、/browser…),不依赖 cli-chat / cli-board。
|
|
9
9
|
import { useState, type CSSProperties } from "react";
|
|
10
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
|
|
10
11
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
|
|
11
12
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
|
|
12
13
|
import { FileViewer, type FileAction } from "./FileViewer.js";
|
|
@@ -39,7 +40,7 @@ export interface CliFilePreviewProps extends BlockProps {
|
|
|
39
40
|
|
|
40
41
|
export function CliFilePreview(props: CliFilePreviewProps) {
|
|
41
42
|
const ctx = useCliStoreContext();
|
|
42
|
-
return withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx)
|
|
43
|
+
return <CliErrorBoundary component="cli-file-preview" theme={props.theme}>{withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function FilePreviewInner({ jobId: jobIdProp, path, onOpenPath, onClose, actions, version, bare, theme = "light", className, style, onEvent, inProvider }: CliFilePreviewProps & { inProvider: boolean }) {
|
|
@@ -67,7 +68,7 @@ export interface CliBrowserViewProps extends BlockProps {
|
|
|
67
68
|
|
|
68
69
|
export function CliBrowserView(props: CliBrowserViewProps) {
|
|
69
70
|
const ctx = useCliStoreContext();
|
|
70
|
-
return withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx)
|
|
71
|
+
return <CliErrorBoundary component="cli-browser" theme={props.theme}>{withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
function BrowserViewInner({ jobId: jobIdProp, readOnly, theme = "light", className, style, onEvent, inProvider }: CliBrowserViewProps & { inProvider: boolean }) {
|
package/src/board/fileKind.ts
CHANGED
|
@@ -79,3 +79,34 @@ export function parseCsv(text: string, maxRows = 500): { rows: string[][]; more:
|
|
|
79
79
|
if (cell || row.length) { row.push(cell); rows.push(row); }
|
|
80
80
|
return { rows: rows.slice(0, maxRows), more: rows.length > maxRows };
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 大 Markdown 分段(每段约 size 字):在代码块外的空行处切,各段单独渲染(整篇几 MB 一次渲染会卡住页面几十秒)。
|
|
85
|
+
* 一直没有空行的超长内容到 4 倍大小也切(在代码块里切时补上收尾、下一段重新开头)。
|
|
86
|
+
*/
|
|
87
|
+
export function splitMarkdown(text: string, size: number): string[] {
|
|
88
|
+
if (text.length <= size) return [text];
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
const lines = text.split("\n");
|
|
91
|
+
let cur: string[] = [];
|
|
92
|
+
let len = 0;
|
|
93
|
+
let fence: string | null = null;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
96
|
+
if (m) {
|
|
97
|
+
if (!fence) fence = line.trim();
|
|
98
|
+
else if (line.trim().startsWith(fence.slice(0, 3)) && line.trim().replace(/[`~]/g, "") === "") fence = null;
|
|
99
|
+
}
|
|
100
|
+
cur.push(line);
|
|
101
|
+
len += line.length + 1;
|
|
102
|
+
const atBreak = !fence && line.trim() === "" && len >= size;
|
|
103
|
+
if (atBreak || len >= size * 4) {
|
|
104
|
+
if (fence) cur.push(fence.replace(/[^`~].*$/, ""));
|
|
105
|
+
out.push(cur.join("\n"));
|
|
106
|
+
cur = fence ? [fence] : [];
|
|
107
|
+
len = 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (cur.length && cur.some((l) => l !== "")) out.push(cur.join("\n"));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 工作区文件的基础操作(不依赖界面):可直接打开的地址、下载到本地、读成 Blob。
|
|
2
|
+
// 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
|
|
3
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
|
|
4
|
+
import { extOf, fmtSize } from "./fileKind.js";
|
|
5
|
+
|
|
6
|
+
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
|
|
7
|
+
const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export const MIME: Record<string, string> = {
|
|
10
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
11
|
+
bmp: "image/bmp", ico: "image/x-icon", avif: "image/avif", pdf: "application/pdf", html: "text/html", htm: "text/html",
|
|
12
|
+
md: "text/markdown", txt: "text/plain", csv: "text/csv", json: "application/json", zip: "application/zip",
|
|
13
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
14
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
15
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
16
|
+
};
|
|
17
|
+
export const mimeOf = (path: string) => MIME[extOf(path)] ?? "application/octet-stream";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 浏览器能直接打开的地址(不需要请求头):同源代理时就是文件接口;没有代理注入令牌时用中枢发的短期链接。
|
|
21
|
+
* 中枢太旧发不了短期链接时返回 null(调用方改用读成 Blob)。
|
|
22
|
+
*/
|
|
23
|
+
export async function directFileUrl(backend: BoardBackend, jobId: string, path: string, download = false): Promise<string | null> {
|
|
24
|
+
if (backend.directLinks) return backend.fileUrl(jobId, path, { download });
|
|
25
|
+
const linkBase = backend.fileLinkBase ? await backend.fileLinkBase(jobId) : null;
|
|
26
|
+
return linkBase ? fileUrlFromBase(linkBase, path, download) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 把 Blob 存到本地(浏览器下载)。 */
|
|
30
|
+
export function saveBlob(blob: Blob, name: string) {
|
|
31
|
+
const url = URL.createObjectURL(blob);
|
|
32
|
+
clickDownload(url, name);
|
|
33
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clickDownload(href: string, name: string) {
|
|
37
|
+
const a = document.createElement("a");
|
|
38
|
+
a.href = href;
|
|
39
|
+
a.download = name;
|
|
40
|
+
document.body.appendChild(a);
|
|
41
|
+
a.click();
|
|
42
|
+
a.remove();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 下载:用浏览器能直接打开的地址(流式、可断点续传,不占内存);同源代理以外的接入向中枢换短期链接。
|
|
47
|
+
* 只有中枢太旧时才 fetch 成 blob 再存(太大的拒绝,免得撑爆浏览器内存)。
|
|
48
|
+
*/
|
|
49
|
+
export async function downloadFile(backend: BoardBackend, jobId: string, path: string, size?: number) {
|
|
50
|
+
const name = path.split("/").pop() || "file";
|
|
51
|
+
const url = await directFileUrl(backend, jobId, path, true);
|
|
52
|
+
if (url) { clickDownload(url, name); return; }
|
|
53
|
+
if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
|
|
54
|
+
saveBlob(new Blob([await backend.readBuffer(jobId, path)]), name);
|
|
55
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// 工作区「下载整个文件夹」:逐级列目录、逐个读文件(经中枢),在浏览器里打成 ZIP 下载。
|
|
2
|
+
// 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
|
|
3
|
+
// 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
|
|
4
|
+
// 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
|
|
5
|
+
import { CenterError, type BoardBackend } from "../core/client.js";
|
|
6
|
+
import { fmtSize } from "./fileKind.js";
|
|
7
|
+
import { saveBlob } from "./fileOps.js";
|
|
8
|
+
import { buildZip, type ZipEntry } from "./zip.js";
|
|
9
|
+
|
|
10
|
+
export const FOLDER_MAX_FILES = 2000;
|
|
11
|
+
export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
|
|
12
|
+
/** 同时读几个文件:中枢 / 节点不被一个人的下载占满,浏览器连接也留给别的请求。 */
|
|
13
|
+
const PARALLEL = 3;
|
|
14
|
+
/** 每个请求最多试几次,两次之间等多久(毫秒;带 ±30% 浮动)。 */
|
|
15
|
+
const ATTEMPTS = 3;
|
|
16
|
+
const BACKOFF_MS = [1_000, 3_000];
|
|
17
|
+
export const FAILED_LIST_NAME = "_下载失败的文件.txt";
|
|
18
|
+
|
|
19
|
+
export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
|
|
20
|
+
export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }> };
|
|
21
|
+
export type FolderZip = FolderResult & { blob: Blob; name: string };
|
|
22
|
+
export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
|
|
23
|
+
|
|
24
|
+
const abortError = () => new DOMException("已取消", "AbortError");
|
|
25
|
+
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
|
|
26
|
+
|
|
27
|
+
/** 值得再试的失败:网络(超时、断线)、限流、中枢 / 节点临时出错。404、403 之类再试也一样。 */
|
|
28
|
+
export function isTransient(error: unknown): boolean {
|
|
29
|
+
if (!(error instanceof CenterError)) return !isAbort(error);
|
|
30
|
+
return error.code === "NETWORK" || error.status === 429 || error.status === 408 || error.status >= 500 || error.code === "node_unreachable";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sleep(ms: number, signal?: AbortSignal) {
|
|
34
|
+
return new Promise<void>((resolve, reject) => {
|
|
35
|
+
if (signal?.aborted) return reject(abortError());
|
|
36
|
+
const t = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
|
|
37
|
+
const stop = () => { clearTimeout(t); reject(abortError()); };
|
|
38
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 失败了按退避再试(只试临时性的失败)。onRetry 告诉界面「正在重试」。 */
|
|
43
|
+
export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
|
|
44
|
+
for (let attempt = 1; ; attempt++) {
|
|
45
|
+
if (signal?.aborted) throw abortError();
|
|
46
|
+
try {
|
|
47
|
+
return await fn();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
50
|
+
if (attempt >= ATTEMPTS || !isTransient(error)) throw error;
|
|
51
|
+
onRetry?.(attempt, error);
|
|
52
|
+
const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
|
|
53
|
+
await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 列出 dir 下所有文件(递归)。超过上限(缺省 FOLDER_MAX_FILES 个、FOLDER_MAX_BYTES)抛错。 */
|
|
59
|
+
export async function listFolder(backend: BoardBackend, jobId: string, dir: string, opts: ListOptions = {}): Promise<Array<{ path: string; size: number; mtime: number }>> {
|
|
60
|
+
const { signal, backoff, maxFiles = FOLDER_MAX_FILES, maxBytes = FOLDER_MAX_BYTES } = opts;
|
|
61
|
+
const out: Array<{ path: string; size: number; mtime: number }> = [];
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
const queue = [dir || "."];
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
if (signal?.aborted) throw abortError();
|
|
66
|
+
const cur = queue.shift()!;
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await withRetry(() => backend.stat(jobId, cur), signal, undefined, backoff);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// 子目录在列的时候被任务删了:跳过它;要下载的目录本身没了照常报错。
|
|
72
|
+
if (cur !== (dir || ".") && error instanceof CenterError && error.status === 404) continue;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (res.type !== "dir") continue;
|
|
76
|
+
if (res.truncated) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
|
|
77
|
+
for (const e of res.entries) {
|
|
78
|
+
if (e.type === "dir") { queue.push(e.path); continue; }
|
|
79
|
+
out.push({ path: e.path, size: e.size, mtime: e.mtime });
|
|
80
|
+
bytes += e.size;
|
|
81
|
+
if (out.length > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
|
|
82
|
+
if (bytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?: AbortSignal; zipName?: string; backoff?: number[] };
|
|
89
|
+
|
|
90
|
+
/** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
|
|
91
|
+
export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
|
|
92
|
+
const { blob, name, files, failed } = await zipFolder(backend, jobId, dir, opts);
|
|
93
|
+
saveBlob(blob, name);
|
|
94
|
+
return { files, failed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
|
|
98
|
+
export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
|
|
99
|
+
const { onProgress, signal } = opts;
|
|
100
|
+
onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
|
|
101
|
+
const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
|
|
102
|
+
if (!files.length) throw new Error("这个目录里没有文件");
|
|
103
|
+
const totalBytes = files.reduce((n, f) => n + f.size, 0);
|
|
104
|
+
const base = dir ? `${dir.replace(/\/+$/, "")}/` : "";
|
|
105
|
+
const entries: Array<ZipEntry | null> = new Array(files.length).fill(null);
|
|
106
|
+
const failed: FolderResult["failed"] = [];
|
|
107
|
+
// 每个文件已收到的字节(含正在传的),进度条按它走:一个大文件慢慢传时也看得到在动。
|
|
108
|
+
const got = new Array<number>(files.length).fill(0);
|
|
109
|
+
let done = 0;
|
|
110
|
+
let next = 0;
|
|
111
|
+
let retrying: string | undefined;
|
|
112
|
+
let lastReport = 0;
|
|
113
|
+
const report = (force = false) => {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (!force && now - lastReport < 200) return;
|
|
116
|
+
lastReport = now;
|
|
117
|
+
onProgress?.({ phase: "reading", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length, retrying });
|
|
118
|
+
};
|
|
119
|
+
const worker = async () => {
|
|
120
|
+
while (next < files.length) {
|
|
121
|
+
const i = next++;
|
|
122
|
+
if (signal?.aborted) throw abortError();
|
|
123
|
+
const f = files[i];
|
|
124
|
+
try {
|
|
125
|
+
const buffer = await withRetry(
|
|
126
|
+
() => { got[i] = 0; return backend.readBuffer(jobId, f.path, { signal, onBytes: (n) => { got[i] = n; report(); } }); },
|
|
127
|
+
signal,
|
|
128
|
+
() => { retrying = f.path; report(true); },
|
|
129
|
+
opts.backoff,
|
|
130
|
+
);
|
|
131
|
+
const data = new Uint8Array(buffer);
|
|
132
|
+
got[i] = data.length;
|
|
133
|
+
// 任务在写、文件比列的时候大了很多:总量还是要守住上限。
|
|
134
|
+
if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES * 1.1) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
|
|
135
|
+
// ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
|
|
136
|
+
entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
139
|
+
if (!(error instanceof CenterError)) throw error;
|
|
140
|
+
got[i] = 0;
|
|
141
|
+
failed.push({ path: f.path, reason: error.message });
|
|
142
|
+
}
|
|
143
|
+
if (retrying === f.path) retrying = undefined;
|
|
144
|
+
done++;
|
|
145
|
+
report(true);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
// 列完马上报「读取 0 / N」:网络慢时第一个文件要好一会儿才有字节,不能一直停在「正在列出」。
|
|
149
|
+
report(true);
|
|
150
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, files.length) }, worker));
|
|
151
|
+
const ok = entries.filter((e): e is ZipEntry => !!e);
|
|
152
|
+
if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
|
|
153
|
+
onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
|
|
154
|
+
if (failed.length) {
|
|
155
|
+
const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
|
|
156
|
+
ok.push({ path: FAILED_LIST_NAME, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
|
|
157
|
+
}
|
|
158
|
+
const blob = buildZip(ok);
|
|
159
|
+
const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
|
|
160
|
+
return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed };
|
|
161
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// HTML / Word 预览里「选中文字 → 引用到对话」。
|
|
2
|
+
//
|
|
3
|
+
// 这两种预览放在沙箱 iframe 里(不给同源,页面碰不到宿主),宿主反过来也读不到 iframe 里的选区。
|
|
4
|
+
// 所以预览改成 srcdoc:在文档最前面插一段小脚本,选区变了用 postMessage 报给宿主(带口令,宿主只认自己这个 iframe)。
|
|
5
|
+
// · 页面自己的脚本照旧不让跑(和执行节点返回 HTML 时的 CSP 一致):插入的 CSP 只放行带 nonce 的这一段;
|
|
6
|
+
// · 相对路径的样式 / 图片按文件原地址解析(<base>),文档里的 #锚点 在页内滚动(不然会按 <base> 整页跳走)。
|
|
7
|
+
|
|
8
|
+
/** iframe → 宿主:选区(text 为空 = 取消了选择)或滚动了(收起按钮)。 */
|
|
9
|
+
export interface FrameSelMessage { cliUiSel: string; text?: string; rect?: { left: number; top: number; width: number } | null; scroll?: boolean }
|
|
10
|
+
|
|
11
|
+
const randomId = () => Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12);
|
|
12
|
+
|
|
13
|
+
/** 每个预览一个口令 / nonce。 */
|
|
14
|
+
export const frameToken = randomId;
|
|
15
|
+
|
|
16
|
+
function bridgeScript(token: string) {
|
|
17
|
+
const t = JSON.stringify(token);
|
|
18
|
+
return `(function(){var t=${t};function post(m){m.cliUiSel=t;try{parent.postMessage(m,"*")}catch(e){}}
|
|
19
|
+
function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
|
|
20
|
+
if(text&&s.rangeCount){var b=s.getRangeAt(0).getBoundingClientRect();rect={left:b.left,top:b.top,width:b.width}}post({text:text,rect:rect})}
|
|
21
|
+
function later(){setTimeout(send,0)}
|
|
22
|
+
document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
|
|
23
|
+
document.addEventListener("selectionchange",function(){var s=getSelection();if(!s||s.isCollapsed)post({text:""})});
|
|
24
|
+
addEventListener("scroll",function(){post({scroll:true})},true);
|
|
25
|
+
addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
|
|
26
|
+
document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
|
|
27
|
+
var id=decodeURIComponent(a.getAttribute("href").slice(1)),el=id?document.getElementById(id)||document.getElementsByName(id)[0]:document.body;el&&el.scrollIntoView()});})();`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 给 HTML 文档插上选区桥:CSP(页面脚本不跑、只能从文件所在地址取资源)、<base>、桥脚本。
|
|
34
|
+
* base = 文件的绝对地址(相对资源按它解析);不给(Word 转出来的,图片都内嵌)时只允许 data: 资源。
|
|
35
|
+
*/
|
|
36
|
+
export function bridgeDoc(html: string, opts: { token: string; base?: string }) {
|
|
37
|
+
let origin = "";
|
|
38
|
+
if (opts.base) { try { origin = new URL(opts.base).origin; } catch { origin = ""; } }
|
|
39
|
+
const src = origin ? `${origin} ` : "";
|
|
40
|
+
const csp = `default-src 'none'; script-src 'nonce-${opts.token}'; img-src ${src}data: blob:; style-src ${src}'unsafe-inline'; font-src ${src}data:; media-src ${src}data: blob:`;
|
|
41
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${escAttr(csp)}">`
|
|
42
|
+
+ (opts.base ? `<base href="${escAttr(opts.base)}">` : "")
|
|
43
|
+
+ `<script nonce="${opts.token}">${bridgeScript(opts.token)}</script>`;
|
|
44
|
+
// 插在 <head> 里最前面;没有 <head> 就插在 doctype 后面(解析器会自动补出 head),不放到 doctype 前面免得变成怪异模式。
|
|
45
|
+
const headTag = /<head(\s[^>]*)?>/i.exec(html);
|
|
46
|
+
if (headTag) { const at = headTag.index + headTag[0].length; return html.slice(0, at) + head + html.slice(at); }
|
|
47
|
+
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
|
48
|
+
if (doctype) return doctype[0] + head + html.slice(doctype[0].length);
|
|
49
|
+
return head + html;
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 预览里文本文件(代码、日志、Markdown、CSV)的读取。
|
|
2
|
+
//
|
|
3
|
+
// 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
|
|
4
|
+
// 文件在变(任务在写日志、在改报告)时不整个重读:后端有 readBytes 时,从上次读到的地方往前多取一小段(OVERLAP),
|
|
5
|
+
// 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
|
|
6
|
+
// 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
|
|
7
|
+
|
|
8
|
+
import type { BoardBackend } from "../core/client.js";
|
|
9
|
+
import { decodeText } from "../core/textDecode.js";
|
|
10
|
+
|
|
11
|
+
/** 往前多取这么多字节,用来确认文件只是追加。 */
|
|
12
|
+
export const OVERLAP = 256;
|
|
13
|
+
/** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
|
|
14
|
+
const MAX_PARTS = 200;
|
|
15
|
+
|
|
16
|
+
export interface LoadedText {
|
|
17
|
+
text: string;
|
|
18
|
+
/** 分段的文字(追加的每次一段):<pre> 里按段渲染,追加时前面的不用重排。 */
|
|
19
|
+
parts: string[];
|
|
20
|
+
/** 文件比读到的大(只读了开头或末尾)。 */
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
/** 文件总大小。 */
|
|
23
|
+
size: number;
|
|
24
|
+
/** 读的是末尾。 */
|
|
25
|
+
tail: boolean;
|
|
26
|
+
encoding?: string;
|
|
27
|
+
/** 读到的内容在文件里的字节范围 [start, end)。 */
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
/** end 之前的最后 OVERLAP 个字节(只有 UTF-8、后端有 readBytes 时有),用来确认文件只是追加。 */
|
|
31
|
+
tailBytes: Uint8Array | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 末尾有几个字节是没写完的 UTF-8 字(0 = 完整)。 */
|
|
35
|
+
export function incompleteUtf8Tail(bytes: Uint8Array) {
|
|
36
|
+
const len = bytes.length;
|
|
37
|
+
for (let i = len - 1; i >= 0 && i >= len - 4; i--) {
|
|
38
|
+
const b = bytes[i];
|
|
39
|
+
if ((b & 0xc0) === 0x80) continue;
|
|
40
|
+
const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
|
|
41
|
+
const have = len - i;
|
|
42
|
+
return have < need ? have : 0;
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
48
|
+
|
|
49
|
+
/** 第一次读:开头(或末尾)最多 max 字节。 */
|
|
50
|
+
export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<LoadedText> {
|
|
51
|
+
if (!backend.readBytes) {
|
|
52
|
+
const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes });
|
|
53
|
+
return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
|
|
54
|
+
}
|
|
55
|
+
const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes });
|
|
56
|
+
const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
|
|
57
|
+
const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
|
|
58
|
+
const end = chunk.start + chunk.bytes.length - k;
|
|
59
|
+
const tailBytes = encoding === "utf-8" ? chunk.bytes.slice(Math.max(0, chunk.bytes.length - k - OVERLAP), chunk.bytes.length - k) : null;
|
|
60
|
+
return {
|
|
61
|
+
text, parts: [text], truncated: chunk.start + chunk.bytes.length < chunk.size || chunk.start > 0, size: chunk.size,
|
|
62
|
+
tail: chunk.start > 0, encoding, start: chunk.start, end, tailBytes,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 文件变了:能接上就只取新增的部分,返回新的内容;返回 null = 接不上(被改写 / 变短 / 不是 UTF-8 / 后端不支持),调用方整个重读。
|
|
68
|
+
* 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
|
|
69
|
+
*/
|
|
70
|
+
export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number): Promise<LoadedText | null> {
|
|
71
|
+
if (!backend.readBytes || !prev.tailBytes) return null;
|
|
72
|
+
const overlap = prev.tailBytes.length;
|
|
73
|
+
const from = prev.end - overlap;
|
|
74
|
+
const headOnly = !prev.tail && prev.truncated;
|
|
75
|
+
// 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
|
|
76
|
+
if (prev.tail && prev.end - prev.start > max * 1.5) return null;
|
|
77
|
+
const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
|
|
78
|
+
const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room });
|
|
79
|
+
if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
|
|
80
|
+
if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
|
|
81
|
+
const readEnd = from + chunk.bytes.length;
|
|
82
|
+
if (headOnly) return { ...prev, size: chunk.size };
|
|
83
|
+
const added = chunk.bytes.subarray(overlap);
|
|
84
|
+
const k = incompleteUtf8Tail(added);
|
|
85
|
+
const delta = added.subarray(0, added.length - k);
|
|
86
|
+
let addedText: string;
|
|
87
|
+
try { addedText = new TextDecoder("utf-8", { fatal: true }).decode(delta); } catch { return null; }
|
|
88
|
+
const end = prev.end + delta.length;
|
|
89
|
+
const tailBytes = new Uint8Array(Math.min(OVERLAP, overlap + delta.length));
|
|
90
|
+
const joined = new Uint8Array(overlap + delta.length);
|
|
91
|
+
joined.set(prev.tailBytes, 0);
|
|
92
|
+
joined.set(delta, overlap);
|
|
93
|
+
tailBytes.set(joined.subarray(joined.length - tailBytes.length));
|
|
94
|
+
const parts = addedText ? (prev.parts.length >= MAX_PARTS ? [prev.text, addedText] : [...prev.parts, addedText]) : prev.parts;
|
|
95
|
+
return {
|
|
96
|
+
...prev, text: addedText ? prev.text + addedText : prev.text, parts, size: chunk.size, end, tailBytes,
|
|
97
|
+
truncated: prev.tail || chunk.size > readEnd,
|
|
98
|
+
};
|
|
99
|
+
}
|