0.2.1 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 10:12:53 +08:00
parent 94957dc361
commit 19907bccdc
102 changed files with 7188 additions and 186 deletions
@@ -2,6 +2,10 @@ import { notFound, redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { DocumentShell } from "@/components/editor/document-shell";
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
interface DocumentPageProps {
params: Promise<{ id: string }>;
@@ -14,6 +18,49 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
const resolvedSearch = (await searchParams) ?? {};
const openTableIdRaw = resolvedSearch?.openTableId;
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
if (!doc) {
notFound();
}
const initialOptions: PageOptionsState = {
wideLayout: doc.wide_layout ?? false,
smallText: doc.use_small_text ?? false,
showHeadingNumbers: doc.show_heading_numbers ?? true,
showToc: doc.show_toc ?? false,
showStructure: doc.show_structure ?? false,
protectEditing: doc.protect_editing ?? false,
showWordCount: doc.show_word_count ?? true,
};
const initialStats: DocumentStats = {
wordCount: doc.word_count ?? 0,
characterCount: doc.character_count ?? 0,
blockCount: doc.block_count ?? 0,
};
return (
<div className="flex h-screen flex-col">
<div className="min-h-0 flex-1">
<DocumentShell
documentId={doc.id}
workspaceId={doc.workspace_id}
title={doc.title ?? "无标题"}
updatedAt={doc.updated_at}
initialContent={null}
initialOptions={initialOptions}
initialStats={initialStats}
openTableId={openTableId}
/>
</div>
</div>
);
}
const supabase = await createSupabaseServerClient();
const {
data: { session },
+181 -1
View File
@@ -10,9 +10,189 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { SearchPalette } from "@/components/search/search-palette";
import { detectLocalMindmapDocs, detectLocalTrashedMindmapAssets } from "@/lib/server/mindmap-files";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import type { MediaAsset } from "@/types/media";
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => {
if (!input || typeof input !== "object") return input;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const record = input as any;
return record && typeof record === "object" && "root" in record ? record.root : input;
})();
const ids: string[] = [];
const seen = new Set<string>();
const push = (value: unknown) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.slice("asset:".length).trim();
if (!id) return;
if (seen.has(id)) return;
seen.add(id);
ids.push(id);
};
const get = (obj: unknown, key: string): unknown => {
if (!obj || typeof obj !== "object") return undefined;
return (obj as Record<string, unknown>)[key];
};
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return;
const data = get(node, "data");
const image = get(node, "image");
push(get(data, "image"));
push(image);
push(get(image, "url"));
push(get(get(data, "image"), "url"));
const children = get(node, "children");
if (Array.isArray(children)) children.forEach(walk);
};
walk(root);
return ids;
}
function makeId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
export default async function AppLayout({ children }: { children: ReactNode }) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间",
workspaceIdIfCreate: makeId(),
});
const workspaces = ensured.workspaces;
const activeWorkspaceId = ensured.activeWorkspaceId;
let documents: DocumentRecord[] = [];
let sidebarInitialData: SidebarInitialData | null = null;
if (activeWorkspaceId) {
const [docRows, trashedDocs] = await Promise.all([
client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId,
}),
client.query(api.documents.listTrashedByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId,
}),
]);
documents = docRows as unknown as DocumentRecord[];
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId,
includeDeleted: true,
});
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
const mindmapAssetChildren: Record<string, string[]> = {};
activeMindmaps.forEach((r) => {
const ids = extractMindmapImageAssetIdsFromData(r.data);
if (ids.length > 0) mindmapAssetChildren[r.mindmap_id] = ids;
});
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
const isLegacy = r.mindmap_id.startsWith("legacy-");
return {
id: r.mindmap_id,
workspace_id: r.workspace_id ?? activeWorkspaceId,
document_id: r.document_id,
asset_type: "mindmap",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: r.created_at ?? "",
updated_at: r.updated_at ?? "",
};
});
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
const isLegacy = r.mindmap_id.startsWith("legacy-");
return {
id: r.mindmap_id,
workspace_id: r.workspace_id ?? activeWorkspaceId,
document_id: r.document_id,
asset_type: "mindmap",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
deleted_at: r.deleted_at ?? null,
deleted_by: r.deleted_by ?? null,
purged_at: null,
signed_url: null,
created_at: r.created_at ?? "",
updated_at: r.updated_at ?? "",
};
});
sidebarInitialData = {
activeWorkspaceId,
workspaces,
documents: docRows as unknown as DocumentRecord[],
trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"],
trashedMediaAssets: [],
trashedMindmapAssets,
mediaAssets: [],
mindmapDocs,
mindmapAssets,
mindmapAssetChildren,
};
}
return (
<div className="flex h-screen bg-white">
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
<MobileSidebarTrigger />
<Breadcrumb documents={documents} />
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<BottomToolbar />
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
</div>
</div>
);
}
const supabase = await createSupabaseServerClient();
const {
data: { session },
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import {
buildClientToolKey,
resolveClientToolCall,
@@ -26,11 +28,26 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
const userId = (() => {
if (isConvexEnabled()) {
const auth = requireAuthContext();
return auth.userId;
}
return null;
})();
const resolvedUserId = async () => {
if (userId) return userId;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return null;
return session.user.id;
};
const finalUserId = await resolvedUserId();
if (!finalUserId) return NextResponse.json({ error: "未登录" }, { status: 401 });
const result: ClientToolResult = payload.ok
? { ok: true, result: "result" in payload ? payload.result : null }
@@ -39,7 +56,7 @@ export async function POST(request: Request) {
const key = buildClientToolKey(requestId, callId);
const resolved = resolveClientToolCall({
key,
userId: session.user.id,
userId: finalUserId,
result,
});
if (!resolved.ok) {
@@ -48,4 +65,3 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
}
+233 -19
View File
@@ -5,6 +5,7 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
@@ -14,6 +15,8 @@ import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent
import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools";
import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools";
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
@@ -70,12 +73,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
}
// v1先要求登录(避免在生产环境暴露推理能力);后续可做更细的权限控制
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
// v1鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session
const convexOn = isConvexEnabled();
const { userId, supabase, convexClient } = await (async () => {
if (convexOn) {
const { auth, client } = getAuthedConvexClient();
return { userId: auth.userId, supabase: null as any, convexClient: client };
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return { userId: "", supabase: null as any, convexClient: null as any };
return { userId: session.user.id, supabase, convexClient: null as any };
})();
if (!userId) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
@@ -167,6 +179,25 @@ export async function POST(request: Request) {
allowedToolIds.delete("doc_replace_range");
}
// 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。
// 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。
if (convexOn) {
for (const id of [...allowedToolIds]) {
if (
id === "search_web" ||
id === "image_read" ||
id === "slash_run" ||
id.startsWith("rag_") ||
id.startsWith("mindmap_") ||
id.startsWith("doc_") ||
id.startsWith("docs_")
) {
continue;
}
allowedToolIds.delete(id);
}
}
const systemContextText = (() => {
const lines: string[] = [];
if (documentId) lines.push(`documentId=${documentId}`);
@@ -181,12 +212,67 @@ export async function POST(request: Request) {
return lines.join("\n").trim();
})();
const normalizeBlocksForTools = (content: unknown): unknown[] => {
if (Array.isArray(content)) return content;
if (content && typeof content === "object" && "blocks" in (content as any)) {
const blocks = (content as any).blocks;
if (Array.isArray(blocks)) return blocks;
}
return [];
};
const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => {
const pieces: string[] = [];
const walk = (list: unknown[]) => {
for (const b of list) {
if (!b || typeof b !== "object") continue;
const content = (b as any).content;
if (Array.isArray(content)) {
for (const n of content) {
const t = n && typeof n === "object" ? String((n as any).text ?? "") : "";
if (t) pieces.push(t);
if (pieces.join("").length >= maxChars) return;
}
}
const children = (b as any).children;
if (Array.isArray(children)) {
walk(children);
if (pieces.join("").length >= maxChars) return;
}
}
};
walk(blocks);
const raw = pieces.join("").replace(/\s+/g, " ").trim();
return raw.length > maxChars ? `${raw.slice(0, maxChars)}` : raw;
};
const mindmapTools = hasMindmapContext
? createMindmapServerTools({
supabase: supabase as unknown as MindmapSupabaseClient,
ctx: { documentId, mindmapId, userId: session.user.id, selectedUids, attachments },
ctx: { documentId, mindmapId, userId, selectedUids, attachments },
cfg: { ...cfg, model: modelOverride ?? cfg.model },
allowedToolIds,
...(convexOn
? {
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 }),
]);
const title = meta?.title ?? null;
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
return {
doc: { id: documentId, title, workspace_id: workspaceId },
base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] },
};
},
saveMindmap: async ({ data }) => {
if (!convexClient) throw new Error("Convex 未初始化");
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
},
}
: {}),
})
: null;
@@ -198,14 +284,30 @@ export async function POST(request: Request) {
allowedToolIds.has("doc_replace_range"))
? createDocServerTools({
supabase: supabase as unknown as DocSupabaseClient,
ctx: { documentId, userId: session.user.id, baseBlocks: documentBlocks },
ctx: { documentId, userId, baseBlocks: documentBlocks },
allowedToolIds,
...(convexOn
? {
loadBlocks: async () => {
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 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 });
},
}
: {}),
})
: null;
const ragTools = allowedToolIds.has("rag_lightrag_query")
? createRagServerTools({
ctx: { userId: session.user.id },
ctx: { userId },
allowedToolIds,
})
: null;
@@ -214,24 +316,88 @@ export async function POST(request: Request) {
allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read")
? createDocsServerTools({
supabase: supabase as unknown as DocsSupabaseClient,
ctx: { userId: session.user.id },
ctx: { userId },
allowedToolIds,
...(convexOn
? {
searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => {
if (!convexClient) throw new Error("Convex 未初始化");
const wsIds = workspaceId
? [workspaceId]
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.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 extra = includeDeleted
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
: [];
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
for (const d of all) {
const title = String((d as any)?.title ?? "");
if (!title.toLowerCase().includes(q)) continue;
results.push({
id: String((d as any)?.id ?? ""),
title,
workspaceId: String((d as any)?.workspace_id ?? wid),
parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null,
updatedAt: (d as any)?.updated_at ?? null,
snippet: title.slice(0, 120),
});
if (results.length >= limit) break;
}
if (results.length >= limit) break;
}
return results.slice(0, limit);
},
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
if (!convexClient) throw new Error("Convex 未初始化");
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
if (!meta) throw new Error("页面不存在");
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
return {
ok: true,
documentId: rid,
title: String(meta.title ?? ""),
workspaceId: String((meta as any).workspace_id ?? ""),
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
updatedAt: (meta as any).updated_at ?? null,
rawTextLength: rawText.length,
rawText,
...(includeContent ? { content: contentRes?.content ?? null } : {}),
};
},
}
: {}),
})
: null;
const mediaTools = allowedToolIds.has("image_read")
? createMediaServerTools({
supabase: supabase as unknown as MediaSupabaseClient,
ctx: { userId: session.user.id, attachments },
ctx: { userId, attachments },
allowedToolIds,
...(convexOn
? {
loadById: async (id: string) => {
if (!convexClient) throw new Error("Convex 未初始化");
return await convexClient.query(api.mediaAssets.getById, { userId, id });
},
loadByFileUrl: async (_fileUrl: string) => null,
}
: {}),
})
: null;
const onlyofficeTools =
allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap")
!convexOn && (allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap"))
? createOnlyOfficeServerTools({
supabase: supabase as unknown as OnlyOfficeSupabaseClient,
ctx: { userId: session.user.id, documentId: documentId || undefined, attachments },
ctx: { userId, documentId: documentId || undefined, attachments },
allowedToolIds,
})
: null;
@@ -239,8 +405,56 @@ export async function POST(request: Request) {
const slashTools = allowedToolIds.has("slash_run")
? createSlashServerTools({
supabase: supabase as unknown as SlashSupabaseClient,
ctx: { userId: session.user.id, currentDocumentId: documentId || undefined },
ctx: { userId, currentDocumentId: documentId || undefined },
allowedToolIds,
...(convexOn
? {
loadWorkspaceIds: async (uid: string) => {
if (!convexClient) throw new Error("Convex 未初始化");
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
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 });
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,
title,
accessScope: "private",
content: [],
});
return {
id: String((created as any).id ?? id),
title: String((created as any).title ?? title),
workspaceId: String((created as any).workspace_id ?? workspaceId),
parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId,
createdAt: (created as any).created_at ?? null,
updatedAt: (created as any).updated_at ?? null,
};
},
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 });
if (!meta) throw new Error("页面不存在");
return {
id: String((meta as any).id ?? did),
title: String((meta as any).title ?? title),
workspaceId: String((meta as any).workspace_id ?? ""),
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
updatedAt: (meta as any).updated_at ?? null,
};
},
}
: {}),
})
: null;
@@ -323,11 +537,11 @@ export async function POST(request: Request) {
if (isOnlyOfficeClientTool(toolId)) {
const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`;
const key = buildClientToolKey(requestId, callId);
const wait = registerClientToolCall({
key,
userId: session.user.id,
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
});
const wait = registerClientToolCall({
key,
userId,
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
});
// 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result
send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs });
@@ -0,0 +1,116 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
type EmbedBlockPayload = {
sourceDocumentId: string;
blockId: string;
targetDocumentId: string;
position?: "end";
};
export async function POST(request: Request) {
const { sourceDocumentId, blockId, targetDocumentId }: EmbedBlockPayload = await request.json();
if (!sourceDocumentId || !blockId || !targetDocumentId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
if (sourceDocumentId === targetDocumentId) {
return NextResponse.json({ error: "禁止嵌入到当前页面" }, { status: 400 });
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(source.content);
const hit = findBlockInTree(sourceBlocks, blockId);
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(target.content);
const referenceBlock = {
id: randomUUID(),
type: "blockReference",
props: {
sourceDocumentId,
targetBlockId: blockId,
display: "embed",
},
content: [],
children: [],
};
const nextBlocks = [...targetBlocks, referenceBlock];
const payload = withBlocksWrittenBack(target.content, nextBlocks);
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: payload });
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { data: sourceDoc } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", sourceDocumentId)
.eq("user_id", session.user.id)
.single();
if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content);
if (!findBlockInTree(sourceBlocks, blockId)) {
return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
}
const { data: targetDoc } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", targetDocumentId)
.eq("user_id", session.user.id)
.single();
if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(targetDoc.content);
const referenceBlock = {
id: randomUUID(),
type: "blockReference",
props: {
sourceDocumentId,
targetBlockId: blockId,
display: "embed",
},
content: [],
children: [],
};
const nextBlocks = [...targetBlocks, referenceBlock];
const payload = withBlocksWrittenBack(targetDoc.content, nextBlocks);
const { error } = await supabase
.from("documents")
.update({ content: payload })
.eq("id", targetDocumentId)
.eq("user_id", session.user.id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
export async function GET(request: Request) {
const url = new URL(request.url);
const sourceDocumentId = url.searchParams.get("sourceDocumentId") || "";
const blockId = url.searchParams.get("blockId") || "";
if (!sourceDocumentId || !blockId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content);
const hit = findBlockInTree(blocks, blockId);
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
return NextResponse.json({ ok: true, block: hit.block });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { data: doc } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", sourceDocumentId)
.eq("user_id", session.user.id)
.single();
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content);
const hit = findBlockInTree(blocks, blockId);
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
return NextResponse.json({ ok: true, block: hit.block });
}
@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
type MoveBlockPayload = {
sourceDocumentId: string;
blockId: string;
targetDocumentId: string;
position?: "end";
};
export async function POST(request: Request) {
const { sourceDocumentId, blockId, targetDocumentId }: MoveBlockPayload = await request.json();
if (!sourceDocumentId || !blockId || !targetDocumentId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
if (sourceDocumentId === targetDocumentId) {
// 说明:同页移动先不做“定位插入”,视为 no-op。
return NextResponse.json({ ok: true, noop: true });
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(source.content);
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(target.content);
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: nextSourcePayload });
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: nextTargetPayload });
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { data: sourceDoc } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", sourceDocumentId)
.eq("user_id", session.user.id)
.single();
if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content);
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
const { data: targetDoc } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", targetDocumentId)
.eq("user_id", session.user.id)
.single();
if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(targetDoc.content);
const nextSourcePayload = withBlocksWrittenBack(sourceDoc.content, removedRes.nextBlocks);
const nextTargetPayload = withBlocksWrittenBack(targetDoc.content, [...targetBlocks, removedRes.removed]);
const { error: srcErr } = await supabase
.from("documents")
.update({ content: nextSourcePayload })
.eq("id", sourceDocumentId)
.eq("user_id", session.user.id);
if (srcErr) return NextResponse.json({ error: srcErr.message }, { status: 500 });
const { error: tgtErr } = await supabase
.from("documents")
.update({ content: nextTargetPayload })
.eq("id", targetDocumentId)
.eq("user_id", session.user.id);
if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
type PatchPayload = {
sourceDocumentId: string;
blockId: string;
nextBlock: unknown;
};
export async function POST(request: Request) {
const { sourceDocumentId, blockId, nextBlock }: PatchPayload = await request.json();
if (!sourceDocumentId || !blockId || !nextBlock) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content);
const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any);
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: payload });
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { data: doc } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", sourceDocumentId)
.eq("user_id", session.user.id)
.single();
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content);
const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any);
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
const { error } = await supabase
.from("documents")
.update({ content: payload })
.eq("id", sourceDocumentId)
.eq("user_id", session.user.id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const { ms }: { ms?: number } = await request.json().catch(() => ({}));
const id = randomUUID();
const result = await client.mutation(api.jobs.enqueueDemo, {
userId: auth.userId,
id,
ms,
});
return NextResponse.json(result);
}
export async function GET(request: Request) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const url = new URL(request.url);
const id = url.searchParams.get("id") ?? "";
if (!id) {
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
}
const job = await client.query(api.jobs.get, { userId: auth.userId, id });
if (!job) {
return NextResponse.json({ error: "任务不存在" }, { status: 404 });
}
return NextResponse.json(job);
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const auth = requireAuthContext();
return NextResponse.json({ ok: true, auth }, { status: 200 });
} catch (err) {
const status = err instanceof HttpError ? err.status : 500;
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ ok: false, error: message }, { status });
}
}
@@ -1,9 +1,35 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const url = new URL(request.url);
const documentId = url.searchParams.get("documentId") ?? "";
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const client = getConvexHttpClient();
const result = await client.query(api.documents.getContent, {
userId: auth.userId,
id: documentId,
});
if (!result) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
return NextResponse.json({ content: result.content ?? null });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -37,4 +63,3 @@ export async function GET(request: Request) {
return NextResponse.json({ content: document.content ?? null });
}
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import path from "path";
import { promises as fs } from "fs";
import type { Json } from "@/types/supabase";
@@ -158,6 +162,142 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const payload = (await request.json()) as CopyTreePayload;
if (!payload?.items?.length) {
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
}
const normalizedItems = payload.items.filter((it) => it?.documentId);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = payload.targetParentId ?? null;
let workspaceId: string | null = null;
if (targetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: targetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
const firstMeta = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceIds[0] });
if (!firstMeta) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
if (!workspaceId) {
workspaceId = firstMeta.workspace_id;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const wid = workspaceId;
const allDocs = await client.query(api.documents.listAllForCopy, {
userId: auth.userId,
workspaceId: wid,
});
const sourceById = new Map<string, DocRow>();
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
const missing = sourceIds.find((id) => !sourceById.has(id));
if (missing) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
const childrenByParent = new Map<string | null, DocRow[]>();
(allDocs as unknown as DocRow[]).forEach((doc) => {
const list = childrenByParent.get(doc.parent_id) ?? [];
list.push(doc);
childrenByParent.set(doc.parent_id, list);
});
const existingTitleSetByParent = new Map<string | null, Set<string>>();
const seedTitleSet = (parent: string | null) => {
if (existingTitleSetByParent.has(parent)) return;
const titles = new Set<string>();
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
existingTitleSetByParent.set(parent, titles);
};
seedTitleSet(targetParentId);
const newIdByOldId = new Map<string, string>();
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
const newId =
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
newIdByOldId.set(node.id, newId);
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
if (!recursive) return;
const children = getChildrenSorted(childrenByParent, node.id);
children.forEach((child) => visit(child, newId, newId));
};
visit(root, newParent, newParent);
};
normalizedItems.forEach((item) => {
const doc = sourceById.get(item.documentId) ?? null;
if (doc) {
enqueueTree(doc, targetParentId, Boolean(item.recursive));
}
});
if (copyQueue.length === 0) {
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
}
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
for (const item of copyQueue) {
const newId = newIdByOldId.get(item.old.id)!;
const parentId = item.newParentId;
if (!existingTitleSetByParent.has(parentId)) {
seedTitleSet(parentId);
}
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
existingTitleSetByParent.set(parentId, titleSet);
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
titleSet.add(newTitle);
await client.mutation(api.documents.create, {
userId: auth.userId,
id: newId,
workspaceId: wid,
parentId,
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
title: newTitle,
content: item.old.content ?? [],
});
await ensureDocumentScaffold(newId, newTitle);
await client.mutation(api.mindmaps.copyByDocument, {
userId: auth.userId,
sourceDocId: item.old.id,
targetDocId: newId,
});
insertedDocs.push({ oldId: item.old.id, newId });
}
return NextResponse.json({
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
});
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,6 +1,16 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
function makeId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
type CreateChildPayload = {
parentId: string | null;
@@ -11,6 +21,58 @@ type CreateChildPayload = {
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const { parentId, title, blocks }: CreateChildPayload = await request.json();
if (typeof parentId === "undefined") {
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
}
let workspaceId: string;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: parentId });
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间",
workspaceIdIfCreate: makeId(),
});
workspaceId = ensured.activeWorkspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
const contentPayload = Array.isArray(blocks) ? blocks : [];
const pageId = makeId();
const created = await client.mutation(api.documents.create, {
userId: auth.userId,
id: pageId,
workspaceId,
parentId: parentId ?? null,
title: resolvedTitle,
accessScope,
content: contentPayload,
});
return NextResponse.json({
pageId: created.id,
title: created.title ?? resolvedTitle,
});
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -7,6 +7,10 @@ import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/docume
import path from "path";
import { promises as fs } from "fs";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
const documentsBaseDir = getDocumentsBaseDir();
@@ -25,6 +29,9 @@ async function ensureDocumentScaffold(id: string, title: string | null) {
export async function POST(request: Request) {
try {
if (isConvexEnabled()) {
return await handleCreateRequestConvex(request);
}
return await handleCreateRequest(request);
} catch (error) {
console.error("创建页面失败", error);
@@ -33,6 +40,83 @@ export async function POST(request: Request) {
}
}
async function handleCreateRequestConvex(request: Request) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const { parentId }: { parentId?: string | null } = await request.json();
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(),
});
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: parentId,
});
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
const parentContentRes = await client.query(api.documents.getContent, { userId: auth.userId, id: parentId });
parentContent = (parentContentRes?.content as Json | null) ?? null;
} else {
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const id = randomUUID();
const data = await client.mutation(api.documents.create, {
userId: auth.userId,
id,
workspaceId,
parentId: parentId ?? null,
title: "无标题",
accessScope,
content: [],
});
// 为文件树创建本地目录和 index.md
if (data?.id) {
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
}
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
await client.mutation(api.documents.updateContent, {
userId: auth.userId,
id: parentId,
content: payload,
});
}
return NextResponse.json(data);
}
async function handleCreateRequest(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
@@ -1,9 +1,24 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.documents.softDelete, { userId: auth.userId, id: documentId });
return NextResponse.json({ success: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import path from "path";
import { promises as fs } from "fs";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
@@ -43,6 +47,50 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const { documentId }: DuplicatePayload = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const fallbackTitle =
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const newId = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const duplicated = await client.mutation(api.documents.duplicate, {
userId: auth.userId,
sourceId: documentId,
newId,
title: duplicatedTitle,
});
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await client.mutation(api.mindmaps.copyByDocument, {
userId: auth.userId,
sourceDocId: documentId,
targetDocId: duplicated.id,
});
return NextResponse.json({
id: duplicated.id,
title: duplicated.title ?? duplicatedTitle,
parent_id: duplicated.parent_id ?? null,
sort_order: duplicated.sort_order ?? null,
});
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -3,6 +3,9 @@ import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
interface EmbedPayload {
sourceId: string;
@@ -10,6 +13,49 @@ interface EmbedPayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { sourceId, targetId }: EmbedPayload = await request.json();
if (!sourceId || !targetId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceId });
if (!sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const targetContent = await client.query(api.documents.getContent, { userId: auth.userId, id: targetId });
if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const currentBlocks = extractBlocksFromContent(targetContent.content);
const nextBlocks: Json[] = [
...currentBlocks,
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceDoc.id,
title: sourceDoc.title ?? "无标题",
},
},
];
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
await client.mutation(api.documents.updateContent, {
userId: auth.userId,
id: targetId,
content: payload,
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
@@ -8,6 +12,17 @@ interface EmptyTrashPayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.documents.emptyTrashByWorkspace, { userId: auth.userId, workspaceId });
return NextResponse.json({ success: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
interface MovePayload {
documentId: string;
@@ -8,6 +12,20 @@ interface MovePayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const client = getConvexHttpClient();
await client.mutation(api.documents.move, {
userId: auth.userId,
id: documentId,
parentId,
sortOrder,
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import type { PageOptionsState } from "@/types/page-options";
import type { Database } from "@/types/supabase";
@@ -19,6 +23,31 @@ const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Table
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId, options }: OptionsPayload = await request.json();
if (!documentId || !options) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.documents.updateOptions, {
userId: auth.userId,
id: documentId,
options: {
wideLayout: options.wideLayout,
smallText: options.smallText,
showHeadingNumbers: options.showHeadingNumbers,
showToc: options.showToc,
showStructure: options.showStructure,
protectEditing: options.protectEditing,
showWordCount: options.showWordCount,
},
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,9 +1,24 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.documents.purge, { userId: auth.userId, id: documentId });
return NextResponse.json({ success: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,9 +1,24 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.documents.restore, { userId: auth.userId, id: documentId });
return NextResponse.json({ success: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
interface SavePayload {
documentId: string;
@@ -7,6 +11,18 @@ interface SavePayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId, content }: SavePayload = await request.json();
const client = getConvexHttpClient();
await client.mutation(api.documents.updateContent, {
userId: auth.userId,
id: documentId,
content,
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import type { DocumentStats } from "@/types/page-options";
interface StatsPayload {
@@ -8,6 +12,25 @@ interface StatsPayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId, stats }: StatsPayload = await request.json();
if (!documentId || !stats) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.documents.updateStats, {
userId: auth.userId,
id: documentId,
wordCount: stats.wordCount,
characterCount: stats.characterCount,
blockCount: stats.blockCount,
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
interface RenamePayload {
documentId: string;
@@ -7,6 +11,18 @@ interface RenamePayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const { documentId, title }: RenamePayload = await request.json();
const client = getConvexHttpClient();
await client.mutation(api.documents.updateTitle, {
userId: auth.userId,
id: documentId,
title,
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,10 +1,46 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
const assetType = searchParams.get("assetType") ?? undefined;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
const items = await client.query(api.mediaAssets.listByWorkspace, {
userId: auth.userId,
workspaceId,
assetType,
includeDeleted: false,
limit: Number.isNaN(limit) ? 12 : limit,
});
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
}
const supabase = await createSupabaseRouteClient();
const {
data: { user },
@@ -46,6 +82,75 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const assetId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const now = new Date().toISOString();
const asset: MediaAsset = {
id: assetId,
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName ?? null,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
ocr_text: null,
ocr_status: null,
created_at: now,
updated_at: now,
};
const client = getConvexHttpClient();
await client.mutation(api.mediaAssets.create, {
userId: auth.userId,
asset: {
id: asset.id,
workspace_id: asset.workspace_id,
document_id: asset.document_id,
asset_type: asset.asset_type,
file_url: asset.file_url,
thumbnail_url: asset.thumbnail_url,
bucket: null,
storage_path: null,
file_name: asset.file_name,
file_size: asset.file_size,
mime_type: asset.mime_type,
},
});
return NextResponse.json({ asset });
}
const supabase = await createSupabaseRouteClient();
const {
data: { user },
+149 -1
View File
@@ -1,9 +1,12 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { extname } from "path";
import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -65,6 +68,151 @@ function sanitizeSubPath(input: string | undefined): string {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const client = getConvexHttpClient();
const assets = (await client.query(api.mediaAssets.listByIds, {
userId: auth.userId,
ids: payload.assetIds,
})) as any[];
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
const nowIso = () => new Date().toISOString();
try {
switch (payload.action) {
case "delete": {
for (const a of assets) {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(a.id),
patch: { deleted_at: nowIso(), deleted_by: auth.userId },
});
}
return NextResponse.json({ ok: true });
}
case "restore": {
for (const a of assets) {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(a.id),
patch: { deleted_at: null, deleted_by: null, purged_at: null },
});
}
return NextResponse.json({ ok: true });
}
case "rename": {
if (payload.assetIds.length !== 1 || !payload.newName) {
return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 });
}
const asset = assets[0];
const currentName = String(asset.file_name ?? "");
const ext = currentName.includes(".") ? `.${currentName.split(".").pop()}` : "";
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
const safeName = newFileName.replace(/[\\/]/g, "_");
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: { file_name: safeName },
});
return NextResponse.json({ ok: true });
}
case "copy":
case "move": {
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const targetDoc = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: payload.targetDocumentId,
});
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
const existing = (await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId: payload.targetDocumentId,
limit: 500,
})) as any[];
const existingNames = new Set<string>(
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
);
const results: any[] = [];
for (const asset of assets) {
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
if (!storageId) continue;
if (payload.action === "copy") {
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: newId,
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
asset_type: String(asset.asset_type ?? "file"),
file_name: fileName,
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
mime_type: (asset.mime_type ?? null) as any,
},
});
results.push(created);
} else {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: {
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
file_name: fileName,
},
});
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
}
}
return NextResponse.json({ items: results });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
userId: auth.userId,
workspaceId,
expiredDeletedAt: makeExpiredDeletedAt(),
});
return NextResponse.json({ success: true, ...(res as any) });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -85,4 +115,3 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, updated: assetIds.length });
}
@@ -1,8 +1,14 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
export async function POST(request: Request) {
if (isConvexEnabled()) {
// 说明:OCR 链路目前仍依赖 Supabase JWT/表结构,迁移阶段先显式禁用,避免 UI/接口误用。
return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId }: PurgePayload = await request.json().catch(() => ({}));
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const client = getConvexHttpClient();
const res = await client.mutation(api.mediaAssets.purgeById, {
userId: auth.userId,
id: assetId,
expiredDeletedAt: makeExpiredDeletedAt(),
});
return NextResponse.json({ success: true, ...(res as any) });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -86,4 +116,3 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true });
}
@@ -3,6 +3,10 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -61,6 +65,47 @@ const resolveAssetObjectLocation = async (params: {
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId");
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const client = getConvexHttpClient();
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
if (!asset) {
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
}
const refreshed = await client.mutation(api.mediaAssets.refreshUrl, { userId: auth.userId, id: assetId });
const signedUrl = (refreshed as { signedUrl?: string | null } | null)?.signedUrl ?? null;
if (!signedUrl) {
return NextResponse.json({ error: "生成签名链接失败" }, { status: 500 });
}
return NextResponse.json({
signedUrl,
asset: {
id: asset.id,
file_name: asset.file_name,
mime_type: asset.mime_type,
file_size: asset.file_size,
},
});
}
const supabase = await createSupabaseRouteClient();
const {
data: { user },
@@ -3,6 +3,8 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic";
@@ -92,6 +94,26 @@ const tryResolveExistingObjectPath = async (params: {
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
try {
requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const fileUrl = searchParams.get("fileUrl");
if (!fileUrl) {
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
}
return NextResponse.json({ signedUrl: fileUrl });
}
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin
@@ -4,6 +4,10 @@ import type { MediaAsset } from "@/types/media";
import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -17,6 +21,86 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const assetId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || "");
const client = getConvexHttpClient();
// 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
if (!uploadUrl || typeof uploadUrl !== "string") {
return NextResponse.json({ error: "获取上传地址失败" }, { status: 500 });
}
// 2) 上传文件到 Convex Files
const uploadRes = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: buffer,
});
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => "");
return NextResponse.json({ error: `上传到 Convex 失败:${uploadRes.status} ${text}` }, { status: 500 });
}
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
const storageId = uploadJson?.storageId ?? "";
if (!storageId) {
return NextResponse.json({ error: "上传到 Convex 失败:缺少 storageId" }, { status: 500 });
}
// 3) 写入 Convex 的 media_assets 元数据(并记录 storageId
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: documentId,
asset_type: assetType,
file_name: file.name || null,
file_size: file.size,
mime_type: file.type || null,
},
});
const asset = created as unknown as MediaAsset;
return NextResponse.json({
asset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { promises as fs } from "fs";
import path from "path";
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
@@ -29,6 +32,24 @@ async function purgeTrashFolder(folder: string): Promise<number> {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
try {
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, {
userId: auth.userId,
workspaceId,
});
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -19,6 +22,53 @@ export async function POST(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
if (!ops.length) {
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
}
if (ops.length > 80) {
return NextResponse.json({ error: "ops 过多(最多 80" }, { status: 400 });
}
try {
const current = await client.query(api.mindmaps.get, {
userId: auth.userId,
docId,
mindmapId,
});
const baseData = current?.data ?? defaultMindmapData;
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
await client.mutation(api.mindmaps.put, {
userId: auth.userId,
docId,
mindmapId,
data: nextData,
});
return NextResponse.json({
ok: true,
applied,
errors,
data: nextData,
meta: {
documentId: docId,
mindmapId,
actor: payload?.actor ?? null,
reason: payload?.reason ?? null,
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -70,4 +120,3 @@ export async function POST(
},
});
}
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { promises as fs } from "fs";
import path from "path";
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -112,6 +115,17 @@ export async function GET(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, {
userId: auth.userId,
docId,
mindmapId,
});
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -155,6 +169,28 @@ export async function POST(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
};
try {
const result = await client.mutation(api.mindmaps.put, {
userId: auth.userId,
docId,
mindmapId,
data: data ?? defaultMindmapData,
...(typeof createOnly === "boolean" ? { createOnly } : {}),
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -203,6 +239,21 @@ export async function DELETE(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
try {
const result = await client.mutation(api.mindmaps.softDelete, {
userId: auth.userId,
docId,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -263,6 +314,37 @@ export async function PATCH(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
try {
if (action === "purge") {
const result = await client.mutation(api.mindmaps.purge, {
userId: auth.userId,
docId,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
}
const result = await client.mutation(api.mindmaps.restore, {
userId: auth.userId,
docId,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} catch (error) {
const msg = (error as Error).message ?? "操作失败";
const status = msg.includes("未找到") ? 404 : 400;
return NextResponse.json({ error: msg }, { status });
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { promises as fs } from "fs";
import path from "path";
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -40,6 +43,18 @@ export async function GET(
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId: id } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const mindmapId = `legacy-${id}`;
const res = await client.query(api.mindmaps.get, {
userId: auth.userId,
docId: id,
mindmapId,
});
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -89,6 +104,20 @@ export async function POST(
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId: id } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const mindmapId = `legacy-${id}`;
const { data } = await request.json();
const result = await client.mutation(api.mindmaps.put, {
userId: auth.userId,
docId: id,
mindmapId,
data: data ?? defaultMindmapData,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -130,6 +159,18 @@ export async function DELETE(
{ params }: { params: Promise<{ docId: string }> },
) {
const { docId: id } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const mindmapId = `legacy-${id}`;
const result = await client.mutation(api.mindmaps.softDelete, {
userId: auth.userId,
docId: id,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,9 +1,13 @@
import { NextResponse } from "next/server";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
type OnlyOfficeCallbackBody = {
status?: number;
@@ -11,6 +15,19 @@ type OnlyOfficeCallbackBody = {
key?: string;
};
const normalizeSecret = (raw: string) => {
const trimmed = String(raw || "").trim();
if (!trimmed) return "";
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
if (
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
try {
const u = new URL(raw);
@@ -33,6 +50,16 @@ export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || "";
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
if (ONLYOFFICE_CALLBACK_SECRET) {
const got = normalizeSecret(searchParams.get("token") || "");
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
if (!got || got !== expected) {
return NextResponse.json({ error: 1 });
}
}
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
if (!body) {
return NextResponse.json({ error: 0 });
@@ -53,6 +80,54 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 1 });
}
if (isConvexEnabled()) {
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
const client = getConvexHttpClient();
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
if (!asset) {
return NextResponse.json({ error: 1 });
}
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
}
const buf = Buffer.from(await upstream.arrayBuffer());
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
if (!uploadUrl || typeof uploadUrl !== "string") {
return NextResponse.json({ error: 1 });
}
const uploadRes = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
body: buf,
});
if (!uploadRes.ok) {
return NextResponse.json({ error: 1 });
}
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
const storageId = String(uploadJson?.storageId || "");
if (!storageId) {
return NextResponse.json({ error: 1 });
}
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
userId,
id: assetId,
storageId: storageId as any,
});
return NextResponse.json({ error: 0 });
}
const { data: asset, error: assetError } = await supabaseAdmin
.from("media_assets")
.select("id,bucket,storage_path,mime_type")
@@ -96,6 +96,7 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
);
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL);
const isSupabasePath =
target.pathname.startsWith("/storage/v1/") ||
@@ -155,7 +156,17 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
addAllowed(storageOverride.hostname, storageOverride.port);
}
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname)) {
if (convexOrigin?.hostname) {
addAllowed(convexOrigin.hostname, convexOrigin.port || "");
if (isLocalHostname(convexOrigin.hostname)) {
addAllowed("127.0.0.1", convexOrigin.port || "");
addAllowed("localhost", convexOrigin.port || "");
addAllowed("host.docker.internal", convexOrigin.port || "");
}
}
// 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) {
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
}
@@ -1,7 +1,33 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
export async function GET(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const pageId = searchParams.get("pageId");
if (!workspaceId || !pageId) {
return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 });
}
const limit = Number(searchParams.get("limit") ?? "50");
const offset = Number(searchParams.get("offset") ?? "0");
const backlinks = await client.query(api.references.listBacklinks, {
userId: auth.userId,
workspaceId,
pageId,
limit: Number.isFinite(limit) ? limit : 50,
offset: Number.isFinite(offset) ? offset : 0,
});
return NextResponse.json({ backlinks });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,8 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
type DisplayMode = "inline" | "embed";
@@ -16,6 +19,30 @@ interface RecordReferencePayload {
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const body = (await request.json()) as RecordReferencePayload;
if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
if (!isValidDisplayMode(String(body.displayMode ?? ""))) {
return NextResponse.json({ error: "非法的引用模式" }, { status: 400 });
}
const reference = await client.mutation(api.references.record, {
userId: auth.userId,
workspaceId: body.workspaceId,
sourcePageId: body.sourcePageId,
targetPageId: body.targetPageId,
sourceBlockId: body.sourceBlockId ?? null,
alias: body.alias ?? null,
displayMode: body.displayMode,
isPreviewable: Boolean(body.isPreviewable ?? true),
});
return NextResponse.json({ reference });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,8 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import type {
DocumentSearchFilters,
DocumentSearchRequest,
@@ -144,6 +147,89 @@ const fetchOcrMatches = async (
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const normalizedLower = normalizedQuery.toLowerCase();
const docs = await client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId,
});
const sortedByRecent = [...docs].sort((a, b) => {
const ta = a.updated_at ?? a.created_at ?? "";
const tb = b.updated_at ?? b.created_at ?? "";
return tb.localeCompare(ta);
});
const recentRows = await client.query(api.recents.listByWorkspace, {
userId: auth.userId,
workspaceId,
limit: 10,
});
const docMap = new Map(docs.map((d) => [d.id, d]));
const recent: DocumentSearchResult[] = recentRows
.map((r) => docMap.get(r.document_id))
.filter((row): row is (typeof docs)[number] => Boolean(row))
.map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: "",
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "recent",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 0,
}));
if (!normalizedQuery) {
const response: DocumentSearchResponse = { results: [], recent };
return NextResponse.json(response);
}
const narrowed = sortedByRecent.filter((row) => {
if (filters.onlyCurrentPage && payload.documentId) {
if (row.id !== payload.documentId) return false;
}
const title = (row.title ?? "无标题").toLowerCase();
return title.includes(normalizedLower);
});
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: buildSnippet(row.title ?? "", normalizedQuery),
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "title",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 2,
}));
const response: DocumentSearchResponse = {
results,
recent,
};
return NextResponse.json(response);
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,5 +1,8 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
interface RecentPayload {
workspaceId: string;
@@ -7,6 +10,24 @@ interface RecentPayload {
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { workspaceId, documentId }: RecentPayload = await request.json();
if (!workspaceId || !documentId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
await client.mutation(api.recents.upsert, {
userId: auth.userId,
workspaceId,
documentId,
lastAccessedAt: new Date().toISOString(),
});
return NextResponse.json({ ok: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
+186
View File
@@ -3,6 +3,11 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
import { randomUUID } from "crypto";
import {
detectLocalMindmapFiles,
detectLocalMindmapDocs,
@@ -13,7 +18,188 @@ import type { MediaAsset } from "@/types/media";
export const dynamic = "force-dynamic";
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => {
if (!input || typeof input !== "object") return input;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const record = input as any;
// 兼容:某些导图结构为 { root: ... }
return record && typeof record === "object" && "root" in record ? record.root : input;
})();
const ids: string[] = [];
const seen = new Set<string>();
const push = (value: unknown) => {
if (typeof value !== "string") return;
if (!value.startsWith("asset:")) return;
const id = value.slice("asset:".length).trim();
if (!id) return;
if (seen.has(id)) return;
seen.add(id);
ids.push(id);
};
const get = (obj: unknown, key: string): unknown => {
if (!obj || typeof obj !== "object") return undefined;
return (obj as Record<string, unknown>)[key];
};
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return;
const data = get(node, "data");
const image = get(node, "image");
// 常见:node.data.image = "asset:xxx"
push(get(data, "image"));
// 兼容:node.image = "asset:xxx"
push(image);
// 兼容:node.image.url = "asset:xxx"
push(get(image, "url"));
// 兼容:node.data.image.url = "asset:xxx"
push(get(get(data, "image"), "url"));
const children = get(node, "children");
if (Array.isArray(children)) {
children.forEach(walk);
}
};
walk(root);
return ids;
}
export async function GET(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(),
});
const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, {
userId: auth.userId,
});
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId;
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
try {
const documents = await client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId,
});
const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId,
});
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId,
includeDeleted: true,
});
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
const mindmapAssetChildren: Record<string, string[]> = {};
activeMindmaps.forEach((r) => {
const ids = extractMindmapImageAssetIdsFromData(r.data);
if (ids.length > 0) {
mindmapAssetChildren[r.mindmap_id] = ids;
}
});
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
const isLegacy = r.mindmap_id.startsWith("legacy-");
return {
id: r.mindmap_id,
workspace_id: r.workspace_id ?? targetWorkspaceId,
document_id: r.document_id,
asset_type: "mindmap",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: r.created_at ?? "",
updated_at: r.updated_at ?? "",
};
});
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
const isLegacy = r.mindmap_id.startsWith("legacy-");
return {
id: r.mindmap_id,
workspace_id: r.workspace_id ?? targetWorkspaceId,
document_id: r.document_id,
asset_type: "mindmap",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
deleted_at: r.deleted_at ?? null,
deleted_by: r.deleted_by ?? null,
purged_at: null,
signed_url: null,
created_at: r.created_at ?? "",
updated_at: r.updated_at ?? "",
};
});
const payload: SidebarInitialData = {
activeWorkspaceId: targetWorkspaceId,
workspaces,
documents,
trashedDocuments,
trashedMediaAssets: [],
trashedMindmapAssets,
mindmapDocs,
mindmapAssets,
mindmapAssetChildren,
tableAssets: [],
mediaAssets: [],
};
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -1,9 +1,30 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const payload = await request.json().catch(() => ({}));
const workspaceId = payload.workspaceId as string | undefined;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
await client.mutation(api.workspaces.switchDefaultWorkspace, {
userId: auth.userId,
workspaceId,
});
return NextResponse.json({ success: true });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
+17 -1
View File
@@ -29,6 +29,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
}
};
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
const m = String(method || "").toUpperCase();
if (m !== "GET" && m !== "HEAD") return "no-store";
if (contentType.includes("text/html")) return "no-store";
const p = `/${(pathParts ?? []).join("/")}`.toLowerCase();
// 说明:/cache 主要是 ONLYOFFICE 运行期二进制缓存,适合短缓存提升性能,但不宜过长。
if (p.includes("editor.bin") || p.endsWith(".bin")) {
return "public, max-age=3600, stale-while-revalidate=600";
}
return "public, max-age=300, stale-while-revalidate=300";
};
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const incomingUrl = new URL(request.url);
const target = new URL(
@@ -60,6 +73,10 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
stripHopByHopHeaders(outHeaders);
outHeaders.delete("content-encoding");
outHeaders.delete("content-length");
outHeaders.set("cache-control", pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method));
outHeaders.delete("pragma");
outHeaders.delete("expires");
outHeaders.delete("set-cookie");
return new NextResponse(upstream.body, {
status: upstream.status,
@@ -83,4 +100,3 @@ export async function OPTIONS(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxyCache(request, path ?? []);
}
+16 -8
View File
@@ -5,6 +5,7 @@ import { SupabaseProvider } from "@/components/providers/supabase-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
const inter = Inter({
subsets: ["latin"],
@@ -22,16 +23,19 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const useConvex = isConvexEnabled();
let session = null;
try {
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
} catch {
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
session = null;
if (!useConvex) {
try {
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
} catch {
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
session = null;
}
}
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop };
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
return (
@@ -46,9 +50,13 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.variable} antialiased`}>
<SupabaseProvider session={session}>
{useConvex ? (
<QueryProvider>{children}</QueryProvider>
</SupabaseProvider>
) : (
<SupabaseProvider session={session}>
<QueryProvider>{children}</QueryProvider>
</SupabaseProvider>
)}
</body>
</html>
);
@@ -32,6 +32,39 @@ window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true;
</script>
`.trim();
const XHR_REWRITE_SNIPPET = `
<script>
// 说明:ONLYOFFICE 在被反向代理(/onlyoffice-server)时,运行期仍可能发起指向内部端口
// http://127.0.0.1:8081/cache/... 的绝对请求(来自 ONLYOFFICE 内部逻辑)。
// 这会导致浏览器从 origin(3000) 跨域请求 8081 并触发 CORS 拦截。
// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR:把内部 8081 的请求改写回同源 /onlyoffice-server/*。
window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
(function () {
try {
var proxyPrefix = location.origin.replace(/\\/+$/, '') + '/onlyoffice-server';
var internal = {
'http://127.0.0.1:8081': true,
'http://localhost:8081': true
};
function rewrite(u) {
try {
var abs = new URL(u, location.origin);
var origin = abs.protocol + '//' + abs.host;
if (!internal[origin]) return u;
return proxyPrefix + abs.pathname + abs.search + abs.hash;
} catch (e) {
return u;
}
}
var origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
return origOpen.call(this, method, rewrite(url), async, user, password);
};
} catch (e) {}
})();
</script>
`.trim();
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:Hop-by-hop headers 不应被代理转发/透传
const hopByHop = [
@@ -51,8 +84,63 @@ const stripHopByHopHeaders = (headers: Headers) => {
const injectDisableServiceWorker = (html: string) => {
// 说明:只注入一次,避免重复拼接
if (html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__")) return html;
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${DISABLE_SERVICE_WORKER_SNIPPET}\n`);
const hasSw = html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__");
const hasXhr = html.includes("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__");
if (hasSw && hasXhr) return html;
const injected = [
hasSw ? "" : DISABLE_SERVICE_WORKER_SNIPPET,
hasXhr ? "" : XHR_REWRITE_SNIPPET,
]
.filter(Boolean)
.join("\n");
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${injected}\n`);
};
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
const m = String(method || "").toUpperCase();
if (m !== "GET" && m !== "HEAD") return "no-store";
if (contentType.includes("text/html")) return "no-store";
const p = `/${(pathParts ?? []).join("/")}`;
const lower = p.toLowerCase();
// 说明:这些路径通常是运行期接口/动态响应,不应缓存。
if (
lower.includes("/docservice/") ||
lower.includes("/coauthoring/") ||
lower.includes("/converter/") ||
lower.includes("/healthcheck") ||
lower.includes("/metrics")
) {
return "no-store";
}
const isStaticByPath =
lower.includes("/web-apps/") ||
lower.includes("/sdkjs/") ||
lower.endsWith(".js") ||
lower.endsWith(".css") ||
lower.endsWith(".map") ||
lower.endsWith(".png") ||
lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") ||
lower.endsWith(".gif") ||
lower.endsWith(".svg") ||
lower.endsWith(".ico") ||
lower.endsWith(".woff") ||
lower.endsWith(".woff2") ||
lower.endsWith(".ttf") ||
lower.endsWith(".otf") ||
lower.endsWith(".json") ||
lower.endsWith(".wasm") ||
lower.endsWith(".bin");
// 说明:ONLYOFFICE 静态资源体积大,且文件名通常稳定;这里尽量给浏览器缓存,提升二次打开速度。
if (isStaticByPath) {
return "public, max-age=604800, stale-while-revalidate=86400";
}
return "public, max-age=300, stale-while-revalidate=300";
};
const proxy = async (request: NextRequest, pathParts: string[]) => {
@@ -63,6 +151,17 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
const headers = new Headers(request.headers);
// 说明:避免把外部 Host 传给上游
headers.delete("host");
// 说明:ONLYOFFICE 在被反向代理时,会根据 X-Forwarded-* 推导自身对外地址,
// 用于生成静态资源/缓存文件的 URL。若缺失这些信息,可能会返回指向内部端口
//(例如 http://127.0.0.1:8081/cache/...)的绝对 URL,导致浏览器跨域请求被 CORS 拦截。
headers.set("x-forwarded-host", incomingUrl.host);
headers.set("x-forwarded-proto", incomingUrl.protocol.replace(":", ""));
if (incomingUrl.port) {
headers.set("x-forwarded-port", incomingUrl.port);
} else {
headers.set("x-forwarded-port", incomingUrl.protocol === "https:" ? "443" : "80");
}
headers.set("x-forwarded-prefix", "/onlyoffice-server");
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
headers.set("accept-encoding", "identity");
@@ -89,6 +188,10 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
outHeaders.delete("content-length");
const contentType = upstream.headers.get("content-type") || "";
outHeaders.set("cache-control", pickCacheControl(pathParts, contentType, request.method));
outHeaders.delete("pragma");
outHeaders.delete("expires");
outHeaders.delete("set-cookie");
if (contentType.includes("text/html")) {
const html = await upstream.text();
const injected = injectDisableServiceWorker(html);
@@ -15,6 +15,7 @@ declare global {
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
__MNOTE_ONLYOFFICE_DEBUG__?: Record<string, unknown>;
}
}
@@ -132,6 +133,97 @@ const base64UrlEncodeUtf8 = (input: string) => {
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
};
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
// 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
if (typeof window === "undefined") return;
if (!baseUrl) return;
const normalizedBase = String(baseUrl || "").trim().replace(/\/+$/, "");
const isProxyMode = normalizedBase === "/onlyoffice-server" || normalizedBase.endsWith("/onlyoffice-server");
if (!isProxyMode) return;
const proxyPrefix = (() => {
if (/^https?:\/\//i.test(normalizedBase)) return normalizedBase;
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
})();
const internalOrigins = new Set<string>(["http://127.0.0.1:8081", "http://localhost:8081"]);
try {
if (onlyofficeBaseUrlDesktop) {
const u = new URL(onlyofficeBaseUrlDesktop);
internalOrigins.add(`${u.protocol}//${u.host}`);
}
} catch {
// ignore
}
const patchWindow = (win: Window) => {
try {
if ((win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__) return;
const rewriteUrl = (input: string) => {
try {
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
const origin = `${u.protocol}//${u.host}`;
if (!internalOrigins.has(origin)) return input;
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
} catch {
return input;
}
};
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
if (typeof origOpen !== "function") return;
// eslint-disable-next-line no-extend-native
(win as any).XMLHttpRequest.prototype.open = function openPatched(
method: string,
url: string,
async?: boolean,
user?: string | null,
password?: string | null,
) {
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
// eslint-disable-next-line prefer-rest-params
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
};
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
} catch {
// ignore
}
};
patchWindow(window);
try {
const start = Date.now();
const timer = window.setInterval(() => {
try {
const frames = Array.from(document.querySelectorAll("iframe"));
for (const f of frames) {
try {
const w = (f as HTMLIFrameElement).contentWindow;
if (!w) continue;
// 说明:同源时才能访问 location;跨域会抛异常,直接跳过。
// eslint-disable-next-line no-unused-expressions
w.location?.origin;
patchWindow(w);
} catch {
// ignore
}
}
} catch {
// ignore
}
if (Date.now() - start > 120_000) {
window.clearInterval(timer);
}
}, 1000);
} catch {
// ignore
}
};
const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"];
@@ -167,9 +259,53 @@ export default function OnlyOfficePage() {
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
const mode = (params.get("mode") ?? "edit") as EditorMode;
const assetId = params.get("assetId") ?? "";
const channel = (params.get("channel") ?? "").trim().toLowerCase();
const [error, setError] = useState<string | null>(null);
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl;
const baseUrlCandidates = useMemo(() => {
const uniq: string[] = [];
const push = (v?: string | null) => {
const s = String(v || "").trim().replace(/\/+$/, "");
if (!s) return;
if (!uniq.includes(s)) uniq.push(s);
};
// 说明:网页端优先走同源 /onlyoffice-serverNext 代理到 ONLYOFFICE_INTERNAL_URL),
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
try {
push("/onlyoffice-server");
push(`${window.location.origin.replace(/\/+$/, "")}/onlyoffice-server`);
} catch {
// ignore
}
// 说明:默认优先使用运行期根据 isDesktop 归一化后的 onlyofficeBaseUrl
// 如遇到端口转发/本机服务不可达,可自动回退到另一套配置。
if (channel === "web") {
push(runtimeConfig.onlyofficeBaseUrlWeb);
push(runtimeConfig.onlyofficeBaseUrl);
push(runtimeConfig.onlyofficeBaseUrlDesktop);
return uniq;
}
if (channel === "desktop") {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
push(runtimeConfig.onlyofficeBaseUrl);
push(runtimeConfig.onlyofficeBaseUrlWeb);
return uniq;
}
push(runtimeConfig.onlyofficeBaseUrl);
if (runtimeConfig.isDesktop) {
push(runtimeConfig.onlyofficeBaseUrlWeb);
} else {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
}
return uniq;
}, [channel, runtimeConfig]);
const [baseUrlIndex, setBaseUrlIndex] = useState(0);
const baseUrl = baseUrlCandidates[baseUrlIndex] || "";
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
@@ -293,15 +429,33 @@ export default function OnlyOfficePage() {
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
let base = storageHostOverride
? fileUrl
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
const isConvexStorageUrl = (() => {
// 说明:Convex Files 的直链通常形如:
// - http://127.0.0.1:3210/api/storage/<id>
// - https://<convex-host>/api/storage/<id>
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
try {
const u = new URL(fileUrl);
return u.pathname.startsWith("/api/storage/");
} catch {
return false;
}
})();
let base =
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
? fileUrl
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
try {
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
const raw = new URL(base);
const alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
const isLocalHost =
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
@@ -312,11 +466,23 @@ export default function OnlyOfficePage() {
raw.host = po.host;
base = raw.toString();
}
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
if (!alreadyProxy && proxyOrigin && isLocalHost) {
const proxyBase = proxyOrigin || window.location.origin;
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
if (!alreadyProxy && raw.searchParams.has("token")) {
const proxyBase = proxyOrigin || window.location.origin;
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
alreadyProxy = true;
}
const u = new URL(base);
@@ -337,6 +503,25 @@ export default function OnlyOfficePage() {
}
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
useEffect(() => {
try {
window.__MNOTE_ONLYOFFICE_DEBUG__ = {
pageOrigin: window.location.origin,
baseUrl,
proxyOrigin,
callbackOrigin,
fileUrlInput: fileUrl,
resolvedFileUrl,
fileName,
fileType,
mode,
assetId,
};
} catch {
// ignore
}
}, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
useEffect(() => {
if (!baseUrl) {
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
@@ -346,6 +531,28 @@ export default function OnlyOfficePage() {
setError("缺少 fileUrl 参数。");
return;
}
setupOnlyOfficeInternalRequestRewrite(baseUrl, runtimeConfig.onlyofficeBaseUrlDesktop);
// 说明:外网访问(例如 frp/隧道)时,OnlyOffice 文档服务器运行在本机 Docker 容器内,无法直接访问
// document.url 里的 127.0.0.1/localhost。此时必须把 document.url 指向一个“容器可访问”的 Next Origin
//onlyofficeProxyOrigin / onlyofficeProxyOriginWeb),让 Next 服务端代为回源下载。
try {
const pageHost = window.location.hostname;
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
const isPageRemote = !isPageLocal;
const u = new URL(fileUrl);
const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
if (isPageRemote && isFileLocal && !proxyOrigin) {
setError(
"外网访问时检测到 fileUrl 为本机地址(127.0.0.1/localhost),但未配置 onlyofficeProxyOriginWeb。请在 public/mnote-env.json 配置 onlyofficeProxyOriginWeb/onlyofficeCallbackOriginWeb(例如 http://host.docker.internal:3000 或当前 Docker 可达的主机 IP)。",
);
return;
}
} catch {
// ignore
}
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
loadScript(scriptUrl)
.then(async () => {
@@ -462,9 +669,15 @@ export default function OnlyOfficePage() {
});
})
.catch((err: Error) => {
// 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。
const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length;
if (hasNext) {
setBaseUrlIndex((i) => i + 1);
return;
}
setError(err.message);
});
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
}, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]);
if (error) {
return (
+43
View File
@@ -1,8 +1,51 @@
import { redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
function makeId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
export default async function Home() {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const client = getConvexHttpClient();
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间",
workspaceIdIfCreate: makeId(),
});
const docs = await client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId,
});
const firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0];
if (firstDoc?.id) {
redirect(`/documents/${firstDoc.id}`);
}
const docId = makeId();
await client.mutation(api.documents.create, {
userId: auth.userId,
id: docId,
workspaceId: activeWorkspaceId,
parentId: null,
title: "新页面",
accessScope: "private",
content: [],
});
redirect(`/documents/${docId}`);
}
const supabase = await createSupabaseServerClient();
const session = (await supabase.auth.getSession()).data.session;
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
@@ -18,56 +17,37 @@ interface Props {
}
export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null);
const [pending, setPending] = useState(false);
const backendUrl = useMemo(() => getMnoteRuntimeConfig().backendUrl, []);
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const backendUrl = runtime.backendUrl;
const useConvex = Boolean(runtime.useConvex);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return;
// 说明:当前后端(FastAPI)仍使用 Supabase JWT 做鉴权;Convex 迁移阶段先不打通这一块。
if (useConvex) return;
if (!backendUrl) return;
setPending(true);
try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({
document_id: documentId,
file_url: "https://example.com/sample.pdf",
}),
});
const data = await response.json();
if (response.ok) {
setTask(data);
}
// TODO:如需恢复该能力,请在接入真实鉴权后,将 access_token 从 AuthContext 注入到这里。
// 这里暂时保持 UI 可渲染,不发起请求。
void documentId;
} finally {
setPending(false);
}
};
useEffect(() => {
if (!backendUrl || !session?.access_token || !task?.task_id) {
if (useConvex) return;
if (!backendUrl || !task?.task_id) {
return;
}
const timer = setInterval(async () => {
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
return;
}
const data = (await response.json()) as TaskResponse;
setTask(data);
if (data.status === "completed") {
clearInterval(timer);
}
// 说明:同上,暂不轮询。
void timer;
}, 2000);
return () => clearInterval(timer);
}, [backendUrl, session?.access_token, task?.task_id]);
}, [backendUrl, task?.task_id, useConvex]);
return (
<Card className="mt-4 bg-white shadow-sm">
@@ -77,9 +57,14 @@ export function DocumentTaskPanel({ documentId }: Props) {
<div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"}
</div>
{useConvex && (
<div className="text-xs text-gray-500">
Convex Supabase JWT
</div>
)}
{task?.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
<Button onClick={triggerTask} disabled={pending} variant="outline">
<Button onClick={triggerTask} disabled={pending || useConvex} variant="outline">
{pending ? "触发中..." : "触发 OCR"}
</Button>
</CardContent>
@@ -0,0 +1,264 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildDocumentTree } from "@/lib/documents";
import { cn } from "@/lib/utils";
import { useDocumentSearch } from "@/hooks/use-document-search";
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
export type MoveEmbedMode = "move" | "embed";
const DEFAULT_FILTERS: DocumentSearchFilters = {
titleOnly: true,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeRange: "any",
timeField: "updated",
};
type PickerItem =
| { kind: "root"; id: null; title: string; subtitle?: string }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
method: "GET",
credentials: "include",
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message = payload?.error ?? "获取页面列表失败";
throw new Error(message);
}
return response.json();
}
interface MoveEmbedPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
defaultMode?: MoveEmbedMode;
modes?: MoveEmbedMode[];
allowRoot?: boolean;
excludeIds?: string[];
onPick: (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;
}
export function MoveEmbedPickerDialog({
open,
onOpenChange,
workspaceId,
defaultMode = "move",
modes = ["move", "embed"],
allowRoot = true,
excludeIds = [],
onPick,
}: MoveEmbedPickerDialogProps) {
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(0);
useEffect(() => {
if (!open) {
setQuery("");
setHighlighted(0);
return;
}
// 说明:每次打开对话框时,强制同步到调用方传入的默认模式(移动/嵌入)。
setMode(defaultMode);
setQuery("");
setHighlighted(0);
}, [defaultMode, open]);
const payload = useMemo(() => {
if (!workspaceId) return null;
return {
workspaceId,
query,
filters: DEFAULT_FILTERS,
limit: 30,
};
}, [query, workspaceId]);
const trimmed = query.trim();
const isEmptyQuery = trimmed.length === 0;
const sidebarQuery = useQuery({
queryKey: ["move-embed-picker-sidebar", workspaceId],
queryFn: () => {
if (!workspaceId) {
throw new Error("缺少 workspaceId");
}
return fetchSidebarData(workspaceId);
},
enabled: open && Boolean(workspaceId) && isEmptyQuery,
staleTime: 30_000,
gcTime: 60_000,
});
const { data, isLoading, error } = useDocumentSearch(payload, open && !isEmptyQuery);
const items = useMemo<PickerItem[]>(() => {
const excluded = new Set(excludeIds);
const result: PickerItem[] = [];
if (allowRoot && mode === "move") {
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
}
if (isEmptyQuery) {
const docs = sidebarQuery.data?.documents ?? [];
const tree = buildDocumentTree(docs);
const flattened: Array<{ id: string; title: string; depth: number }> = [];
const walk = (nodes: ReturnType<typeof buildDocumentTree>, depth: number) => {
for (const node of nodes) {
flattened.push({ id: node.id, title: node.title ?? "无标题", depth });
if (node.children?.length) {
walk(node.children, depth + 1);
}
}
};
walk(tree, 0);
for (const item of flattened) {
if (excluded.has(item.id)) continue;
result.push({
kind: "doc",
id: item.id,
title: item.title,
depth: item.depth,
});
}
return result;
}
const rawList = data?.results?.length ? data?.results : data?.recent ?? [];
for (const r of rawList) {
if (!r || excluded.has(r.id)) continue;
result.push({
kind: "doc",
id: r.id,
title: r.title || "无标题",
subtitle: r.matchField === "recent" ? "最近打开" : undefined,
raw: r,
});
}
return result;
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]);
useEffect(() => {
setHighlighted(0);
}, [mode, query, open]);
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
const handlePick = useCallback(
async (targetId: string | null) => {
await onPick(mode, targetId);
onOpenChange(false);
},
[mode, onOpenChange, onPick],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-full max-w-md overflow-hidden border-none bg-white p-0 shadow-xl">
<DialogTitle className="sr-only"></DialogTitle>
<div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<Tabs value={mode} onValueChange={(v) => setMode(v as MoveEmbedMode)}>
<TabsList className="w-full">
{modes.includes("move") && (
<TabsTrigger value="move" className="flex-1">
</TabsTrigger>
)}
{modes.includes("embed") && (
<TabsTrigger value="embed" className="flex-1">
</TabsTrigger>
)}
</TabsList>
<TabsContent value={mode} className="mt-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm"
/>
</div>
</TabsContent>
</Tabs>
</div>
<div
className="flex-1 overflow-y-auto"
onKeyDown={(event) => {
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const picked = items[highlighted];
if (!picked) return;
void handlePick(picked.id);
}
}}
>
{!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<div className="py-2">
{items.map((item, idx) => (
<button
key={item.kind === "root" ? "root" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
idx === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => void handlePick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
</button>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useCallback, useEffect } from "react";
import { MoveEmbedPickerDialog } from "@/components/documents/move-embed-picker-dialog";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
export function MoveEmbedPickerHost() {
const open = useMoveEmbedPickerStore((s) => s.open);
const workspaceId = useMoveEmbedPickerStore((s) => s.workspaceId);
const defaultMode = useMoveEmbedPickerStore((s) => s.defaultMode);
const modes = useMoveEmbedPickerStore((s) => s.modes);
const allowRoot = useMoveEmbedPickerStore((s) => s.allowRoot);
const excludeIds = useMoveEmbedPickerStore((s) => s.excludeIds);
const onPick = useMoveEmbedPickerStore((s) => s.onPick);
const setWorkspaceId = useMoveEmbedPickerStore((s) => s.setWorkspaceId);
const close = useMoveEmbedPickerStore((s) => s.close);
useEffect(() => {
if (!open) return;
if (workspaceId) return;
let cancelled = false;
const run = async () => {
try {
const res = await fetch("/api/sidebar");
if (!res.ok) return;
const json = (await res.json().catch(() => null)) as { activeWorkspaceId?: string } | null;
const nextId = typeof json?.activeWorkspaceId === "string" ? json.activeWorkspaceId : null;
if (!cancelled) {
setWorkspaceId(nextId);
}
} catch {
// ignore
}
};
void run();
return () => {
cancelled = true;
};
}, [open, setWorkspaceId, workspaceId]);
const handlePick = useCallback(
async (mode: "move" | "embed", targetId: string | null) => {
if (onPick) {
await onPick(mode, targetId);
}
close();
},
[close, onPick],
);
return (
<MoveEmbedPickerDialog
open={open}
onOpenChange={(next) => {
if (!next) {
close();
}
}}
workspaceId={workspaceId}
defaultMode={defaultMode}
modes={modes}
allowRoot={allowRoot}
excludeIds={excludeIds}
onPick={handlePick}
/>
);
}
@@ -21,6 +21,7 @@ import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
@@ -777,13 +778,13 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
)}
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
)}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
@@ -793,6 +794,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
</div>
<MoveEmbedPickerHost />
{/* 全屏表格编辑器 Modal */}
{fullScreenTableId && (
<FullScreenTableEditor
@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { extractBlockText } from "@/lib/blocks";
type RemoteBlock = {
id: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: unknown;
};
const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading";
export const blockReferenceBlock = createReactBlockSpec(
{
type: "blockReference",
propSchema: {
sourceDocumentId: { default: "" },
targetBlockId: { default: "" },
display: { default: "embed" },
},
content: "none",
},
() => ({
render: ({ block }) => <BlockReferenceContent block={block as any} />,
}),
)();
function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) {
const router = useRouter();
const sourceDocumentId = block.props.sourceDocumentId;
const targetBlockId = block.props.targetBlockId;
const [remote, setRemote] = useState<RemoteBlock | null>(null);
const [textDraft, setTextDraft] = useState<string>("");
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [error, setError] = useState<string>("");
const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]);
useEffect(() => {
if (!sourceDocumentId || !targetBlockId) {
setStatus("error");
setError("引用信息不完整");
return;
}
let cancelled = false;
setStatus("loading");
setError("");
setRemote(null);
const run = async () => {
try {
const res = await fetch(
`/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`,
{ method: "GET", credentials: "include" },
);
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.error ?? "获取引用块失败");
}
const json = await res.json();
const next = (json?.block ?? null) as RemoteBlock | null;
if (!cancelled) {
setRemote(next);
if (next && isTextBlock(next)) {
setTextDraft(extractBlockText(next as any));
}
setStatus("idle");
}
} catch (e) {
if (!cancelled) {
setStatus("error");
setError(e instanceof Error ? e.message : "获取引用块失败");
}
}
};
void run();
return () => {
cancelled = true;
};
}, [sourceDocumentId, targetBlockId]);
const openSource = useCallback(() => {
if (sourceDocumentId) {
router.push(`/documents/${sourceDocumentId}`);
}
}, [router, sourceDocumentId]);
const saveText = useCallback(async () => {
if (!remote || !canEdit) return;
const nextBlock: RemoteBlock = {
...remote,
id: remote.id,
content: [{ type: "text", text: textDraft }],
};
const res = await fetch("/api/blocks/patch", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
const msg = payload?.error ?? "同步编辑失败";
if (typeof window !== "undefined") window.alert(msg);
return;
}
if (typeof window !== "undefined") window.alert("已同步编辑到原块");
}, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]);
return (
<div
className="mt-2 rounded-md border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3"
onMouseDown={(e) => {
// 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。
e.stopPropagation();
}}
>
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500">
<span></span>
<span className="ml-auto flex items-center gap-2">
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={openSource}>
</Button>
</span>
</div>
{status === "loading" ? (
<div className="text-sm text-gray-400">...</div>
) : status === "error" ? (
<div className="text-sm text-red-600">{error}</div>
) : !remote ? (
<div className="text-sm text-gray-400"></div>
) : canEdit ? (
<div className="space-y-2">
<textarea
className="w-full resize-y rounded-md border border-[#e2e8f0] bg-white p-2 text-sm text-gray-900 outline-none"
rows={3}
value={textDraft}
onChange={(e) => setTextDraft(e.target.value)}
placeholder="在这里编辑会同步到原块(MVP:仅支持段落/标题纯文本)"
/>
<div className="flex items-center gap-2">
<Button type="button" size="sm" className="h-8 px-3 text-xs" onClick={() => void saveText()}>
</Button>
<span className="text-[11px] text-gray-400">MVP/</span>
</div>
</div>
) : (
<div className="text-sm text-gray-700">
<div className="mb-1 text-xs text-gray-400">{remote.type ?? "unknown"}</div>
<div className="text-sm text-gray-800">{extractBlockText(remote as any) || "(内容为空或暂不支持渲染)"}</div>
</div>
)}
</div>
);
}
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { RiFileTextFill } from "react-icons/ri";
import { useRouter } from "next/navigation";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const normalizeTitle = (value?: string | null) => {
if (!value || !value.trim()) {
@@ -15,7 +16,7 @@ const normalizeTitle = (value?: string | null) => {
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const router = useRouter();
const supabaseBrowser = getSupabaseBrowserClient();
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
const fallbackTitle = normalizeTitle(title);
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
@@ -27,6 +28,10 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
if (!pageId) {
return;
}
if (useConvex) {
// 说明:Convex 迁移阶段先不做 title 的实时订阅/拉取,直接使用 block props 里的 title。
return;
}
let cancelled = false;
const applyTitle = (nextTitle?: string | null) => {
if (!cancelled) {
@@ -36,6 +41,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
const fetchTitle = async () => {
try {
const supabaseBrowser = getSupabaseBrowserClient();
const { data } = await supabaseBrowser
.from("documents")
.select("title")
@@ -51,6 +57,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
void fetchTitle();
const supabaseBrowser = getSupabaseBrowserClient();
const channel = supabaseBrowser
.channel(`page-ref-${pageId}`)
.on(
@@ -67,7 +74,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
cancelled = true;
supabaseBrowser.removeChannel(channel);
};
}, [pageId]);
}, [pageId, useConvex]);
const navigate = () => {
if (pageId) {
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
import type { CustomBlockSchema } from "../schema";
import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
@@ -32,6 +33,7 @@ type ConvertOption = {
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
};
const extractText = (block: Block<CustomBlockSchema>) => {
@@ -43,10 +45,11 @@ const extractText = (block: Block<CustomBlockSchema>) => {
return "未命名页面";
};
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
@@ -156,39 +159,71 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
router.refresh();
}, [block, currentDocumentId, editor, router]);
const handleMoveEmbedPick = useCallback(
async (mode: "move" | "embed", targetDocumentId: string | null) => {
if (!targetDocumentId) {
return;
}
if (mode === "embed" && targetDocumentId === currentDocumentId) {
if (typeof window !== "undefined") {
window.alert("禁止嵌入到当前页面");
}
return;
}
const endpoint = mode === "embed" ? "/api/blocks/embed" : "/api/blocks/move";
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceDocumentId: currentDocumentId,
blockId: block.id,
targetDocumentId,
position: "end",
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message =
payload?.error ?? (mode === "embed" ? "嵌入失败,请检查目标页面" : "移动失败,请检查目标页面");
if (typeof window !== "undefined") {
window.alert(message);
}
return;
}
if (mode === "move") {
// 说明:移动块本体:本地编辑器也要移除该块,避免等待刷新造成错觉。
try {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
router.refresh();
return;
}
if (typeof window !== "undefined") {
window.alert("已在目标页面末尾插入嵌入引用块");
}
},
[block.id, currentDocumentId, editor, router],
);
const moveOrEmbedBlock = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId);
if (!targetParent) {
return;
}
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: targetParent.trim(),
title: extractText(block),
blocks: [block],
}),
// 说明:拖拽菜单点击后会立即卸载,必须使用全局 Host 承载弹窗。
openPicker({
workspaceId,
defaultMode: "move",
modes: ["move", "embed"],
allowRoot: false,
excludeIds: [currentDocumentId],
onPick: handleMoveEmbedPick,
});
if (!response.ok) {
window.alert("移动失败,请确认页面 ID");
return;
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
return;
}, [currentDocumentId, handleMoveEmbedPick, openPicker, workspaceId]);
const convertOptions = useMemo<ConvertOption[]>(
() => [
@@ -372,6 +407,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
};
export const CustomSideMenu = (props: CustomSideMenuProps) => (
@@ -381,6 +417,7 @@ export const CustomSideMenu = (props: CustomSideMenuProps) => (
<CustomDragHandleMenu
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
currentDocumentId={props.currentDocumentId}
workspaceId={props.workspaceId}
/>
)}
/>
@@ -13,6 +13,7 @@ import { progressBlock } from "./blocks/ProgressBlock";
import { mediaBlock } from "./blocks/MediaBlock";
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
import { mindmapBlock } from "./blocks/MindmapBlock";
import { blockReferenceBlock } from "./blocks/BlockReferenceBlock";
const headingSpec = createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
@@ -24,6 +25,7 @@ export const customBlockSchema = BlockNoteSchema.create({
...defaultBlockSpecs,
heading: headingSpec,
pageReference: pageReferenceBlock,
blockReference: blockReferenceBlock,
advancedTodo: advancedTodoBlock,
progressMeter: progressBlock,
media: mediaBlock,
@@ -15,6 +15,7 @@ import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoad
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
type LuckysheetSelection =
| {
@@ -52,7 +53,15 @@ const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
const supabaseBrowser = useMemo(() => {
if (useConvex) return null;
try {
return getSupabaseBrowserClient();
} catch {
return null;
}
}, [useConvex]);
const containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null);
@@ -234,6 +243,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
useEffect(() => {
if (!tableId) return;
if (!supabaseBrowser) return;
const channel = supabaseBrowser
.channel(`table-${tableId}-live`)
.on(
@@ -261,7 +271,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [tableId]);
}, [tableId, supabaseBrowser]);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
@@ -48,6 +48,7 @@ import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
inferPasteTargetDocId,
isTextInputTarget,
@@ -128,7 +129,15 @@ interface ContextMenuState {
export function Sidebar({ initialData }: SidebarProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
useSidebarStore();
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
const supabaseBrowser = useMemo(() => {
if (useConvex) return null;
try {
return getSupabaseBrowserClient();
} catch {
return null;
}
}, [useConvex]);
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
@@ -154,6 +163,9 @@ export function Sidebar({ initialData }: SidebarProps) {
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
@@ -242,6 +254,7 @@ export function Sidebar({ initialData }: SidebarProps) {
}, [sidebarQuery]);
useEffect(() => {
if (!supabaseBrowser) return;
const channel = supabaseBrowser
.channel("documents-feed")
.on(
@@ -255,9 +268,10 @@ export function Sidebar({ initialData }: SidebarProps) {
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [refreshTree]);
}, [refreshTree, supabaseBrowser]);
useEffect(() => {
if (!supabaseBrowser) return;
const channel = supabaseBrowser
.channel("media-assets-feed")
.on(
@@ -276,7 +290,7 @@ export function Sidebar({ initialData }: SidebarProps) {
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
}, [sidebarData.activeWorkspaceId, sidebarQuery, supabaseBrowser]);
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
@@ -476,7 +490,19 @@ export function Sidebar({ initialData }: SidebarProps) {
[refreshTree],
);
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
setMoveEmbedSource(node);
setMoveEmbedMode(nextMode);
setMoveEmbedOpen(true);
}, []);
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
openMoveEmbedPicker(node, "embed");
return;
if (sidebarData.activeWorkspaceId) {
openMoveEmbedPicker(node, "embed");
return;
}
if (typeof window === "undefined") {
return;
}
@@ -1465,6 +1491,12 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleMovePrompt = useCallback(
async (node: DocumentNode) => {
openMoveEmbedPicker(node, "move");
return;
if (sidebarData.activeWorkspaceId) {
openMoveEmbedPicker(node, "move");
return;
}
if (typeof window === "undefined") {
return;
}
@@ -2034,6 +2066,44 @@ export function Sidebar({ initialData }: SidebarProps) {
onDelete={() => void handleDeleteFileTreeSelection()}
/>
)}
<MoveEmbedPickerDialog
open={moveEmbedOpen}
onOpenChange={setMoveEmbedOpen}
workspaceId={sidebarData.activeWorkspaceId ?? null}
defaultMode={moveEmbedMode}
excludeIds={moveEmbedSource?.id ? [moveEmbedSource.id] : []}
onPick={async (pickedMode, targetId) => {
const source = moveEmbedSource;
if (!source) {
return;
}
if (pickedMode === "move") {
await handleMove(source.id, targetId, 0);
return;
}
if (!targetId) {
return;
}
if (typeof window !== "undefined" && source.id === targetId) {
window.alert("不能嵌入到自身页面");
return;
}
const response = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: source.id, targetId }),
});
if (!response.ok) {
if (typeof window !== "undefined") {
window.alert("嵌入失败,请检查目标页面");
}
return;
}
if (typeof window !== "undefined") {
window.alert("已在目标页面末尾插入引用块");
}
}}
/>
{assetMenu && (
<AssetContextMenu
asset={assetMenu.asset}
@@ -162,10 +162,28 @@ const saveDocumentBlocks = async (supabase: DocSupabaseClient, ctx: DocToolConte
};
export const createDocServerTools = (args: {
supabase: DocSupabaseClient;
supabase?: DocSupabaseClient;
ctx: DocToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
loadBlocks?: () => Promise<{ blocks: unknown[]; source: string }>;
saveBlocks?: (blocks: unknown[]) => Promise<void>;
}) => {
const loadBlocks = async () => {
if (args.loadBlocks) return await args.loadBlocks();
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks");
return await loadDocumentBlocks(args.supabase, args.ctx);
};
const saveBlocks = async (blocks: unknown[]) => {
if (args.saveBlocks) {
await args.saveBlocks(blocks);
return;
}
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadBlocks/saveBlocks");
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
};
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) {
throw new Error(`工具未被允许:${toolId}`);
@@ -174,7 +192,7 @@ export const createDocServerTools = (args: {
if (toolId === "doc_get") {
const maxNodesRaw = Number(toolArgs.maxBlocks ?? 80);
const maxBlocks = Math.max(10, Math.min(240, Number.isFinite(maxNodesRaw) ? Math.floor(maxNodesRaw) : 80));
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const summary = walkSummaries(blocks, maxBlocks);
return { ok: true, source, totalTopLevelBlocks: blocks.length, blocks: summary };
}
@@ -184,7 +202,7 @@ export const createDocServerTools = (args: {
if (!query) throw new Error("缺少 query");
const maxRaw = Number(toolArgs.maxResults ?? 8);
const maxResults = Math.max(1, Math.min(30, Number.isFinite(maxRaw) ? Math.floor(maxRaw) : 8));
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const summary = walkSummaries(blocks, 400);
const q = query.toLowerCase();
const hits = summary.filter((x) => x.text.toLowerCase().includes(q)).slice(0, maxResults);
@@ -207,7 +225,7 @@ export const createDocServerTools = (args: {
const created = specs.map(buildBlockFromSpec);
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const targetId = beforeBlockId || afterBlockId;
const found = targetId ? findContainerById(blocks, targetId) : null;
if (targetId && !found) {
@@ -221,7 +239,7 @@ export const createDocServerTools = (args: {
found.container.splice(insertAt, 0, ...created);
}
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
await saveBlocks(blocks);
return {
ok: true,
source,
@@ -238,7 +256,7 @@ export const createDocServerTools = (args: {
const modeRaw = String(toolArgs.mode ?? "replace").trim();
const mode = modeRaw === "append" || modeRaw === "prepend" ? modeRaw : "replace";
const { blocks, source } = await loadDocumentBlocks(args.supabase, args.ctx);
const { blocks, source } = await loadBlocks();
const found = findContainerById(blocks, blockId);
if (!found) throw new Error(`未找到 blockId${blockId}`);
const block = found.container[found.index];
@@ -247,7 +265,7 @@ export const createDocServerTools = (args: {
const nextText = mode === "append" ? `${prevText}${text}` : mode === "prepend" ? `${text}${prevText}` : text;
found.container[found.index] = { ...block, content: createTextContent(nextText) };
await saveDocumentBlocks(args.supabase, args.ctx, blocks);
await saveBlocks(blocks);
return { ok: true, source, blockId, mode, data: blocks };
}
@@ -45,9 +45,42 @@ const loadWorkspaceIds = async (supabase: DocsSupabaseClient, userId: string) =>
};
export const createDocsServerTools = (args: {
supabase: DocsSupabaseClient;
supabase?: DocsSupabaseClient;
ctx: DocsToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
searchDocs?: (args: {
userId: string;
query: string;
limit: number;
workspaceId: string | null;
includeDeleted: boolean;
}) => Promise<
Array<{
id: string;
title: string;
workspaceId: string;
parentId: string | null;
updatedAt: unknown;
snippet: string;
}>
>;
readDoc?: (args: {
userId: string;
documentId: string;
maxChars: number;
includeContent: boolean;
}) => Promise<{
ok: true;
documentId: string;
title: string;
workspaceId: string;
parentId: string | null;
updatedAt: unknown;
rawTextLength: number;
rawText: string;
content?: unknown;
}>;
}) => {
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) {
@@ -62,6 +95,21 @@ export const createDocsServerTools = (args: {
const workspaceId = String(toolArgs.workspaceId ?? "").trim() || null;
const includeDeleted = Boolean(toolArgs.includeDeleted ?? false);
if (args.searchDocs) {
const results = await args.searchDocs({
userId: args.ctx.userId,
query,
limit,
workspaceId,
includeDeleted,
});
return { ok: true, query, results };
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 searchDocs/readDoc");
}
const wsIds = await loadWorkspaceIds(args.supabase, args.ctx.userId);
const wsFilter = workspaceId ? [workspaceId] : wsIds;
if (wsFilter.length === 0) return { ok: true, query, results: [] };
@@ -108,6 +156,19 @@ export const createDocsServerTools = (args: {
const maxChars = Math.max(200, Math.min(20_000, Number.isFinite(maxCharsRaw) ? Math.floor(maxCharsRaw) : 2500));
const includeContent = Boolean(toolArgs.includeContent ?? false);
if (args.readDoc) {
return await args.readDoc({
userId: args.ctx.userId,
documentId,
maxChars,
includeContent,
});
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 searchDocs/readDoc");
}
const { data, error } = await args.supabase
.from("documents")
.select(includeContent ? "id,title,raw_text,content,workspace_id,parent_id,updated_at" : "id,title,raw_text,workspace_id,parent_id,updated_at")
@@ -30,9 +30,12 @@ const resolveAttachment = (ctx: MediaToolContext, ref: string): ResolvedAttachme
const pick = (obj: unknown, key: string) => (isRecord(obj) ? obj[key] : undefined);
export const createMediaServerTools = (args: {
supabase: MediaSupabaseClient;
supabase?: MediaSupabaseClient;
ctx: MediaToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
loadById?: (id: string) => Promise<unknown | null>;
loadByFileUrl?: (fileUrl: string) => Promise<unknown | null>;
}) => {
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
@@ -48,27 +51,37 @@ export const createMediaServerTools = (args: {
let row: unknown = null;
if (targetAssetId) {
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("id", targetAssetId)
.single();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
if (args.loadById) {
row = await args.loadById(targetAssetId);
} else {
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl");
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("id", targetAssetId)
.single();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
}
row = data;
}
row = data;
} else if (targetUrl) {
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("file_url", targetUrl)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
if (args.loadByFileUrl) {
row = await args.loadByFileUrl(targetUrl);
} else {
if (!args.supabase) throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadById/loadByFileUrl");
const { data, error } = await args.supabase
.from("media_assets")
.select("id,file_name,file_url,mime_type,ocr_text,ocr_status,ocr_payload,storage_path,bucket,document_id,workspace_id,deleted_at,purged_at,updated_at")
.eq("file_url", targetUrl)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (error) {
throw new Error(isRecord(error) && typeof error.message === "string" ? error.message : "读取图片失败");
}
row = data;
}
row = data;
} else {
throw new Error("缺少 assetId / fileUrl / attachmentRef");
}
@@ -109,4 +122,3 @@ export const createMediaServerTools = (args: {
return { run };
};
@@ -188,13 +188,25 @@ const sanitizeAddChildOps = (args: {
};
export const createMindmapServerTools = (args: {
supabase: SupabaseRouteClient;
supabase?: SupabaseRouteClient;
ctx: MindmapToolContext;
cfg: OpenAiCompatibleChatOptions;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase/local 文件。
loadMindmap?: () => Promise<{
doc: { id: string; title: string | null; workspace_id: string | null };
base: MindmapTreeNode;
}>;
saveMindmap?: (args: {
doc: { id: string; title: string | null; workspace_id: string | null };
data: MindmapTreeNode;
}) => Promise<void>;
}) => {
const loadDoc = async () => {
const { documentId, userId } = args.ctx;
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 loadMindmap/saveMindmap");
}
const query = args.supabase
.from("documents")
.select("id,title,workspace_id,mindmap_data")
@@ -210,6 +222,11 @@ export const createMindmapServerTools = (args: {
};
const loadMindmap = async () => {
if (args.loadMindmap) {
const loaded = await args.loadMindmap();
ensureMindmapUids(loaded.base);
return loaded;
}
const doc = await loadDoc();
const local = await readMindmapLocal(args.ctx.documentId, args.ctx.mindmapId);
const base = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
@@ -217,6 +234,14 @@ export const createMindmapServerTools = (args: {
return { doc, base };
};
const persistMindmap = async (doc: { id: string; title: string | null; workspace_id: string | null }, nextData: MindmapTreeNode) => {
if (args.saveMindmap) {
await args.saveMindmap({ doc, data: nextData });
return;
}
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
};
const mindmap_get = async (toolArgs: Record<string, unknown>) => {
const maxNodes = Number(toolArgs.maxNodes ?? 120);
const { base } = await loadMindmap();
@@ -313,7 +338,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const { data: nextData, applied, errors } = applyMindmapOps(base, normalized);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -334,7 +359,7 @@ export const createMindmapServerTools = (args: {
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
};
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -355,7 +380,7 @@ export const createMindmapServerTools = (args: {
node: { text, ...(hyperlink ? { hyperlink } : {}), ...(note ? { note } : {}), ...(refs.length ? { refs } : {}) },
};
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -368,7 +393,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "updateText", uid, text };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -384,7 +409,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "setHyperlink", uid, hyperlink: hyperlinkRaw === null ? null : hyperlink };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -397,7 +422,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "appendNote", uid, markdown };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -410,7 +435,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "setRefs", uid, refs };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -421,7 +446,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "deleteNode", uid };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -493,7 +518,7 @@ export const createMindmapServerTools = (args: {
const nextRefs = mode === "replace" ? [ref] : mergeRefsUnique(prevRefs, [ref]);
const op: MindmapOp = { op: "setRefs", uid, refs: nextRefs };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -546,7 +571,7 @@ export const createMindmapServerTools = (args: {
node: { text, ...(hyperlink ? { hyperlink } : {}), refs: [ref], ...(note ? { note } : {}) },
};
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -579,7 +604,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return { ok: true, applied, errors, data: nextData, meta: { reason } };
};
@@ -600,7 +625,7 @@ export const createMindmapServerTools = (args: {
const { doc, base } = await loadMindmap();
const op: MindmapOp = { op: "appendNote", uid, markdown };
const { data: nextData, applied, errors } = applyMindmapOps(base, [op]);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return {
ok: true,
applied,
@@ -701,7 +726,7 @@ export const createMindmapServerTools = (args: {
const fixed = sanitizeAddChildOps({ targetUid, currentChildren, ops, searxResults });
const { data: nextData, applied, errors } = applyMindmapOps(base, fixed);
await writeMindmapLocal(args.ctx.documentId, args.ctx.mindmapId, nextData, doc.title ?? "无标题");
await persistMindmap(doc, nextData);
return {
ok: true,
@@ -63,9 +63,27 @@ const parseSlash = (text: string): ParsedSlash => {
};
export const createSlashServerTools = (args: {
supabase: SlashSupabaseClient;
supabase?: SlashSupabaseClient;
ctx: SlashToolContext;
allowedToolIds: Set<string>;
// 说明:Convex 迁移阶段用于“去 Supabase 化”。如果提供该能力,则完全不依赖 Supabase。
loadWorkspaceIds?: (userId: string) => Promise<string[]>;
inferWorkspaceIdFromDoc?: (documentId: string) => Promise<string | null>;
createDoc?: (args: { userId: string; workspaceId: string; parentId: string | null; title: string }) => Promise<{
id: string;
title: string;
workspaceId: string;
parentId: string | null;
createdAt: unknown;
updatedAt: unknown;
}>;
renameDoc?: (args: { userId: string; documentId: string; title: string }) => Promise<{
id: string;
title: string;
workspaceId: string;
parentId: string | null;
updatedAt: unknown;
}>;
}) => {
const run = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (!args.allowedToolIds.has(toolId)) throw new Error(`工具未被允许:${toolId}`);
@@ -106,11 +124,35 @@ export const createSlashServerTools = (args: {
const workspaceIdFromParams = parsed.params.workspaceId ? String(parsed.params.workspaceId) : null;
const workspaceId =
workspaceIdFromParams ||
(args.ctx.currentDocumentId ? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId) : null) ||
(await loadWorkspaceIds(args.supabase, args.ctx.userId))[0] ||
(args.ctx.currentDocumentId
? args.inferWorkspaceIdFromDoc
? await args.inferWorkspaceIdFromDoc(args.ctx.currentDocumentId)
: args.supabase
? await inferWorkspaceIdFromDoc(args.supabase, args.ctx.currentDocumentId)
: null
: null) ||
((args.loadWorkspaceIds
? (await args.loadWorkspaceIds(args.ctx.userId))[0]
: args.supabase
? (await loadWorkspaceIds(args.supabase, args.ctx.userId))[0]
: null) ?? null) ||
null;
if (!workspaceId) throw new Error("无法推断 workspaceId(请在 params.workspaceId 指定)");
if (args.createDoc) {
const doc = await args.createDoc({
userId: args.ctx.userId,
workspaceId,
parentId,
title,
});
return { ok: true, command: "new_doc", document: doc };
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc");
}
const payload = {
workspace_id: workspaceId,
user_id: args.ctx.userId,
@@ -142,6 +184,15 @@ export const createSlashServerTools = (args: {
const title = String(parsed.params.title ?? "").trim();
if (!documentId || !title) throw new Error("缺少 documentId 或 title");
if (args.renameDoc) {
const doc = await args.renameDoc({ userId: args.ctx.userId, documentId, title });
return { ok: true, command: "rename_doc", document: doc };
}
if (!args.supabase) {
throw new Error("缺少 Supabase 数据源(请在 Convex 模式下传入 createDoc/renameDoc");
}
const { data, error } = await args.supabase
.from("documents")
.update({ title })
@@ -172,4 +223,3 @@ export const createSlashServerTools = (args: {
return { run };
};
@@ -0,0 +1,27 @@
import type { AuthContext } from "@/lib/auth/types";
import { getDevUser, isDevAuthEnabled } from "@/lib/auth/devUser";
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
export function getAuthContext(): AuthContext {
if (isDevAuthEnabled()) return getDevUser();
// 说明:后续接入真实鉴权时,在这里替换为 Supabase/Convex Auth 的校验逻辑。
throw new Error("Auth is not configured");
}
export function requireAuthContext(): AuthContext {
try {
return getAuthContext();
} catch (err) {
const message = err instanceof Error ? err.message : "Unauthorized";
throw new HttpError(401, message);
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { AuthContext } from "@/lib/auth/types";
function _readEnv(key: string): string | undefined {
const value = process.env[key];
if (!value) return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function getDevUser(): AuthContext {
// 说明:第三阶段先用固定用户跑通迁移链路,后续接入真实鉴权时再替换这一层。
const userId = _readEnv("DEV_USER_ID") ?? "dev-user";
const email = _readEnv("DEV_USER_EMAIL") ?? "dev@mnote.local";
const name = _readEnv("DEV_USER_NAME") ?? "开发用户";
return { userId, email, name };
}
export function isDevAuthEnabled(): boolean {
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。
return process.env.USE_CONVEX === "1";
}
+6
View File
@@ -0,0 +1,6 @@
export type AuthContext = {
userId: string;
email?: string;
name?: string;
};
+114
View File
@@ -0,0 +1,114 @@
import type { Json } from "@/types/supabase";
type BlockLike = {
id: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: BlockLike[];
};
const asBlockArray = (value: unknown): BlockLike[] => {
if (!Array.isArray(value)) return [];
return value.filter((b) => b && typeof b === "object" && typeof (b as { id?: unknown }).id === "string") as BlockLike[];
};
export const findBlockInTree = (
blocks: BlockLike[],
blockId: string,
): { block: BlockLike; parent: BlockLike | null; index: number } | null => {
const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
while (stack.length) {
const item = stack.pop()!;
const list = item.list;
for (let i = 0; i < list.length; i += 1) {
const b = list[i]!;
if (b.id === blockId) {
return { block: b, parent: item.parent, index: i };
}
if (Array.isArray(b.children) && b.children.length > 0) {
stack.push({ list: b.children, parent: b });
}
}
}
return null;
};
const cloneBlock = (block: BlockLike): BlockLike => {
return {
...block,
props: block.props ? { ...block.props } : undefined,
content: Array.isArray(block.content) ? [...block.content] : block.content,
children: Array.isArray(block.children) ? block.children.map(cloneBlock) : block.children,
};
};
export const removeBlockSubtree = (
blocks: BlockLike[],
blockId: string,
): { removed: BlockLike | null; nextBlocks: BlockLike[] } => {
// 说明:这里不直接修改入参 blocks,返回新的 nextBlocks。
const nextTop = blocks.map(cloneBlock);
const hit = findBlockInTree(nextTop, blockId);
if (!hit) return { removed: null, nextBlocks: nextTop };
if (hit.parent) {
const parent = hit.parent;
const nextChildren = asBlockArray(parent.children).map(cloneBlock);
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
parent.children = nextChildren;
return { removed, nextBlocks: nextTop };
}
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
return { removed, nextBlocks: nextTop };
};
export const replaceBlockInTree = (
blocks: BlockLike[],
blockId: string,
nextBlock: BlockLike,
): { ok: boolean; nextBlocks: BlockLike[] } => {
const nextTop = blocks.map(cloneBlock);
const hit = findBlockInTree(nextTop, blockId);
if (!hit) return { ok: false, nextBlocks: nextTop };
const normalized = cloneBlock({ ...nextBlock, id: blockId });
if (hit.parent) {
const parent = hit.parent;
const nextChildren = asBlockArray(parent.children).map(cloneBlock);
nextChildren[hit.index] = normalized;
parent.children = nextChildren;
return { ok: true, nextBlocks: nextTop };
}
nextTop[hit.index] = normalized;
return { ok: true, nextBlocks: nextTop };
};
export const extractBlockText = (block: BlockLike): string => {
const inline = Array.isArray(block.content) ? (block.content as Array<{ text?: unknown }>) : [];
const text = inline.map((n) => (typeof n?.text === "string" ? n.text : "")).join("");
return text.trim();
};
export const getBlocksFromDocumentContent = (content: unknown): BlockLike[] => {
if (Array.isArray(content)) return asBlockArray(content);
if (content && typeof content === "object") {
const blocks = (content as { blocks?: unknown }).blocks;
return asBlockArray(blocks);
}
return [];
};
export const withBlocksWrittenBack = (content: unknown, blocks: BlockLike[]): Json => {
// 复用现有结构:数组或 {blocks: []}
if (Array.isArray(content)) {
return blocks as unknown as Json;
}
if (content && typeof content === "object") {
return { ...(content as Record<string, unknown>), blocks } as Json;
}
return { blocks } as Json;
};
+2
View File
@@ -0,0 +1,2 @@
export { api, internal } from "../../../convex/_generated/api";
+4
View File
@@ -0,0 +1,4 @@
export function isConvexEnabled(): boolean {
return process.env.USE_CONVEX === "1";
}
+11
View File
@@ -0,0 +1,11 @@
import type { ConvexHttpClient } from "convex/browser";
import type { AuthContext } from "@/lib/auth/types";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
export function getAuthedConvexClient(): { auth: AuthContext; client: ConvexHttpClient } {
const auth = requireAuthContext();
const client = getConvexHttpClient();
return { auth, client };
}
+22
View File
@@ -0,0 +1,22 @@
import { ConvexHttpClient } from "convex/browser";
let cached: ConvexHttpClient | null = null;
export function getConvexHttpClient(): ConvexHttpClient {
if (cached) return cached;
const url = process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL;
if (!url) {
throw new Error("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL 配置");
}
const client = new ConvexHttpClient(url);
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
if (adminKey) {
client.setAdminAuth(adminKey);
}
cached = client;
return client;
}
+7 -1
View File
@@ -1,4 +1,9 @@
export type MnoteRuntimeConfig = {
/**
* Convex
* window.__MNOTE_RUNTIME_CONFIG__ Supabase
*/
useConvex?: boolean;
supabaseUrl?: string;
/**
* / Supabase HTTP FRP/ Node TLS
@@ -39,10 +44,11 @@ declare global {
}
const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
@@ -0,0 +1,53 @@
"use client";
import { create } from "zustand";
import type { MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
type OnPick = (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;
interface MoveEmbedPickerState {
open: boolean;
workspaceId: string | null;
defaultMode: MoveEmbedMode;
modes: MoveEmbedMode[];
allowRoot: boolean;
excludeIds: string[];
onPick: OnPick | null;
setWorkspaceId: (workspaceId: string | null) => void;
openPicker: (args: {
workspaceId: string | null;
defaultMode: MoveEmbedMode;
modes?: MoveEmbedMode[];
allowRoot?: boolean;
excludeIds?: string[];
onPick: OnPick;
}) => void;
close: () => void;
}
export const useMoveEmbedPickerStore = create<MoveEmbedPickerState>((set) => ({
open: false,
workspaceId: null,
defaultMode: "move",
modes: ["move", "embed"],
allowRoot: true,
excludeIds: [],
onPick: null,
setWorkspaceId: (workspaceId) => set({ workspaceId }),
openPicker: ({ workspaceId, defaultMode, modes, allowRoot, excludeIds, onPick }) =>
set({
open: true,
workspaceId,
defaultMode,
modes: modes ?? ["move", "embed"],
allowRoot: allowRoot ?? true,
excludeIds: excludeIds ?? [],
onPick,
}),
close: () =>
set({
open: false,
onPick: null,
excludeIds: [],
}),
}));
+1
View File
@@ -5,6 +5,7 @@ export interface MediaAsset {
asset_type: string;
file_url: string | null;
thumbnail_url: string | null;
storage_id?: string | null;
bucket?: string | null;
storage_path?: string | null;
file_name: string | null;