dsh-long-plugins 1.3.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/LICENSE +21 -0
- package/README.md +129 -0
- package/client/client.js +2821 -0
- package/client/vendor/chart.umd.min.js +20 -0
- package/client/vendor/docx-preview.min.js +8 -0
- package/client/vendor/jszip.min.js +13 -0
- package/client/vendor/pptxviewjs.min.js +1 -0
- package/cordis.patch.yml +12 -0
- package/dsh.plugin.json +14 -0
- package/lib/index.js +2027 -0
- package/lib/md2docx.py +210 -0
- package/package.json +58 -0
- package/patches/dsh-client-connection-heartbeat.sh +107 -0
- package/skill/dsh-common-plugins-install/SKILL.md +128 -0
- package/skill/dsh-long-plugins-install/SKILL.md +200 -0
- package/skill/dsh-upgrade/SKILL.md +89 -0
- package/skill/dsh-web-start-panel-install/SKILL.md +349 -0
- package/skill/dsh-web-win-service-install/SKILL.md +243 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2027 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-long-plugins host entry.
|
|
3
|
+
*
|
|
4
|
+
* Merged single plugin bundling the former separate plugins:
|
|
5
|
+
*
|
|
6
|
+
* - dsh-file-uploads — upload manager + workspace "输出文件" section
|
|
7
|
+
* (`/api/dsh-uploads/*`)
|
|
8
|
+
* - dsh-skill-docs — settings "技能文档" section (`/dsh-skill-docs/*`)
|
|
9
|
+
* - dsh-token-usage — DeepSeek account balance (`/dsh-token-usage/balance`)
|
|
10
|
+
*
|
|
11
|
+
* All routes are loopback / same-origin gated via `isTrustedUploadRequest`
|
|
12
|
+
* (which honours `config.trustedHosts`); file paths are resolved inside
|
|
13
|
+
* their configured root so traversal is impossible.
|
|
14
|
+
*/
|
|
15
|
+
import { createReadStream } from "node:fs";
|
|
16
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { link, lstat, mkdir, open, readdir, stat, unlink } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { inflateRawSync, inflateSync } from "node:zlib";
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
25
|
+
import mammoth from "mammoth";
|
|
26
|
+
import ExcelJS from "exceljs";
|
|
27
|
+
|
|
28
|
+
// 插件根下的 client/vendor 目录(存放 docx-preview / jszip 前端库,供 docx 预览端点读取)。
|
|
29
|
+
// 用 module 级定位,让 createHandlers / 各 handler 都能访问(不依赖 apply 内的 PACKAGE_DIR)。
|
|
30
|
+
const VENDOR_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "client", "vendor");
|
|
31
|
+
|
|
32
|
+
export const name = "dsh-long-plugins";
|
|
33
|
+
|
|
34
|
+
/** Server services required: the web route carrier and the credential seam. */
|
|
35
|
+
export const inject = ["webServer", "credentials", "sessions", "sessionPersistence", "tools"];
|
|
36
|
+
|
|
37
|
+
export const API_PATH = "/api/dsh-uploads";
|
|
38
|
+
export const DOWNLOAD_PATH = "/api/dsh-uploads/download";
|
|
39
|
+
export const PREVIEW_PATH = "/api/dsh-uploads/preview";
|
|
40
|
+
export const DEFAULT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
41
|
+
export const DEFAULT_TOTAL_MAX_BYTES = 1024 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
/** DeepSeek account balance endpoint. */
|
|
44
|
+
const BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
45
|
+
|
|
46
|
+
/** Largest preview body we will inline (256 KiB); larger files preview as metadata only. */
|
|
47
|
+
const PREVIEW_LIMIT = 256 * 1024;
|
|
48
|
+
|
|
49
|
+
/** Skills root: default $HOME/skills, overridable via config.skillsRoot. */
|
|
50
|
+
const DEFAULT_SKILLS_ROOT = resolve(process.env.HOME ?? process.env.DSH_HOME ?? "", "skills");
|
|
51
|
+
|
|
52
|
+
class HttpError extends Error {
|
|
53
|
+
constructor(status, message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.status = status;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resolveUploadRoot(env = process.env) {
|
|
60
|
+
const dshHome = env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
61
|
+
return resolve(env.DSH_UPLOAD_DIR?.trim() || join(dshHome, "uploads"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function positiveInteger(value, fallback) {
|
|
65
|
+
const configured = Number(value);
|
|
66
|
+
return Number.isSafeInteger(configured) && configured > 0 ? configured : fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveMaxFileBytes(env = process.env) {
|
|
70
|
+
return positiveInteger(env.DSH_UPLOAD_MAX_BYTES, DEFAULT_MAX_FILE_BYTES);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveTotalMaxBytes(env = process.env) {
|
|
74
|
+
return positiveInteger(env.DSH_UPLOAD_TOTAL_MAX_BYTES, DEFAULT_TOTAL_MAX_BYTES);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sanitizeUploadName(value) {
|
|
78
|
+
const decoded = String(value || "").normalize("NFC");
|
|
79
|
+
let safe = basename(decoded)
|
|
80
|
+
.replace(/[\\/\u0000-\u001f\u007f]/g, "_")
|
|
81
|
+
.replace(/^\.+/, "")
|
|
82
|
+
.trim();
|
|
83
|
+
|
|
84
|
+
if (!safe) safe = "upload.bin";
|
|
85
|
+
if (safe.startsWith(".upload-")) safe = `file-${safe}`;
|
|
86
|
+
|
|
87
|
+
if (safe.length > 180) {
|
|
88
|
+
const extension = extname(safe).slice(0, 24);
|
|
89
|
+
const stem = safe.slice(0, Math.max(1, 180 - extension.length));
|
|
90
|
+
safe = `${stem}${extension}`;
|
|
91
|
+
}
|
|
92
|
+
return safe;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isSafeStoredName(value) {
|
|
96
|
+
return typeof value === "string"
|
|
97
|
+
&& value.length > 0
|
|
98
|
+
&& value.length <= 180
|
|
99
|
+
&& value === basename(value)
|
|
100
|
+
&& value !== "."
|
|
101
|
+
&& value !== ".."
|
|
102
|
+
&& !value.startsWith(".upload-")
|
|
103
|
+
&& !/[\\/\u0000-\u001f\u007f]/.test(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function requestUrl(req) {
|
|
107
|
+
return new URL(req.url || "/", "http://dsh.internal");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function queryName(req) {
|
|
111
|
+
const value = requestUrl(req).searchParams.get("name");
|
|
112
|
+
if (!isSafeStoredName(value)) throw new HttpError(400, "invalid file name");
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function uploadHeaderName(req) {
|
|
117
|
+
const value = header(req.headers, "x-file-name");
|
|
118
|
+
if (value === undefined || value.length === 0) {
|
|
119
|
+
throw new HttpError(400, "x-file-name header is required");
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return sanitizeUploadName(decodeURIComponent(value));
|
|
123
|
+
} catch {
|
|
124
|
+
throw new HttpError(400, "x-file-name must be URI encoded UTF-8");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function header(headers, key) {
|
|
129
|
+
const value = headers[key];
|
|
130
|
+
return typeof value === "string" ? value : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseAuthority(authority) {
|
|
134
|
+
try {
|
|
135
|
+
return new URL(`http://${authority}`);
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
142
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
143
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertTrustedAuthority(entry) {
|
|
147
|
+
const entryUrl = parseAuthority(entry);
|
|
148
|
+
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return;
|
|
149
|
+
throw new Error(`dsh-long-plugins: trusted host ${JSON.stringify(entry)} is not a bare host[:port] authority`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isLoopbackHostname(hostname) {
|
|
153
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
154
|
+
const parts = hostname.split(".");
|
|
155
|
+
return parts.length === 4
|
|
156
|
+
&& parts[0] === "127"
|
|
157
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
161
|
+
return trustedHosts.some((entry) => {
|
|
162
|
+
const entryUrl = parseAuthority(entry);
|
|
163
|
+
if (entryUrl === undefined) return false;
|
|
164
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
165
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
166
|
+
: entryUrl.host === hostUrl.host;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Loopback / same-origin gate for every route (browser requests must match
|
|
172
|
+
* Origin to Host; Origin-less calls only from loopback; `trustedHosts` from
|
|
173
|
+
* the profile patch extend loopback to trusted reverse-proxy authorities).
|
|
174
|
+
*/
|
|
175
|
+
export function isTrustedUploadRequest(req, trustedHosts = []) {
|
|
176
|
+
const host = header(req.headers, "host");
|
|
177
|
+
if (host === undefined) return false;
|
|
178
|
+
const hostUrl = parseAuthority(host);
|
|
179
|
+
if (hostUrl === undefined) return false;
|
|
180
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
181
|
+
if (header(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
182
|
+
|
|
183
|
+
const origin = header(req.headers, "origin");
|
|
184
|
+
if (origin === undefined) return true;
|
|
185
|
+
try {
|
|
186
|
+
return new URL(origin).host === hostUrl.host;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Read a JSON request body (bounded; 1 MiB to fit large edited documents). */
|
|
193
|
+
function readJsonBody(request) {
|
|
194
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
195
|
+
let size = 0;
|
|
196
|
+
const chunks = [];
|
|
197
|
+
request.on("data", (chunk) => {
|
|
198
|
+
size += chunk.length;
|
|
199
|
+
if (size > 1024 * 1024) {
|
|
200
|
+
rejectPromise(new Error("body too large"));
|
|
201
|
+
request.destroy();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
chunks.push(chunk);
|
|
205
|
+
});
|
|
206
|
+
request.on("end", () => {
|
|
207
|
+
try {
|
|
208
|
+
resolvePromise(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
rejectPromise(error);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
request.on("error", rejectPromise);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sendJson(res, status, value) {
|
|
218
|
+
const body = JSON.stringify(value);
|
|
219
|
+
res.writeHead(status, {
|
|
220
|
+
"content-type": "application/json; charset=utf-8",
|
|
221
|
+
"content-length": Buffer.byteLength(body),
|
|
222
|
+
"cache-control": "no-store",
|
|
223
|
+
});
|
|
224
|
+
res.end(body);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sendError(res, error, onError) {
|
|
228
|
+
if (error instanceof HttpError) {
|
|
229
|
+
sendJson(res, error.status, { error: error.message });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
onError?.(error);
|
|
233
|
+
sendJson(res, 500, { error: "internal server error" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function methodNotAllowed(res, methods) {
|
|
237
|
+
res.writeHead(405, { allow: methods.join(", "), "content-length": 0 });
|
|
238
|
+
res.end();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function writeRequestToFile(req, target, maxBytes, limitStatus, limitMessage) {
|
|
242
|
+
const declared = Number(header(req.headers, "content-length"));
|
|
243
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
244
|
+
req.resume();
|
|
245
|
+
throw new HttpError(limitStatus, limitMessage);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const handle = await open(target, "wx", 0o600);
|
|
249
|
+
let bytes = 0;
|
|
250
|
+
try {
|
|
251
|
+
for await (const chunk of req) {
|
|
252
|
+
bytes += chunk.length;
|
|
253
|
+
if (bytes > maxBytes) {
|
|
254
|
+
req.resume();
|
|
255
|
+
throw new HttpError(limitStatus, limitMessage);
|
|
256
|
+
}
|
|
257
|
+
await handle.write(chunk);
|
|
258
|
+
}
|
|
259
|
+
await handle.sync();
|
|
260
|
+
return bytes;
|
|
261
|
+
} finally {
|
|
262
|
+
await handle.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function numberedName(name, index) {
|
|
267
|
+
if (index === 0) return name;
|
|
268
|
+
const extension = extname(name).slice(0, 24);
|
|
269
|
+
const stem = name.slice(0, name.length - extname(name).length);
|
|
270
|
+
const suffix = ` (${index})`;
|
|
271
|
+
const maxStemLength = Math.max(1, 180 - extension.length - suffix.length);
|
|
272
|
+
return `${stem.slice(0, maxStemLength)}${suffix}${extension}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function publishUnique(tempPath, root, requestedName) {
|
|
276
|
+
for (let index = 0; index < 10_000; index += 1) {
|
|
277
|
+
const name = numberedName(requestedName, index);
|
|
278
|
+
const target = join(root, name);
|
|
279
|
+
try {
|
|
280
|
+
await link(tempPath, target);
|
|
281
|
+
await unlink(tempPath);
|
|
282
|
+
return { name, path: target };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error?.code === "EEXIST") continue;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
throw new HttpError(409, "too many files share this name");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function fileRecord(root, name, info) {
|
|
292
|
+
return {
|
|
293
|
+
name,
|
|
294
|
+
path: join(root, name),
|
|
295
|
+
size: info.size,
|
|
296
|
+
modifiedAt: info.mtime.toISOString(),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function listUploadedFiles(root) {
|
|
301
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
302
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
303
|
+
const files = [];
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (!entry.isFile() || entry.name.startsWith(".upload-")) continue;
|
|
306
|
+
try {
|
|
307
|
+
const info = await stat(join(root, entry.name));
|
|
308
|
+
files.push(fileRecord(root, entry.name, info));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error?.code !== "ENOENT") throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name));
|
|
314
|
+
return files;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function resolveWorkspaceRoot(env = process.env) {
|
|
318
|
+
return dirname(resolveUploadRoot(env));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function workspaceExcludedName(env = process.env) {
|
|
322
|
+
return basename(resolveUploadRoot(env)) || "upload";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Resolve a user-supplied relative path safely inside the root. */
|
|
326
|
+
function safeResolve(root, rel) {
|
|
327
|
+
if (typeof rel !== "string" || rel.length === 0 || rel.includes("\0")) return undefined;
|
|
328
|
+
const full = resolve(root, rel);
|
|
329
|
+
if (full !== root && !full.startsWith(root + sep)) return undefined;
|
|
330
|
+
return full;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 输出文件白名单:只显示文档类 + 图片(压缩包/代码/音视频/脚本一律隐藏) */
|
|
334
|
+
const DOCUMENT_EXTS = new Set([
|
|
335
|
+
// 文本/文档
|
|
336
|
+
"md", "markdown", "txt", "log", "rtf",
|
|
337
|
+
// Office 文档
|
|
338
|
+
"doc", "docx", "pdf",
|
|
339
|
+
// 表格
|
|
340
|
+
"xls", "xlsx", "csv", "tsv",
|
|
341
|
+
// 演示
|
|
342
|
+
"ppt", "pptx",
|
|
343
|
+
// 数据/配置
|
|
344
|
+
"json", "yml", "yaml", "xml",
|
|
345
|
+
// 网页
|
|
346
|
+
"html", "htm",
|
|
347
|
+
// 图片
|
|
348
|
+
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tiff", "heic",
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
/** Collect subdirectories with their files, grouped by folder. */
|
|
352
|
+
async function collectGroups(root, excluded) {
|
|
353
|
+
let entries;
|
|
354
|
+
try {
|
|
355
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return [];
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const groups = [];
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
if (!entry.isDirectory() || entry.name === excluded) continue;
|
|
363
|
+
const files = [];
|
|
364
|
+
const walk = async (dir) => {
|
|
365
|
+
let sub;
|
|
366
|
+
try {
|
|
367
|
+
sub = await readdir(dir, { withFileTypes: true });
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return;
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
sub.sort((a, b) => a.name.localeCompare(b.name));
|
|
373
|
+
for (const item of sub) {
|
|
374
|
+
const abs = join(dir, item.name);
|
|
375
|
+
if (item.isDirectory()) {
|
|
376
|
+
await walk(abs);
|
|
377
|
+
} else if (item.isFile()) {
|
|
378
|
+
// 白名单:只显示文档类 + 图片文件,脚本/压缩包/代码/音视频一律隐藏
|
|
379
|
+
const ext = item.name.slice(item.name.lastIndexOf(".") + 1).toLowerCase();
|
|
380
|
+
if (!DOCUMENT_EXTS.has(ext)) continue;
|
|
381
|
+
try {
|
|
382
|
+
const info = await stat(abs);
|
|
383
|
+
files.push({
|
|
384
|
+
path: relative(root, abs).split(sep).join("/"),
|
|
385
|
+
name: item.name,
|
|
386
|
+
size: info.size,
|
|
387
|
+
mtime: info.mtimeMs,
|
|
388
|
+
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error && typeof error === "object" && error.code !== "ENOENT") throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
await walk(join(root, entry.name));
|
|
396
|
+
files.sort((a, b) => (b.mtime - a.mtime) || a.path.localeCompare(b.path));
|
|
397
|
+
groups.push({ folder: entry.name, files });
|
|
398
|
+
}
|
|
399
|
+
groups.sort((a, b) => a.folder.localeCompare(b.folder));
|
|
400
|
+
return groups;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function sweepUploadTemps(root) {
|
|
404
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
405
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
406
|
+
let removed = 0;
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
if (!entry.name.startsWith(".upload-")) continue;
|
|
409
|
+
try {
|
|
410
|
+
await unlink(join(root, entry.name));
|
|
411
|
+
removed += 1;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (error?.code !== "ENOENT" && error?.code !== "EISDIR" && error?.code !== "EPERM") throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return removed;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function requireRegularFile(root, name) {
|
|
420
|
+
const target = join(root, name);
|
|
421
|
+
let info;
|
|
422
|
+
try {
|
|
423
|
+
info = await lstat(target);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error?.code === "ENOENT") throw new HttpError(404, "file not found");
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
429
|
+
return { target, info };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function asciiDownloadName(name) {
|
|
433
|
+
const value = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
434
|
+
return value || "download";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function contentDisposition(name) {
|
|
438
|
+
return `attachment; filename="${asciiDownloadName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Content type for preview by extension. */
|
|
442
|
+
function contentType(name) {
|
|
443
|
+
const extension = extname(name).toLowerCase();
|
|
444
|
+
return ({
|
|
445
|
+
".txt": "text/plain; charset=utf-8",
|
|
446
|
+
".log": "text/plain; charset=utf-8",
|
|
447
|
+
".md": "text/markdown; charset=utf-8",
|
|
448
|
+
".json": "application/json; charset=utf-8",
|
|
449
|
+
".yml": "text/yaml; charset=utf-8",
|
|
450
|
+
".yaml": "text/yaml; charset=utf-8",
|
|
451
|
+
".js": "text/javascript; charset=utf-8",
|
|
452
|
+
".html": "text/html; charset=utf-8",
|
|
453
|
+
".css": "text/css; charset=utf-8",
|
|
454
|
+
".pdf": "application/pdf",
|
|
455
|
+
".png": "image/png",
|
|
456
|
+
".jpg": "image/jpeg",
|
|
457
|
+
".jpeg": "image/jpeg",
|
|
458
|
+
".gif": "image/gif",
|
|
459
|
+
".webp": "image/webp",
|
|
460
|
+
".zip": "application/zip",
|
|
461
|
+
})[extension] || "application/octet-stream";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const OFFICE_EXTS = new Set([".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt"]);
|
|
465
|
+
|
|
466
|
+
/** Escape HTML special characters (attribute/body safe). */
|
|
467
|
+
function escHtml(value) {
|
|
468
|
+
return String(value)
|
|
469
|
+
.replace(/&/g, "&")
|
|
470
|
+
.replace(/</g, "<")
|
|
471
|
+
.replace(/>/g, ">")
|
|
472
|
+
.replace(/"/g, """);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Render a .docx as HTML (keeps paragraphs, headings, bold, lists, tables). */
|
|
476
|
+
async function docxHtml(buf) {
|
|
477
|
+
try {
|
|
478
|
+
const result = await mammoth.convertToHtml({ buffer: buf });
|
|
479
|
+
return result.value || "<p>(空文档)</p>";
|
|
480
|
+
} catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Render the first sheet of a .xlsx as an HTML table. */
|
|
486
|
+
async function xlsxHtml(buf) {
|
|
487
|
+
try {
|
|
488
|
+
const workbook = new ExcelJS.Workbook();
|
|
489
|
+
await workbook.xlsx.load(buf);
|
|
490
|
+
const sheet = workbook.worksheets[0];
|
|
491
|
+
if (!sheet) return "<p>(空工作簿)</p>";
|
|
492
|
+
const rows = [];
|
|
493
|
+
const limit = 500; // cell cap to keep the preview bounded
|
|
494
|
+
let rendered = 0;
|
|
495
|
+
for (const row of sheet.eachRow({ includeEmpty: false })) {
|
|
496
|
+
if (rendered >= limit) break;
|
|
497
|
+
const cells = [];
|
|
498
|
+
for (let col = 1; col <= Math.min(row.cellCount, 32); col += 1) {
|
|
499
|
+
const cell = row.getCell(col);
|
|
500
|
+
let value = cell.value;
|
|
501
|
+
if (value !== null && typeof value === "object") {
|
|
502
|
+
if (value.richText) value = value.richText.map((t) => t.text).join("");
|
|
503
|
+
else if (value.text !== undefined) value = value.text;
|
|
504
|
+
else if (value.result !== undefined) value = value.result;
|
|
505
|
+
else value = "";
|
|
506
|
+
}
|
|
507
|
+
cells.push(`<td>${escHtml(value ?? "")}</td>`);
|
|
508
|
+
}
|
|
509
|
+
rows.push(`<tr>${cells.join("")}</tr>`);
|
|
510
|
+
rendered += 1;
|
|
511
|
+
}
|
|
512
|
+
const header = rows.length > 0 ? `<thead><tr>${rows[0]}</tr></thead>` : "";
|
|
513
|
+
const body = rows.length > 1 ? `<tbody>${rows.slice(1).join("")}</tbody>` : "";
|
|
514
|
+
const css = "<style>table{border-collapse:collapse;width:100%;font-size:12px}td{border:1px solid #d0d0d0;padding:4px 8px;white-space:pre-wrap;word-break:break-all}</style>";
|
|
515
|
+
return `${css}<table>${header}${body}</table>`;
|
|
516
|
+
} catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** List the entries of a minimal ZIP container (local-file-header walk). */
|
|
522
|
+
function zipEntries(buf) {
|
|
523
|
+
const entries = new Map();
|
|
524
|
+
let off = 0;
|
|
525
|
+
while (off + 30 <= buf.length) {
|
|
526
|
+
if (buf.readUInt32LE(off) !== 0x04034b50) break;
|
|
527
|
+
const method = buf.readUInt16LE(off + 8);
|
|
528
|
+
const compSize = buf.readUInt32LE(off + 18);
|
|
529
|
+
const nameLen = buf.readUInt16LE(off + 26);
|
|
530
|
+
const extraLen = buf.readUInt16LE(off + 28);
|
|
531
|
+
const name = buf.subarray(off + 30, off + 30 + nameLen).toString("utf8");
|
|
532
|
+
const dataStart = off + 30 + nameLen + extraLen;
|
|
533
|
+
entries.set(name, { method, data: buf.subarray(dataStart, dataStart + compSize) });
|
|
534
|
+
off = dataStart + compSize;
|
|
535
|
+
}
|
|
536
|
+
return entries;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function inflateEntry(entry) {
|
|
540
|
+
if (!entry) return null;
|
|
541
|
+
try {
|
|
542
|
+
return entry.method === 0 ? entry.data : inflateRawSync(entry.data);
|
|
543
|
+
} catch {
|
|
544
|
+
try {
|
|
545
|
+
return inflateSync(entry.data);
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Join the text of every `<w:t>` (docx) or `<a:t>` (pptx) run. */
|
|
553
|
+
function taggedText(xml, tag) {
|
|
554
|
+
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "g");
|
|
555
|
+
return [...xml.matchAll(re)].map((m) => m[1]).join("").replace(/\s+/g, " ").trim();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Render .pptx as simple HTML (one section per slide with its text runs). */
|
|
559
|
+
function pptxHtml(buf) {
|
|
560
|
+
const parts = [];
|
|
561
|
+
const entries = zipEntries(buf);
|
|
562
|
+
const names = [...entries.keys()].filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort();
|
|
563
|
+
for (const name of names) {
|
|
564
|
+
const xml = inflateEntry(entries.get(name))?.toString("utf8");
|
|
565
|
+
if (xml === undefined) continue;
|
|
566
|
+
const texts = [...xml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => m[1]).join(" ").trim();
|
|
567
|
+
if (texts !== "") parts.push(`<div style="margin:0 0 14px;padding:10px 12px;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa"><div style="font-size:11px;color:#999;margin-bottom:4px">${escHtml(name.replace(/^ppt\/slides\/|\.xml$/g, ""))}</div><div>${escHtml(texts)}</div></div>`);
|
|
568
|
+
}
|
|
569
|
+
return parts.length > 0 ? parts.join("") : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Render a binary Office document to HTML for layout-preserving preview.
|
|
574
|
+
* Returns `null` when the file is not a supported Office format or the
|
|
575
|
+
* conversion fails.
|
|
576
|
+
* @param name - file name (extension decides the format).
|
|
577
|
+
* @param buffer - raw file bytes.
|
|
578
|
+
* @returns HTML string or null.
|
|
579
|
+
*/
|
|
580
|
+
async function officePreviewHtml(name, buffer) {
|
|
581
|
+
const ext = extname(name).toLowerCase();
|
|
582
|
+
if (ext === ".docx") return docxHtml(buffer);
|
|
583
|
+
if (ext === ".xlsx") return xlsxHtml(buffer);
|
|
584
|
+
if (ext === ".pptx") return pptxHtml(buffer);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function createHandlers(options = {}) {
|
|
589
|
+
const root = resolve(options.root || resolveUploadRoot());
|
|
590
|
+
const maxFileBytes = positiveInteger(options.maxFileBytes, resolveMaxFileBytes());
|
|
591
|
+
const totalMaxBytes = positiveInteger(options.totalMaxBytes, resolveTotalMaxBytes());
|
|
592
|
+
const trustedHosts = Array.isArray(options.trustedHosts) ? [...options.trustedHosts] : [];
|
|
593
|
+
const onError = typeof options.onError === "function" ? options.onError : undefined;
|
|
594
|
+
for (const entry of trustedHosts) assertTrustedAuthority(entry);
|
|
595
|
+
|
|
596
|
+
let mutationTail = Promise.resolve();
|
|
597
|
+
const enqueueMutation = (operation) => {
|
|
598
|
+
const current = mutationTail.then(operation, operation);
|
|
599
|
+
mutationTail = current.catch(() => {});
|
|
600
|
+
return current;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const requireTrusted = (req) => {
|
|
604
|
+
if (!isTrustedUploadRequest(req, trustedHosts)) throw new HttpError(403, "forbidden");
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const api = async (req, res) => {
|
|
608
|
+
try {
|
|
609
|
+
requireTrusted(req);
|
|
610
|
+
|
|
611
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
612
|
+
const files = await listUploadedFiles(root);
|
|
613
|
+
const usedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
614
|
+
if (req.method === "HEAD") {
|
|
615
|
+
res.writeHead(200, { "cache-control": "no-store", "content-length": 0 });
|
|
616
|
+
res.end();
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
sendJson(res, 200, { root, maxFileBytes, totalMaxBytes, usedBytes, files });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (req.method === "POST") {
|
|
624
|
+
await enqueueMutation(async () => {
|
|
625
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
626
|
+
const requestedName = uploadHeaderName(req);
|
|
627
|
+
const declared = Number(header(req.headers, "content-length"));
|
|
628
|
+
if (Number.isFinite(declared) && declared > maxFileBytes) {
|
|
629
|
+
req.resume();
|
|
630
|
+
throw new HttpError(413, `file exceeds ${maxFileBytes} bytes`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const files = await listUploadedFiles(root);
|
|
634
|
+
const usedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
635
|
+
const remainingBytes = totalMaxBytes - usedBytes;
|
|
636
|
+
if (remainingBytes <= 0) {
|
|
637
|
+
req.resume();
|
|
638
|
+
throw new HttpError(507, "upload storage quota exceeded");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const allowedBytes = Math.min(maxFileBytes, remainingBytes);
|
|
642
|
+
const quotaLimited = allowedBytes < maxFileBytes;
|
|
643
|
+
const tempPath = join(root, `.upload-${randomUUID()}.tmp`);
|
|
644
|
+
try {
|
|
645
|
+
const size = await writeRequestToFile(
|
|
646
|
+
req,
|
|
647
|
+
tempPath,
|
|
648
|
+
allowedBytes,
|
|
649
|
+
quotaLimited ? 507 : 413,
|
|
650
|
+
quotaLimited ? "upload storage quota exceeded" : `file exceeds ${maxFileBytes} bytes`,
|
|
651
|
+
);
|
|
652
|
+
const published = await publishUnique(tempPath, root, requestedName);
|
|
653
|
+
const info = await stat(published.path);
|
|
654
|
+
sendJson(res, 201, {
|
|
655
|
+
root,
|
|
656
|
+
file: fileRecord(root, published.name, { size, mtime: info.mtime }),
|
|
657
|
+
});
|
|
658
|
+
} finally {
|
|
659
|
+
await unlink(tempPath).catch(() => {});
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (req.method === "DELETE") {
|
|
666
|
+
await enqueueMutation(async () => {
|
|
667
|
+
const fileName = queryName(req);
|
|
668
|
+
const { target } = await requireRegularFile(root, fileName);
|
|
669
|
+
await unlink(target);
|
|
670
|
+
sendJson(res, 200, { deleted: fileName });
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
methodNotAllowed(res, ["GET", "HEAD", "POST", "DELETE"]);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
sendError(res, error, onError);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const serveFile = async (req, res, disposition) => {
|
|
682
|
+
try {
|
|
683
|
+
requireTrusted(req);
|
|
684
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
685
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const fileName = queryName(req);
|
|
689
|
+
const { target, info } = await requireRegularFile(root, fileName);
|
|
690
|
+
const dispositionValue = disposition === "inline"
|
|
691
|
+
? `inline; filename="${asciiDownloadName(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
|
692
|
+
: contentDisposition(fileName);
|
|
693
|
+
// Office 文档 inline 预览:返回渲染好的 HTML(保留原布局)
|
|
694
|
+
if (disposition === "inline" && OFFICE_EXTS.has(extname(fileName).toLowerCase())) {
|
|
695
|
+
const buffer = await readFile(target);
|
|
696
|
+
const officeHtml = await officePreviewHtml(fileName, buffer);
|
|
697
|
+
sendJson(res, 200, {
|
|
698
|
+
ok: true,
|
|
699
|
+
name: fileName,
|
|
700
|
+
size: info.size,
|
|
701
|
+
mtime: info.mtimeMs,
|
|
702
|
+
binary: true,
|
|
703
|
+
truncated: officeHtml !== null && officeHtml.length > PREVIEW_LIMIT,
|
|
704
|
+
contentType: contentType(fileName),
|
|
705
|
+
content: undefined,
|
|
706
|
+
officeHtml: officeHtml ?? undefined,
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// Markdown inline 预览:返回渲染后的 HTML 页面(真实效果,而非源码文本)。
|
|
711
|
+
if (disposition === "inline" && /\.(md|markdown)$/i.test(fileName)) {
|
|
712
|
+
const buffer = await readFile(target);
|
|
713
|
+
const body = `<article class="md">${markdownToHtml(buffer.toString("utf8"))}</article>`;
|
|
714
|
+
const downloadHref = `${DOWNLOAD_PATH}?name=${encodeURIComponent(fileName)}&download=1`;
|
|
715
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
716
|
+
res.end(previewPageHtml(fileName, fileName, info.size, downloadHref, body, `${PREVIEW_PATH}?name=${encodeURIComponent(fileName)}&inline=1`));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
res.writeHead(200, {
|
|
720
|
+
"content-type": contentType(fileName),
|
|
721
|
+
"content-length": info.size,
|
|
722
|
+
"content-disposition": dispositionValue,
|
|
723
|
+
"cache-control": "private, no-store",
|
|
724
|
+
"x-content-type-options": "nosniff",
|
|
725
|
+
});
|
|
726
|
+
if (req.method === "HEAD") {
|
|
727
|
+
res.end();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const stream = createReadStream(target);
|
|
731
|
+
stream.on("error", (error) => res.destroy(error));
|
|
732
|
+
stream.pipe(res);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
sendError(res, error, onError);
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const download = (req, res) => serveFile(req, res, "attachment");
|
|
739
|
+
const preview = (req, res) => serveFile(req, res, "inline");
|
|
740
|
+
|
|
741
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
742
|
+
const workspaceExcluded = workspaceExcludedName();
|
|
743
|
+
|
|
744
|
+
const workspaceList = async (req, res) => {
|
|
745
|
+
try {
|
|
746
|
+
requireTrusted(req);
|
|
747
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
748
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const groups = await collectGroups(workspaceRoot, workspaceExcluded);
|
|
752
|
+
sendJson(res, 200, { ok: true, root: workspaceRoot, groups });
|
|
753
|
+
} catch (error) {
|
|
754
|
+
sendError(res, error, onError);
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const workspaceFile = async (req, res) => {
|
|
759
|
+
try {
|
|
760
|
+
requireTrusted(req);
|
|
761
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
762
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const rel = (() => {
|
|
766
|
+
try {
|
|
767
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
768
|
+
} catch {
|
|
769
|
+
return "";
|
|
770
|
+
}
|
|
771
|
+
})();
|
|
772
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
773
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
774
|
+
const info = await stat(full);
|
|
775
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
776
|
+
const download = new URL(req.url || "/", "http://dsh.internal").searchParams.get("download") === "1";
|
|
777
|
+
const inline = new URL(req.url || "/", "http://dsh.internal").searchParams.get("inline") === "1";
|
|
778
|
+
const name = rel.split("/").pop() || "file";
|
|
779
|
+
if (download || inline) {
|
|
780
|
+
// inline=1: 流式返回原始文件(浏览器内嵌渲染,如 PDF 查看器),
|
|
781
|
+
// download=1: attachment 下载。两者都跳过 JSON 包装。
|
|
782
|
+
const disposition = inline
|
|
783
|
+
? `inline; filename="${asciiDownloadName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
|
784
|
+
: contentDisposition(name);
|
|
785
|
+
res.writeHead(200, {
|
|
786
|
+
"content-type": inline ? contentType(name) : "application/octet-stream",
|
|
787
|
+
"content-disposition": disposition,
|
|
788
|
+
"content-length": String(info.size),
|
|
789
|
+
"cache-control": "private, no-store",
|
|
790
|
+
"x-content-type-options": "nosniff",
|
|
791
|
+
});
|
|
792
|
+
const stream = createReadStream(full);
|
|
793
|
+
stream.on("error", (error) => res.destroy(error));
|
|
794
|
+
stream.pipe(res);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const buffer = await readFile(full);
|
|
798
|
+
const binary = buffer.subarray(0, 8192).includes(0);
|
|
799
|
+
const truncated = buffer.length > PREVIEW_LIMIT;
|
|
800
|
+
const officeHtml = binary && OFFICE_EXTS.has(extname(name).toLowerCase())
|
|
801
|
+
? await officePreviewHtml(name, buffer)
|
|
802
|
+
: undefined;
|
|
803
|
+
// Markdown 返回内联样式后的完整 HTML 片段(含 MD_CSS),供前端 srcDoc 渲染真实效果,
|
|
804
|
+
// 避免内嵌带独立头部的 workspace-preview 页面导致按钮重复、放大失效。
|
|
805
|
+
const mdHtml = !binary && /\.(md|markdown)$/i.test(name)
|
|
806
|
+
? `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${MD_CSS}</style></head><body><article class="md">${markdownToHtml(buffer.toString("utf8"))}</article></body></html>`
|
|
807
|
+
: undefined;
|
|
808
|
+
sendJson(res, 200, {
|
|
809
|
+
ok: true,
|
|
810
|
+
path: rel,
|
|
811
|
+
name,
|
|
812
|
+
size: info.size,
|
|
813
|
+
mtime: info.mtimeMs,
|
|
814
|
+
binary,
|
|
815
|
+
truncated,
|
|
816
|
+
contentType: contentType(extname(name)),
|
|
817
|
+
content: binary ? undefined : buffer.subarray(0, PREVIEW_LIMIT).toString("utf8"),
|
|
818
|
+
officeHtml: officeHtml ?? undefined,
|
|
819
|
+
mdHtml: mdHtml ?? undefined,
|
|
820
|
+
});
|
|
821
|
+
} catch (error) {
|
|
822
|
+
sendError(res, error, onError);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const workspaceDelete = async (req, res) => {
|
|
827
|
+
try {
|
|
828
|
+
requireTrusted(req);
|
|
829
|
+
if (req.method !== "POST") {
|
|
830
|
+
methodNotAllowed(res, ["POST"]);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const body = await readJsonBody(req);
|
|
834
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
835
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
836
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
837
|
+
const info = await stat(full);
|
|
838
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
839
|
+
await unlink(full);
|
|
840
|
+
sendJson(res, 200, { ok: true, deleted: rel });
|
|
841
|
+
} catch (error) {
|
|
842
|
+
sendError(res, error, onError);
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const workspaceSave = async (req, res) => {
|
|
847
|
+
try {
|
|
848
|
+
requireTrusted(req);
|
|
849
|
+
if (req.method !== "POST") {
|
|
850
|
+
methodNotAllowed(res, ["POST"]);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const body = await readJsonBody(req);
|
|
854
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
855
|
+
const content = typeof body === "object" && body !== null ? body.content : undefined;
|
|
856
|
+
if (typeof content !== "string") throw new HttpError(400, "content required");
|
|
857
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
858
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
859
|
+
const info = await stat(full);
|
|
860
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
861
|
+
await writeFile(full, content, "utf8");
|
|
862
|
+
sendJson(res, 200, { ok: true, path: rel });
|
|
863
|
+
} catch (error) {
|
|
864
|
+
sendError(res, error, onError);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const workspacePreview = async (req, res) => {
|
|
869
|
+
try {
|
|
870
|
+
requireTrusted(req);
|
|
871
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
872
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const rel = (() => {
|
|
876
|
+
try {
|
|
877
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
878
|
+
} catch {
|
|
879
|
+
return "";
|
|
880
|
+
}
|
|
881
|
+
})();
|
|
882
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
883
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
884
|
+
const info = await stat(full);
|
|
885
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
886
|
+
const name = rel.split("/").pop() || "file";
|
|
887
|
+
const ext = extname(name).toLowerCase();
|
|
888
|
+
const buffer = await readFile(full);
|
|
889
|
+
const size = info.size;
|
|
890
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
891
|
+
let body;
|
|
892
|
+
if (OFFICE_EXTS.has(ext)) {
|
|
893
|
+
const html = await officePreviewHtml(name, buffer);
|
|
894
|
+
body = html ? `<div class="office">${html}</div>` : `<p class="unsupported">该 Office 文档无法渲染,请下载查看。</p>`;
|
|
895
|
+
} else if (/\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(name)) {
|
|
896
|
+
body = `<img class="image" src="data:${contentType(ext) || "image/png"};base64,${buffer.toString("base64")}" alt="${escapeHtml(name)}">`;
|
|
897
|
+
} else if (ext === ".svg") {
|
|
898
|
+
body = `<img class="image" src="data:image/svg+xml;base64,${buffer.toString("base64")}" alt="${escapeHtml(name)}">`;
|
|
899
|
+
} else if (ext === ".pdf") {
|
|
900
|
+
// 直接指向原始文件 inline 流(浏览器 PDF 查看器原生渲染),
|
|
901
|
+
// 避免 base64 data URI 在 iframe 内被 Chrome 拒绝。
|
|
902
|
+
body = `<iframe class="pdf" src="workspace-file?path=${encodeURIComponent(rel)}&inline=1"></iframe>`;
|
|
903
|
+
} else if (/\.(txt|log)$/i.test(name)) {
|
|
904
|
+
body = `<pre class="text">${escapeHtml(buffer.toString("utf8"))}</pre>`;
|
|
905
|
+
} else if (/\.(md|markdown)$/i.test(name)) {
|
|
906
|
+
// Markdown → 直接渲染成 HTML(真实效果),失败回退源码文本。
|
|
907
|
+
body = `<article class="md">${markdownToHtml(buffer.toString("utf8"))}</article>`;
|
|
908
|
+
} else if (/\.(json|ya?ml|py|js|mjs|cjs|ts|sh|css|html?|xml|csv|ini|conf|env|toml|sql|rs|go|c|h|cpp|java|kt|swift|rb|php|vue|jsx|tsx)$/i.test(name)) {
|
|
909
|
+
body = `<pre class="text">${escapeHtml(buffer.toString("utf8"))}</pre>`;
|
|
910
|
+
} else {
|
|
911
|
+
body = `<p class="unsupported">该文件类型暂不支持预览,请点击右上角「下载」。</p>`;
|
|
912
|
+
}
|
|
913
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
914
|
+
res.end(previewPageHtml(name, rel, size, downloadHref, body, `workspace-file?path=${encodeURIComponent(rel)}&inline=1`));
|
|
915
|
+
} catch (error) {
|
|
916
|
+
sendError(res, error, onError);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const workspaceBrowse = async (req, res) => {
|
|
921
|
+
try {
|
|
922
|
+
requireTrusted(req);
|
|
923
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
924
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const params = new URL(req.url || "/", "http://dsh.internal").searchParams;
|
|
928
|
+
const ws = params.get("ws") || "";
|
|
929
|
+
const all = params.get("all") === "1";
|
|
930
|
+
const groups = await collectGroups(workspaceRoot, workspaceExcluded);
|
|
931
|
+
const view = all ? groups : (ws ? groups.filter((g) => g.folder === ws) : groups);
|
|
932
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
933
|
+
res.end(workspaceBrowseHtml(view, ws, all));
|
|
934
|
+
} catch (error) {
|
|
935
|
+
sendError(res, error, onError);
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
// ---- docx-preview 真实预览(浏览器端渲染)----
|
|
940
|
+
// 返回一个自包含 HTML:引 jszip + docx-preview,再 fetch workspace-file?inline=1
|
|
941
|
+
// 拿 .docx 原始字节,用 docx-preview 真实渲染(所见即所得)。仅供 .docx 预览。
|
|
942
|
+
const docxPreviewPage = async (req, res) => {
|
|
943
|
+
try {
|
|
944
|
+
requireTrusted(req);
|
|
945
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
946
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const rel = (() => {
|
|
950
|
+
try {
|
|
951
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
952
|
+
} catch {
|
|
953
|
+
return "";
|
|
954
|
+
}
|
|
955
|
+
})();
|
|
956
|
+
// 只允许 .docx(避免把别的文件喂给 docx-preview)
|
|
957
|
+
if (!/\.docx$/i.test(rel)) throw new HttpError(400, "only .docx supported");
|
|
958
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
959
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
960
|
+
const info = await stat(full);
|
|
961
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
962
|
+
const name = rel.split("/").pop() || "file";
|
|
963
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
964
|
+
const inlineHref = `workspace-file?path=${encodeURIComponent(rel)}&inline=1`;
|
|
965
|
+
const assetBase = "/api/dsh-uploads/docx-preview-asset";
|
|
966
|
+
// 用 encodeURIComponent 但保留正斜杠,保证 query 里合法
|
|
967
|
+
const q = encodeURIComponent(rel).replace(/%2F/g, "/");
|
|
968
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
969
|
+
res.end(`<!DOCTYPE html>
|
|
970
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
971
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
972
|
+
<style>
|
|
973
|
+
body{margin:0;background:#313b48;color:#1f2937}
|
|
974
|
+
#container{padding:16px 0}
|
|
975
|
+
/* docx-preview 分页:每页一张"纸",页间留间隙(breakPages 模式下 .docx-wrapper 内每个 .docx 是一页) */
|
|
976
|
+
#container .docx-wrapper{background:transparent;padding:0;margin:0}
|
|
977
|
+
#container .docx-wrapper > .docx,
|
|
978
|
+
#container .docx-wrapper > section.docx,
|
|
979
|
+
#container .docx-wrapper section.docx{background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.28);max-width:900px;margin:0 auto 24px;padding:56px 64px;box-sizing:border-box}
|
|
980
|
+
#container .docx-wrapper > .docx{min-height:1100px}
|
|
981
|
+
#container img{max-width:100%}
|
|
982
|
+
</style>
|
|
983
|
+
</head><body>
|
|
984
|
+
<div style="position:sticky;top:0;z-index:20;display:flex;gap:8px;padding:10px 14px;align-items:center;background:#1a2530">
|
|
985
|
+
<strong style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb">${escapeHtml(name)}</strong>
|
|
986
|
+
<a class="btn" href="${downloadHref}" download style="padding:6px 12px;border:1px solid #2c3a47;border-radius:8px;color:#e5e7eb;text-decoration:none;font-size:13px">下载</a>
|
|
987
|
+
<button type="button" onclick="closePreview()" style="padding:6px 12px;border:1px solid #2c3a47;border-radius:8px;color:#e5e7eb;background:transparent;text-decoration:none;font-size:13px;cursor:pointer">✕ 关闭</button>
|
|
988
|
+
</div>
|
|
989
|
+
<div id="container"><div style="padding:40px;text-align:center;color:#999">加载中…</div></div>
|
|
990
|
+
<script src="${assetBase}?f=jszip.min.js"></script>
|
|
991
|
+
<script src="${assetBase}?f=docx-preview.min.js"></script>
|
|
992
|
+
<script>
|
|
993
|
+
// 「✕ 关闭」:预览页自带的关闭按钮(与 md 预览页一致的"两层关闭"行为)。
|
|
994
|
+
// 在切换式弹窗(父窗口)里 → 通知父窗口回到文件列表;独立标签页 → 尝试关窗。
|
|
995
|
+
function closePreview() {
|
|
996
|
+
try {
|
|
997
|
+
if (window.self !== window.top && window.parent) {
|
|
998
|
+
window.parent.postMessage({ type: 'dsh-close-preview' }, location.origin);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
} catch (e) { /* 跨域忽略 */ }
|
|
1002
|
+
window.close();
|
|
1003
|
+
}
|
|
1004
|
+
const c=document.getElementById('container');
|
|
1005
|
+
(async()=>{
|
|
1006
|
+
try{
|
|
1007
|
+
const r=await fetch(${JSON.stringify(inlineHref)}, {cache:'no-store'});
|
|
1008
|
+
if(!r.ok) throw new Error('HTTP '+r.status);
|
|
1009
|
+
const buf=await r.arrayBuffer();
|
|
1010
|
+
c.innerHTML='';
|
|
1011
|
+
// styleContainer 传 null:docx-preview 用自带样式。分页靠下方 .docx-wrapper > .docx 的纸张 CSS 控制。
|
|
1012
|
+
await docx.renderAsync(buf, c, null, {inWrapper:true, breakPages:true, ignoreLastRenderedPageBreak:false, experimental:true, className:'docx', useBase64URL:false});
|
|
1013
|
+
}catch(e){ c.innerHTML='<p style="color:#dc2626;padding:20px">渲染失败:'+(e&&e.message||e)+'</p>'; }
|
|
1014
|
+
})();
|
|
1015
|
+
</script>
|
|
1016
|
+
</body></html>`);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
sendError(res, error, onError);
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// serve jszip / docx-preview 库文件(从 PACKAGE_DIR/client/vendor 读)
|
|
1023
|
+
const docxPreviewAsset = async (req, res) => {
|
|
1024
|
+
try {
|
|
1025
|
+
requireTrusted(req);
|
|
1026
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1027
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const f = decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("f") || "");
|
|
1031
|
+
// 允许的 vendor 前端库(docx-preview 与 pptx-preview 共用这一端点)
|
|
1032
|
+
if (!["jszip.min.js", "docx-preview.min.js", "pptxviewjs.min.js", "chart.umd.min.js"].includes(f)) throw new HttpError(400, "unknown asset");
|
|
1033
|
+
const target = join(VENDOR_DIR, f);
|
|
1034
|
+
const buf = await readFile(target);
|
|
1035
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
1036
|
+
res.end(buf);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
sendError(res, error, onError);
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
// ---- PptxViewJS 真实预览(浏览器端渲染,所见即所得)----
|
|
1043
|
+
// 返回一个自包含 HTML:引 jszip + chart.js + pptxviewjs,再 fetch workspace-file?inline=1
|
|
1044
|
+
// 拿 .pptx 原始字节,用 PptxViewJS 在 Canvas 上渲染每页幻灯片(可翻页)。仅供 .pptx 预览。
|
|
1045
|
+
const pptxPreviewPage = async (req, res) => {
|
|
1046
|
+
try {
|
|
1047
|
+
requireTrusted(req);
|
|
1048
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1049
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const rel = (() => {
|
|
1053
|
+
try {
|
|
1054
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
1055
|
+
} catch {
|
|
1056
|
+
return "";
|
|
1057
|
+
}
|
|
1058
|
+
})();
|
|
1059
|
+
// 只允许 .pptx(避免把别的文件喂给 PptxViewJS)
|
|
1060
|
+
if (!/\.pptx$/i.test(rel)) throw new HttpError(400, "only .pptx supported");
|
|
1061
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
1062
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
1063
|
+
const info = await stat(full);
|
|
1064
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
1065
|
+
const name = rel.split("/").pop() || "file";
|
|
1066
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
1067
|
+
const inlineHref = `workspace-file?path=${encodeURIComponent(rel)}&inline=1`;
|
|
1068
|
+
const assetBase = "/api/dsh-uploads/docx-preview-asset";
|
|
1069
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1070
|
+
res.end(`<!DOCTYPE html>
|
|
1071
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1072
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
1073
|
+
<style>
|
|
1074
|
+
body{margin:0;background:#1a2530;color:#e5e7eb;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
1075
|
+
.bar{position:sticky;top:0;z-index:20;display:flex;gap:8px;padding:10px 14px;align-items:center;background:#1a2530;border-bottom:1px solid #2c3a47;flex-wrap:wrap}
|
|
1076
|
+
.bar strong{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb;font-size:13px}
|
|
1077
|
+
.bar button,.bar a{padding:6px 12px;border:1px solid #2c3a47;border-radius:8px;background:transparent;color:#e5e7eb;text-decoration:none;font-size:13px;cursor:pointer;white-space:nowrap}
|
|
1078
|
+
.bar button:disabled{opacity:.45;cursor:default}
|
|
1079
|
+
#stage{display:flex;align-items:center;justify-content:center;padding:56px 22px 48px;min-height:calc(100vh - 60px)}
|
|
1080
|
+
#stage canvas{display:block;max-width:100%;max-height:calc(100vh - 150px);background:#fff;border-radius:8px;box-shadow:0 4px 22px rgba(0,0,0,.45)}
|
|
1081
|
+
#status{font-size:12px;color:#9ca3af;min-width:70px;text-align:center}
|
|
1082
|
+
#msg{color:#9ca3af}
|
|
1083
|
+
</style>
|
|
1084
|
+
</head><body>
|
|
1085
|
+
<div class="bar">
|
|
1086
|
+
<strong>${escapeHtml(name)}</strong>
|
|
1087
|
+
<button type="button" id="prev" disabled>‹ 上一页</button>
|
|
1088
|
+
<span id="status">— / —</span>
|
|
1089
|
+
<button type="button" id="next" disabled>下一页 ›</button>
|
|
1090
|
+
<a href="${downloadHref}" download>下载</a>
|
|
1091
|
+
<button type="button" onclick="closePreview()">✕ 关闭</button>
|
|
1092
|
+
</div>
|
|
1093
|
+
<div id="stage"><div id="msg">加载中…</div><canvas id="canvas" style="display:none"></canvas></div>
|
|
1094
|
+
<script src="${assetBase}?f=jszip.min.js"></script>
|
|
1095
|
+
<script src="${assetBase}?f=chart.umd.min.js"></script>
|
|
1096
|
+
<script src="${assetBase}?f=pptxviewjs.min.js"></script>
|
|
1097
|
+
<script>
|
|
1098
|
+
// 「✕ 关闭」:预览页自带的关闭按钮(与 md/docx 预览一致的"两层关闭"行为)。
|
|
1099
|
+
function closePreview(){
|
|
1100
|
+
try{ if(window.self!==window.top&&window.parent){ window.parent.postMessage({type:'dsh-close-preview'},location.origin); return; } }catch(e){ /* 跨域忽略 */ }
|
|
1101
|
+
window.close();
|
|
1102
|
+
}
|
|
1103
|
+
const canvas=document.getElementById('canvas');
|
|
1104
|
+
const stage=document.getElementById('stage');
|
|
1105
|
+
const msg=document.getElementById('msg');
|
|
1106
|
+
const prevBtn=document.getElementById('prev');
|
|
1107
|
+
const nextBtn=document.getElementById('next');
|
|
1108
|
+
const status=document.getElementById('status');
|
|
1109
|
+
let viewer=null,total=0;
|
|
1110
|
+
function update(){
|
|
1111
|
+
if(!viewer)return;
|
|
1112
|
+
const cur=viewer.getCurrentSlideIndex();
|
|
1113
|
+
status.textContent='第 '+(cur+1)+' / '+total+' 页';
|
|
1114
|
+
prevBtn.disabled=cur<=0;
|
|
1115
|
+
nextBtn.disabled=cur>=total-1;
|
|
1116
|
+
}
|
|
1117
|
+
(async()=>{
|
|
1118
|
+
try{
|
|
1119
|
+
const r=await fetch(${JSON.stringify(inlineHref)},{cache:'no-store'});
|
|
1120
|
+
if(!r.ok) throw new Error('HTTP '+r.status);
|
|
1121
|
+
const blob=await r.blob();
|
|
1122
|
+
msg.style.display='none';
|
|
1123
|
+
canvas.style.display='block';
|
|
1124
|
+
// 给一个确定的初始尺寸,确保库能正常渲染;显示由 CSS max-width/max-height 等比缩放
|
|
1125
|
+
canvas.width=1280; canvas.height=720;
|
|
1126
|
+
viewer=new window.PptxViewJS.PPTXViewer({canvas});
|
|
1127
|
+
await viewer.loadFile(new File([blob],${JSON.stringify(name)},{type:'application/vnd.openxmlformats-officedocument.presentationml.presentation'}));
|
|
1128
|
+
await viewer.render();
|
|
1129
|
+
total=viewer.getSlideCount();
|
|
1130
|
+
update();
|
|
1131
|
+
}catch(e){
|
|
1132
|
+
msg.style.display='';
|
|
1133
|
+
msg.textContent='渲染失败:'+((e&&e.message)||e);
|
|
1134
|
+
console.error(e);
|
|
1135
|
+
}
|
|
1136
|
+
})();
|
|
1137
|
+
prevBtn.addEventListener('click',async()=>{if(viewer){await viewer.previousSlide();update();}});
|
|
1138
|
+
nextBtn.addEventListener('click',async()=>{if(viewer){await viewer.nextSlide();update();}});
|
|
1139
|
+
</script>
|
|
1140
|
+
</body></html>`);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
sendError(res, error, onError);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
return { root, maxFileBytes, totalMaxBytes, api, download, preview, workspaceList, workspaceFile, workspacePreview, workspaceBrowse, workspaceDelete, workspaceSave, docxPreviewPage, docxPreviewAsset, pptxPreviewPage };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** 人类可读文件大小。 */
|
|
1150
|
+
function humanSize(bytes) {
|
|
1151
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "-";
|
|
1152
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1153
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1154
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1155
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** 人类可读相对时间。 */
|
|
1159
|
+
function humanTime(ms) {
|
|
1160
|
+
if (!Number.isFinite(ms)) return "-";
|
|
1161
|
+
const diff = Date.now() - ms;
|
|
1162
|
+
if (diff < 60 * 1000) return "刚刚";
|
|
1163
|
+
if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60000)} 分钟前`;
|
|
1164
|
+
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)} 小时前`;
|
|
1165
|
+
if (diff < 7 * 24 * 60 * 60 * 1000) return `${Math.floor(diff / 86400000)} 天前`;
|
|
1166
|
+
const d = new Date(ms);
|
|
1167
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1168
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/** 扩展名是否可内嵌预览(与前端 INLINE_PREVIEW_EXTS + 图片 + Office 一致)。 */
|
|
1172
|
+
const INLINE_PREVIEW_EXTS = new Set([
|
|
1173
|
+
".pdf", ".txt", ".md", ".markdown", ".json", ".yml", ".yaml", ".xml", ".html", ".htm",
|
|
1174
|
+
".csv", ".tsv", ".log", ".ini", ".conf", ".env", ".toml", ".rtf",
|
|
1175
|
+
".py", ".js", ".mjs", ".cjs", ".ts", ".sh", ".css", ".sql", ".rs", ".go", ".c", ".h", ".cpp",
|
|
1176
|
+
".java", ".kt", ".swift", ".rb", ".php", ".vue", ".jsx", ".tsx",
|
|
1177
|
+
]);
|
|
1178
|
+
const INLINE_PREVIEW_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg"]);
|
|
1179
|
+
function isInlinePreviewableName(name) {
|
|
1180
|
+
const ext = extname(name).toLowerCase();
|
|
1181
|
+
return OFFICE_EXTS.has(ext) || INLINE_PREVIEW_EXTS.has(ext) || INLINE_PREVIEW_IMAGE_EXTS.has(ext);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** 工作区文件浏览页面。顶部并列切换:📁工作区文件 / 📂总文件;ws=所在工作区,all=1 显示全部。 */
|
|
1185
|
+
function workspaceBrowseHtml(groups, ws = "", all = false) {
|
|
1186
|
+
const total = groups.reduce((sum, g) => sum + g.files.length, 0);
|
|
1187
|
+
const enc = encodeURIComponent(ws);
|
|
1188
|
+
const wsHref = ws ? `workspace-browse?ws=${enc}` : "";
|
|
1189
|
+
const allHref = ws ? `workspace-browse?ws=${enc}&all=1` : "workspace-browse";
|
|
1190
|
+
const refreshHref = all ? allHref : (wsHref || "workspace-browse");
|
|
1191
|
+
const metaText = all ? `全部工作区 · ${total} 个文件` : (ws ? `${escapeHtml(ws)} · ${total} 个文件` : `全部工作区 · ${total} 个文件`);
|
|
1192
|
+
const section = (group) => {
|
|
1193
|
+
if (group.files.length === 0) return "";
|
|
1194
|
+
const rows = group.files.map((f) => {
|
|
1195
|
+
const rel = encodeURIComponent(f.path);
|
|
1196
|
+
const previewable = isInlinePreviewableName(f.name);
|
|
1197
|
+
const isPdf = /\.pdf$/i.test(f.name);
|
|
1198
|
+
const isDocx = /\.docx$/i.test(f.name);
|
|
1199
|
+
const isPptx = /\.pptx$/i.test(f.name);
|
|
1200
|
+
// PDF 直接嵌原始流(单层 iframe,浏览器原生查看器可滚动翻页);
|
|
1201
|
+
// docx 走 docx-preview、pptx 走 PptxViewJS 真实渲染页(浏览器端解析,所见即所得);
|
|
1202
|
+
// 其它可预览类型走渲染页;不可预览 → 下载。
|
|
1203
|
+
const viewHref = previewable
|
|
1204
|
+
? (isPdf ? `workspace-file?path=${rel}&inline=1` : isDocx ? `docx-preview?path=${rel}` : isPptx ? `pptx-preview?path=${rel}` : `workspace-preview?path=${rel}&from=list`)
|
|
1205
|
+
: `workspace-file?path=${rel}&download=1`;
|
|
1206
|
+
return `<tr>
|
|
1207
|
+
<td class="name"><a class="flink" href="${viewHref}" data-preview="${escapeHtml(viewHref)}" title="${escapeHtml(f.path)}">${escapeHtml(f.name)}</a></td>
|
|
1208
|
+
<td class="size">${humanSize(f.size)}</td>
|
|
1209
|
+
<td class="time">${humanTime(f.mtime)}</td>
|
|
1210
|
+
<td class="acts">
|
|
1211
|
+
${previewable ? `<a class="tag" href="${viewHref}" data-preview="${escapeHtml(viewHref)}">${ICON_EYE} 预览</a>` : `<a class="tag" href="${viewHref}">${ICON_DL} 下载</a>`}
|
|
1212
|
+
<a class="tag dl" href="workspace-file?path=${rel}&download=1">${ICON_DL} 下载</a>
|
|
1213
|
+
</td>
|
|
1214
|
+
</tr>`;
|
|
1215
|
+
}).join("");
|
|
1216
|
+
return `<div class="group"><h2>${escapeHtml(group.folder)} <span class="cnt">${group.files.length}</span></h2>
|
|
1217
|
+
<table><thead><tr><th>文件</th><th>大小</th><th>修改</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
1218
|
+
};
|
|
1219
|
+
return `<!DOCTYPE html>
|
|
1220
|
+
<html lang="zh-CN">
|
|
1221
|
+
<head>
|
|
1222
|
+
<meta charset="utf-8">
|
|
1223
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1224
|
+
<title>工作区文件</title>
|
|
1225
|
+
<style>
|
|
1226
|
+
* { box-sizing: border-box; }
|
|
1227
|
+
:root {
|
|
1228
|
+
--lp-bg:#0f1720; --lp-fg:#e5e7eb; --lp-bar-bg:#1a2530; --lp-border:#2c3a47;
|
|
1229
|
+
--lp-meta:#9ca3af; --lp-hover:#273449; --lp-btn-bg:#374151; --lp-btn-fg:#e5e7eb;
|
|
1230
|
+
--lp-btn-hover:#4b5563; --lp-h2:#93c5fd; --lp-accent:#93c5fd; --lp-ok:#86efac;
|
|
1231
|
+
--lp-table-bg:#111a24; --lp-table-border:#263241; --lp-cell-border:#1c2836;
|
|
1232
|
+
--lp-th-bg:#16222f; --lp-row-hover:#17232f; --lp-flink:#e5e7eb;
|
|
1233
|
+
--lp-tag-bg:#1e293b; --lp-tag-hover:#273449; --lp-seg-hover:#273449;
|
|
1234
|
+
}
|
|
1235
|
+
@media (prefers-color-scheme: light) {
|
|
1236
|
+
:root {
|
|
1237
|
+
--lp-bg:#ffffff; --lp-fg:#1f2937; --lp-bar-bg:#f3f4f6; --lp-border:#e5e7eb;
|
|
1238
|
+
--lp-meta:#6b7280; --lp-hover:#e5e7eb; --lp-btn-bg:#e5e7eb; --lp-btn-fg:#374151;
|
|
1239
|
+
--lp-btn-hover:#d1d5db; --lp-h2:#1d4ed8; --lp-accent:#1d4ed8; --lp-ok:#15803d;
|
|
1240
|
+
--lp-table-bg:#ffffff; --lp-table-border:#e5e7eb; --lp-cell-border:#f3f4f6;
|
|
1241
|
+
--lp-th-bg:#f9fafb; --lp-row-hover:#f3f4f6; --lp-flink:#1f2937;
|
|
1242
|
+
--lp-tag-bg:#eff6ff; --lp-tag-hover:#dbeafe; --lp-seg-hover:#e5e7eb;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
body { margin:0; font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; background:var(--lp-bg); color:var(--lp-fg); }
|
|
1246
|
+
.bar { position:sticky; top:0; display:flex; align-items:center; gap:14px; padding:12px 20px; background:var(--lp-bar-bg); border-bottom:1px solid var(--lp-border); z-index:5; flex-wrap:wrap; }
|
|
1247
|
+
.seg { display:flex; border:1px solid var(--lp-border); border-radius:10px; overflow:hidden; flex:none; }
|
|
1248
|
+
.seg a { display:inline-flex; align-items:center; gap:6px; padding:8px 18px; font-size:14px; text-decoration:none; color:var(--lp-meta); background:transparent; }
|
|
1249
|
+
.seg a + a { border-left:1px solid var(--lp-border); }
|
|
1250
|
+
.seg a.on { background:#2563eb; color:#fff; }
|
|
1251
|
+
.seg a:hover:not(.on) { background:var(--lp-seg-hover); color:var(--lp-fg); }
|
|
1252
|
+
.bar .meta { color:var(--lp-meta); font-size:13px; }
|
|
1253
|
+
.bar .spacer { flex:1; }
|
|
1254
|
+
.btn { display:inline-flex; align-items:center; gap:6px; background:var(--lp-btn-bg); color:var(--lp-btn-fg); text-decoration:none; border-radius:8px; padding:7px 14px; font-size:13px; flex:none; }
|
|
1255
|
+
.btn:hover { background:var(--lp-btn-hover); }
|
|
1256
|
+
.wrap { max-width:980px; margin:0 auto; padding:20px; }
|
|
1257
|
+
.group { margin-bottom:26px; }
|
|
1258
|
+
.group h2 { font-size:15px; margin:0 0 8px; color:var(--lp-h2); }
|
|
1259
|
+
.cnt { color:var(--lp-meta); font-size:12px; font-weight:400; }
|
|
1260
|
+
table { width:100%; border-collapse:collapse; background:var(--lp-table-bg); border:1px solid var(--lp-table-border); border-radius:10px; overflow:hidden; }
|
|
1261
|
+
th, td { text-align:left; padding:9px 14px; font-size:13px; border-bottom:1px solid var(--lp-cell-border); }
|
|
1262
|
+
th { background:var(--lp-th-bg); color:var(--lp-meta); font-weight:500; }
|
|
1263
|
+
tr:last-child td { border-bottom:none; }
|
|
1264
|
+
tr:hover td { background:var(--lp-row-hover); }
|
|
1265
|
+
.name { max-width:380px; }
|
|
1266
|
+
.flink { color:var(--lp-flink); text-decoration:none; word-break:break-all; }
|
|
1267
|
+
.flink:hover { color:var(--lp-accent); text-decoration:underline; }
|
|
1268
|
+
.size, .time { color:var(--lp-meta); white-space:nowrap; }
|
|
1269
|
+
.acts { white-space:nowrap; }
|
|
1270
|
+
.tag { display:inline-flex; align-items:center; gap:4px; text-decoration:none; font-size:12px; padding:4px 10px; border-radius:6px; margin-right:6px; background:var(--lp-tag-bg); color:var(--lp-accent); }
|
|
1271
|
+
.tag:hover { background:var(--lp-tag-hover); }
|
|
1272
|
+
.tag.dl { color:var(--lp-ok); }
|
|
1273
|
+
.empty { color:var(--lp-meta); text-align:center; padding:60px 0; }
|
|
1274
|
+
.ic { width:14px; height:14px; flex:none; }
|
|
1275
|
+
@media (max-width: 640px) {
|
|
1276
|
+
.bar { padding:10px 12px; gap:10px; }
|
|
1277
|
+
.seg a { padding:7px 14px; font-size:13px; }
|
|
1278
|
+
.group { margin-bottom:14px; }
|
|
1279
|
+
table, thead, tbody, tr, th, td { display:block; }
|
|
1280
|
+
thead { display:none; }
|
|
1281
|
+
tbody { display:block; }
|
|
1282
|
+
tr { background:var(--lp-table-bg); border:1px solid var(--lp-table-border); border-radius:10px; padding:10px 12px; margin-bottom:8px; }
|
|
1283
|
+
td { border:none; padding:2px 0; }
|
|
1284
|
+
.name { max-width:none; }
|
|
1285
|
+
.flink { font-size:14px; }
|
|
1286
|
+
.size, .time { display:inline-block; margin-right:12px; }
|
|
1287
|
+
.acts { margin-top:6px; }
|
|
1288
|
+
}
|
|
1289
|
+
</style>
|
|
1290
|
+
</head>
|
|
1291
|
+
<body>
|
|
1292
|
+
<div class="bar">
|
|
1293
|
+
<div class="seg">
|
|
1294
|
+
<a class="${!all && ws ? "on" : ""}" href="${wsHref || "#"}" ${wsHref ? "" : 'aria-disabled="true" title="从会话窗口的「📂 文件」进入可回到当前工作区"'}>${ICON_FOLDER} 工作区文件</a>
|
|
1295
|
+
<a class="${all ? "on" : ""}" href="${allHref}">${ICON_FOLDER_OPEN} 总文件</a>
|
|
1296
|
+
</div>
|
|
1297
|
+
<span class="meta">${metaText}</span>
|
|
1298
|
+
<span class="spacer"></span>
|
|
1299
|
+
<a class="btn" href="${refreshHref}">⟳ 刷新</a>
|
|
1300
|
+
</div>
|
|
1301
|
+
<div class="wrap">
|
|
1302
|
+
${groups.map(section).join("") || `<p class="empty">${ws && !all ? `工作区「${escapeHtml(ws)}」还没有文件` : "工作区还没有文件"}</p>`}
|
|
1303
|
+
</div>
|
|
1304
|
+
<script>
|
|
1305
|
+
// 预览链接点击 → 通知父窗口(wsOverlay)打开,父窗口用 embed/iframe 渲染,
|
|
1306
|
+
// 避免在嵌套 iframe 内直接导航导致 PDF 无法滚动。
|
|
1307
|
+
document.addEventListener('click', function (e) {
|
|
1308
|
+
var el = e.target && e.target.closest ? e.target.closest('[data-preview]') : null;
|
|
1309
|
+
if (!el) return;
|
|
1310
|
+
// el.href:浏览器解码 HTML 实体(& → &)并绝对化,消除歧义
|
|
1311
|
+
var abs = el.href;
|
|
1312
|
+
if (!abs) return;
|
|
1313
|
+
e.preventDefault();
|
|
1314
|
+
e.stopPropagation();
|
|
1315
|
+
var name = (el.getAttribute('title') || el.textContent || '').trim().split('/').pop();
|
|
1316
|
+
try {
|
|
1317
|
+
if (window.parent !== window) {
|
|
1318
|
+
window.parent.postMessage({ type: 'dsh-open-preview', url: abs, title: name }, location.origin);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
} catch (err) { /* 跨域忽略 */ }
|
|
1322
|
+
location.href = abs;
|
|
1323
|
+
});
|
|
1324
|
+
</script>
|
|
1325
|
+
</body>
|
|
1326
|
+
</html>`;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** 内联 SVG 图标(眼睛/下载/文件夹,比 emoji 干净)。 */
|
|
1330
|
+
const ICON_EYE = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
1331
|
+
const ICON_DL = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
1332
|
+
const ICON_FOLDER = '<svg class="ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>';
|
|
1333
|
+
const ICON_FOLDER_OPEN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
1334
|
+
const ICON_X = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
1335
|
+
/** 独立可访问的 SVG 图标文件(供聊天 markdown 图片内联,颜色固定、尺寸 14px)。 */
|
|
1336
|
+
const ICON_FILES = {
|
|
1337
|
+
"eye.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
1338
|
+
"download.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#86efac" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
/** 渲染后 Markdown 的通用样式(供预览页与 mdHtml srcDoc 共用,跟随明暗主题)。 */
|
|
1342
|
+
const MD_CSS = `
|
|
1343
|
+
:root { --lp-bg:#0f1720; --lp-fg:#e5e7eb; --lp-text-bg:#0b1219; --lp-border:#2c3a47; --lp-bar-bg:#1a2530; --lp-meta:#9ca3af; }
|
|
1344
|
+
@media (prefers-color-scheme: light) { :root { --lp-bg:#ffffff; --lp-fg:#1f2937; --lp-text-bg:#f9fafb; --lp-border:#e5e7eb; --lp-bar-bg:#f3f4f6; --lp-meta:#6b7280; } }
|
|
1345
|
+
body { margin:0; background:var(--lp-bg); color:var(--lp-fg); }
|
|
1346
|
+
.md { line-height:1.8; font-size:14px; word-break:break-word; padding:24px 32px 48px; max-width:820px; margin:0 auto; }
|
|
1347
|
+
.md h1,.md h2,.md h3,.md h4,.md h5,.md h6 { line-height:1.45; margin:1.7em 0 .8em; }
|
|
1348
|
+
.md h1 { font-size:1.5em; } .md h2 { font-size:1.3em; } .md h3 { font-size:1.15em; }
|
|
1349
|
+
.md h1:first-child,.md h2:first-child,.md h3:first-child { margin-top:.6em; }
|
|
1350
|
+
.md p { margin:1em 0; }
|
|
1351
|
+
.md a { color:#60a5fa; text-decoration:none; } .md a:hover { text-decoration:underline; }
|
|
1352
|
+
.md code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:4px; padding:1px 5px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.9em; }
|
|
1353
|
+
.md pre.md-code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:8px; padding:12px; overflow:auto; }
|
|
1354
|
+
.md pre.md-code code { background:none; border:none; padding:0; font-size:12px; line-height:1.6; }
|
|
1355
|
+
.md blockquote { margin:.8em 0; padding:.2em 1em; border-left:3px solid var(--lp-border); color:var(--lp-meta); }
|
|
1356
|
+
.md ul,.md ol { margin:.6em 0; padding-left:1.6em; } .md li { margin:.2em 0; }
|
|
1357
|
+
.md table { border-collapse:collapse; margin:.8em 0; max-width:100%; display:block; overflow:auto; }
|
|
1358
|
+
.md th,.md td { border:1px solid var(--lp-border); padding:6px 10px; font-size:13px; }
|
|
1359
|
+
.md th { background:var(--lp-bar-bg); font-weight:600; }
|
|
1360
|
+
.md img { max-width:100%; border-radius:8px; }
|
|
1361
|
+
.md hr { border:none; border-top:1px solid var(--lp-border); margin:1.5em 0; }
|
|
1362
|
+
.md del { color:var(--lp-meta); }
|
|
1363
|
+
`;
|
|
1364
|
+
|
|
1365
|
+
/** HTML 转义。 */
|
|
1366
|
+
function escapeHtml(value) {
|
|
1367
|
+
return String(value)
|
|
1368
|
+
.replace(/&/g, "&")
|
|
1369
|
+
.replace(/</g, "<")
|
|
1370
|
+
.replace(/>/g, ">")
|
|
1371
|
+
.replace(/"/g, """)
|
|
1372
|
+
.replace(/'/g, "'");
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* 最小 Markdown → HTML 渲染器(零依赖,供 md/markdown 预览用)。
|
|
1377
|
+
* 覆盖常见语法:标题、加粗/斜体、行内代码、代码块、有序/无序列表、
|
|
1378
|
+
* 表格、引用、分隔线、链接、图片、段落。仅用于预览,不做完整 GFM。
|
|
1379
|
+
* 任何解析失败都回退到源码文本(escape 后 <pre>),不会抛错。
|
|
1380
|
+
*/
|
|
1381
|
+
function markdownToHtml(md) {
|
|
1382
|
+
const esc = escapeHtml;
|
|
1383
|
+
let text = String(md).replace(/\r\n?/g, "\n");
|
|
1384
|
+
|
|
1385
|
+
// 1) 代码块(多行 ``` 或 缩进 4 空格)——整体抽取,避免内部内容被后续规则误处理
|
|
1386
|
+
const codeBlocks = [];
|
|
1387
|
+
text = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_m, code) => {
|
|
1388
|
+
codeBlocks.push(code.replace(/^\n/, ""));
|
|
1389
|
+
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
|
1390
|
+
});
|
|
1391
|
+
// 缩进代码块(连续 >=4 空格行)
|
|
1392
|
+
text = text.replace(/(?:^|\n)((?: {4}[^\n]*\n?)+)/g, (_m, block) => {
|
|
1393
|
+
codeBlocks.push(block.split("\n").filter((l) => l.trim()).map((l) => l.replace(/^ {4}/, "")).join("\n"));
|
|
1394
|
+
return `\n\u0000CODE${codeBlocks.length - 1}\u0000`;
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// 2) 行内元素处理函数(在分段后应用)
|
|
1398
|
+
const inline = (s) =>
|
|
1399
|
+
s
|
|
1400
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, alt, src, title) => {
|
|
1401
|
+
const t = title ? ` title="${esc(title)}"` : "";
|
|
1402
|
+
return `<img src="${esc(src)}" alt="${esc(alt)}"${t}>`;
|
|
1403
|
+
})
|
|
1404
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, label, href, title) => {
|
|
1405
|
+
const t = title ? ` title="${esc(title)}"` : "";
|
|
1406
|
+
return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer"${t}>${esc(label)}</a>`;
|
|
1407
|
+
})
|
|
1408
|
+
.replace(/`([^`]+)`/g, (_m, code) => `<code>${esc(code)}</code>`)
|
|
1409
|
+
.replace(/\*\*([^*]+)\*\*/g, (_m, b) => `<strong>${esc(b)}</strong>`)
|
|
1410
|
+
.replace(/__([^_]+)__/g, (_m, b) => `<strong>${esc(b)}</strong>`)
|
|
1411
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, (_m, pre, i) => `${pre}<em>${esc(i)}</em>`)
|
|
1412
|
+
.replace(/(^|[^_])_([^_]+)_/g, (_m, pre, i) => `${pre}<em>${esc(i)}</em>`)
|
|
1413
|
+
.replace(/~~([^~]+)~~/g, (_m, d) => `<del>${esc(d)}</del>`);
|
|
1414
|
+
// 注意:链接/图片的 URL 不转义内部空格,且 href 用 esc 防注入。
|
|
1415
|
+
|
|
1416
|
+
// 3) 按块处理(保留代码块哨兵与表格)
|
|
1417
|
+
const lines = text.split("\n");
|
|
1418
|
+
const out = [];
|
|
1419
|
+
let para = []; // 累积段落行
|
|
1420
|
+
let list = null; // { ordered, items:[{indent,text}] }
|
|
1421
|
+
let table = null;
|
|
1422
|
+
|
|
1423
|
+
const flushPara = () => {
|
|
1424
|
+
if (para.length) {
|
|
1425
|
+
const content = inline(para.join("\n"));
|
|
1426
|
+
out.push(`<p>${content}</p>`);
|
|
1427
|
+
para = [];
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
const flushList = () => {
|
|
1431
|
+
if (!list) return;
|
|
1432
|
+
const tag = list.ordered ? "ol" : "ul";
|
|
1433
|
+
const items = list.items.map((it) => `<li>${inline(it.text)}</li>`).join("");
|
|
1434
|
+
out.push(`<${tag}>${items}</${tag}>`);
|
|
1435
|
+
list = null;
|
|
1436
|
+
};
|
|
1437
|
+
const flushTable = () => {
|
|
1438
|
+
if (!table) return;
|
|
1439
|
+
const thead = table.header.map((c) => `<th>${inline(c)}</th>`).join("");
|
|
1440
|
+
const rows = table.rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("");
|
|
1441
|
+
out.push(`<table><thead><tr>${thead}</tr></thead><tbody>${rows}</tbody></table>`);
|
|
1442
|
+
table = null;
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1446
|
+
const line = lines[i];
|
|
1447
|
+
const trimmed = line.trim();
|
|
1448
|
+
|
|
1449
|
+
// 代码块哨兵
|
|
1450
|
+
const codeMatch = trimmed.match(/^\u0000CODE(\d+)\u0000$/);
|
|
1451
|
+
if (codeMatch) {
|
|
1452
|
+
flushPara(); flushList(); flushTable();
|
|
1453
|
+
out.push(`<pre class="md-code"><code>${esc(codeBlocks[Number(codeMatch[1])])}</code></pre>`);
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// 空行
|
|
1458
|
+
if (!trimmed) { flushPara(); flushList(); flushTable(); continue; }
|
|
1459
|
+
|
|
1460
|
+
// 分隔线
|
|
1461
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushPara(); flushList(); flushTable(); out.push("<hr>"); continue; }
|
|
1462
|
+
|
|
1463
|
+
// 标题
|
|
1464
|
+
const h = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
|
1465
|
+
if (h) {
|
|
1466
|
+
flushPara(); flushList(); flushTable();
|
|
1467
|
+
const n = h[1].length;
|
|
1468
|
+
out.push(`<h${n}>${inline(h[2])}</h${n}>`);
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// 引用(连续 > 行)
|
|
1473
|
+
if (/^>\s?/.test(trimmed)) {
|
|
1474
|
+
flushPara(); flushList(); flushTable();
|
|
1475
|
+
const quote = [];
|
|
1476
|
+
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
|
1477
|
+
quote.push(inline(lines[i].trim().replace(/^>\s?/, "")));
|
|
1478
|
+
i++;
|
|
1479
|
+
}
|
|
1480
|
+
i--;
|
|
1481
|
+
out.push(`<blockquote>${quote.join("<br>")}</blockquote>`);
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// 表格(含分隔行 |---|)
|
|
1486
|
+
if (trimmed.startsWith("|") && trimmed.endsWith("|") && i + 1 < lines.length && /^\|?[\s:|-]+\|?$/.test(lines[i + 1].trim())) {
|
|
1487
|
+
flushPara(); flushList();
|
|
1488
|
+
const header = trimmed.slice(1, -1).split("|").map((c) => c.trim());
|
|
1489
|
+
const alignRow = lines[i + 1].trim().slice(1, -1).split("|");
|
|
1490
|
+
// 只解析表头对齐(简化),行内容保持结构
|
|
1491
|
+
const rows = [];
|
|
1492
|
+
i += 2;
|
|
1493
|
+
while (i < lines.length && lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|")) {
|
|
1494
|
+
rows.push(lines[i].trim().slice(1, -1).split("|").map((c) => c.trim()));
|
|
1495
|
+
i++;
|
|
1496
|
+
}
|
|
1497
|
+
i--;
|
|
1498
|
+
table = { header, rows };
|
|
1499
|
+
flushTable();
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// 有序列表
|
|
1504
|
+
const ol = trimmed.match(/^(\d+)\.\s+(.*)$/);
|
|
1505
|
+
if (ol) {
|
|
1506
|
+
flushPara();
|
|
1507
|
+
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; }
|
|
1508
|
+
list.items.push({ text: ol[2] });
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
// 无序列表 - / * / +
|
|
1512
|
+
const ul = trimmed.match(/^[-*+]\s+(.*)$/);
|
|
1513
|
+
if (ul) {
|
|
1514
|
+
flushPara();
|
|
1515
|
+
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; }
|
|
1516
|
+
list.items.push({ text: ul[1] });
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// 其他 → 段落累积(多行用 <br> 连接)
|
|
1521
|
+
flushList();
|
|
1522
|
+
para.push(trimmed);
|
|
1523
|
+
}
|
|
1524
|
+
flushPara(); flushList(); flushTable();
|
|
1525
|
+
|
|
1526
|
+
return out.join("\n");
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
/** 工作区文件预览页面骨架(自包含,无外部依赖)。 */
|
|
1531
|
+
function previewPageHtml(name, rel, size, downloadHref, body, inlineHref = "") {
|
|
1532
|
+
return `<!DOCTYPE html>
|
|
1533
|
+
<html lang="zh-CN">
|
|
1534
|
+
<head>
|
|
1535
|
+
<meta charset="utf-8">
|
|
1536
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1537
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
1538
|
+
<style>
|
|
1539
|
+
* { box-sizing: border-box; }
|
|
1540
|
+
:root {
|
|
1541
|
+
--lp-bg:#0f1720; --lp-fg:#e5e7eb; --lp-bar-bg:#1a2530; --lp-border:#2c3a47;
|
|
1542
|
+
--lp-meta:#9ca3af; --lp-text-bg:#0b1219; --lp-hover:#2c3a47; --lp-btn2-fg:#e5e7eb;
|
|
1543
|
+
}
|
|
1544
|
+
@media (prefers-color-scheme: light) {
|
|
1545
|
+
:root {
|
|
1546
|
+
--lp-bg:#ffffff; --lp-fg:#1f2937; --lp-bar-bg:#f3f4f6; --lp-border:#e5e7eb;
|
|
1547
|
+
--lp-meta:#6b7280; --lp-text-bg:#f9fafb; --lp-hover:#e5e7eb; --lp-btn2-fg:#374151;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
body { margin:0; font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; background:var(--lp-bg); color:var(--lp-fg); }
|
|
1551
|
+
html, body { height:100%; }
|
|
1552
|
+
.bar { position:sticky; top:0; display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--lp-bar-bg); border-bottom:1px solid var(--lp-border); }
|
|
1553
|
+
.bar .name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1554
|
+
.bar .meta { color:var(--lp-meta); font-size:12px; }
|
|
1555
|
+
.bar .spacer { flex:1; }
|
|
1556
|
+
.btn, .btn2 { display:inline-flex; align-items:center; gap:6px; height:32px; padding:0 14px; border-radius:8px; font-size:13px; line-height:1; }
|
|
1557
|
+
.btn { background:#2563eb; color:#fff; text-decoration:none; }
|
|
1558
|
+
.btn:hover { background:#1d4ed8; }
|
|
1559
|
+
.btn2 { background:transparent; color:var(--lp-btn2-fg); border:1px solid var(--lp-border); cursor:pointer; }
|
|
1560
|
+
.btn2:hover { background:var(--lp-hover); }
|
|
1561
|
+
.btn .ic, .btn2 .ic { width:14px; height:14px; }
|
|
1562
|
+
.hint { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); background:#7c2d12; color:#fdba74; border-radius:8px; padding:10px 18px; font-size:13px; display:none; z-index:9; }
|
|
1563
|
+
.content { padding:20px; max-width:960px; margin:0 auto; }
|
|
1564
|
+
.text { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:10px; padding:16px; overflow:auto; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-word; }
|
|
1565
|
+
.md { line-height:1.8; font-size:14px; word-break:break-word; }
|
|
1566
|
+
.md h1,.md h2,.md h3,.md h4,.md h5,.md h6 { line-height:1.45; margin:1.7em 0 .8em; }
|
|
1567
|
+
.md h1 { font-size:1.5em; }
|
|
1568
|
+
.md h2 { font-size:1.3em; }
|
|
1569
|
+
.md h3 { font-size:1.15em; }
|
|
1570
|
+
.md h1:first-child,.md h2:first-child,.md h3:first-child { margin-top:.6em; }
|
|
1571
|
+
.md p { margin:1em 0; }
|
|
1572
|
+
.md a { color:#60a5fa; text-decoration:none; }
|
|
1573
|
+
.md a:hover { text-decoration:underline; }
|
|
1574
|
+
.md code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:4px; padding:1px 5px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.9em; }
|
|
1575
|
+
.md pre.md-code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:8px; padding:12px; overflow:auto; }
|
|
1576
|
+
.md pre.md-code code { background:none; border:none; padding:0; font-size:12px; line-height:1.6; }
|
|
1577
|
+
.md blockquote { margin:.8em 0; padding:.2em 1em; border-left:3px solid var(--lp-border); color:var(--lp-meta); }
|
|
1578
|
+
.md ul,.md ol { margin:.6em 0; padding-left:1.6em; }
|
|
1579
|
+
.md li { margin:.2em 0; }
|
|
1580
|
+
.md table { border-collapse:collapse; margin:.8em 0; max-width:100%; display:block; overflow:auto; }
|
|
1581
|
+
.md th,.md td { border:1px solid var(--lp-border); padding:6px 10px; font-size:13px; }
|
|
1582
|
+
.md th { background:var(--lp-bar-bg); font-weight:600; }
|
|
1583
|
+
.md img { max-width:100%; border-radius:8px; }
|
|
1584
|
+
.md hr { border:none; border-top:1px solid var(--lp-border); margin:1.5em 0; }
|
|
1585
|
+
.md del { color:var(--lp-meta); }
|
|
1586
|
+
body.maximized .md { max-width:none; }
|
|
1587
|
+
.image { max-width:100%; border-radius:8px; }
|
|
1588
|
+
.pdf { display:block; width:100%; height:86vh; border:1px solid var(--lp-border); border-radius:8px; background:#fff; }
|
|
1589
|
+
.office { background:#fff; color:#111; border-radius:8px; padding:16px; overflow:auto; }
|
|
1590
|
+
.unsupported { color:#f59e0b; text-align:center; padding:40px 0; }
|
|
1591
|
+
/* 放大模式:顶栏常驻(固定悬浮),内容占满视口、PDF 全高 */
|
|
1592
|
+
body.maximized .bar { position:fixed; top:0; left:0; right:0; z-index:20; }
|
|
1593
|
+
body.maximized .content { max-width:none; padding:0; margin:0; height:100vh; padding-top:53px; }
|
|
1594
|
+
body.maximized .md { padding:24px 32px 48px; max-width:820px; margin:0 auto; }
|
|
1595
|
+
body.maximized .pdf { height:calc(100vh - 53px); border:none; border-radius:0; }
|
|
1596
|
+
body.maximized .text { height:calc(100vh - 53px); border:none; border-radius:0; overflow:auto; }
|
|
1597
|
+
body.maximized .office { height:calc(100vh - 53px); overflow:auto; }
|
|
1598
|
+
body.maximized .image { max-width:100vw; max-height:100vh; object-fit:contain; }
|
|
1599
|
+
@media (max-width: 767px) {
|
|
1600
|
+
.bar { flex-wrap:wrap; gap:8px; padding:8px 10px; }
|
|
1601
|
+
.bar .name { font-size:13px; }
|
|
1602
|
+
.btn, .btn2 { padding:4px 12px; font-size:12px; border-radius:7px; }
|
|
1603
|
+
}
|
|
1604
|
+
</style>
|
|
1605
|
+
</head>
|
|
1606
|
+
<body>
|
|
1607
|
+
<div class="bar">
|
|
1608
|
+
<span class="name">${escapeHtml(name)}</span>
|
|
1609
|
+
<span class="meta">${escapeHtml(rel)} · ${(size / 1024).toFixed(1)} KB</span>
|
|
1610
|
+
<span class="spacer"></span>
|
|
1611
|
+
${inlineHref ? `<a class="btn" href="${escapeHtml(inlineHref)}" target="_blank" rel="noopener noreferrer">${ICON_EYE} 打开</a>` : ""}
|
|
1612
|
+
<a class="btn" href="${escapeHtml(downloadHref)}" download>${ICON_DL} 下载</a>
|
|
1613
|
+
<button class="btn2" type="button" id="maxBtn" onclick="toggleMax()">${ICON_FOLDER} 放大</button>
|
|
1614
|
+
<button class="btn2" type="button" onclick="closePreview()">${ICON_X} 关闭</button>
|
|
1615
|
+
</div>
|
|
1616
|
+
<div class="content">${body}</div>
|
|
1617
|
+
<div class="hint" id="closeHint">浏览器不允许脚本直接关闭此标签页,请手动关闭本标签页(或按 Ctrl+W / ⌘+W)。</div>
|
|
1618
|
+
<script>
|
|
1619
|
+
function toggleMax() {
|
|
1620
|
+
var max = document.body.classList.toggle('maximized');
|
|
1621
|
+
document.getElementById('maxBtn').textContent = max ? '还原' : '放大';
|
|
1622
|
+
}
|
|
1623
|
+
function closePreview() {
|
|
1624
|
+
// 从文件列表页进入(URL 带 from=list)→ 返回列表页,只关掉当前文件
|
|
1625
|
+
var fromList = /[?&]from=list(&|$)/.test(location.search);
|
|
1626
|
+
try {
|
|
1627
|
+
if (window.self !== window.top && window.parent) {
|
|
1628
|
+
// 在 iframe(会话内联预览)里:
|
|
1629
|
+
// 从列表页进入 → 返回列表;直接打开 → 通知父窗口关闭整个面板
|
|
1630
|
+
if (fromList) { if (window.history.length > 1) { window.history.back(); return; } }
|
|
1631
|
+
window.parent.postMessage({ type: 'dsh-close-preview' }, location.origin);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
} catch (e) { /* 跨域忽略 */ }
|
|
1635
|
+
if (fromList && window.history.length > 1) { window.history.back(); return; }
|
|
1636
|
+
window.close();
|
|
1637
|
+
setTimeout(function () { document.getElementById('closeHint').style.display = 'block'; }, 300);
|
|
1638
|
+
}
|
|
1639
|
+
</script>
|
|
1640
|
+
</body>
|
|
1641
|
+
</html>`;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Mount every route once the profile composes the webServer and credentials
|
|
1646
|
+
* services.
|
|
1647
|
+
* @param ctx - host plugin context.
|
|
1648
|
+
* @param config - optional profile override (trustedHosts, skillsRoot).
|
|
1649
|
+
*/
|
|
1650
|
+
export async function apply(ctx, config = {}) {
|
|
1651
|
+
const trustedHosts = Array.isArray(config.trustedHosts) ? [...config.trustedHosts] : [];
|
|
1652
|
+
const skillsRoot = resolve(config.skillsRoot ?? DEFAULT_SKILLS_ROOT);
|
|
1653
|
+
const onError = (error) => ctx.logger.error(error instanceof Error ? error : new Error(String(error)));
|
|
1654
|
+
|
|
1655
|
+
const handlers = createHandlers({ trustedHosts, onError });
|
|
1656
|
+
|
|
1657
|
+
// ---- md2docx 工具: Markdown → Word (.docx, 带页码) ----
|
|
1658
|
+
// 调用 md2docx.py (pandoc-free, python-docx + 页脚 PAGE 字段)。
|
|
1659
|
+
// 默认脚本随插件包分发(lib/md2docx.py),换机器也可靠;可用 config.md2docxScript 覆盖。
|
|
1660
|
+
const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
1661
|
+
const MD2DOCX_SCRIPT = resolve(config.md2docxScript ?? join(PACKAGE_DIR, "md2docx.py"));
|
|
1662
|
+
const runScript = (args) => new Promise((resolvePromise) => {
|
|
1663
|
+
const child = spawn("python3", [MD2DOCX_SCRIPT, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1664
|
+
let stdout = "", stderr = "";
|
|
1665
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
1666
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
1667
|
+
child.on("error", (error) => resolvePromise({ ok: false, error: String(error), stdout, stderr }));
|
|
1668
|
+
child.on("close", (code) => resolvePromise({ ok: code === 0, code, stdout, stderr }));
|
|
1669
|
+
});
|
|
1670
|
+
ctx.tools.register(defineTool({
|
|
1671
|
+
name: "md2docx",
|
|
1672
|
+
description: "Convert a Markdown file to a styled Word (.docx) document with a page-number footer. Ships a bundled python-docx script (lib/md2docx.py) that renders headings, tables, bold/italic, and lists; the docx includes a footer '第 N 页' field that updates when opened in Word or exported to PDF. Requires python3 and python-docx installed on the host. Override the script path via config.md2docxScript if needed.",
|
|
1673
|
+
parameters: {
|
|
1674
|
+
input: {
|
|
1675
|
+
type: "string",
|
|
1676
|
+
required: true,
|
|
1677
|
+
description: "Absolute path to the input .md file."
|
|
1678
|
+
},
|
|
1679
|
+
output: {
|
|
1680
|
+
type: "string",
|
|
1681
|
+
description: "Optional absolute path for the output .docx. Defaults to the input path with a .docx extension."
|
|
1682
|
+
}
|
|
1683
|
+
},
|
|
1684
|
+
output: {
|
|
1685
|
+
schema: {
|
|
1686
|
+
type: "object",
|
|
1687
|
+
additionalProperties: false,
|
|
1688
|
+
properties: {
|
|
1689
|
+
ok: { type: "boolean", required: true },
|
|
1690
|
+
docxPath: { type: "string" },
|
|
1691
|
+
error: { type: "string" }
|
|
1692
|
+
}
|
|
1693
|
+
},
|
|
1694
|
+
render: (_args, value) => [{
|
|
1695
|
+
type: "text",
|
|
1696
|
+
text: value && value.ok === true
|
|
1697
|
+
? `已生成 Word 文档:${value.docxPath}\n(含页码页脚,Word/另存 PDF 时自动更新)`
|
|
1698
|
+
: `md2docx 失败:${value?.error ?? "未知错误"}`
|
|
1699
|
+
}]
|
|
1700
|
+
},
|
|
1701
|
+
// 声明交付物:DSH 从 presentCall 的 locations 识别本工具产出的文件,
|
|
1702
|
+
// 从而把 docx 渲染成可点击的交付物卡片(消息里的文件引用也能打开)。
|
|
1703
|
+
presentCall: (args) => {
|
|
1704
|
+
const inPath = resolve(String(args.input ?? ""));
|
|
1705
|
+
const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".docx");
|
|
1706
|
+
return {
|
|
1707
|
+
card: "generic",
|
|
1708
|
+
title: "md2docx",
|
|
1709
|
+
kind: "edit",
|
|
1710
|
+
locations: [{ path: outPath }]
|
|
1711
|
+
};
|
|
1712
|
+
},
|
|
1713
|
+
async execute(args) {
|
|
1714
|
+
const inPath = resolve(String(args.input ?? ""));
|
|
1715
|
+
const outPath = args.output ? resolve(String(args.output)) : inPath.replace(/\.md$/i, ".docx");
|
|
1716
|
+
const result = await runScript([inPath, outPath]);
|
|
1717
|
+
if (!result.ok) {
|
|
1718
|
+
return { ok: false, error: (result.stderr || result.stdout || String(result.error)).trim() || `md2docx failed (exit ${result.code})` };
|
|
1719
|
+
}
|
|
1720
|
+
return { ok: true, docxPath: outPath };
|
|
1721
|
+
}
|
|
1722
|
+
}));
|
|
1723
|
+
|
|
1724
|
+
await sweepUploadTemps(handlers.root);
|
|
1725
|
+
|
|
1726
|
+
const requireTrusted = (req) => {
|
|
1727
|
+
if (!isTrustedUploadRequest(req, trustedHosts)) throw new HttpError(403, "forbidden");
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1731
|
+
kind: "exact",
|
|
1732
|
+
path: API_PATH,
|
|
1733
|
+
handler: handlers.api,
|
|
1734
|
+
}), "dsh-long-plugins: upload/list/delete route");
|
|
1735
|
+
|
|
1736
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1737
|
+
kind: "exact",
|
|
1738
|
+
path: DOWNLOAD_PATH,
|
|
1739
|
+
handler: handlers.download,
|
|
1740
|
+
}), "dsh-long-plugins: download route");
|
|
1741
|
+
|
|
1742
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1743
|
+
kind: "exact",
|
|
1744
|
+
path: PREVIEW_PATH,
|
|
1745
|
+
handler: handlers.preview,
|
|
1746
|
+
}), "dsh-long-plugins: preview route");
|
|
1747
|
+
|
|
1748
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1749
|
+
kind: "exact",
|
|
1750
|
+
path: "/api/dsh-uploads/workspace",
|
|
1751
|
+
handler: handlers.workspaceList,
|
|
1752
|
+
}), "dsh-long-plugins: workspace list route");
|
|
1753
|
+
|
|
1754
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1755
|
+
kind: "exact",
|
|
1756
|
+
path: "/api/dsh-uploads/workspace-file",
|
|
1757
|
+
handler: handlers.workspaceFile,
|
|
1758
|
+
}), "dsh-long-plugins: workspace file route");
|
|
1759
|
+
|
|
1760
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1761
|
+
kind: "exact",
|
|
1762
|
+
path: "/api/dsh-uploads/workspace-preview",
|
|
1763
|
+
handler: handlers.workspacePreview,
|
|
1764
|
+
}), "dsh-long-plugins: workspace preview route");
|
|
1765
|
+
|
|
1766
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1767
|
+
kind: "exact",
|
|
1768
|
+
path: "/api/dsh-uploads/docx-preview",
|
|
1769
|
+
handler: handlers.docxPreviewPage,
|
|
1770
|
+
}), "dsh-long-plugins: docx real-preview route");
|
|
1771
|
+
|
|
1772
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1773
|
+
kind: "exact",
|
|
1774
|
+
path: "/api/dsh-uploads/docx-preview-asset",
|
|
1775
|
+
handler: handlers.docxPreviewAsset,
|
|
1776
|
+
}), "dsh-long-plugins: docx-preview vendor assets");
|
|
1777
|
+
|
|
1778
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1779
|
+
kind: "exact",
|
|
1780
|
+
path: "/api/dsh-uploads/pptx-preview",
|
|
1781
|
+
handler: handlers.pptxPreviewPage,
|
|
1782
|
+
}), "dsh-long-plugins: pptx real-preview route");
|
|
1783
|
+
|
|
1784
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1785
|
+
kind: "exact",
|
|
1786
|
+
path: "/api/dsh-uploads/workspace-browse",
|
|
1787
|
+
handler: handlers.workspaceBrowse,
|
|
1788
|
+
}), "dsh-long-plugins: workspace browse route");
|
|
1789
|
+
|
|
1790
|
+
// 聊天 markdown 内联用的 SVG 图标文件(eye/download)
|
|
1791
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1792
|
+
kind: "prefix",
|
|
1793
|
+
path: "/api/dsh-uploads/icons",
|
|
1794
|
+
handler: (req, res) => {
|
|
1795
|
+
try {
|
|
1796
|
+
requireTrusted(req);
|
|
1797
|
+
const name = new URL(req.url || "/", "http://dsh.internal").pathname.split("/").pop() || "";
|
|
1798
|
+
const svg = ICON_FILES[name];
|
|
1799
|
+
if (svg === undefined) {
|
|
1800
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1801
|
+
res.end("not found");
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" });
|
|
1805
|
+
res.end(svg);
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
sendError(res, error, onError);
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
}), "dsh-long-plugins: chat icon files route");
|
|
1811
|
+
|
|
1812
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1813
|
+
kind: "exact",
|
|
1814
|
+
path: "/api/dsh-uploads/workspace-file/delete",
|
|
1815
|
+
handler: handlers.workspaceDelete,
|
|
1816
|
+
}), "dsh-long-plugins: workspace delete route");
|
|
1817
|
+
|
|
1818
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1819
|
+
kind: "exact",
|
|
1820
|
+
path: "/api/dsh-uploads/workspace-file/save",
|
|
1821
|
+
handler: handlers.workspaceSave,
|
|
1822
|
+
}), "dsh-long-plugins: workspace save route");
|
|
1823
|
+
|
|
1824
|
+
// ---- 技能文档 (skill docs) routes ----
|
|
1825
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1826
|
+
kind: "exact",
|
|
1827
|
+
path: "/dsh-skill-docs/skill-docs",
|
|
1828
|
+
handler: async (req, res) => {
|
|
1829
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1830
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
try {
|
|
1834
|
+
requireTrusted(req);
|
|
1835
|
+
const groups = await collectGroups(skillsRoot, "__dsh_none__");
|
|
1836
|
+
sendJson(res, 200, { ok: true, root: skillsRoot, groups });
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
sendError(res, error, onError);
|
|
1839
|
+
}
|
|
1840
|
+
},
|
|
1841
|
+
}), "dsh-long-plugins: skill-docs list route");
|
|
1842
|
+
|
|
1843
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1844
|
+
kind: "exact",
|
|
1845
|
+
path: "/dsh-skill-docs/skill-doc",
|
|
1846
|
+
handler: async (req, res) => {
|
|
1847
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1848
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
let rel;
|
|
1852
|
+
try {
|
|
1853
|
+
rel = decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
1854
|
+
} catch {
|
|
1855
|
+
rel = "";
|
|
1856
|
+
}
|
|
1857
|
+
try {
|
|
1858
|
+
requireTrusted(req);
|
|
1859
|
+
const full = safeResolve(skillsRoot, rel);
|
|
1860
|
+
if (full === undefined) throw new HttpError(400, "bad path");
|
|
1861
|
+
const info = await stat(full);
|
|
1862
|
+
if (!info.isFile()) throw new HttpError(400, "not a file");
|
|
1863
|
+
const download = new URL(req.url || "/", "http://dsh.internal").searchParams.get("download") === "1";
|
|
1864
|
+
const name = rel.split("/").pop() || "file";
|
|
1865
|
+
if (download) {
|
|
1866
|
+
res.writeHead(200, {
|
|
1867
|
+
"content-type": "application/octet-stream",
|
|
1868
|
+
"content-disposition": contentDisposition(name),
|
|
1869
|
+
"content-length": String(info.size),
|
|
1870
|
+
"cache-control": "no-store",
|
|
1871
|
+
});
|
|
1872
|
+
const stream = createReadStream(full);
|
|
1873
|
+
stream.on("error", (error) => res.destroy(error));
|
|
1874
|
+
stream.pipe(res);
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
const buffer = await readFile(full);
|
|
1878
|
+
const binary = buffer.subarray(0, 8192).includes(0);
|
|
1879
|
+
const truncated = buffer.length > PREVIEW_LIMIT;
|
|
1880
|
+
sendJson(res, 200, {
|
|
1881
|
+
ok: true,
|
|
1882
|
+
path: rel,
|
|
1883
|
+
name,
|
|
1884
|
+
size: info.size,
|
|
1885
|
+
mtime: info.mtimeMs,
|
|
1886
|
+
binary,
|
|
1887
|
+
truncated,
|
|
1888
|
+
contentType: contentType(name),
|
|
1889
|
+
content: binary || truncated ? undefined : buffer.subarray(0, PREVIEW_LIMIT).toString("utf8"),
|
|
1890
|
+
});
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
const code = error && typeof error === "object" && error.code;
|
|
1893
|
+
sendError(res, error, onError);
|
|
1894
|
+
}
|
|
1895
|
+
},
|
|
1896
|
+
}), "dsh-long-plugins: skill-doc route");
|
|
1897
|
+
|
|
1898
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1899
|
+
kind: "exact",
|
|
1900
|
+
path: "/dsh-skill-docs/skill-doc/save",
|
|
1901
|
+
handler: async (req, res) => {
|
|
1902
|
+
if (req.method !== "POST") {
|
|
1903
|
+
methodNotAllowed(res, ["POST"]);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
try {
|
|
1907
|
+
requireTrusted(req);
|
|
1908
|
+
const body = await readJsonBody(req);
|
|
1909
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
1910
|
+
const content = typeof body === "object" && body !== null ? body.content : undefined;
|
|
1911
|
+
if (typeof content !== "string") throw new HttpError(400, "content required");
|
|
1912
|
+
const full = safeResolve(skillsRoot, rel);
|
|
1913
|
+
if (full === undefined) throw new HttpError(400, "bad path");
|
|
1914
|
+
const info = await stat(full);
|
|
1915
|
+
if (!info.isFile()) throw new HttpError(400, "not a file");
|
|
1916
|
+
await writeFile(full, content, "utf8");
|
|
1917
|
+
sendJson(res, 200, { ok: true, path: rel });
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
sendError(res, error, onError);
|
|
1920
|
+
}
|
|
1921
|
+
},
|
|
1922
|
+
}), "dsh-long-plugins: skill-doc save route");
|
|
1923
|
+
|
|
1924
|
+
// ---- 余额 (account balance) route ----
|
|
1925
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1926
|
+
kind: "exact",
|
|
1927
|
+
path: "/dsh-token-usage/balance",
|
|
1928
|
+
handler: async (req, res) => {
|
|
1929
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1930
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
try {
|
|
1934
|
+
requireTrusted(req);
|
|
1935
|
+
const resolved = await ctx.credentials.resolve("DEEPSEEK_API_KEY");
|
|
1936
|
+
if (resolved === undefined) {
|
|
1937
|
+
sendJson(res, 503, { ok: false, error: "no-api-key" });
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const upstream = await fetch(BALANCE_URL, {
|
|
1941
|
+
headers: {
|
|
1942
|
+
Authorization: `Bearer ${resolved.value}`,
|
|
1943
|
+
Accept: "application/json",
|
|
1944
|
+
},
|
|
1945
|
+
signal: AbortSignal.timeout(10000),
|
|
1946
|
+
});
|
|
1947
|
+
const text = await upstream.text();
|
|
1948
|
+
res.writeHead(upstream.status, {
|
|
1949
|
+
"content-type": "application/json; charset=utf-8",
|
|
1950
|
+
"cache-control": "no-store",
|
|
1951
|
+
});
|
|
1952
|
+
res.end(text);
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
sendJson(res, 502, { ok: false, error: String(error instanceof Error ? error.message : error) });
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
}), "dsh-long-plugins: balance route");
|
|
1958
|
+
|
|
1959
|
+
// ---- 本会话消费 (session spend) route ----
|
|
1960
|
+
// Reads the live session's event log and prices every assistant/message
|
|
1961
|
+
// usage sample against DeepSeek V4-Flash official pricing (effective
|
|
1962
|
+
// 2026-08-17, peak/off-peak split). Peak hours are Beijing time
|
|
1963
|
+
// 09:00-12:00 and 14:00-18:00; off-peak is everything else.
|
|
1964
|
+
const SESSION_PRICE_PEAK = { input: 3, cache: 0.1, output: 9 }; // ¥ per 1M tokens
|
|
1965
|
+
const SESSION_PRICE_OFFPEAK = { input: 1.5, cache: 0.05, output: 4.5 };
|
|
1966
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1967
|
+
kind: "exact",
|
|
1968
|
+
path: "/dsh-token-usage/session-cost",
|
|
1969
|
+
handler: async (req, res) => {
|
|
1970
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1971
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
try {
|
|
1975
|
+
requireTrusted(req);
|
|
1976
|
+
const url = new URL(req.url ?? "", "http://localhost");
|
|
1977
|
+
const sessionId = url.searchParams.get("session");
|
|
1978
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
1979
|
+
sendJson(res, 400, { ok: false, error: "session required" });
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
const session = ctx.sessions?.get(sessionId);
|
|
1983
|
+
let events;
|
|
1984
|
+
if (session !== undefined) {
|
|
1985
|
+
events = session.events;
|
|
1986
|
+
} else if (ctx.sessionPersistence !== undefined) {
|
|
1987
|
+
// Cold (historical) session: restore through the durable persistence
|
|
1988
|
+
// backend. inspect() prefers the live store and falls back to disk.
|
|
1989
|
+
const inspected = await ctx.sessionPersistence.inspect(sessionId);
|
|
1990
|
+
events = inspected && Array.isArray(inspected.events) ? inspected.events : undefined;
|
|
1991
|
+
}
|
|
1992
|
+
if (events === undefined) {
|
|
1993
|
+
sendJson(res, 404, { ok: false, error: "session not found" });
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
let input = 0, output = 0, cacheRead = 0;
|
|
1997
|
+
let peakCny = 0, offPeakCny = 0;
|
|
1998
|
+
for (const ev of events) {
|
|
1999
|
+
if (ev.type !== "assistant/message") continue;
|
|
2000
|
+
const usage = ev.data && ev.data.usage;
|
|
2001
|
+
if (usage == null || typeof usage !== "object") continue;
|
|
2002
|
+
const inT = Number(usage.inputTokens) || 0;
|
|
2003
|
+
const outT = Number(usage.outputTokens) || 0;
|
|
2004
|
+
const cR = Number(usage.cacheReadTokens) || 0;
|
|
2005
|
+
if (inT + outT + cR <= 0) continue;
|
|
2006
|
+
input += inT; output += outT; cacheRead += cR;
|
|
2007
|
+
// Beijing-time peak check (server local time is CST on this host,
|
|
2008
|
+
// but compute against UTC+8 explicitly to be safe).
|
|
2009
|
+
const d = new Date(ev.time);
|
|
2010
|
+
const bjHour = (d.getUTCHours() + 8) % 24;
|
|
2011
|
+
const isPeak = (bjHour >= 9 && bjHour < 12) || (bjHour >= 14 && bjHour < 18);
|
|
2012
|
+
const price = isPeak ? SESSION_PRICE_PEAK : SESSION_PRICE_OFFPEAK;
|
|
2013
|
+
const cny = (inT * price.input + cR * price.cache + outT * price.output) / 1e6;
|
|
2014
|
+
if (isPeak) peakCny += cny; else offPeakCny += cny;
|
|
2015
|
+
}
|
|
2016
|
+
sendJson(res, 200, {
|
|
2017
|
+
ok: true,
|
|
2018
|
+
sessionId,
|
|
2019
|
+
tokens: { input, output, cacheRead },
|
|
2020
|
+
cny: { peak: Number(peakCny.toFixed(4)), offPeak: Number(offPeakCny.toFixed(4)), total: Number((peakCny + offPeakCny).toFixed(4)) },
|
|
2021
|
+
});
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
sendError(res, error, onError);
|
|
2024
|
+
}
|
|
2025
|
+
},
|
|
2026
|
+
}), "dsh-long-plugins: session-cost route");
|
|
2027
|
+
}
|