0.1.11 ai修复与全屏

This commit is contained in:
liaibo
2026-01-10 10:35:21 +08:00
parent e74219c802
commit 0bcdc3e730
55 changed files with 6597 additions and 174 deletions
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const documentId = url.searchParams.get("documentId") ?? "";
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { data: document, error } = await supabase
.from("documents")
.select("id,content")
.eq("id", documentId)
.eq("user_id", session.user.id)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (!document) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
return NextResponse.json({ content: document.content ?? null });
}
@@ -325,7 +325,8 @@ export async function POST(request: Request) {
.from("media_assets")
.select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path")
.in("document_id", oldDocIds)
.eq("workspace_id", workspaceId);
.eq("workspace_id", workspaceId)
.is("deleted_at", null);
if (assetErr) {
return NextResponse.json({ error: assetErr.message }, { status: 500 });
@@ -26,6 +26,7 @@ export async function GET(request: Request) {
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
@@ -5,7 +5,7 @@ import { makeUniqueFileName } from "@/lib/file-tree/naming";
export const dynamic = "force-dynamic";
type Action = "copy" | "move" | "delete" | "rename";
type Action = "copy" | "move" | "delete" | "rename" | "restore";
interface BatchPayload {
action: Action;
@@ -80,14 +80,26 @@ export async function POST(request: Request) {
try {
switch (payload.action) {
case "delete": {
await Promise.all(
assets.map(async (asset) => {
const location = resolveAssetLocation(asset);
if (!location) return;
await supabase.storage.from(location.bucket || BUCKET).remove([location.path]);
}),
);
const { error } = await supabase.from("media_assets").delete().in("id", payload.assetIds);
// 可撤销删除:仅标记 deleted_at,真正清理(OCR/Storage/LightRAG)由后台宽限期任务处理
const { error } = await supabase
.from("media_assets")
.update({
deleted_at: new Date().toISOString(),
deleted_by: session.user.id,
})
.in("id", payload.assetIds);
if (error) throw error;
return NextResponse.json({ ok: true });
}
case "restore": {
const { error } = await supabase
.from("media_assets")
.update({
deleted_at: null,
deleted_by: null,
purged_at: null,
})
.in("id", payload.assetIds);
if (error) throw error;
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
interface EmptyTrashPayload {
workspaceId?: string;
}
function resolveGraceSeconds(): number {
const raw =
process.env.DELETE_GRACE_SECONDS ??
process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ??
"600";
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 600;
}
return Math.floor(parsed);
}
function makeExpiredDeletedAt(): string {
const graceSeconds = resolveGraceSeconds();
return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString();
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
}
const { data: assets, error: fetchError } = await supabase
.from("media_assets")
.select("id")
.eq("workspace_id", workspaceId)
.not("deleted_at", "is", null)
.is("purged_at", null)
.limit(2000);
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
const assetIds = (assets ?? []).map((row) => row.id).filter(Boolean);
if (assetIds.length === 0) {
return NextResponse.json({ success: true, updated: 0 });
}
const { error: updateError } = await supabase
.from("media_assets")
.update({
deleted_at: makeExpiredDeletedAt(),
deleted_by: session.user.id,
})
.in("id", assetIds);
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
return NextResponse.json({ success: true, updated: assetIds.length });
}
@@ -20,6 +20,7 @@ export async function POST(request: Request) {
.from("media_assets")
.update({ ocr_status: "processing" })
.eq("id", assetId)
.is("deleted_at", null)
.limit(1);
if (error) {
@@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
interface PurgePayload {
assetId?: string;
}
function resolveGraceSeconds(): number {
const raw =
process.env.DELETE_GRACE_SECONDS ??
process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ??
"600";
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 600;
}
return Math.floor(parsed);
}
function makeExpiredDeletedAt(): string {
const graceSeconds = resolveGraceSeconds();
return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString();
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId }: PurgePayload = await request.json().catch(() => ({}));
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { data: asset, error: fetchError } = await supabase
.from("media_assets")
.select("id,workspace_id,deleted_at,purged_at")
.eq("id", assetId)
.single();
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
if (!asset) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", asset.workspace_id)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权操作该附件" }, { status: 403 });
}
if (asset.purged_at) {
return NextResponse.json({ success: true, alreadyPurged: true });
}
const { error: updateError } = await supabase
.from("media_assets")
.update({
deleted_at: makeExpiredDeletedAt(),
deleted_by: session.user.id,
})
.eq("id", assetId);
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,788 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentAttachment = {
id: string;
title: string;
fileUrl: string;
mimeType?: string | null;
};
type RequestPayload = {
documentId: string;
mindmapId: string;
selectedUids?: string[];
messages: AgentMessage[];
attachments?: AgentAttachment[];
toolChoice?: { mode: "auto" | "manual"; tools?: string[] };
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
};
type ToolName =
| "mindmap_get"
| "mindmap_get_subtree"
| "search_web"
| "mindmap_apply_ops"
| "pdf_replace_mindmap";
type ToolCall =
| { type: "tool"; tool: ToolName; args: Record<string, unknown> }
| { type: "final"; message: string; summary?: string };
type SearxResult = { title: string; url: string; snippet?: string; engine?: string };
const defaultMindmapData: MindmapTreeNode = {
data: { text: "中心主题" },
children: [],
};
const safeUrlOrNull = (value: unknown) => {
const s = typeof value === "string" ? value.trim() : "";
if (!s) return null;
try {
const u = new URL(s);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return u.toString();
} catch {
return null;
}
};
const searchSearxng = async (q: string, count = 6): Promise<SearxResult[]> => {
const base = (process.env.SEARXNG_BASE_URL ?? "http://127.0.0.1:8889").replace(/\/+$/, "");
const token = (process.env.SEARXNG_API_TOKEN ?? "").trim();
const url = `${base}/search?q=${encodeURIComponent(q)}&format=json&language=zh-CN&categories=general&safesearch=1`;
const tryFetch = async (headers: Record<string, string>) => {
const res = await fetch(url, { headers, method: "GET" });
if (!res.ok) return null;
return (await res.json().catch(() => null)) as any;
};
let json: any = null;
if (token) {
json = (await tryFetch({ Authorization: `Bearer ${token}` })) ?? (await tryFetch({ "X-API-Key": token })) ?? null;
}
if (!json) json = await tryFetch({});
const results = Array.isArray(json?.results) ? json.results : [];
return results
.map((r: any) => ({
title: String(r?.title ?? "").trim(),
url: String(r?.url ?? "").trim(),
snippet: String(r?.content ?? r?.snippet ?? "").trim(),
engine: String(r?.engine ?? "").trim(),
}))
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
.slice(0, Math.max(1, Math.min(10, count)));
};
const walkSummaries = (root: MindmapTreeNode, maxNodes = 120) => {
const list: Array<{ uid: string; text: string; parentUid: string | null; depth: number; childCount: number }> = [];
const queue: Array<{ node: MindmapTreeNode; parentUid: string | null; depth: number }> = [
{ node: root, parentUid: null, depth: 0 },
];
while (queue.length && list.length < maxNodes) {
const { node, parentUid, depth } = queue.shift()!;
const uid = String(node?.data?.uid || "");
const text = String(node?.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
const children = Array.isArray(node?.children) ? node.children : [];
if (uid) {
list.push({ uid, text, parentUid, depth, childCount: children.length });
}
children.forEach((c) => queue.push({ node: c, parentUid: uid || parentUid, depth: depth + 1 }));
}
return list;
};
const findNodeByUid = (root: MindmapTreeNode, uid: string): MindmapTreeNode | null => {
const target = String(uid || "");
if (!target) return null;
const walk = (n: MindmapTreeNode): MindmapTreeNode | null => {
if (String(n?.data?.uid || "") === target) return n;
const children = Array.isArray(n.children) ? n.children : [];
for (const c of children) {
const hit = walk(c);
if (hit) return hit;
}
return null;
};
return walk(root);
};
const summarizeSubtree = (node: MindmapTreeNode, depthLimit = 2, maxNodes = 60) => {
const list: Array<{ uid: string; text: string; depth: number; childCount: number }> = [];
const queue: Array<{ node: MindmapTreeNode; depth: number }> = [{ node, depth: 0 }];
while (queue.length && list.length < maxNodes) {
const { node: n, depth } = queue.shift()!;
const uid = String(n?.data?.uid || "");
const text = String(n?.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
const children = Array.isArray(n.children) ? n.children : [];
if (uid) list.push({ uid, text, depth, childCount: children.length });
if (depth < depthLimit) children.forEach((c) => queue.push({ node: c, depth: depth + 1 }));
}
return list;
};
const coerceToolCall = (raw: Record<string, unknown>): ToolCall | null => {
const type = String((raw as any)?.type ?? "");
if (type === "final") {
return { type: "final", message: String((raw as any)?.message ?? ""), summary: String((raw as any)?.summary ?? "") || undefined };
}
if (type === "tool") {
const tool = String((raw as any)?.tool ?? "") as ToolName;
const args = ((raw as any)?.args ?? {}) as Record<string, unknown>;
return { type: "tool", tool, args };
}
return null;
};
const normalizeAllowedTools = (payload: RequestPayload): Set<ToolName> => {
const mode = payload.toolChoice?.mode ?? "auto";
if (mode !== "manual") {
return new Set<ToolName>(["mindmap_get", "mindmap_get_subtree", "search_web", "mindmap_apply_ops", "pdf_replace_mindmap"]);
}
const list = Array.isArray(payload.toolChoice?.tools) ? payload.toolChoice!.tools! : [];
const allowed = new Set<ToolName>();
for (const x of list) {
const t = String(x || "") as ToolName;
if (t === "mindmap_get" || t === "mindmap_get_subtree" || t === "search_web" || t === "mindmap_apply_ops" || t === "pdf_replace_mindmap") {
allowed.add(t);
}
}
// 手动模式但未选择:默认仍允许读导图(避免完全不可用)
if (allowed.size === 0) allowed.add("mindmap_get");
return allowed;
};
const refsFromSearx = (items: SearxResult[]): NodeRef[] =>
items
.map((r) => ({
kind: "url" as const,
fileUrl: r.url,
title: r.title,
snippet: r.snippet ? r.snippet.slice(0, 320) : undefined,
}))
.filter((x) => safeUrlOrNull(x.fileUrl));
const stripHtml = (value: unknown) => String(value ?? "").replace(/<[^>]+>/g, "").trim();
const stableStringify = (value: unknown): string => {
if (value === null) return "null";
const t = typeof value;
if (t === "string") return JSON.stringify(value);
if (t === "number" || t === "boolean") return String(value);
if (Array.isArray(value)) return `[${value.map((x) => stableStringify(x)).join(",")}]`;
if (t === "object") {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
}
return JSON.stringify(String(value));
};
const formatSearchResults = (items: SearxResult[]) => {
const lines: string[] = [];
for (let i = 0; i < Math.min(8, items.length); i += 1) {
const r = items[i];
const title = String(r.title || "").trim();
const url = String(r.url || "").trim();
const snippet = String(r.snippet || "").replace(/\s+/g, " ").trim();
lines.push(`${i + 1}. ${title || "(无标题)"}\n${url}${snippet ? `\n${snippet.slice(0, 240)}` : ""}`);
}
return lines.join("\n\n").trim();
};
const answerFromSearchResults = async (args: {
cfg: { baseUrl: string; apiKey: string; model: string };
modelOverride?: string | null;
question: string;
results: SearxResult[];
}) => {
const q = String(args.question || "").trim();
const resultsText = formatSearchResults(args.results);
if (!q) return resultsText || "(无问题)";
try {
const { text } = await openAiCompatibleChat(
[
{
role: "system",
content: [
"你是一个严谨的中文助手。",
"用户提出一个问题,你将基于提供的检索结果回答。",
"要求:",
"- 直接回答问题,给出结论;信息不确定时明确说明需要以官方为准。",
"- 尽量在回答末尾附上 1-3 个最相关链接(原样 URL)。",
"- 不要输出 JSON/不要输出 Markdown 代码块。",
].join("\n"),
},
{ role: "user", content: `问题:${q}\n\n检索结果:\n${resultsText || "(无结果)"}` },
],
{
baseUrl: args.cfg.baseUrl,
apiKey: args.cfg.apiKey,
model: (args.modelOverride ?? "").trim() || args.cfg.model,
timeoutMs: 55_000,
maxTokens: 1200,
},
);
const out = String(text ?? "").trim();
if (out) return out;
} catch {
// ignore
}
return resultsText || "(未检索到结果)";
};
const buildFallbackExpandOps = async (args: {
mindmap: MindmapTreeNode;
targetUid: string;
instruction: string;
useSearx: boolean;
}): Promise<MindmapOp[]> => {
const targetUid = String(args.targetUid || "").trim();
const instruction = String(args.instruction || "").trim();
const ops: MindmapOp[] = [];
const seen = new Set<string>();
const results = args.useSearx && instruction ? await searchSearxng(instruction, 6).catch(() => []) : [];
for (const r of results.slice(0, 6)) {
const title = String(r.title || "").trim();
const url = safeUrlOrNull(r.url);
const snippet = String(r.snippet || "").replace(/\s+/g, " ").trim();
if (!title || !url) continue;
const key = `${title}|${url}`;
if (seen.has(key)) continue;
seen.add(key);
const explain = snippet ? snippet.slice(0, 46) : "相关资料(请核验)。";
const text = `${title.slice(0, 20)}${explain}`;
ops.push({
op: "addChild",
parentUid: targetUid,
node: {
text,
hyperlink: url,
refs: [
{
kind: "url",
fileUrl: url,
title,
snippet: snippet ? snippet.slice(0, 300) : undefined,
},
],
},
});
if (ops.length >= 6) break;
}
// 兜底:确保至少 3 条可见的“内容型”节点(不是纯链接)
const base = instruction ? instruction.slice(0, 10) : stripHtml(args.mindmap?.data?.text) || "要点";
let i = 1;
while (ops.length < 3) {
ops.push({
op: "addChild",
parentUid: targetUid,
node: {
text: `${base}(要点)${i}:请补充定义、条件、例子与注意事项。`,
},
});
i += 1;
}
return ops;
};
export async function POST(request: Request) {
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
if (!payload?.documentId || !payload?.mindmapId || !Array.isArray(payload.messages) || payload.messages.length === 0) {
return NextResponse.json({ error: "缺少 documentId/mindmapId/messages" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id,title,workspace_id,mindmap_data")
.eq("id", payload.documentId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const local = await readMindmapLocal(payload.documentId, payload.mindmapId);
let mindmap = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
// 补齐 uid,避免后续 addChild 找不到 root uid
mindmap = applyMindmapOps(mindmap, []).data;
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
const cfg =
provider === "local"
? await loadLocalAiConfig().catch(() => null)
: await loadOnlineAiConfig().catch(() => null);
if (!cfg) {
const tip =
provider === "local"
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md"
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)";
return NextResponse.json({ error: tip }, { status: 500 });
}
const allowed = normalizeAllowedTools(payload);
const useSearx = payload.options?.searxng !== false;
if (!useSearx) {
allowed.delete("search_web");
}
const selected = Array.isArray(payload.selectedUids) ? payload.selectedUids.map((x) => String(x)).filter(Boolean).slice(0, 6) : [];
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 10) : [];
const attachmentLines = attachments
.map((a, idx) => `${idx + 1}. id=${a.id} title=${a.title} mime=${a.mimeType ?? ""} url=${a.fileUrl}`)
.join("\n");
const summary = walkSummaries(mindmap, 120);
const selectedSummaries = selected
.map((uid) => {
const hit = findNodeByUid(mindmap, uid);
if (!hit) return `- uid=${uid} (not found)`;
const t = String(hit.data?.text ?? "").replace(/<[^>]+>/g, "").trim();
return `- uid=${uid} text=${t}`;
})
.join("\n");
const toolSpec = [
{
name: "mindmap_get",
desc: "读取整张导图的精简结构(uid+text+父子关系+子数量)。",
args: { maxNodes: "number? (默认120)" },
},
{
name: "mindmap_get_subtree",
desc: "读取指定 uid 的子树(用于定位/精确改写)。",
args: { uid: "string", depth: "number? (默认2)", maxNodes: "number? (默认60)" },
},
{
name: "search_web",
desc: "使用 SearxNG 搜索,返回标题/URL/摘要;用于提供可追溯来源。",
args: { query: "string", count: "number? (默认6)" },
},
{
name: "mindmap_apply_ops",
desc: "对导图应用增量 ops(支持新增/删/改/加链接/加refs/加note),并自动落盘保存。",
args: { ops: "MindmapOp[]", reason: "string? (可选)" },
},
{
name: "pdf_replace_mindmap",
desc: "从 PDF 生成“章->节->要点”的导图并替换当前导图(会保存)。fileRef 需匹配 attachments 里的 id 或标题。",
args: { fileRef: "string", maxPages: "number? (默认9)" },
},
] as const;
const toolsText = toolSpec
.filter((t) => allowed.has(t.name as ToolName))
.map((t) => `- ${t.name}: ${t.desc} args=${JSON.stringify(t.args)}`)
.join("\n");
const system = [
"你是“Mindmap AI Agent”。你可以像 CLI 工具一样,通过工具(API)完成任务:读导图、写导图、检索、从 PDF 生成导图。",
"",
"输出格式要求(硬性):",
"你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。",
"你只能输出以下两种之一:",
'1) {"type":"tool","tool":ToolName,"args":{...}}',
'2) {"type":"final","message":"...","summary":"...可选"}',
"",
"行为要求(硬性):",
"- 当用户明确要求“修改/生成/补完/写入导图”时,你必须至少调用一次 mindmap_apply_ops 或 pdf_replace_mindmap 来产生实际变更;不能只聊天。",
"- 当用户只是“问问题/咨询”(未要求写入导图)时:你可以不改导图;若需要检索,最多调用 search_web 1 次,然后必须输出 final 直接回答;不要反复调用工具。",
"- 新增节点优先写“内容”(可用 note 或子节点表达),并尽量带 refs(引用来源 URL 或 PDF 页码)。只有纯链接会显得呆板,应避免。",
"- 若调用 search_web,必须把搜索结果转为 refsNodeRef.kind=url),并用于新节点或 note。",
"- ops 中的 uid 必须来自导图数据;不允许凭空捏造现有 uid。",
"- 禁止重复调用同一个工具且参数完全相同。",
"",
"可用工具列表:",
toolsText || "(无)",
].join("\n");
const userIntro = [
`documentId=${payload.documentId}`,
`mindmapId=${payload.mindmapId}`,
selected.length ? `selectedUids=${selected.join(",")}` : "selectedUids=(none)",
"",
"当前导图摘要(最多 120 个节点):",
JSON.stringify(summary, null, 2),
"",
selected.length ? `当前选中节点:\n${selectedSummaries}` : "",
attachments.length ? `附件(可用 fileRef 引用):\n${attachmentLines}` : "附件:(无)",
]
.filter(Boolean)
.join("\n");
const conversation = payload.messages
.slice(-10)
.map((m) => ({ role: m.role, content: String(m.content ?? "") }));
const maxSteps = 6;
const trace: Array<{ step: number; call: ToolCall; toolResult?: unknown }> = [];
let didMutate = false;
const toolCounts = new Map<string, number>();
const origin = new URL(request.url).origin;
const cookie = request.headers.get("cookie") ?? "";
const runPdfReplace = async (fileRef: string, maxPages: number) => {
const ref = String(fileRef || "").trim();
if (!ref) throw new Error("pdf_replace_mindmap: 缺少 fileRef");
const attachment =
attachments.find((a) => a.id === ref) ||
attachments.find((a) => a.title === ref) ||
attachments.find((a) => a.title?.includes(ref)) ||
null;
if (!attachment) throw new Error(`pdf_replace_mindmap: 未找到匹配附件:${ref}`);
const fileUrl = String(attachment.fileUrl || "");
const testName = (() => {
try {
const u = new URL(fileUrl, "http://local");
if (u.pathname !== "/api/mindmap-ai/test-pdf") return null;
const name = u.searchParams.get("name");
return name ? decodeURIComponent(name) : null;
} catch {
return null;
}
})();
const body =
testName
? {
source: { kind: "test", name: testName },
options: { preferProvider: provider === "local" ? "ollama" : "online", maxPages },
}
: {
source: { kind: "url", fileUrl },
options: { preferProvider: provider === "local" ? "ollama" : "online", maxPages },
};
const res = await fetch(`${origin}/api/mindmap-ai/outline-to-mindmap`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => null)) as any;
if (!res.ok) {
throw new Error(String(json?.error ?? `outline-to-mindmap 失败:${res.status}`));
}
if (!json?.mindmapData) {
throw new Error("outline-to-mindmap 返回缺少 mindmapData");
}
mindmap = json.mindmapData as MindmapTreeNode;
await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题");
didMutate = true;
return { ok: true, providerUsed: json?.meta?.providerUsed ?? "unknown", title: json?.meta?.title ?? "" };
};
const lastUser = [...payload.messages].reverse().find((m) => m.role === "user")?.content ?? "";
const userIntentModify = /补完|写入|保存|生成|替换|总结|导图|节点/i.test(String(lastUser));
const rootUid =
String(mindmap?.data?.uid || "") ||
String(walkSummaries(mindmap, 1)?.[0]?.uid || "");
const defaultTargetUid = (selected[0] || rootUid || "").trim();
for (let step = 1; step <= maxSteps; step += 1) {
const { text } = await openAiCompatibleChat(
[
{ role: "system", content: system },
{ role: "user", content: userIntro },
...conversation,
...(trace.length
? [
{
role: "assistant" as const,
content: `(工具执行历史摘要)\n${trace
.map((t) => `#${t.step} ${t.call.type === "tool" ? `tool=${t.call.tool}` : "final"} `)
.join("\n")}`,
},
]
: []),
],
{
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: modelOverride || cfg.model,
timeoutMs: 55_000,
maxTokens: 2200,
maxCompletionTokens: 2200,
responseFormat: "json_object",
},
);
let json = tryExtractJsonObject(text);
let call = json ? coerceToolCall(json) : null;
// 若解析失败,再用更小上下文重试一次;仍失败则走兜底(保证可用性,不返回 500)
if (!call) {
try {
const minimalSystem = [
"你是 Mindmap AI Agent。",
"你必须只输出一个 JSON",
'1) {"type":"tool","tool":ToolName,"args":{...}} 或 2) {"type":"final","message":"..."}',
"",
"可用工具:",
toolsText || "(无)",
].join("\n");
const minimalUser = [
`documentId=${payload.documentId}`,
`mindmapId=${payload.mindmapId}`,
defaultTargetUid ? `targetUid=${defaultTargetUid}` : "",
`用户请求:${String(lastUser).slice(0, 500)}`,
"",
"导图根节点:",
JSON.stringify({ uid: rootUid, text: stripHtml(mindmap?.data?.text) }, null, 2),
]
.filter(Boolean)
.join("\n");
const retry = await openAiCompatibleChat(
[
{ role: "system", content: minimalSystem },
{ role: "user", content: minimalUser },
],
{
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: modelOverride || cfg.model,
timeoutMs: 35_000,
maxTokens: 900,
maxCompletionTokens: 900,
responseFormat: "json_object",
},
);
json = tryExtractJsonObject(retry.text);
call = json ? coerceToolCall(json) : null;
} catch {
call = null;
}
}
if (!call) {
// 兜底:若用户只是问问题,则不要擅自写入导图;优先给出基于检索的回答
if (!userIntentModify) {
const q = String(lastUser || "").trim();
const results = allowed.has("search_web") && q ? await searchSearxng(q, 6).catch(() => []) : [];
if (results.length) {
trace.push({
step,
call: { type: "tool", tool: "search_web", args: { query: q, count: 6 } },
toolResult: results,
});
}
const answer = results.length
? await answerFromSearchResults({ cfg, modelOverride, question: q, results })
: "AI 输出未能解析(未执行任何导图写入)。你可以:1) 开启联网检索;2) 换一个模型;3) 更明确说明要写入导图还是仅回答问题。";
trace.push({ step, call: { type: "final", message: answer } });
return NextResponse.json({ ok: true, message: answer, data: mindmap, trace });
}
// 用户明确要改导图:直接补完(写入并保存),避免用户看到“没做任何事”
const ops = await buildFallbackExpandOps({
mindmap,
targetUid: defaultTargetUid || rootUid,
instruction: String(lastUser || "补完选中节点"),
useSearx: allowed.has("search_web"),
});
const { data: next, applied, errors } = applyMindmapOps(mindmap, ops);
mindmap = next;
await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题");
didMutate = applied > 0 || didMutate;
trace.push({
step,
call: { type: "tool", tool: "mindmap_apply_ops", args: { ops, reason: "fallback: ai-json-parse-failed" } },
toolResult: { applied, errors, fallback: true },
});
return NextResponse.json({
ok: true,
message: "已补完并保存(AI 输出未能解析,已自动使用兜底策略确保可用)。",
data: mindmap,
trace,
});
}
if (call.type === "final") {
trace.push({ step, call });
return NextResponse.json({
ok: true,
message: call.message,
summary: call.summary ?? null,
data: mindmap,
trace,
});
}
// 避免模型在同一个工具上打转(导致“已达最大执行步数”)
const toolKey = `${call.tool}:${stableStringify(call.args)}`;
const prevCount = toolCounts.get(toolKey) ?? 0;
toolCounts.set(toolKey, prevCount + 1);
if (prevCount >= 1) {
trace.push({ step, call, toolResult: { warning: "检测到重复工具调用,已提前停止并返回结果。" } });
const lastSearch = [...trace]
.reverse()
.find((t) => t.call.type === "tool" && t.call.tool === "search_web")?.toolResult as SearxResult[] | undefined;
const answer =
!userIntentModify && Array.isArray(lastSearch) && lastSearch.length
? await answerFromSearchResults({ cfg, modelOverride, question: String(lastUser || ""), results: lastSearch })
: "检测到重复工具调用,已停止。你可以换一个模型或提供更明确的目标/限制。";
trace.push({ step, call: { type: "final", message: answer } });
return NextResponse.json({ ok: true, message: answer, data: mindmap, trace });
}
if (!allowed.has(call.tool)) {
trace.push({ step, call, toolResult: { error: `工具未被允许:${call.tool}` } });
continue;
}
try {
if (call.tool === "mindmap_get") {
const maxNodes = Number(call.args.maxNodes ?? 120) || 120;
const result = walkSummaries(mindmap, Math.max(20, Math.min(300, maxNodes)));
trace.push({ step, call, toolResult: result });
continue;
}
if (call.tool === "mindmap_get_subtree") {
const uid = String(call.args.uid ?? "");
const depth = Number(call.args.depth ?? 2) || 2;
const maxNodes = Number(call.args.maxNodes ?? 60) || 60;
const hit = findNodeByUid(mindmap, uid);
const result = hit ? summarizeSubtree(hit, Math.max(1, Math.min(6, depth)), Math.max(10, Math.min(200, maxNodes))) : null;
trace.push({ step, call, toolResult: result });
continue;
}
if (call.tool === "search_web") {
const query = String(call.args.query ?? "").trim();
const count = Number(call.args.count ?? 6) || 6;
const result = query ? await searchSearxng(query, count) : [];
trace.push({ step, call, toolResult: result });
// 若用户只是问问题(非写导图),拿到检索结果后直接回答,避免继续 tool loop
if (!userIntentModify && query && Array.isArray(result) && result.length) {
const answer = await answerFromSearchResults({
cfg,
modelOverride,
question: String(lastUser || query),
results: result,
});
trace.push({ step, call: { type: "final", message: answer } });
return NextResponse.json({ ok: true, message: answer, data: mindmap, trace });
}
continue;
}
if (call.tool === "mindmap_apply_ops") {
const opsRaw = (call.args as any)?.ops;
const ops = Array.isArray(opsRaw) ? (opsRaw as MindmapOp[]) : [];
const safeOps = ops.filter((op) => op && typeof op === "object" && typeof (op as any).op === "string").slice(0, 80);
// 若用户开启 searxng 且 ops 中缺 refs,可自动把最近一次 search_web 的结果补到 note/refs(避免纯链接/无来源)
const lastSearch = [...trace]
.reverse()
.find((t) => t.call.type === "tool" && t.call.tool === "search_web")?.toolResult as SearxResult[] | undefined;
const inferredRefs = Array.isArray(lastSearch) ? refsFromSearx(lastSearch).slice(0, 6) : [];
const patchedOps: MindmapOp[] = safeOps.map((op) => {
if ((op as any)?.op !== "addChild" && (op as any)?.op !== "addSiblingAfter") return op;
const node = (op as any).node ?? {};
const hasRefs = Array.isArray(node.refs) && node.refs.length > 0;
if (hasRefs || inferredRefs.length === 0) return op;
return {
...op,
node: {
...node,
refs: inferredRefs,
},
} as MindmapOp;
});
const { data: next, applied, errors } = applyMindmapOps(mindmap, patchedOps);
mindmap = next;
await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题");
if (applied > 0) didMutate = true;
trace.push({ step, call, toolResult: { applied, errors } });
continue;
}
if (call.tool === "pdf_replace_mindmap") {
const fileRef = String(call.args.fileRef ?? "").trim();
const maxPages = Number(call.args.maxPages ?? 9) || 9;
const result = await runPdfReplace(fileRef, Math.max(1, Math.min(40, maxPages)));
trace.push({ step, call, toolResult: result });
continue;
}
} catch (e) {
trace.push({ step, call, toolResult: { error: e instanceof Error ? e.message : String(e) } });
continue;
}
}
if (!didMutate && userIntentModify && allowed.has("mindmap_apply_ops") && defaultTargetUid) {
const ops = await buildFallbackExpandOps({
mindmap,
targetUid: defaultTargetUid,
instruction: String(lastUser || "补完选中节点"),
useSearx: allowed.has("search_web"),
});
const { data: next, applied, errors } = applyMindmapOps(mindmap, ops);
mindmap = next;
await writeMindmapLocal(payload.documentId, payload.mindmapId, mindmap, doc.title ?? "无标题");
trace.push({
step: maxSteps + 1,
call: { type: "tool", tool: "mindmap_apply_ops", args: { ops, reason: "fallback: maxSteps-reached" } },
toolResult: { applied, errors, fallback: true },
});
return NextResponse.json({
ok: true,
message: "已补完并保存(AI 未在限定步数内完成工具调用,已自动使用兜底策略)。",
data: mindmap,
trace,
});
}
// 若用户只是问问题(不写导图),但模型卡在 tool loop:尽量基于最后一次检索结果给出回答
const lastSearch = [...trace]
.reverse()
.find((t) => t.call.type === "tool" && t.call.tool === "search_web")?.toolResult as SearxResult[] | undefined;
if (!userIntentModify && Array.isArray(lastSearch) && lastSearch.length) {
const answer = await answerFromSearchResults({ cfg, modelOverride, question: String(lastUser || ""), results: lastSearch });
trace.push({ step: maxSteps + 1, call: { type: "final", message: answer } });
return NextResponse.json({ ok: true, message: answer, data: mindmap, trace }, { status: 200 });
}
return NextResponse.json(
{
ok: true,
message: "已达到最大执行步数,已停止。你可以补充更明确的目标或限制。",
data: mindmap,
trace,
},
{ status: 200 },
);
}
@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { detectLocalMindmapFiles } from "@/lib/mindmap-files";
import type { MediaAsset } from "@/types/media";
import path from "path";
import { promises as fs } from "fs";
export const dynamic = "force-dynamic";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
id: string;
title: string;
fileUrl: string;
mimeType?: string | null;
assetType?: string | null;
fileName?: string | null;
};
const TEST_DIR = path.join(process.cwd(), "test");
async function listTestPdfs(): Promise<AgentAssetItem[]> {
try {
const entries = await fs.readdir(TEST_DIR);
return entries
.filter((name) => name.toLowerCase().endsWith(".pdf"))
.slice(0, 200)
.map((name) => ({
kind: "test-pdf" as const,
id: `test-pdf:${name}`,
title: name,
fileUrl: `/api/mindmap-ai/test-pdf?name=${encodeURIComponent(name)}`,
mimeType: "application/pdf",
assetType: "file",
fileName: name,
}));
} catch {
return [];
}
}
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const documentId = String(searchParams.get("documentId") ?? "").trim();
const q = String(searchParams.get("q") ?? "").trim().toLowerCase();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id,workspace_id,title")
.eq("id", documentId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const { data: mediaRows } = await supabase
.from("media_assets")
.select("id,asset_type,file_url,file_name,mime_type,document_id,workspace_id,deleted_at")
.eq("document_id", documentId)
.eq("workspace_id", doc.workspace_id)
.is("deleted_at", null)
.order("created_at", { ascending: false })
.limit(200);
const mediaAssets = ((mediaRows ?? []) as MediaAsset[]).map((row) => ({
kind: "media" as const,
id: String(row.id),
title: String(row.file_name ?? row.id ?? "附件"),
fileUrl: String(row.file_url ?? ""),
mimeType: (row as any).mime_type ?? null,
assetType: (row as any).asset_type ?? null,
fileName: (row as any).file_name ?? null,
}));
const localMindmaps = (await detectLocalMindmapFiles([documentId]))
.filter((x) => x.documentId === documentId)
.map((x) => ({
kind: "local-mindmap" as const,
id: `mindmap:${x.mindmapId}`,
title: x.fileName,
fileUrl: `/documents/${documentId}/${x.fileName}`,
mimeType: "application/json",
assetType: "mindmap",
fileName: x.fileName,
}));
const testPdfs = await listTestPdfs();
let items: AgentAssetItem[] = [...localMindmaps, ...mediaAssets, ...testPdfs];
if (q) {
items = items.filter((it) => {
const hay = `${it.title} ${it.fileName ?? ""}`.toLowerCase();
return hay.includes(q);
});
}
return NextResponse.json({
ok: true,
workspaceId: doc.workspace_id,
documentId,
items,
});
}
@@ -0,0 +1,311 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
type RequestPayload = {
documentId: string;
mindmapId: string;
targetUid: string;
instruction?: string;
sources?: { searxng?: boolean; rag?: boolean };
};
type SearxResult = { title: string; url: string; snippet?: string; engine?: string };
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
const findNodeByUid = (root: any, uid: string): any | null => {
const target = String(uid || "");
if (!target) return null;
const walk = (node: any): any | null => {
const nuid = String(node?.data?.uid || node?.uid || "");
if (nuid === target) return node;
const children = Array.isArray(node?.children) ? node.children : [];
for (const c of children) {
const hit = walk(c);
if (hit) return hit;
}
return null;
};
return walk(root);
};
const safeUrlOrNull = (value: unknown) => {
const s = typeof value === "string" ? value.trim() : "";
if (!s) return null;
try {
const u = new URL(s);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return u.toString();
} catch {
return null;
}
};
const searchSearxng = async (q: string, count = 5): Promise<SearxResult[]> => {
const base = (process.env.SEARXNG_BASE_URL ?? "http://127.0.0.1:8889").replace(/\/+$/, "");
const token = (process.env.SEARXNG_API_TOKEN ?? "").trim();
const url = `${base}/search?q=${encodeURIComponent(q)}&format=json&language=zh-CN&categories=general&safesearch=1`;
const tryFetch = async (headers: Record<string, string>) => {
const res = await fetch(url, { headers, method: "GET" });
if (!res.ok) return null;
return (await res.json().catch(() => null)) as any;
};
let json: any = null;
if (token) {
json =
(await tryFetch({ Authorization: `Bearer ${token}` })) ??
(await tryFetch({ "X-API-Key": token })) ??
null;
}
if (!json) {
json = await tryFetch({});
}
const results = Array.isArray(json?.results) ? json.results : [];
const mapped: SearxResult[] = results
.map((r: any) => ({
title: String(r?.title ?? "").trim(),
url: String(r?.url ?? "").trim(),
snippet: String(r?.content ?? r?.snippet ?? "").trim(),
engine: String(r?.engine ?? "").trim(),
}))
.filter((r: SearxResult) => r.title && safeUrlOrNull(r.url))
.slice(0, Math.max(1, Math.min(10, count)));
return mapped;
};
const coerceOpsFromAiJson = (raw: Record<string, unknown>): MindmapOp[] => {
const ops = (raw as any)?.ops;
return Array.isArray(ops) ? (ops as MindmapOp[]) : [];
};
export async function POST(request: Request) {
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
if (!payload?.documentId || !payload?.mindmapId || !payload?.targetUid) {
return NextResponse.json({ error: "缺少 documentId/mindmapId/targetUid" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
// 校验页面归属
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id,title,mindmap_data")
.eq("id", payload.documentId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const local = await readMindmapLocal(payload.documentId, payload.mindmapId);
const baseData = (local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData)) as MindmapTreeNode;
const target = findNodeByUid(baseData, payload.targetUid);
if (!target) {
return NextResponse.json({ error: "未找到目标节点(uid 不存在)" }, { status: 404 });
}
const targetText = String(target?.data?.text ?? "").trim();
const currentChildren = Array.isArray(target?.children)
? target.children
.map((c: any) => String(c?.data?.text ?? "").trim())
.filter(Boolean)
.slice(0, 20)
: [];
const instruction = String(payload.instruction ?? "").trim();
const query = [targetText, instruction].filter(Boolean).join(" ");
const useSearx = payload.sources?.searxng !== false;
const searxResults = useSearx && query ? await searchSearxng(query, 6).catch(() => []) : [];
const cfg = await loadOnlineAiConfig().catch(() => null);
if (!cfg) {
return NextResponse.json({ error: "未找到在线 AI 配置(ai.md 或环境变量)" }, { status: 500 });
}
const system = [
"你是一个“思维导图补完器”。",
"你必须输出严格 JSON(不要 Markdown/不要代码块/不要解释)。",
"你只能输出 {\"ops\": MindmapOp[]} 这一个对象。",
"默认策略:为 targetUid 新增 3~6 个子节点(addChild)。",
"每个新增节点必须提供 text,并尽量提供 hyperlink 与 refs(引用来自搜索结果 URL)。",
"不要删除/改名已有节点;不要输出 addSiblingAfter/updateText/deleteNode。",
].join("\n");
const user = [
`documentId=${payload.documentId}`,
`mindmapId=${payload.mindmapId}`,
`targetUid=${payload.targetUid}`,
"",
`目标节点:${targetText || "(empty)"}`,
currentChildren.length ? `当前子节点(供去重):${currentChildren.join("")}` : "",
instruction ? `用户要求:${instruction}` : "",
"",
"可用证据(搜索结果):",
...(searxResults.length
? searxResults.map((r, idx) => {
const snip = (r.snippet || "").replace(/\s+/g, " ").slice(0, 180);
return `${idx + 1}. ${r.title}\n url: ${r.url}\n snippet: ${snip}`;
})
: ["(无)"]),
"",
"MindmapOp JSON Schema(仅供理解):",
'{ "ops": [ { "op": "addChild", "parentUid": string, "node": { "text": string, "hyperlink"?: string, "refs"?: NodeRef[] } } ] }',
'NodeRef 示例:{ "kind": "url", "fileUrl": "https://example.com", "title": "来源标题", "snippet": "..." }',
"",
"硬性约束:",
"- 仅输出 addChildparentUid 必须等于 targetUid。",
"- 新增节点 text 不要与当前子节点重复。",
"- hyperlink 必须是 http(s) URL。",
"- 每个新增节点 refs 至少 1 条(kind=url, fileUrl=来源url)。",
"- 输出规模控制:最多 6 个节点。",
]
.filter(Boolean)
.join("\n");
let finishReason = "";
let ops: MindmapOp[] = [];
try {
const { text, raw } = await openAiCompatibleChat(
[
{ role: "system", content: system },
{ role: "user", content: user },
],
{
baseUrl: cfg.baseUrl,
apiKey: cfg.apiKey,
model: cfg.model,
timeoutMs: 40_000,
maxTokens: 1800,
maxCompletionTokens: 1800,
responseFormat: "json_object",
},
);
finishReason = String((raw as any)?.choices?.[0]?.finish_reason ?? "");
const json = tryExtractJsonObject(text);
if (json) {
ops = coerceOpsFromAiJson(json);
// 安全收敛:仅允许 addChild 且 parentUid==targetUid
ops = ops
.filter((op) => op && typeof op === "object" && (op as any).op === "addChild")
.filter((op) => String((op as any).parentUid || "") === payload.targetUid)
.slice(0, 8);
}
} catch {
// ignore: 后续走兜底策略
ops = [];
}
// 再做一次补齐/校验:refs/hyperlink
const fallbackRefsFrom = (r: SearxResult): NodeRef[] => [
{
kind: "url",
fileUrl: r.url,
title: r.title,
snippet: r.snippet ? r.snippet.slice(0, 300) : undefined,
},
];
const existed = new Set(currentChildren);
const fixed: MindmapOp[] = [];
for (const op of ops) {
const node = (op as any).node ?? {};
const textVal = String(node.text ?? "").trim();
if (!textVal) continue;
if (existed.has(textVal)) continue;
existed.add(textVal);
const href = safeUrlOrNull(node.hyperlink) ?? safeUrlOrNull(node.url) ?? null;
const refs = Array.isArray(node.refs) ? (node.refs as NodeRef[]) : [];
const hasRefUrl = refs.some((x) => x && x.kind === "url" && safeUrlOrNull((x as any).fileUrl));
let finalRefs = refs;
if (!hasRefUrl && searxResults.length) {
finalRefs = fallbackRefsFrom(searxResults[0]);
}
fixed.push({
op: "addChild",
parentUid: payload.targetUid,
node: {
text: textVal,
...(href ? { hyperlink: href } : {}),
...(finalRefs.length ? { refs: finalRefs } : {}),
},
});
if (fixed.length >= 6) break;
}
// 兜底:若 AI 没产出有效 ops,则直接用搜索结果生成节点(保证功能可用 + 可追溯)
if (!fixed.length && searxResults.length) {
for (const r of searxResults.slice(0, 6)) {
const title = String(r.title || "").trim();
const url = safeUrlOrNull(r.url);
if (!title || !url) continue;
if (existed.has(title)) continue;
existed.add(title);
fixed.push({
op: "addChild",
parentUid: payload.targetUid,
node: {
text: title,
hyperlink: url,
refs: fallbackRefsFrom(r),
},
});
if (fixed.length >= 6) break;
}
}
if (!fixed.length) {
// 最后兜底:至少给出 3 个“待核验”节点(无引用)
const base = targetText || "补完";
for (let i = 1; i <= 3; i++) {
fixed.push({
op: "addChild",
parentUid: payload.targetUid,
node: {
text: `${base}(待核验)${i}`,
},
});
}
}
const { data: nextData, applied, errors } = applyMindmapOps(baseData, fixed);
await writeMindmapLocal(payload.documentId, payload.mindmapId, nextData, doc.title ?? "无标题");
return NextResponse.json({
ok: true,
providerUsed: "online",
applied,
errors,
ops: fixed,
data: nextData,
meta: {
finishReason,
searched: useSearx,
searxCount: searxResults.length,
},
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
export const dynamic = "force-dynamic";
const TEST_PDF_DIR = path.join(process.cwd(), "test");
const safeResolveTestPdf = (name: string) => {
const trimmed = (name ?? "").trim();
if (!trimmed) return null;
// 防止目录穿越:只允许同目录文件名
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
return null;
}
if (!trimmed.toLowerCase().endsWith(".pdf")) {
return null;
}
return path.join(TEST_PDF_DIR, trimmed);
};
export async function GET(request: Request) {
// 仅用于本地测试文件的“真跳页链接”
// 生产环境不应该暴露本地磁盘文件,因此默认只允许非生产环境访问
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "生产环境不支持 test-pdf" }, { status: 404 });
}
const url = new URL(request.url);
const name = url.searchParams.get("name") ?? "";
const file = safeResolveTestPdf(name);
if (!file) {
return NextResponse.json({ error: "文件名非法" }, { status: 400 });
}
try {
const buffer = await fs.readFile(file);
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/pdf",
// 内嵌预览,浏览器 PDF 查看器可识别 #page=
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(name)}`,
"Cache-Control": "no-store",
},
});
} catch (error) {
return NextResponse.json({ error: `读取 PDF 失败:${String(error)}` }, { status: 404 });
}
}
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { promises as fs } from "fs";
import path from "path";
export const dynamic = "force-dynamic";
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
interface EmptyTrashPayload {
workspaceId?: string;
}
async function purgeTrashFolder(folder: string): Promise<number> {
const trashDir = path.join(folder, ".trash");
try {
const entries = await fs.readdir(trashDir);
let removed = 0;
for (const name of entries) {
await fs.rm(path.join(trashDir, name), { force: true, recursive: true });
removed += 1;
}
return removed;
} catch {
return 0;
}
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
}
const { data: documents, error: docError } = await supabase
.from("documents")
.select("id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(5000);
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 500 });
}
const docIds = (documents ?? []).map((row) => row.id).filter(Boolean);
let removed = 0;
for (const docId of docIds) {
removed += await purgeTrashFolder(path.join(preferredBaseDir, docId));
removed += await purgeTrashFolder(path.join(legacyBaseDir, docId));
}
return NextResponse.json({ ok: true, removed });
}
@@ -0,0 +1,73 @@
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";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
type RequestPayload = {
ops: MindmapOp[];
actor?: { kind?: string; provider?: string; model?: string };
reason?: string;
};
export async function POST(
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
// 校验页面归属
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id,title,mindmap_data")
.eq("id", docId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
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 });
}
const local = await readMindmapLocal(docId, mindmapId);
const baseData = local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData);
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
await writeMindmapLocal(docId, mindmapId, nextData, doc.title ?? "无标题");
return NextResponse.json({
ok: true,
applied,
errors,
data: nextData,
meta: {
documentId: docId,
mindmapId,
actor: payload?.actor ?? null,
reason: payload?.reason ?? null,
},
});
}
@@ -8,7 +8,8 @@ const defaultMindmapData = {
children: [],
};
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
async function ensureDir(dir: string) {
await fs.mkdir(dir, { recursive: true });
@@ -47,6 +48,64 @@ async function tryReadJson(file: string) {
}
}
type TrashedMindmapMeta = {
docId: string;
mindmapId: string;
originalFileName: string;
originalPath: string;
trashedFileName: string;
deleted_at: string;
};
async function fileExists(file: string) {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
async function moveToTrash(file: string, meta: Omit<TrashedMindmapMeta, "trashedFileName">) {
if (!(await fileExists(file))) {
return null;
}
const dir = path.dirname(file);
const trashDir = path.join(dir, ".trash");
await ensureDir(trashDir);
const originalFileName = path.basename(file);
const trashedFileName = `${originalFileName}.${Date.now()}.deleted`;
const trashedPath = path.join(trashDir, trashedFileName);
const metaPath = path.join(trashDir, `${trashedFileName}.json`);
await fs.rename(file, trashedPath);
await fs.writeFile(
metaPath,
JSON.stringify({ ...meta, originalFileName, trashedFileName }, null, 2),
"utf8",
);
return { trashedPath, metaPath };
}
async function listTrashMetas(folder: string): Promise<Array<{ meta: TrashedMindmapMeta; metaPath: string; trashedPath: string }>> {
const trashDir = path.join(folder, ".trash");
try {
const entries = await fs.readdir(trashDir);
const results: Array<{ meta: TrashedMindmapMeta; metaPath: string; trashedPath: string }> = [];
for (const name of entries) {
if (!name.endsWith(".deleted.json")) continue;
const metaPath = path.join(trashDir, name);
const metaRaw = await tryReadJson(metaPath);
const meta = metaRaw as TrashedMindmapMeta | null;
if (!meta?.docId || !meta?.mindmapId || !meta?.trashedFileName || !meta?.originalPath) continue;
const trashedPath = path.join(trashDir, meta.trashedFileName);
results.push({ meta, metaPath, trashedPath });
}
return results;
} catch {
return [];
}
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
@@ -60,11 +119,18 @@ export async function GET(
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const folder = path.join(documentsBaseDir, docId);
const folder = path.join(preferredBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
const legacyFile = path.join(folder, "mindmap.json");
const legacyDirFile =
path.basename(file) === "mindmap.json"
? path.join(legacyBaseDir, docId, "mindmap.json")
: null;
const localData = (await tryReadJson(file)) ?? (await tryReadJson(legacyFile));
const localData =
(await tryReadJson(file)) ??
(await tryReadJson(legacyFile)) ??
(legacyDirFile ? await tryReadJson(legacyDirFile) : null);
if (localData) {
return NextResponse.json({ data: localData, source: "local" });
}
@@ -110,18 +176,25 @@ export async function POST(
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const { data } = await request.json().catch(() => ({ data: null }));
const folder = path.join(documentsBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
};
const folder = path.join(preferredBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
try {
await ensureDir(folder);
await ensureIndexFile(folder, doc.title ?? "无标题");
// 仅创建:避免“初始化写入”覆盖用户/AI 刚保存的内容(典型于快速操作 + 异步时序)
if (createOnly && (await fileExists(file))) {
return NextResponse.json({ ok: true, created: false, skipped: true });
}
await fs.writeFile(file, JSON.stringify(data ?? defaultMindmapData, null, 2), "utf8");
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, created: true });
}
export async function DELETE(
@@ -151,8 +224,106 @@ export async function DELETE(
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const folder = path.join(documentsBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
await removeFileSafe(file);
const deletedAt = new Date().toISOString();
const fileName = resolveMindmapFileName(mindmapId);
const preferredFolder = path.join(preferredBaseDir, docId);
const preferredFile = path.join(preferredFolder, fileName);
const candidates: string[] = [preferredFile];
if (fileName === "mindmap.json") {
candidates.push(path.join(legacyBaseDir, docId, "mindmap.json"));
}
let moved = 0;
for (const file of candidates) {
const movedInfo = await moveToTrash(file, {
docId,
mindmapId,
originalFileName: path.basename(file),
originalPath: file,
deleted_at: deletedAt,
});
if (movedInfo) {
moved += 1;
}
}
if (moved === 0) {
// 兼容:文件不存在也视为成功(避免前端卡死)
return NextResponse.json({ ok: true, moved: 0 });
}
return NextResponse.json({ ok: true, moved });
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id")
.eq("id", docId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const preferredFolder = path.join(preferredBaseDir, docId);
const legacyFolder = path.join(legacyBaseDir, docId);
const entries = [
...(await listTrashMetas(preferredFolder)),
...(await listTrashMetas(legacyFolder)),
].filter((item) => item.meta.mindmapId === mindmapId && item.meta.docId === docId);
if (entries.length === 0) {
return NextResponse.json({ error: "未找到可操作的垃圾桶记录" }, { status: 404 });
}
if (action === "purge") {
let purged = 0;
for (const item of entries) {
await removeFileSafe(item.trashedPath);
await removeFileSafe(item.metaPath);
purged += 1;
}
return NextResponse.json({ ok: true, purged });
}
// restore:恢复最新的一条
const latest = entries
.slice()
.sort((a, b) => (b.meta.deleted_at ?? "").localeCompare(a.meta.deleted_at ?? ""))[0];
if (!latest?.meta?.originalPath) {
return NextResponse.json({ error: "垃圾桶记录损坏" }, { status: 500 });
}
if (await fileExists(latest.meta.originalPath)) {
return NextResponse.json({ error: "目标文件已存在,无法恢复" }, { status: 409 });
}
await ensureDir(path.dirname(latest.meta.originalPath));
await fs.rename(latest.trashedPath, latest.meta.originalPath);
await removeFileSafe(latest.metaPath);
return NextResponse.json({ ok: true });
}
@@ -132,6 +132,7 @@ const fetchOcrMatches = async (
.from("media_assets")
.select("document_id,ocr_text")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.not("ocr_text", "is", null)
.ilike("ocr_text", likePattern)
.limit(limit);
+36 -3
View File
@@ -3,7 +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 { detectLocalMindmapFiles, detectLocalMindmapDocs } from "@/lib/mindmap-files";
import {
detectLocalMindmapFiles,
detectLocalMindmapDocs,
detectLocalTrashedMindmapAssets,
} from "@/lib/mindmap-files";
import type { MediaAsset } from "@/types/media";
export const dynamic = "force-dynamic";
@@ -30,12 +34,13 @@ export async function GET(request: Request) {
}
try {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const docIds = dataset.documents.map((d) => d.id);
const localMindmapFiles = await detectLocalMindmapFiles(docIds);
const mindmapDocs = Array.from(new Set([...(dataset.mindmapDocs ?? []), ...(await detectLocalMindmapDocs(docIds))]));
const trashedMindmapAssets = await detectLocalTrashedMindmapAssets(targetWorkspaceId, docIds);
const docById = new Map(dataset.documents.map((d) => [d.id, d]));
const mindmapAssets: MediaAsset[] = localMindmapFiles.map((item) => {
const mindmapAssets: MediaAsset[] = localMindmapFiles.map((item) => {
const doc = docById.get(item.documentId);
const workspaceId = doc?.workspace_id ?? targetWorkspaceId;
const fileUrlBase = item.source === "legacy" ? `/mindmaps/${item.documentId}` : `/documents/${item.documentId}`;
@@ -61,13 +66,41 @@ export async function GET(request: Request) {
};
});
const tableAssets: MediaAsset[] = (dataset.tables ?? []).map((row) => {
const base = (row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? targetWorkspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: `/tables/${row.id}/view`,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
const payload: SidebarInitialData = {
activeWorkspaceId: targetWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
trashedMediaAssets: dataset.trashedMediaAssets ?? [],
trashedMindmapAssets,
mindmapDocs,
mindmapAssets,
tableAssets,
mediaAssets: dataset.mediaAssets ?? [],
};