0.1.11 ai修复与全屏
This commit is contained in:
@@ -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,必须把搜索结果转为 refs(NodeRef.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": "..." }',
|
||||
"",
|
||||
"硬性约束:",
|
||||
"- 仅输出 addChild;parentUid 必须等于 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);
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user