0.5 缩减重构

This commit is contained in:
lix-2026
2026-04-13 19:21:42 +08:00
parent af92c4b149
commit 71fb1aee7e
2023 changed files with 21113 additions and 394493 deletions
+2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar/sidebar";
import { GlobalAiAgentHost } from "@/components/ai-agent/GlobalAiAgentHost";
import { Breadcrumb } from "@/components/breadcrumb";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import type { DocumentRecord } from "@/lib/documents";
@@ -243,6 +244,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
<GlobalAiAgentHost />
</div>
</div>
);
+130 -130
View File
@@ -1,5 +1,5 @@
"use client";
"use client";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { useAuthActions } from "@convex-dev/auth/react";
import { useState, useCallback, useEffect } from "react";
@@ -7,22 +7,22 @@ import { useRouter } from "next/navigation";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getUserFacingErrorMessage } from "@/lib/auth/errors";
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
/**
* Convex Auth 登录/注册页面
*
* 支持功能:
* - 邮箱密码登录
* - 邮箱密码注册
*/
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
/**
* Convex Auth 登录/注册页面
*
* 支持功能:
* - 邮箱密码登录
* - 邮箱密码注册
*/
export default function AuthPage() {
const { isLoading, isAuthenticated } = useConvexAuth();
const { signIn } = useAuthActions();
@@ -39,7 +39,7 @@ export default function AuthPage() {
router.replace("/");
}
}, [currentUser, isAuthenticated, isLoading, router]);
const [flow, setFlow] = useState<AuthStep>("signIn");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -127,36 +127,36 @@ export default function AuthPage() {
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "操作失败,请重试") });
}
}, [router, signIn, username]);
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await performSignIn(email, password, flow);
}, [email, password, flow, performSignIn]);
// 检查是否启用了 Convex
const isConvex = isConvexEnabled();
if (!isConvex) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4"></h1>
<p className="text-gray-600"> Convex </p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await performSignIn(email, password, flow);
}, [email, password, flow, performSignIn]);
// 检查是否启用了 Convex
const isConvex = isConvexEnabled();
if (!isConvex) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4"></h1>
<p className="text-gray-600"> Convex </p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
if (isAuthenticated && currentUser && currentUser.name) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
@@ -244,29 +244,29 @@ export default function AuthPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow === "signIn" ? "登录账户" : "创建账户"}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
使 Convex Auth
</p>
</div>
{message && (
<div className={`rounded-md p-4 ${
message.type === "success" ? "bg-green-50 text-green-800" :
message.type === "error" ? "bg-red-50 text-red-800" :
"bg-blue-50 text-blue-800"
}`}>
<p className="text-sm">{message.text}</p>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow === "signIn" ? "登录账户" : "创建账户"}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
使 Convex Auth
</p>
</div>
{message && (
<div className={`rounded-md p-4 ${
message.type === "success" ? "bg-green-50 text-green-800" :
message.type === "error" ? "bg-red-50 text-red-800" :
"bg-blue-50 text-blue-800"
}`}>
<p className="text-sm">{message.text}</p>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only"></label>
<input
@@ -298,61 +298,61 @@ export default function AuthPage() {
</div>
)}
<div>
<label htmlFor="password" className="sr-only"></label>
<input
id="password"
name="password"
type="password"
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="密码(至少 8 位)"
/>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{flow === "signIn" ? "登录" : "注册"}
</button>
</div>
{flow === "signIn" && (
<div>
<button
type="button"
onClick={() => {
setEmail(TEST_CREDENTIALS.email);
setPassword(TEST_CREDENTIALS.password);
// 直接调用登录逻辑
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
}}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
</div>
)}
<div className="text-center">
<button
type="button"
onClick={() => {
setFlow(flow === "signIn" ? "signUp" : "signIn");
setMessage(null);
}}
className="text-blue-600 hover:text-blue-500 text-sm"
>
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
</button>
</div>
</form>
</div>
</div>
);
}
<label htmlFor="password" className="sr-only"></label>
<input
id="password"
name="password"
type="password"
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="密码(至少 8 位)"
/>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{flow === "signIn" ? "登录" : "注册"}
</button>
</div>
{flow === "signIn" && (
<div>
<button
type="button"
onClick={() => {
setEmail(TEST_CREDENTIALS.email);
setPassword(TEST_CREDENTIALS.password);
// 直接调用登录逻辑
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
}}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
</div>
)}
<div className="text-center">
<button
type="button"
onClick={() => {
setFlow(flow === "signIn" ? "signUp" : "signIn");
setMessage(null);
}}
className="text-blue-600 hover:text-blue-500 text-sm"
>
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry";
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
@@ -33,6 +34,8 @@ export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
stream?: boolean;
@@ -48,7 +51,7 @@ type RequestPayload = {
// v1BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
documentBlocks?: unknown;
};
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
options?: { searxng?: boolean; ai?: { provider?: AiProvider; model?: string; sessionId?: string } };
};
/** 按作用域分组的工具集 ID 映射 */
@@ -116,6 +119,38 @@ const toSseFrame = (event: string, data: unknown) => {
return `event: ${event}\ndata: ${json}\n\n`;
};
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const normalizeProvider = (raw: unknown): AiProvider => {
const s = String(raw ?? "").trim();
if (s === "local" || s === "online" || s === "ollama" || s === "codex") return s;
return "online";
};
const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => {
const s = String(text ?? "");
const m = s.match(/^\s*#(chat|test|dev)\b[\s:\-–—]*/i);
if (!m) return { mode: null, text: s };
const mode = String(m[1] ?? "").toLowerCase() as CodexMode;
const rest = s.slice(m[0].length);
return { mode, text: rest.trimStart() };
};
const extractCodexModeFromMessages = (messages: AgentMessage[]) => {
const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content ?? "";
const picked = stripCodexModePrefix(lastUser);
const mode: CodexMode = picked.mode ?? "chat";
const cleaned: AgentMessage[] = messages.map((m) => {
if (m.role !== "user") return m;
const r = stripCodexModePrefix(m.content);
return { ...m, content: r.text };
});
return { mode, cleanedMessages: cleaned };
};
export async function POST(request: Request) {
const payload = await safeGetJsonBody<RequestPayload>(request);
if (!payload) {
@@ -144,14 +179,228 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
const provider = normalizeProvider(payload.options?.ai?.provider);
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
const cfg =
provider === "local"
? await loadLocalAiConfig().catch(() => null)
: await loadOnlineAiConfig().catch(() => null);
// Codex:三种模式(默认 #chat
let codexMode: CodexMode = "chat";
let effectiveMessages: AgentMessage[] = payload.messages.slice(0, 50);
let codexWorkspaceRoot: string | null = null;
if (provider === "codex") {
const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50));
codexMode = mode;
effectiveMessages = cleanedMessages;
codexWorkspaceRoot = await findWorkspaceRoot(process.cwd());
// #chat / #dev:直接运行 codex exec(不走本 Agent 工具链)
if (codexMode !== "test") {
const stream = payload.stream !== false;
const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
const encoder = new TextEncoder();
let runKillOuter: (() => void) | null = null;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(toSseFrame(event, data)));
};
const requestId = makeRunId();
send("ready", { ok: true, requestId });
// 说明:同一对话内允许 #chat/#test/#dev 来回切换;为了保证后续随时可进入 #dev,
// web 侧创建的新 session 统一用 workspace-write(是否改文件由 prompt 约束)。
const sandbox: "read-only" | "workspace-write" = "workspace-write";
const sys =
codexMode === "dev"
? "你当前处于 #dev 模式:行为尽量与 Codex CLI 一致。你可以在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。"
: "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。";
const prompt = (() => {
// 新会话:把系统说明 + 对话历史一起喂给 Codex(保证一致性)
if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]);
// 续聊:只发送本次用户输入(带模式前缀),同时重复一遍系统约束以对齐行为
const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? "";
const userText = String(lastUser || "").trim();
if (!userText) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]);
return codexMessagesToPrompt([{ role: "system", content: sys }, { role: "user", content: userText }]);
})();
const toolStartAt = new Map<string, number>();
let sessionSent = false;
let assistantSent = false;
let runKill: (() => void) | null = null;
runKillOuter = () => {
try {
runKill?.();
} catch {
// ignore
}
};
const onAbort = () => {
try {
runKillOuter?.();
} catch {
// ignore
}
};
try {
request.signal?.addEventListener("abort", onAbort, { once: true });
} catch {
// ignore
}
(async () => {
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot!,
sandbox,
prompt,
model: null,
sessionId: sessionIdRaw || null,
onJsonLine: (line) => {
if (line.type === "thread.started") {
const sid = String((line as any).thread_id ?? "").trim();
if (sid && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: sid });
}
return;
}
if (line.type === "item.started" && (line as any).item?.type === "command_execution") {
const item = (line as any).item;
const id = String(item?.id ?? "").trim();
const cmd = String(item?.command ?? "");
if (!id) return;
toolStartAt.set(id, Date.now());
send("tool_call", { id, tool: "codex_command", args: { command: cmd } });
return;
}
if (line.type === "item.completed" && (line as any).item?.type === "command_execution") {
const item = (line as any).item;
const id = String(item?.id ?? "").trim();
if (!id) return;
const t0 = toolStartAt.get(id) ?? Date.now();
const ms = Math.max(0, Date.now() - t0);
const exitCode = Number(item?.exit_code ?? 0);
send("tool_result", {
id,
tool: "codex_command",
ok: exitCode === 0,
ms,
result: { exitCode, output: String(item?.aggregated_output ?? "") },
});
return;
}
if (line.type === "item.completed" && (line as any).item?.type === "agent_message") {
const item = (line as any).item;
const text = String(item?.text ?? "").trim();
if (text) {
assistantSent = true;
send("assistant_message", { text });
}
}
},
});
runKill = run.kill;
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error });
return;
}
if (result.threadId && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: result.threadId });
}
if (!assistantSent && result.text) {
assistantSent = true;
send("assistant_message", { text: result.text });
}
send("completion", { ok: true, text: result.text, steps: 1 });
})()
.catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
send("error", { ok: false, message: msg });
})
.finally(() => {
try {
request.signal?.removeEventListener("abort", onAbort);
} catch {
// ignore
}
controller.close();
});
},
cancel() {
// 前端 abort fetch 时会触发 cancel:尽量终止 codex 进程(类似按 ESC)
// 说明:kill 函数在 start 的闭包里赋值;这里不做任何强假设。
try {
runKillOuter?.();
} catch {
// ignore
}
},
});
if (!stream) {
const prompt = (() => {
if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]);
const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? "";
const userText = String(lastUser || "").trim();
if (!userText) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]);
return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, { role: "user", content: userText }]);
})();
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot!,
sandbox: "workspace-write",
prompt,
model: null,
sessionId: sessionIdRaw || null,
});
const result = await run.done;
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 });
return NextResponse.json({ text: result.text, steps: 1, events: [], sessionId: result.threadId || sessionIdRaw || null });
}
return new Response(body, { headers: sseHeaders });
}
}
// 非 Codexonline/local/ollama 走 OpenAI 兼容网关
const cfg = await (async () => {
if (provider === "codex") {
// #test:工具链可能需要 cfg(例如 mindmap_expand_node),因此这里尽量给一个可用的兜底 cfg
return (
(await loadLocalAiConfig().catch(() => null)) ??
(await loadOnlineAiConfig().catch(() => null)) ?? {
baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL,
apiKey: "",
model: OLLAMA_QWEN3_30B,
}
);
}
if (provider === "ollama") {
return {
baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL,
apiKey: "",
model: modelOverride ?? OLLAMA_QWEN3_30B,
};
}
if (provider === "local") return await loadLocalAiConfig().catch(() => null);
return await loadOnlineAiConfig().catch(() => null);
})();
if (!cfg) {
return errorResponses.aiConfigError(provider);
// 仅 online/local 需要配置文件/环境变量
return errorResponses.aiConfigError(provider === "local" ? "local" : "online");
}
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
@@ -547,9 +796,53 @@ export async function POST(request: Request) {
const stream = payload.stream !== false;
if (!stream) {
const events: Array<{ type: string; data: unknown }> = [];
const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
let codexSessionIdInRequest: string | null = codexSessionIdRaw || null;
let codexSessionEmitted = false;
const chatForAgent =
provider === "codex"
? async (messages: Array<{ role: "system" | "user" | "assistant"; content: string }>) => {
const prompt = codexMessagesToPrompt([
{
role: "system",
content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。",
},
...messages,
]);
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot ?? process.cwd(),
sandbox: "workspace-write",
prompt,
model: null,
sessionId: codexSessionIdInRequest,
onJsonLine: (line) => {
if (line.type !== "thread.started") return;
const sid = String((line as any).thread_id ?? "").trim();
if (!sid) return;
if (!codexSessionIdInRequest) codexSessionIdInRequest = sid;
if (!codexSessionEmitted) {
codexSessionEmitted = true;
events.push({ type: "codex_session", data: { sessionId: sid } });
}
},
});
const result = await run.done;
if (!result.ok) throw new Error(result.error);
if (result.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = result.threadId;
if (result.threadId && !codexSessionEmitted) {
codexSessionEmitted = true;
events.push({ type: "codex_session", data: { sessionId: result.threadId } });
}
return { text: result.text, raw: null };
}
: undefined;
const result = await runAiAgent({
userMessages: payload.messages.slice(0, 50),
userMessages: effectiveMessages,
cfg: { ...cfg, model: modelOverride ?? cfg.model },
...(chatForAgent ? { chat: chatForAgent } : {}),
allowedToolIds,
runTool,
maxSteps,
@@ -562,6 +855,16 @@ export async function POST(request: Request) {
}
const encoder = new TextEncoder();
let activeCodexKill: (() => void) | null = null;
const stopActiveCodexRun = () => {
try {
activeCodexKill?.();
} catch {
// ignore
} finally {
activeCodexKill = null;
}
};
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
@@ -573,6 +876,9 @@ export async function POST(request: Request) {
send("ready", { ok: true, requestId });
let lastToolCall: { id: string; tool: string; args: Record<string, unknown> } | null = null;
const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
let codexSessionIdInRequest: string | null = codexSessionIdRaw || null;
let codexSessionEmitted = false;
const runToolStream = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (isOnlyOfficeClientTool(toolId)) {
@@ -603,10 +909,60 @@ export async function POST(request: Request) {
}
}, 15_000);
const onAbort = () => {
stopActiveCodexRun();
};
try {
request.signal?.addEventListener("abort", onAbort, { once: true });
} catch {
// ignore
}
(async () => {
const result = await runAiAgent({
userMessages: payload.messages.slice(0, 50),
userMessages: effectiveMessages,
cfg: { ...cfg, model: modelOverride ?? cfg.model },
...(provider === "codex"
? {
chat: async (messages) => {
const prompt = codexMessagesToPrompt([
{
role: "system",
content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。",
},
...messages,
]);
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot ?? process.cwd(),
sandbox: "workspace-write",
prompt,
model: null,
sessionId: codexSessionIdInRequest,
onJsonLine: (line) => {
if (line.type !== "thread.started") return;
const sid = String((line as any).thread_id ?? "").trim();
if (!sid) return;
if (!codexSessionIdInRequest) codexSessionIdInRequest = sid;
if (!codexSessionEmitted) {
codexSessionEmitted = true;
send("codex_session", { sessionId: sid });
}
},
});
activeCodexKill = run.kill;
const r = await run.done;
if (activeCodexKill === run.kill) activeCodexKill = null;
if (!r.ok) throw new Error(r.error);
if (r.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = r.threadId;
if (r.threadId && !codexSessionEmitted) {
codexSessionEmitted = true;
send("codex_session", { sessionId: r.threadId });
}
return { text: r.text, raw: null };
},
}
: {}),
allowedToolIds,
runTool: runToolStream,
maxSteps,
@@ -648,9 +1004,18 @@ export async function POST(request: Request) {
})
.finally(() => {
clearInterval(ping);
try {
request.signal?.removeEventListener("abort", onAbort);
} catch {
// ignore
}
stopActiveCodexRun();
controller.close();
});
},
cancel() {
stopActiveCodexRun();
},
});
return new Response(body, { headers: sseHeaders });
+15 -15
View File
@@ -1,21 +1,21 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
interface SignInRequest {
email: string;
password: string;
name?: string;
flow: "signIn" | "signUp";
}
/**
* POST /api/auth/signin
*
* 处理登录/注册请求
* - 在 Convex 模式下调用 Convex mutation
* - 在 Supabase 模式下调用 Supabase Auth
*/
interface SignInRequest {
email: string;
password: string;
name?: string;
flow: "signIn" | "signUp";
}
/**
* POST /api/auth/signin
*
* 处理登录/注册请求
* - 在 Convex 模式下调用 Convex mutation
* - 在 Supabase 模式下调用 Supabase Auth
*/
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
@@ -32,11 +32,11 @@ export async function POST(request: Request) {
return await handleCreateRequest(request);
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequestConvex(request: Request) {
const { auth, client } = await getAuthedConvexClient();
@@ -117,78 +117,78 @@ async function handleCreateRequest(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: rawSiblingCount, error: countError } = await siblingQuery;
const siblingCount = rawSiblingCount ?? 0;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
@@ -200,28 +200,28 @@ async function handleCreateRequest(request: Request) {
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
*/
}
@@ -54,39 +54,39 @@ export async function GET(request: Request) {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
let query = supabase
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
const assetType = searchParams.get("assetType");
if (assetType) {
query = query.eq("asset_type", assetType);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
let query = supabase
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
const assetType = searchParams.get("assetType");
if (assetType) {
query = query.eq("asset_type", assetType);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
}
export async function POST(request: Request) {
@@ -171,46 +171,46 @@ export async function POST(request: Request) {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const { data, error } = await supabase
.from("media_assets")
.insert({
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: data as MediaAsset });
}
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const { data, error } = await supabase
.from("media_assets")
.insert({
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: data as MediaAsset });
}
+43 -43
View File
@@ -9,9 +9,9 @@ import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
type Action = "copy" | "move" | "delete" | "rename" | "restore";
interface BatchPayload {
action: Action;
assetIds: string[];
@@ -19,7 +19,7 @@ interface BatchPayload {
targetSubPath?: string;
newName?: string;
}
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
@@ -225,31 +225,31 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: assets, error: fetchError } = await supabase
.from("media_assets")
.select("*")
.in("id", payload.assetIds);
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
try {
switch (payload.action) {
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: assets, error: fetchError } = await supabase
.from("media_assets")
.select("*")
.in("id", payload.assetIds);
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
try {
switch (payload.action) {
case "delete": {
// 可撤销删除:仅标记 deleted_at,真正清理(OCR/Storage/LightRAG)由后台宽限期任务处理
const { error } = await supabase
@@ -327,14 +327,14 @@ export async function POST(request: Request) {
}
case "copy":
case "move": {
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const { data: targetDoc, error: docErr } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", payload.targetDocumentId)
.single();
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const { data: targetDoc, error: docErr } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", payload.targetDocumentId)
.single();
if (docErr || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
@@ -411,12 +411,12 @@ export async function POST(request: Request) {
}
return NextResponse.json({ items: results });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
*/
}
+13 -13
View File
@@ -15,23 +15,23 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json()) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json()) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { error } = await supabase
.from("media_assets")
.update({ ocr_status: "processing" })
.eq("id", assetId)
.is("deleted_at", null)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
console.warn("触发后端 OCR 失败", err);
});
}
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true });
*/
}
@@ -113,44 +113,44 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
// 与 /api/media/batch 的 move/rename 规则对齐:放到 workspaceId/documentId 下
const mindmapId =
mindmapIdRaw && /^[a-zA-Z0-9_-]{1,128}$/.test(mindmapIdRaw) ? mindmapIdRaw : "";
const subdir = mindmapId ? `mindmaps/${mindmapId}` : "";
const path = `${workspaceId}/${documentId}${subdir ? `/${subdir}` : ""}/${Date.now()}-${uniqueId}${extension}`;
const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, {
contentType: file.type,
upsert: false,
});
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 });
}
// 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新
// 对于图片,使用高质量参数以获得更好的显示效果
const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, {
contentType: file.type,
upsert: false,
});
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 });
}
// 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新
// 对于图片,使用高质量参数以获得更好的显示效果
let signedUrl = "";
if (assetType === "image") {
const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7, {
@@ -166,37 +166,37 @@ export async function POST(request: Request) {
}
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
const { data: asset, error } = await supabase
.from("media_assets")
.insert({
workspace_id: workspaceId,
document_id: documentId,
file_url: signedUrl,
thumbnail_url: signedUrl,
bucket: DOC_BUCKET,
storage_path: path,
asset_type: assetType,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// 返回 asset 和一个特殊的 asset:id 格式用于存储在思维导图中
return NextResponse.json({
asset: asset as MediaAsset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
const { data: asset, error } = await supabase
.from("media_assets")
.insert({
workspace_id: workspaceId,
document_id: documentId,
file_url: signedUrl,
thumbnail_url: signedUrl,
bucket: DOC_BUCKET,
storage_path: path,
asset_type: assetType,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// 返回 asset 和一个特殊的 asset:id 格式用于存储在思维导图中
return NextResponse.json({
asset: asset as MediaAsset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
*/
}
@@ -1,66 +0,0 @@
import { NextResponse } from "next/server";
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
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;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
if (!ops.length) {
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
}
if (ops.length > 80) {
return NextResponse.json({ error: "ops 过多(最大 80" }, { status: 400 });
}
try {
const current = await client.query(api.mindmaps.get, { docId, mindmapId });
const baseData = current?.data ?? defaultMindmapData;
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
await client.mutation(api.mindmaps.put, {
docId,
mindmapId,
data: nextData,
});
return NextResponse.json({
ok: true,
applied,
errors,
data: nextData,
meta: {
documentId: docId,
mindmapId,
actor: payload?.actor ?? null,
reason: payload?.reason ?? null,
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
+21 -21
View File
@@ -268,22 +268,22 @@ export async function GET(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
try {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const docIds = dataset.documents.map((d) => d.id);
@@ -360,10 +360,10 @@ export async function GET(request: Request) {
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
*/
}
+101 -101
View File
@@ -3,115 +3,115 @@ import { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
// 定义请求体类型
interface CreateTableRequestBody {
documentId: string;
title: string;
schema: TableSchema;
snapshot?: DocumentTableSnapshot | null;
}
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
// 定义请求体类型
interface CreateTableRequestBody {
documentId: string;
title: string;
schema: TableSchema;
snapshot?: DocumentTableSnapshot | null;
}
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 获取 document 所在的 workspace_id
const document = await client.query(api.documents.getMeta, {
id: documentId,
});
if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// 创建表格
const result = await client.mutation(api.tables.create, {
userId: auth.userId,
workspaceId: document.workspace_id,
documentId,
title,
schema,
snapshot,
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// 创建表格
const result = await client.mutation(api.tables.create, {
userId: auth.userId,
workspaceId: document.workspace_id,
documentId,
title,
schema,
snapshot,
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 1. 获取 document 所在的 workspace_id
const { data: documentData, error: documentError } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", documentId)
.single();
if (documentError || !documentData?.workspace_id) {
console.error("Error fetching document or workspace:", documentError);
return NextResponse.json({ error: "Document not found or missing workspace_id" }, { status: 404 });
}
const workspaceId = documentData.workspace_id;
// 2. 插入新的 document_tables 记录
const { data: newTable, error: insertError } = await supabase
.from("document_tables")
.insert({
workspace_id: workspaceId,
document_id: documentId,
title: title,
schema: schema,
view_preferences: {},
is_archived: false,
created_by: user.id,
updated_by: user.id,
snapshot: snapshot ?? {},
})
.select()
.single();
if (insertError) {
console.error("Error inserting table:", insertError);
return NextResponse.json({ error: "Failed to create table in database" }, { status: 500 });
}
// 假设 document_tables 返回的结构与 DocumentTable 接口兼容
return NextResponse.json(newTable, { status: 201 });
} catch (error) {
console.error("API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 1. 获取 document 所在的 workspace_id
const { data: documentData, error: documentError } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", documentId)
.single();
if (documentError || !documentData?.workspace_id) {
console.error("Error fetching document or workspace:", documentError);
return NextResponse.json({ error: "Document not found or missing workspace_id" }, { status: 404 });
}
const workspaceId = documentData.workspace_id;
// 2. 插入新的 document_tables 记录
const { data: newTable, error: insertError } = await supabase
.from("document_tables")
.insert({
workspace_id: workspaceId,
document_id: documentId,
title: title,
schema: schema,
view_preferences: {},
is_archived: false,
created_by: user.id,
updated_by: user.id,
snapshot: snapshot ?? {},
})
.select()
.single();
if (insertError) {
console.error("Error inserting table:", insertError);
return NextResponse.json({ error: "Failed to create table in database" }, { status: 500 });
}
// 假设 document_tables 返回的结构与 DocumentTable 接口兼容
return NextResponse.json(newTable, { status: 201 });
} catch (error) {
console.error("API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
*/
}
+10 -6
View File
@@ -2,13 +2,17 @@ import { AiAgentPanel } from "@/components/ai-agent/AiAgentPanel";
export default function DevAiAgentPage() {
return (
<div className="mx-auto max-w-[1200px] p-4">
<div className="mb-3 text-lg font-semibold">AI Agent v1M0</div>
<div className="mb-4 text-sm text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">/api/ai-agent/run</code>SSE
<div className="min-h-screen bg-[#04070f] px-4 py-6 text-white">
<div className="mx-auto flex max-w-[1600px] flex-col gap-4">
<div className="px-1">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE · Global AI Lab</div>
<div className="mt-2 text-sm text-white/55">
<code className="rounded bg-white/10 px-1.5 py-0.5 text-white">/api/ai-agent/run</code> 使 SSE
</div>
</div>
<AiAgentPanel />
</div>
<AiAgentPanel />
</div>
);
}
+16 -16
View File
@@ -1,5 +1,5 @@
"use client";
"use client";
import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock";
import type { BlockNoteEditor } from "@blocknote/core";
import type { CustomBlockSchema } from "@/components/editor/schema";
@@ -14,17 +14,17 @@ const stubBlock = {
content: [],
children: [],
} as any;
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
"use client";
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
@@ -103,19 +103,19 @@ setupOnlyOfficeGlobalErrorCapture();
const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
const hashKey = (input: string) => {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
@@ -1063,11 +1063,11 @@ export default function OnlyOfficePage() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-slate-50">
<p className="text-base font-semibold text-red-600">ONLYOFFICE </p>
<p className="text-sm text-gray-600">{error}</p>
</div>
);
}
<p className="text-sm text-gray-600">{error}</p>
</div>
);
}
return (
<div className="relative h-screen w-screen bg-slate-50">
<div id="onlyoffice-frame" className="h-full w-full" />