0.3.3 外网下载修复
This commit is contained in:
@@ -16,6 +16,18 @@ import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/li
|
||||
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import {
|
||||
DEFAULT_AGENT_MAX_STEPS,
|
||||
MAX_AGENT_STEPS,
|
||||
MIN_AGENT_STEPS,
|
||||
DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
||||
MAX_MINDMAP_ATTACHMENTS,
|
||||
MAX_SELECTED_NODES,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
} from "@/lib/constants";
|
||||
import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils";
|
||||
import { isPlainObject, hasProperty, toRecord } from "@/lib/type-guards";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -39,8 +51,46 @@ type RequestPayload = {
|
||||
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_STEPS = 10;
|
||||
const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
/** 按作用域分组的工具集 ID 映射 */
|
||||
const SCOPE_TOOLSET_MAP: Record<AgentScope, string[]> = {
|
||||
mindmap: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.mindmap_read",
|
||||
"toolset.mindmap_write",
|
||||
],
|
||||
document: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.doc_read",
|
||||
"toolset.doc_write",
|
||||
"toolset.slash_write",
|
||||
],
|
||||
onlyoffice: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.onlyoffice_read",
|
||||
"toolset.onlyoffice_write",
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
global: [
|
||||
"toolset.readonly",
|
||||
"toolset.rag_read",
|
||||
"toolset.media_read",
|
||||
"toolset.docs_read",
|
||||
"toolset.slash_write",
|
||||
],
|
||||
};
|
||||
|
||||
/** 获取指定作用域允许的工具集 ID */
|
||||
const getToolSetIdsForScope = (scope: AgentScope): string[] => {
|
||||
return SCOPE_TOOLSET_MAP[scope] ?? SCOPE_TOOLSET_MAP.global;
|
||||
};
|
||||
|
||||
const makeRunId = () => {
|
||||
try {
|
||||
@@ -67,22 +117,31 @@ const toSseFrame = (event: string, data: unknown) => {
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||
if (!payload || !Array.isArray(payload.messages) || payload.messages.length === 0) {
|
||||
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
|
||||
const payload = await safeGetJsonBody<RequestPayload>(request);
|
||||
if (!payload) {
|
||||
return errorResponses.badRequest("请求体不能为空");
|
||||
}
|
||||
const validationError = validateRequestBody(payload, ["messages"] as const);
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
if (!Array.isArray(payload.messages) || payload.messages.length === 0) {
|
||||
return errorResponses.badRequest("缺少 messages");
|
||||
}
|
||||
|
||||
// v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session)
|
||||
const convexOn = isConvexEnabled();
|
||||
const { userId, supabase, convexClient } = await (async () => {
|
||||
if (convexOn) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
||||
}
|
||||
return { userId: "", supabase: null as any, convexClient: null as any };
|
||||
})();
|
||||
const supabase = null as unknown;
|
||||
let userId = "";
|
||||
let convexClient: ConvexHttpClient | null = null;
|
||||
|
||||
if (convexOn) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
userId = auth.userId ?? "";
|
||||
convexClient = client;
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
|
||||
@@ -92,11 +151,7 @@ export async function POST(request: Request) {
|
||||
? await loadLocalAiConfig().catch(() => null)
|
||||
: await loadOnlineAiConfig().catch(() => null);
|
||||
if (!cfg) {
|
||||
const tip =
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)";
|
||||
return NextResponse.json({ error: tip }, { status: 500 });
|
||||
return errorResponses.aiConfigError(provider);
|
||||
}
|
||||
|
||||
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
|
||||
@@ -116,15 +171,8 @@ export async function POST(request: Request) {
|
||||
return mindmapId ? "mindmap" : "global";
|
||||
})();
|
||||
|
||||
// v1:按“使用位置”隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤)
|
||||
const allowToolSetIds: string[] =
|
||||
scope === "mindmap"
|
||||
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.mindmap_read", "toolset.mindmap_write"]
|
||||
: scope === "document"
|
||||
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.doc_read", "toolset.doc_write", "toolset.slash_write"]
|
||||
: scope === "onlyoffice"
|
||||
? ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.onlyoffice_read", "toolset.onlyoffice_write", "toolset.onlyoffice_editor"]
|
||||
: ["toolset.readonly", "toolset.rag_read", "toolset.media_read", "toolset.docs_read", "toolset.slash_write"];
|
||||
// v1:按"使用位置"隔离工具,避免工具混淆/误调用(即使用户手动传入,也会被过滤)
|
||||
const allowToolSetIds = getToolSetIdsForScope(scope);
|
||||
const allowlist = new Set<string>();
|
||||
for (const sid of allowToolSetIds) {
|
||||
const s = registry.toolSetsById.get(sid);
|
||||
@@ -139,12 +187,12 @@ export async function POST(request: Request) {
|
||||
|
||||
// v1:mindmap 工具必须在提供上下文时才允许,避免模型盲调导致误操作
|
||||
const selectedUids = Array.isArray(payload.context?.selectedUids)
|
||||
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, 6)
|
||||
? payload.context!.selectedUids!.map((x) => String(x)).filter(Boolean).slice(0, MAX_SELECTED_NODES)
|
||||
: [];
|
||||
const hasMindmapContext = Boolean(documentId && mindmapId);
|
||||
const hasDocumentContext = Boolean(documentId);
|
||||
const documentBlocks = payload.context?.documentBlocks ?? null;
|
||||
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : [];
|
||||
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, MAX_MINDMAP_ATTACHMENTS) : [];
|
||||
const attachmentLines = attachments
|
||||
.map((a, idx) => `${idx + 1}. id=${String(a.id)} title=${String(a.title)} mime=${String(a.mimeType ?? "")} url=${String(a.fileUrl)}`)
|
||||
.join("\n");
|
||||
@@ -251,8 +299,8 @@ export async function POST(request: Request) {
|
||||
loadMindmap: async () => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const [mm, meta] = await Promise.all([
|
||||
convexClient.query(api.mindmaps.get, { userId, docId: documentId, mindmapId }),
|
||||
convexClient.query(api.documents.getMeta, { userId, id: documentId }),
|
||||
convexClient.query(api.mindmaps.get, { docId: documentId, mindmapId }),
|
||||
convexClient.query(api.documents.getMeta, { id: documentId }),
|
||||
]);
|
||||
const title = meta?.title ?? null;
|
||||
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
|
||||
@@ -263,7 +311,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
saveMindmap: async ({ data }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
|
||||
await convexClient.mutation(api.mindmaps.put, { docId: documentId, mindmapId, data });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -286,13 +334,13 @@ export async function POST(request: Request) {
|
||||
const base = normalizeBlocksForTools(documentBlocks);
|
||||
if (base.length > 0) return { blocks: base, source: "client" };
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const res = await convexClient.query(api.documents.getContent, { userId, id: documentId });
|
||||
const res = await convexClient.query(api.documents.getContent, { id: documentId });
|
||||
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
||||
return { blocks, source: "convex" };
|
||||
},
|
||||
saveBlocks: async (blocks: unknown[]) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateContent, { userId, id: documentId, content: blocks });
|
||||
await convexClient.mutation(api.documents.updateContent, { id: documentId, content: blocks });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -318,15 +366,15 @@ export async function POST(request: Request) {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const wsIds = workspaceId
|
||||
? [workspaceId]
|
||||
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) =>
|
||||
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {}))?.workspaces ?? []).map((w: any) =>
|
||||
String(w?.id ?? ""),
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
const results: any[] = [];
|
||||
for (const wid of wsIds.filter(Boolean)) {
|
||||
const docs = await convexClient.query(api.documents.listByWorkspace, { userId, workspaceId: wid });
|
||||
const docs = await convexClient.query(api.documents.listByWorkspace, { workspaceId: wid });
|
||||
const extra = includeDeleted
|
||||
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
|
||||
? await convexClient.query(api.documents.listTrashedByWorkspace, { workspaceId: wid }).catch(() => [])
|
||||
: [];
|
||||
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
|
||||
for (const d of all) {
|
||||
@@ -348,9 +396,9 @@ export async function POST(request: Request) {
|
||||
},
|
||||
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { id: rid });
|
||||
if (!meta) throw new Error("页面不存在");
|
||||
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
|
||||
const contentRes = await convexClient.query(api.documents.getContent, { id: rid });
|
||||
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
|
||||
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
|
||||
return {
|
||||
@@ -403,21 +451,20 @@ export async function POST(request: Request) {
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadWorkspaceIds: async (uid: string) => {
|
||||
loadWorkspaceIds: async (_uid: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
|
||||
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, {});
|
||||
return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean);
|
||||
},
|
||||
inferWorkspaceIdFromDoc: async (docId: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: docId });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { id: docId });
|
||||
return meta ? String((meta as any).workspace_id ?? "") || null : null;
|
||||
},
|
||||
createDoc: async ({ workspaceId, parentId, title }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const created = await convexClient.mutation(api.documents.create, {
|
||||
userId,
|
||||
id,
|
||||
workspaceId,
|
||||
parentId,
|
||||
@@ -436,8 +483,8 @@ export async function POST(request: Request) {
|
||||
},
|
||||
renameDoc: async ({ documentId: did, title }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateTitle, { userId, id: did, title });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: did });
|
||||
await convexClient.mutation(api.documents.updateTitle, { id: did, title });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { id: did });
|
||||
if (!meta) throw new Error("页面不存在");
|
||||
return {
|
||||
id: String((meta as any).id ?? did),
|
||||
@@ -458,8 +505,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (toolId === "search_web") {
|
||||
const query = String(toolArgs.query ?? "").trim();
|
||||
const count = Number(toolArgs.count ?? 6);
|
||||
return await searchSearxng(query, Number.isFinite(count) ? count : 6);
|
||||
const count = Number(toolArgs.count ?? DEFAULT_SEARCH_COUNT);
|
||||
return await searchSearxng(query, Number.isFinite(count) ? count : DEFAULT_SEARCH_COUNT);
|
||||
}
|
||||
if (toolId.startsWith("rag_")) {
|
||||
if (!ragTools) throw new Error(`工具未初始化:${toolId}`);
|
||||
@@ -492,9 +539,9 @@ export async function POST(request: Request) {
|
||||
};
|
||||
|
||||
const maxSteps = (() => {
|
||||
const raw = Number(payload.maxSteps ?? DEFAULT_MAX_STEPS);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_MAX_STEPS;
|
||||
return Math.max(1, Math.min(24, Math.floor(raw)));
|
||||
const raw = Number(payload.maxSteps ?? DEFAULT_AGENT_MAX_STEPS);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_AGENT_MAX_STEPS;
|
||||
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(raw)));
|
||||
})();
|
||||
|
||||
const stream = payload.stream !== false;
|
||||
|
||||
@@ -6,6 +6,41 @@ import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
@@ -40,7 +75,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl,
|
||||
signedUrl: maybeProxyForBrowser(request, signedUrl),
|
||||
asset: {
|
||||
id: asset.id,
|
||||
file_name: asset.file_name,
|
||||
|
||||
@@ -4,6 +4,41 @@ import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
@@ -23,9 +58,8 @@ export async function GET(request: Request) {
|
||||
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
return NextResponse.json({ signedUrl: fileUrl });
|
||||
return NextResponse.json({ signedUrl: maybeProxyForBrowser(request, fileUrl) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = decodeBase64UrlToUtf8(encoded);
|
||||
// eslint-disable-next-line no-new
|
||||
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import path from "path";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { BlockNoteEditor, BlockNoteSchema, markdownToBlocks } from "@blocknote/core";
|
||||
import type { Block } from "@blocknote/core";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { promises as fs } from "fs";
|
||||
import os from "os";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
let domShimReady = false;
|
||||
function ensureDomShim() {
|
||||
if (domShimReady) return;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
const g = globalThis as any;
|
||||
|
||||
// 说明:Node.js(以及 Next.js 运行时)可能已经提供了只读的 globalThis.navigator(getter-only)。
|
||||
// 这里仅在必要时注入 window/document 等基础对象,避免覆盖只读属性导致 500。
|
||||
if (!g.window) g.window = dom.window;
|
||||
if (!g.document) g.document = dom.window.document;
|
||||
if (!g.DOMParser) g.DOMParser = dom.window.DOMParser;
|
||||
if (!g.HTMLElement) g.HTMLElement = dom.window.HTMLElement;
|
||||
if (!g.Node) g.Node = dom.window.Node;
|
||||
domShimReady = true;
|
||||
}
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
type InputStore = {
|
||||
kind: "dir";
|
||||
baseDir: string;
|
||||
listFiles: () => Promise<string[]>;
|
||||
readText: (relPath: string) => Promise<string>;
|
||||
readBytes: (relPath: string) => Promise<Uint8Array>;
|
||||
exists: (relPath: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type ImportNode =
|
||||
| { kind: "doc"; key: string; title: string; mdPath: string; parentKey: string | null }
|
||||
| { kind: "folder"; key: string; title: string; dirPath: string; parentKey: string | null };
|
||||
|
||||
type TokenRecord =
|
||||
| { kind: "image"; token: string; url: string; alt: string }
|
||||
| { kind: "file"; token: string; url: string; label: string }
|
||||
| { kind: "page"; token: string; targetMdRel: string; label: string };
|
||||
|
||||
function normalizeZipPath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function isRemoteUrl(url: string): boolean {
|
||||
// 说明:Wolai 导出里可能包含 mailto/tel/#锚点等“非文件路径”链接,这里统一视为远端(不当作本地文件处理)。
|
||||
return /^(https?:\/\/|data:|mailto:|tel:)/i.test(url) || url.trim().startsWith("#");
|
||||
}
|
||||
|
||||
function stripAngleBrackets(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeBasename(filePath: string): string {
|
||||
const base = path.posix.basename(filePath);
|
||||
return base || "未命名资源";
|
||||
}
|
||||
|
||||
function parseTopHeadingTitle(markdown: string): string | null {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
const m = /^#\s+(.+)$/.exec(t);
|
||||
if (!m) return null;
|
||||
const title = m[1]?.trim() ?? "";
|
||||
return title || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripTopHeadingIfMatches(markdown: string, title: string): string {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
let idx = 0;
|
||||
while (idx < lines.length && !lines[idx].trim()) idx += 1;
|
||||
const first = lines[idx] ?? "";
|
||||
const m = /^#\s+(.+)$/.exec(first.trim());
|
||||
if (!m) return markdown;
|
||||
const head = (m[1] ?? "").trim();
|
||||
if (!head || head !== title.trim()) return markdown;
|
||||
const rest = [...lines.slice(0, idx), ...lines.slice(idx + 1)];
|
||||
while (rest.length > 0 && !rest[0].trim()) rest.shift();
|
||||
return rest.join("\n");
|
||||
}
|
||||
|
||||
function resolveRelativePosix(baseDirRel: string, rel: string): string {
|
||||
const cleaned = normalizeZipPath(stripAngleBrackets(rel));
|
||||
if (!cleaned) return cleaned;
|
||||
if (cleaned.startsWith("/")) return cleaned.replace(/^\/+/, "");
|
||||
return path.posix.normalize(path.posix.join(baseDirRel, cleaned));
|
||||
}
|
||||
|
||||
function stripMarkdownLinkTitle(raw: string): string {
|
||||
// 说明:处理 Wolai 导出的 title 属性:path "title"
|
||||
return raw.split(/\s+\"/)[0]?.trim() ?? raw.trim();
|
||||
}
|
||||
|
||||
function stripLocalQueryAndHash(url: string): string {
|
||||
// 说明:本地文件路径不应包含 ?query/#hash;否则会导致资源无法匹配。
|
||||
const noHash = url.split("#")[0] ?? url;
|
||||
const noQuery = (noHash ?? url).split("?")[0] ?? noHash ?? url;
|
||||
return noQuery.trim();
|
||||
}
|
||||
|
||||
function guessMimeType(fileName: string): string {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
case ".doc":
|
||||
return "application/msword";
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel";
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint";
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
case ".mp3":
|
||||
return "audio/mpeg";
|
||||
case ".wav":
|
||||
return "audio/wav";
|
||||
case ".m4a":
|
||||
return "audio/mp4";
|
||||
case ".mp4":
|
||||
return "video/mp4";
|
||||
case ".mov":
|
||||
return "video/quicktime";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssetTypeByMime(mime: string): "image" | "video" | "audio" | "file" {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function toFsPath(baseDir: string, relPath: string): string {
|
||||
const normalized = normalizeZipPath(relPath);
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return path.join(baseDir, ...parts);
|
||||
}
|
||||
|
||||
async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: Array<{ abs: string; rel: string }> = [{ abs: rootDir, rel: "" }];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
const entries = await fs.readdir(current.abs, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(current.abs, entry.name);
|
||||
const rel = current.rel ? `${current.rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
stack.push({ abs, rel });
|
||||
} else if (entry.isFile()) {
|
||||
out.push(normalizeZipPath(rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapePwshSingleQuoted(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
async function extractZipToTemp(zipPath: string): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "wolai-import-"));
|
||||
const literalZip = escapePwshSingleQuoted(zipPath);
|
||||
const literalOut = escapePwshSingleQuoted(tmp);
|
||||
const command = `Expand-Archive -LiteralPath '${literalZip}' -DestinationPath '${literalOut}' -Force`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("powershell", ["-NoProfile", "-Command", command], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (err) => reject(err));
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`解压失败(code=${code}):${stderr || "unknown"}`));
|
||||
});
|
||||
});
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
async function createDirStore(baseDir: string): Promise<InputStore> {
|
||||
const abs = path.resolve(baseDir);
|
||||
return {
|
||||
kind: "dir",
|
||||
baseDir: abs,
|
||||
listFiles: async () => await listFilesRecursive(abs),
|
||||
readText: async (relPath) => await fs.readFile(toFsPath(abs, relPath), "utf8"),
|
||||
readBytes: async (relPath) => new Uint8Array(await fs.readFile(toFsPath(abs, relPath))),
|
||||
exists: async (relPath) => {
|
||||
try {
|
||||
await fs.access(toFsPath(abs, relPath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_ASSET_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
function detectRootMdPath(mdPaths: string[]): { rootMdPath: string | null; candidates: string[] } {
|
||||
const normalized = mdPaths.map(normalizeZipPath);
|
||||
const topDirs = Array.from(new Set(normalized.map((p) => p.split("/")[0]).filter(Boolean)));
|
||||
if (topDirs.length === 1) {
|
||||
const top = topDirs[0]!;
|
||||
const candidates = normalized
|
||||
.filter((p) => p.startsWith(`${top}/`))
|
||||
.filter((p) => p.split("/").length === 2)
|
||||
.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
|
||||
if (candidates.length === 1) return { rootMdPath: candidates[0]!, candidates };
|
||||
if (candidates.length > 1) return { rootMdPath: null, candidates };
|
||||
}
|
||||
|
||||
const sorted = normalized
|
||||
.slice()
|
||||
.sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b, "zh-Hans-CN"));
|
||||
if (sorted.length === 0) return { rootMdPath: null, candidates: [] };
|
||||
return { rootMdPath: sorted[0]!, candidates: sorted.slice(0, 8) };
|
||||
}
|
||||
|
||||
function buildImportNodes(opts: {
|
||||
scopePrefix: string;
|
||||
rootMdPath: string;
|
||||
mdPaths: string[];
|
||||
}): { nodes: ImportNode[]; mdRelToKey: Map<string, string> } {
|
||||
const scope = opts.scopePrefix.endsWith("/") ? opts.scopePrefix : `${opts.scopePrefix}/`;
|
||||
const withinScope = opts.mdPaths.map(normalizeZipPath).filter((p) => p.startsWith(scope));
|
||||
const root = normalizeZipPath(opts.rootMdPath);
|
||||
const mdSet = new Set(withinScope);
|
||||
|
||||
const allDirs: Set<string> = new Set();
|
||||
for (const md of withinScope) {
|
||||
const dir = path.posix.dirname(md);
|
||||
const segments = dir.split("/").filter(Boolean);
|
||||
let accum = "";
|
||||
for (const seg of segments) {
|
||||
accum = accum ? `${accum}/${seg}` : seg;
|
||||
allDirs.add(`${accum}/`);
|
||||
}
|
||||
}
|
||||
|
||||
const representing: Map<string, string> = new Map();
|
||||
for (const dir of allDirs) {
|
||||
const dirName = dir.split("/").filter(Boolean).pop() ?? "";
|
||||
if (!dirName) continue;
|
||||
const prefer = `${dir}${dirName}.md`;
|
||||
const index = `${dir}index.md`;
|
||||
if (mdSet.has(prefer)) representing.set(dir, prefer);
|
||||
else if (mdSet.has(index)) representing.set(dir, index);
|
||||
}
|
||||
representing.set(scope, root);
|
||||
|
||||
// 说明:仅为“包含 markdown 的目录”创建合成文件夹页(避免 image/ 这类纯资源目录)。
|
||||
const folderNodes: Array<Extract<ImportNode, { kind: "folder" }>> = [];
|
||||
for (const dir of allDirs) {
|
||||
if (dir === scope) continue;
|
||||
if (representing.has(dir)) continue;
|
||||
folderNodes.push({
|
||||
kind: "folder",
|
||||
key: `__dir__${dir}`,
|
||||
title: (dir.split("/").filter(Boolean).pop() ?? "目录").trim() || "目录",
|
||||
dirPath: dir,
|
||||
parentKey: null,
|
||||
});
|
||||
}
|
||||
|
||||
const docNodes: Array<Extract<ImportNode, { kind: "doc" }>> = withinScope.map((md) => ({
|
||||
kind: "doc",
|
||||
key: md,
|
||||
title: path.posix.basename(md, ".md") || "无标题",
|
||||
mdPath: md,
|
||||
parentKey: null,
|
||||
}));
|
||||
|
||||
const nodesByKey = new Map<string, ImportNode>();
|
||||
for (const n of [...folderNodes, ...docNodes]) nodesByKey.set(n.key, n);
|
||||
|
||||
const getContainerKeyForDir = (dir: string): string => {
|
||||
const normalizedDir = dir.endsWith("/") ? dir : `${dir}/`;
|
||||
const rep = representing.get(normalizedDir);
|
||||
if (rep) return rep;
|
||||
return `__dir__${normalizedDir}`;
|
||||
};
|
||||
|
||||
for (const node of folderNodes) {
|
||||
const parentDir = path.posix.dirname(node.dirPath.endsWith("/") ? node.dirPath.slice(0, -1) : node.dirPath);
|
||||
const parentDirNorm = parentDir === "." ? "" : `${parentDir}/`;
|
||||
node.parentKey = parentDirNorm ? getContainerKeyForDir(parentDirNorm) : root;
|
||||
}
|
||||
|
||||
for (const node of docNodes) {
|
||||
if (node.mdPath === root) {
|
||||
node.parentKey = null;
|
||||
continue;
|
||||
}
|
||||
const dir = `${path.posix.dirname(node.mdPath)}/`;
|
||||
const rep = representing.get(dir);
|
||||
if (rep && rep !== node.mdPath) {
|
||||
node.parentKey = rep;
|
||||
continue;
|
||||
}
|
||||
if (rep && rep === node.mdPath) {
|
||||
const parentDir = path.posix.dirname(dir.endsWith("/") ? dir.slice(0, -1) : dir);
|
||||
const parentDirNorm = parentDir === "." ? "" : `${parentDir}/`;
|
||||
node.parentKey = parentDirNorm ? getContainerKeyForDir(parentDirNorm) : root;
|
||||
continue;
|
||||
}
|
||||
node.parentKey = getContainerKeyForDir(dir);
|
||||
}
|
||||
|
||||
for (const node of [...folderNodes, ...docNodes]) {
|
||||
if (node.key === root) continue;
|
||||
if (!node.parentKey) continue;
|
||||
if (!nodesByKey.has(node.parentKey)) node.parentKey = root;
|
||||
}
|
||||
|
||||
// 说明:构建 md 相对路径 -> key 的映射,用于内部链接替换
|
||||
const mdRelToKey = new Map<string, string>();
|
||||
for (const md of withinScope) {
|
||||
const rel = md.startsWith(scope) ? md.slice(scope.length) : md;
|
||||
mdRelToKey.set(rel, md);
|
||||
}
|
||||
|
||||
// 说明:输出需要按“父先子后”的顺序创建
|
||||
const all = [...folderNodes, ...docNodes];
|
||||
const depth = (key: string): number => {
|
||||
let d = 0;
|
||||
let cur = nodesByKey.get(key) ?? null;
|
||||
const seen = new Set<string>();
|
||||
while (cur && cur.parentKey) {
|
||||
if (seen.has(cur.parentKey)) break;
|
||||
seen.add(cur.parentKey);
|
||||
d += 1;
|
||||
cur = nodesByKey.get(cur.parentKey) ?? null;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
all.sort((a, b) => depth(a.key) - depth(b.key) || a.title.localeCompare(b.title, "zh-Hans-CN"));
|
||||
return { nodes: all, mdRelToKey };
|
||||
}
|
||||
|
||||
function extractStandaloneMarkdownLink(line: string): { label: string; href: string } | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.includes("](") || !trimmed.endsWith(")")) return null;
|
||||
const m = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(trimmed);
|
||||
if (!m) return null;
|
||||
const label = (m[1] ?? "").trim();
|
||||
const raw = (m[2] ?? "").trim();
|
||||
const href = stripAngleBrackets(stripMarkdownLinkTitle(raw));
|
||||
return { label, href };
|
||||
}
|
||||
|
||||
function replaceImagesAndLinks(opts: {
|
||||
markdown: string;
|
||||
mdDirRel: string;
|
||||
mdRelToDocId: Map<string, string>;
|
||||
uploadedUrlByRelAssetPath: Map<
|
||||
string,
|
||||
{
|
||||
url: string;
|
||||
assetId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
assetType: "image" | "video" | "audio" | "file";
|
||||
}
|
||||
>;
|
||||
}): { markdown: string; tokens: TokenRecord[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const tokens: TokenRecord[] = [];
|
||||
|
||||
const lines = opts.markdown.split(/\r?\n/);
|
||||
const out: string[] = [];
|
||||
|
||||
const imageRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
let line = rawLine;
|
||||
|
||||
// 1) 先处理“整行的页面链接/附件链接”,便于转成块
|
||||
const standalone = extractStandaloneMarkdownLink(line);
|
||||
if (standalone) {
|
||||
const href0 = standalone.href;
|
||||
const href = isRemoteUrl(href0) ? href0 : stripLocalQueryAndHash(href0);
|
||||
if (!isRemoteUrl(href)) {
|
||||
const target = resolveRelativePosix(opts.mdDirRel, href);
|
||||
if (target.toLowerCase().endsWith(".md")) {
|
||||
const docId = opts.mdRelToDocId.get(target);
|
||||
if (docId) {
|
||||
const token = `[[[MNOTE_PAGE_REF:${randomUUID()}]]]`;
|
||||
tokens.push({ kind: "page", token, targetMdRel: target, label: standalone.label });
|
||||
out.push(token);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const asset = opts.uploadedUrlByRelAssetPath.get(target);
|
||||
if (asset) {
|
||||
const token = `[[[MNOTE_FILE:${randomUUID()}]]]`;
|
||||
tokens.push({ kind: "file", token, url: asset.url, label: standalone.label });
|
||||
out.push(token);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 图片语法替换为 token(避免 BlockNote 的图片解析在 Node 环境报错)
|
||||
line = line.replace(imageRe, (_full, altRaw, urlRaw) => {
|
||||
const alt = String(altRaw ?? "").trim();
|
||||
const raw0 = stripAngleBrackets(stripMarkdownLinkTitle(String(urlRaw ?? "").trim()));
|
||||
const url = isRemoteUrl(raw0) ? raw0 : stripLocalQueryAndHash(raw0);
|
||||
const token = `[[[MNOTE_IMAGE:${randomUUID()}]]]`;
|
||||
tokens.push({ kind: "image", token, url, alt });
|
||||
// 说明:用空行包起来,尽量让 markdownToBlocks 生成“单独段落”,便于后续替换为 media block。
|
||||
return `\n\n${token}\n\n`;
|
||||
});
|
||||
|
||||
// 3) 重写内联链接(页面链接 -> /documents/<id>,附件链接 -> Convex url)
|
||||
line = line.replace(linkRe, (full, labelRaw, hrefRaw) => {
|
||||
const label = String(labelRaw ?? "");
|
||||
const href0 = stripAngleBrackets(stripMarkdownLinkTitle(String(hrefRaw ?? "").trim()));
|
||||
const href = isRemoteUrl(href0) ? href0 : stripLocalQueryAndHash(href0);
|
||||
if (isRemoteUrl(href)) return full;
|
||||
|
||||
const target = resolveRelativePosix(opts.mdDirRel, href);
|
||||
if (target.toLowerCase().endsWith(".md")) {
|
||||
const docId = opts.mdRelToDocId.get(target);
|
||||
if (docId) return `[${label}](/documents/${encodeURIComponent(docId)})`;
|
||||
return full;
|
||||
}
|
||||
|
||||
const asset = opts.uploadedUrlByRelAssetPath.get(target);
|
||||
if (asset) return `[${label}](${asset.url})`;
|
||||
return full;
|
||||
});
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return { markdown: out.join("\n"), tokens, warnings };
|
||||
}
|
||||
|
||||
function isSingleTokenParagraph(block: any, token: string): boolean {
|
||||
if (!block || typeof block !== "object") return false;
|
||||
if (!Array.isArray(block.content)) return false;
|
||||
if (block.content.length !== 1) return false;
|
||||
const node = block.content[0];
|
||||
if (!node || typeof node !== "object") return false;
|
||||
if (node.type !== "text") return false;
|
||||
return String(node.text ?? "").trim() === token;
|
||||
}
|
||||
|
||||
function replaceTokensInBlocks(opts: {
|
||||
blocks: any[];
|
||||
tokens: TokenRecord[];
|
||||
pageRefResolver: (rel: string) => { pageId: string; title: string } | null;
|
||||
mediaResolver: (url: string) => { props: Record<string, unknown> } | null;
|
||||
}): { blocks: any[]; unresolved: TokenRecord[] } {
|
||||
const tokenMap = new Map<string, TokenRecord>(opts.tokens.map((t) => [t.token, t]));
|
||||
const unresolved = new Set(opts.tokens.map((t) => t.token));
|
||||
|
||||
const walk = (items: any[]): any[] => {
|
||||
const out: any[] = [];
|
||||
for (const block of items) {
|
||||
const maybe =
|
||||
block && Array.isArray(block.content) && block.content.length === 1 && block.content[0]?.type === "text"
|
||||
? String(block.content[0].text ?? "").trim()
|
||||
: null;
|
||||
|
||||
if (maybe && tokenMap.has(maybe) && isSingleTokenParagraph(block, maybe)) {
|
||||
const token = tokenMap.get(maybe)!;
|
||||
unresolved.delete(maybe);
|
||||
|
||||
if (token.kind === "page") {
|
||||
const resolved = opts.pageRefResolver(token.targetMdRel);
|
||||
if (!resolved) {
|
||||
out.push(block);
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: { pageId: resolved.pageId, title: token.label || resolved.title || "无标题" },
|
||||
content: [],
|
||||
children: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.kind === "image" || token.kind === "file") {
|
||||
const resolved = opts.mediaResolver(token.url);
|
||||
if (!resolved) {
|
||||
out.push(block);
|
||||
continue;
|
||||
}
|
||||
out.push({ id: randomUUID(), type: "media", props: resolved.props, content: [], children: [] });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const next = { ...block };
|
||||
if (Array.isArray(next.children) && next.children.length > 0) {
|
||||
next.children = walk(next.children);
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return { blocks: walk(opts.blocks), unresolved: opts.tokens.filter((t) => unresolved.has(t.token)) };
|
||||
}
|
||||
|
||||
async function uploadToConvexMedia(opts: {
|
||||
client: any;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileName: string;
|
||||
bytes: Uint8Array;
|
||||
mimeType: string;
|
||||
}): Promise<MediaAsset> {
|
||||
const assetId = randomUUID();
|
||||
const assetType = resolveAssetTypeByMime(opts.mimeType);
|
||||
|
||||
const uploadUrl = await opts.client.mutation(api.mediaAssets.generateUploadUrl, { userId: opts.userId });
|
||||
if (!uploadUrl || typeof uploadUrl !== "string") throw new Error("获取上传地址失败");
|
||||
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": opts.mimeType || "application/octet-stream" },
|
||||
body: Buffer.from(opts.bytes),
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const text = await uploadRes.text().catch(() => "");
|
||||
throw new Error(`上传到 Convex 失败:${uploadRes.status} ${text}`);
|
||||
}
|
||||
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||
const storageId = uploadJson?.storageId ?? "";
|
||||
if (!storageId) throw new Error("上传到 Convex 失败:缺少 storageId");
|
||||
|
||||
const created = await opts.client.mutation(api.mediaAssets.createWithStorage, {
|
||||
userId: opts.userId,
|
||||
storageId: storageId as any,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: opts.workspaceId,
|
||||
document_id: opts.documentId,
|
||||
asset_type: assetType,
|
||||
file_name: opts.fileName || null,
|
||||
file_size: opts.bytes.length,
|
||||
mime_type: opts.mimeType || null,
|
||||
},
|
||||
});
|
||||
|
||||
return created as unknown as MediaAsset;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDomShim();
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const isJson = contentType.includes("application/json");
|
||||
|
||||
let parentId: string | null = null;
|
||||
let rootMdPathInput: string | null = null;
|
||||
let zipPath: string | null = null;
|
||||
let extractedDir: string | null = null;
|
||||
let store: InputStore | null = null;
|
||||
|
||||
if (isJson) {
|
||||
const payload = (await request.json().catch(() => null)) as any;
|
||||
parentId = String(payload?.parentId ?? "").trim() || null;
|
||||
rootMdPathInput = String(payload?.rootMdPath ?? "").trim() || null;
|
||||
zipPath = String(payload?.zipPath ?? "").trim() || null;
|
||||
extractedDir = String(payload?.extractedDir ?? "").trim() || null;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
parentId = String(formData.get("parentId") ?? "").trim() || null;
|
||||
rootMdPathInput = String(formData.get("rootMdPath") ?? "").trim() || null;
|
||||
const localZipPath = String(formData.get("zipPath") ?? "").trim();
|
||||
zipPath = localZipPath || null;
|
||||
|
||||
const file = formData.get("file");
|
||||
if (file instanceof File) {
|
||||
// 说明:浏览器上传大 ZIP(比如 1GB)会导致内存/超时问题,这里直接给出提示。
|
||||
const size = Number((file as any).size ?? 0);
|
||||
if (size > 200 * 1024 * 1024) {
|
||||
return NextResponse.json(
|
||||
{ error: "ZIP 太大,请使用“本地 ZIP 路径”方式导入(不要上传文件)" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "当前仅支持“本地 ZIP 路径”导入(大文件避免上传)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedDir) {
|
||||
store = await createDirStore(extractedDir);
|
||||
} else if (zipPath) {
|
||||
const stat = await fs.stat(zipPath);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "zipPath 不是文件" }, { status: 400 });
|
||||
}
|
||||
const extractDir = await extractZipToTemp(zipPath);
|
||||
// 说明:使用解压输出目录作为 store 根目录,使相对路径保持 dQeAax/... 这种形态。
|
||||
store = await createDirStore(extractDir);
|
||||
extractedDir = extractDir;
|
||||
} else {
|
||||
return NextResponse.json({ error: "缺少 zipPath(本地 ZIP 路径)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allFilePaths = await store.listFiles();
|
||||
const mdPaths = allFilePaths.filter((p) => p.toLowerCase().endsWith(".md"));
|
||||
if (mdPaths.length === 0) return NextResponse.json({ error: "未发现 .md 文件" }, { status: 400 });
|
||||
|
||||
let rootMdPath = rootMdPathInput ? normalizeZipPath(rootMdPathInput) : null;
|
||||
if (rootMdPath && !(await store.exists(rootMdPath))) {
|
||||
return NextResponse.json({ error: "rootMdPath 不存在", rootMdPath }, { status: 400 });
|
||||
}
|
||||
if (!rootMdPath) {
|
||||
const detected = detectRootMdPath(mdPaths);
|
||||
if (!detected.rootMdPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法自动判断入口页面,请指定 rootMdPath", candidates: detected.candidates },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
rootMdPath = detected.rootMdPath;
|
||||
}
|
||||
|
||||
const scopePrefix = `${path.posix.dirname(rootMdPath)}/`;
|
||||
const { nodes, mdRelToKey } = buildImportNodes({ scopePrefix, rootMdPath, mdPaths });
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await getConvexAuthedHttpClient();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "未登录";
|
||||
const status = msg.includes("未登录") ? 401 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
|
||||
// workspace / accessScope:有 parentId 则继承,否则走默认 workspace
|
||||
let workspaceId = "";
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
if (parentId) {
|
||||
const parentMeta = await client.query(api.documents.getMeta, { id: parentId });
|
||||
if (!parentMeta) return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
workspaceId = String((parentMeta as any).workspace_id ?? "");
|
||||
accessScope = ((parentMeta as any).access_scope ?? "private") as typeof accessScope;
|
||||
if (!workspaceId) return NextResponse.json({ error: "无法读取父页面 workspaceId" }, { status: 400 });
|
||||
} else {
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
workspaceId = String((workspaceBootstrap as any)?.activeWorkspaceId ?? "");
|
||||
if (!workspaceId) return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 读取每个 md 的标题(优先 # 顶部标题),并写回 node.title
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "doc") continue;
|
||||
const text = await store.readText(node.mdPath).catch(() => "");
|
||||
if (!text) continue;
|
||||
node.title = parseTopHeadingTitle(text) ?? node.title;
|
||||
}
|
||||
|
||||
// 先创建全部节点(documents / 合成目录页)
|
||||
const keyToDocId = new Map<string, string>();
|
||||
for (const node of nodes) {
|
||||
const docId = randomUUID();
|
||||
const parentDocId =
|
||||
node.key === rootMdPath
|
||||
? parentId
|
||||
: node.parentKey
|
||||
? keyToDocId.get(node.parentKey) ?? parentId
|
||||
: parentId;
|
||||
|
||||
const created = await client.mutation(api.documents.create, {
|
||||
id: docId,
|
||||
workspaceId,
|
||||
parentId: parentDocId ?? null,
|
||||
title: node.title,
|
||||
accessScope,
|
||||
content: [],
|
||||
});
|
||||
|
||||
if (!created || (created as any).id !== docId) {
|
||||
return NextResponse.json({ error: "创建页面失败(Convex 返回异常)" }, { status: 500 });
|
||||
}
|
||||
keyToDocId.set(node.key, docId);
|
||||
await ensureDocumentScaffold(docId, node.title);
|
||||
}
|
||||
|
||||
// 逐页导入内容(markdown -> blocks;图片/附件 -> media block + media_assets)
|
||||
const schema = BlockNoteSchema.create();
|
||||
const editor = BlockNoteEditor.create({ schema });
|
||||
const warnings: string[] = [];
|
||||
let importedCount = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "doc") continue;
|
||||
const docId = keyToDocId.get(node.key) ?? "";
|
||||
if (!docId) continue;
|
||||
let markdown = await store.readText(node.mdPath);
|
||||
markdown = stripTopHeadingIfMatches(markdown, node.title);
|
||||
|
||||
const mdDirFull = `${path.posix.dirname(node.mdPath)}/`;
|
||||
const mdDirRel = mdDirFull.startsWith(scopePrefix) ? mdDirFull.slice(scopePrefix.length) : mdDirFull;
|
||||
|
||||
// 预扫:把本页引用到的本地资源上传到 Convex,并建立 relPath -> url 映射
|
||||
const uploadedUrlByRelAssetPath = new Map<
|
||||
string,
|
||||
{
|
||||
url: string;
|
||||
assetId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
assetType: "image" | "video" | "audio" | "file";
|
||||
}
|
||||
>();
|
||||
|
||||
const localAssetPaths = new Set<string>();
|
||||
{
|
||||
const imgRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = imgRe.exec(markdown))) {
|
||||
const raw0 = stripAngleBrackets(stripMarkdownLinkTitle(String(m[1] ?? "").trim()));
|
||||
const raw = isRemoteUrl(raw0) ? raw0 : stripLocalQueryAndHash(raw0);
|
||||
if (!raw || isRemoteUrl(raw)) continue;
|
||||
const resolved = resolveRelativePosix(mdDirRel, raw);
|
||||
if (resolved) localAssetPaths.add(resolved);
|
||||
}
|
||||
}
|
||||
{
|
||||
const linkRe = /\[[^\]]+\]\(([^)]+)\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRe.exec(markdown))) {
|
||||
const raw0 = stripAngleBrackets(stripMarkdownLinkTitle(String(m[1] ?? "").trim()));
|
||||
const raw = isRemoteUrl(raw0) ? raw0 : stripLocalQueryAndHash(raw0);
|
||||
if (!raw || isRemoteUrl(raw)) continue;
|
||||
const resolved = resolveRelativePosix(mdDirRel, raw);
|
||||
if (!resolved) continue;
|
||||
if (resolved.toLowerCase().endsWith(".md")) continue;
|
||||
localAssetPaths.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relAssetPath of Array.from(localAssetPaths)) {
|
||||
const zipPath = `${scopePrefix}${relAssetPath}`.replace(/\/{2,}/g, "/");
|
||||
if (!(await store.exists(zipPath))) {
|
||||
warnings.push(`[${node.title}] 未找到资源文件:${relAssetPath}`);
|
||||
continue;
|
||||
}
|
||||
const absAssetPath = toFsPath(store.baseDir, zipPath);
|
||||
const stat = await fs.stat(absAssetPath).catch(() => null);
|
||||
if (stat && typeof stat.size === "number" && stat.size > MAX_ASSET_BYTES) {
|
||||
const mb = (stat.size / 1024 / 1024).toFixed(1);
|
||||
warnings.push(`[${node.title}] 资源过大已跳过(${mb}MB):${relAssetPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = await store.readBytes(zipPath);
|
||||
const fileName = safeBasename(relAssetPath);
|
||||
const mimeType = guessMimeType(fileName);
|
||||
const assetType = resolveAssetTypeByMime(mimeType);
|
||||
const created = await uploadToConvexMedia({
|
||||
client,
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
documentId: docId,
|
||||
fileName,
|
||||
bytes,
|
||||
mimeType,
|
||||
});
|
||||
uploadedUrlByRelAssetPath.set(relAssetPath, {
|
||||
url: String((created as any).file_url ?? ""),
|
||||
assetId: String((created as any).id ?? ""),
|
||||
fileName: String((created as any).file_name ?? fileName),
|
||||
mimeType: String((created as any).mime_type ?? mimeType),
|
||||
fileSize: Number((created as any).file_size ?? bytes.length),
|
||||
assetType,
|
||||
});
|
||||
}
|
||||
|
||||
const mdRelToDocId = new Map<string, string>();
|
||||
for (const [rel, key] of mdRelToKey.entries()) {
|
||||
const id = keyToDocId.get(key);
|
||||
if (id) mdRelToDocId.set(rel, id);
|
||||
}
|
||||
|
||||
const replaced = replaceImagesAndLinks({
|
||||
markdown,
|
||||
mdDirRel,
|
||||
mdRelToDocId,
|
||||
uploadedUrlByRelAssetPath,
|
||||
});
|
||||
markdown = replaced.markdown;
|
||||
warnings.push(...replaced.warnings);
|
||||
|
||||
let blocks: Block<any, any, any>[] = [];
|
||||
try {
|
||||
blocks = markdownToBlocks(markdown, editor.pmSchema) as any;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`[${node.title}] Markdown 解析失败,已降级为纯文本:${msg}`);
|
||||
blocks = [
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "paragraph",
|
||||
props: { backgroundColor: "default", textColor: "default", textAlignment: "left" },
|
||||
content: [{ type: "text", text: markdown, styles: {} }],
|
||||
children: [],
|
||||
} as any,
|
||||
];
|
||||
}
|
||||
|
||||
const replacedBlocks = replaceTokensInBlocks({
|
||||
blocks: blocks as any[],
|
||||
tokens: replaced.tokens,
|
||||
pageRefResolver: (rel) => {
|
||||
const id = mdRelToDocId.get(rel) ?? "";
|
||||
if (!id) return null;
|
||||
return { pageId: id, title: path.posix.basename(rel, ".md") || "无标题" };
|
||||
},
|
||||
mediaResolver: (url) => {
|
||||
if (isRemoteUrl(url)) {
|
||||
const fileName = safeBasename(url);
|
||||
const mimeType = guessMimeType(fileName);
|
||||
const assetType = resolveAssetTypeByMime(mimeType);
|
||||
return {
|
||||
props: {
|
||||
fileUrl: url,
|
||||
thumbnailUrl: url,
|
||||
caption: "",
|
||||
captionAlign: "left",
|
||||
hasBorder: true,
|
||||
linkUrl: "",
|
||||
assetId: "",
|
||||
assetType,
|
||||
fileName,
|
||||
fileSize: 0,
|
||||
mimeType,
|
||||
width: 0,
|
||||
ocrStatus: "idle",
|
||||
documentId: docId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const relAssetPath = resolveRelativePosix(mdDirRel, url);
|
||||
const asset = uploadedUrlByRelAssetPath.get(relAssetPath);
|
||||
if (!asset || !asset.url) return null;
|
||||
return {
|
||||
props: {
|
||||
fileUrl: asset.url,
|
||||
thumbnailUrl: asset.url,
|
||||
caption: "",
|
||||
captionAlign: "left",
|
||||
hasBorder: true,
|
||||
linkUrl: "",
|
||||
assetId: asset.assetId || "",
|
||||
assetType: asset.assetType,
|
||||
fileName: asset.fileName || safeBasename(relAssetPath),
|
||||
fileSize: asset.fileSize || 0,
|
||||
mimeType: asset.mimeType || guessMimeType(asset.fileName || safeBasename(relAssetPath)),
|
||||
width: 0,
|
||||
ocrStatus: "idle",
|
||||
documentId: docId,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
for (const token of replacedBlocks.unresolved) {
|
||||
warnings.push(`[${node.title}] 未能解析 token:${token.token}`);
|
||||
}
|
||||
|
||||
// 说明:Convex 的 Json 不允许出现 undefined(尤其是表格类 block 里可能带有 columnWidths: [undefined])。
|
||||
// 用 JSON 序列化做一次深度净化:对象属性的 undefined 会被移除,数组里的 undefined 会变成 null。
|
||||
const sanitizedBlocks = JSON.parse(JSON.stringify(replacedBlocks.blocks)) as Json;
|
||||
await client.mutation(api.documents.updateContent, { id: docId, content: sanitizedBlocks });
|
||||
importedCount += 1;
|
||||
}
|
||||
|
||||
const rootId = keyToDocId.get(rootMdPath) ?? "";
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
rootDocumentId: rootId,
|
||||
importedCount,
|
||||
warnings: warnings.slice(0, 200),
|
||||
documentsBaseDir,
|
||||
extractedDir,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Wolai 导入失败", error);
|
||||
const message = error instanceof Error ? error.message : "导入失败";
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
return NextResponse.json({ error: message, stack }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user