0.5 缩减重构
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
// v1:BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 非 Codex:online/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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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 平台 v1(M0)</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+606
-606
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" />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AiAgentPanel } from "./AiAgentPanel";
|
||||
|
||||
vi.mock("@/components/ui/scroll-area", () => ({
|
||||
ScrollArea: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="scroll-area" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("AiAgentPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("空状态会展示全局 AI 标题与能力说明", () => {
|
||||
act(() => {
|
||||
root.render(<AiAgentPanel />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("全局 AI");
|
||||
expect(container.textContent).toContain("自动工具编排");
|
||||
expect(container.textContent).toContain("联网检索");
|
||||
expect(container.textContent).toContain("LightRAG");
|
||||
expect(container.textContent).toContain("跨页面文档");
|
||||
expect(container.textContent).toContain("图片 OCR");
|
||||
});
|
||||
|
||||
it("可以切换工具活动面板显隐", () => {
|
||||
act(() => {
|
||||
root.render(<AiAgentPanel />);
|
||||
});
|
||||
|
||||
const toggleButton = container.querySelector('button[aria-label="切换工具活动面板"]');
|
||||
expect(toggleButton).not.toBeNull();
|
||||
expect(container.textContent).toContain("本轮活动");
|
||||
|
||||
act(() => {
|
||||
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("本轮活动");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Command,
|
||||
DatabaseZap,
|
||||
FileSearch,
|
||||
Image,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
SquareStop,
|
||||
Trash2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type ChatMsg = { role: "user" | "assistant"; content: string };
|
||||
type ToolLog =
|
||||
@@ -13,6 +29,58 @@ type ToolLog =
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type CapabilityItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
type ToolSetChip = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PROMPT = "请给出 gemini-3 tokens 价格,并提供来源链接。";
|
||||
const MIN_PANEL_AGENT_STEPS = 1;
|
||||
const MAX_PANEL_AGENT_STEPS = 24;
|
||||
|
||||
const TOOLSET_CHIPS: ToolSetChip[] = [
|
||||
{ id: "toolset.readonly", title: "联网检索", description: "SearxNG 可追溯来源" },
|
||||
{ id: "toolset.rag_read", title: "LightRAG", description: "本地知识检索" },
|
||||
{ id: "toolset.docs_read", title: "跨页面文档", description: "搜索与读取工作区文档" },
|
||||
{ id: "toolset.media_read", title: "图片 OCR", description: "读取图片与附件文字" },
|
||||
{ id: "toolset.slash_write", title: "斜杠命令", description: "受控执行写入动作" },
|
||||
];
|
||||
|
||||
const CAPABILITY_ITEMS: CapabilityItem[] = [
|
||||
{
|
||||
title: "联网检索",
|
||||
description: "搜索公开网页并返回来源链接,适合查价格、规格、资料。",
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
title: "LightRAG",
|
||||
description: "结合知识库做语义检索与生成,适合已有资料沉淀场景。",
|
||||
icon: DatabaseZap,
|
||||
},
|
||||
{
|
||||
title: "跨页面文档",
|
||||
description: "搜索并读取当前工作区中的文档内容,用于对比和归纳。",
|
||||
icon: FileSearch,
|
||||
},
|
||||
{
|
||||
title: "图片 OCR",
|
||||
description: "若当前请求已带图片或附件,可读取其中的 OCR 文字内容。",
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
title: "斜杠命令",
|
||||
description: "执行受控写操作,例如创建文档或改名,需要明确确认。",
|
||||
icon: Command,
|
||||
},
|
||||
];
|
||||
|
||||
const parseSseChunks = async (res: Response, onEvent: (event: string, dataText: string) => void) => {
|
||||
if (!res.body) throw new Error("响应不支持流式读取");
|
||||
const reader = res.body.getReader();
|
||||
@@ -42,16 +110,60 @@ const parseSseChunks = async (res: Response, onEvent: (event: string, dataText:
|
||||
}
|
||||
};
|
||||
|
||||
export function AiAgentPanel() {
|
||||
const [input, setInput] = useState("请给出 gemini-3 tokens 价格,并提供来源链接。");
|
||||
const formatJson = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isAbortLikeError = (error: unknown) => {
|
||||
return (
|
||||
(typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") ||
|
||||
(error instanceof Error && error.name === "AbortError")
|
||||
);
|
||||
};
|
||||
|
||||
const clampStep = (value: number) => {
|
||||
return Math.min(Math.max(value, MIN_PANEL_AGENT_STEPS), MAX_PANEL_AGENT_STEPS);
|
||||
};
|
||||
|
||||
const getLogToneClass = (log: ToolLog) => {
|
||||
if (log.type === "error") {
|
||||
return "border-red-500/30 bg-red-500/10";
|
||||
}
|
||||
if (log.type === "tool_result") {
|
||||
return log.ok ? "border-emerald-500/20 bg-emerald-500/10" : "border-amber-500/25 bg-amber-500/10";
|
||||
}
|
||||
return "border-white/10 bg-white/[0.03]";
|
||||
};
|
||||
|
||||
const getLogLabel = (log: ToolLog) => {
|
||||
if (log.type === "tool_call") return "工具请求";
|
||||
if (log.type === "tool_result") return log.ok ? "工具结果 · 成功" : "工具结果 · 失败";
|
||||
return "运行错误";
|
||||
};
|
||||
|
||||
export function AiAgentPanel({ onClose }: { onClose?: () => void } = {}) {
|
||||
const [input, setInput] = useState(DEFAULT_PROMPT);
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [logs, setLogs] = useState<ToolLog[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [maxSteps, setMaxSteps] = useState(10);
|
||||
const [showLogs, setShowLogs] = useState(true);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const messageEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const canSend = useMemo(() => input.trim().length > 0 && !running, [input, running]);
|
||||
const canClear = messages.length > 0 || logs.length > 0;
|
||||
const assistantCount = messages.filter((message) => message.role === "assistant").length;
|
||||
const userCount = messages.length - assistantCount;
|
||||
|
||||
useEffect(() => {
|
||||
messageEndRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [messages, logs, showLogs]);
|
||||
|
||||
const stop = () => {
|
||||
abortRef.current?.abort();
|
||||
@@ -59,6 +171,18 @@ export function AiAgentPanel() {
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
const restoreDefaultPrompt = () => {
|
||||
setInput(DEFAULT_PROMPT);
|
||||
};
|
||||
|
||||
const clearConversation = () => {
|
||||
if (running) {
|
||||
stop();
|
||||
}
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
@@ -84,7 +208,7 @@ export function AiAgentPanel() {
|
||||
messages: nextMessages.slice(-20),
|
||||
toolChoice: {
|
||||
mode: "auto",
|
||||
toolSets: ["toolset.readonly", "toolset.rag_read", "toolset.docs_read", "toolset.media_read", "toolset.slash_write"],
|
||||
toolSets: TOOLSET_CHIPS.map((chip) => chip.id),
|
||||
},
|
||||
options: { searxng: true, ai: { provider: "online" } },
|
||||
}),
|
||||
@@ -155,12 +279,14 @@ export function AiAgentPanel() {
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortLikeError(error)) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLogs((prev) => [...prev, { type: "error", message }]);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunning(false);
|
||||
@@ -168,97 +294,339 @@ export function AiAgentPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-80px)] w-full gap-4">
|
||||
<Card className="flex w-[60%] flex-col p-3">
|
||||
<div className="mb-2 text-sm font-medium">对话</div>
|
||||
<ScrollArea className="flex-1 rounded border">
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
{messages.length === 0 ? <div className="text-muted-foreground">暂无消息</div> : null}
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
|
||||
<div className="whitespace-pre-wrap">{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<section className="flex h-[calc(100vh-112px)] min-h-[720px] w-full overflow-hidden rounded-[28px] border border-white/10 bg-[#070b14] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-4 border-b border-white/8 px-5 py-4">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-400/10 text-sky-100">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入你的问题(Enter 发送,Shift+Enter 换行)"
|
||||
className="min-h-[72px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
最大步数
|
||||
<input
|
||||
className="w-[72px] rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={MIN_AGENT_STEPS}
|
||||
max={MAX_AGENT_STEPS}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
setMaxSteps(clamp(Math.floor(v), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
}}
|
||||
disabled={running}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={!canSend} onClick={() => void send()}>
|
||||
发送
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE</span>
|
||||
<span className="h-1 w-1 rounded-full bg-white/25" />
|
||||
<span className="text-sm text-white/60">全局 AI</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-white">全局 AI</h1>
|
||||
<Badge className="border-sky-400/25 bg-sky-400/10 text-sky-100 hover:bg-sky-400/10" variant="outline">
|
||||
自动工具编排
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
{running ? "运行中" : "待命"}
|
||||
</Badge>
|
||||
{onClose ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="关闭全局 AI"
|
||||
title="关闭"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="切换工具活动面板"
|
||||
title={showLogs ? "隐藏工具活动面板" : "显示工具活动面板"}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => setShowLogs((prev) => !prev)}
|
||||
>
|
||||
{showLogs ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!running} onClick={stop}>
|
||||
停止
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="恢复示例问题"
|
||||
title="恢复示例问题"
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={restoreDefaultPrompt}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="清空对话"
|
||||
title="清空对话"
|
||||
disabled={!canClear}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={clearConversation}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="停止运行"
|
||||
title="停止本轮运行"
|
||||
disabled={!running}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={stop}
|
||||
>
|
||||
<SquareStop className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</header>
|
||||
|
||||
<Card className="flex w-[40%] flex-col p-3">
|
||||
<div className="mb-2 text-sm font-medium">工具日志</div>
|
||||
<ScrollArea className="flex-1 rounded border">
|
||||
<div className="space-y-3 p-3 text-sm">
|
||||
{logs.length === 0 ? <div className="text-muted-foreground">暂无工具日志</div> : null}
|
||||
{logs.map((l, idx) => {
|
||||
if (l.type === "error") {
|
||||
return (
|
||||
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
|
||||
错误:{l.message}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="flex min-h-0 min-w-0 flex-col bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.08),_transparent_36%),linear-gradient(180deg,_rgba(255,255,255,0.02),_rgba(255,255,255,0.01))]">
|
||||
<div className="border-b border-white/8 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-white/45">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
当前能力
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
|
||||
</div>
|
||||
<div className="font-medium">{l.tool}</div>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
|
||||
<div className="mt-2 text-sm text-white/65">保留 MNOTE 当前真实工具,不伪装未实现的会话历史和附件能力。</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
用户 {userCount}
|
||||
</Badge>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
AI {assistantCount}
|
||||
</Badge>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
活动 {logs.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{TOOLSET_CHIPS.map((chip) => (
|
||||
<Badge
|
||||
key={chip.id}
|
||||
variant="outline"
|
||||
className="rounded-full border-white/10 bg-white/[0.03] px-3 py-1.5 text-white/78 hover:bg-white/[0.03]"
|
||||
title={chip.description}
|
||||
>
|
||||
{chip.title}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-5 py-5">
|
||||
{messages.length === 0 ? (
|
||||
<div className="max-w-3xl rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_10px_40px_rgba(0,0,0,0.24)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-white/85">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-white">你好,我是 MNOTE 全局 AI。</div>
|
||||
<div className="mt-2 text-sm leading-7 text-white/72">我会基于当前已接入的工具完成可追溯的检索、阅读与受控写入任务:</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{CAPABILITY_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="rounded-2xl border border-white/8 bg-black/15 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Icon className="h-4 w-4 text-sky-200/80" />
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-white/58">{item.description}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-white/70">请输入你的目标,我会在允许的工具范围内自动完成分析,并把活动轨迹展示在右侧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message, index) => {
|
||||
const isUser = message.role === "user";
|
||||
return (
|
||||
<article key={`${message.role}-${index}`} className={`flex gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
{!isUser ? (
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-sm font-semibold text-white/88">
|
||||
AI
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`max-w-[min(760px,92%)] rounded-[22px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_30px_rgba(0,0,0,0.16)] ${
|
||||
isUser
|
||||
? "border-sky-400/25 bg-sky-500/15 text-sky-50"
|
||||
: "border-white/10 bg-white/[0.04] text-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 text-xs ${isUser ? "text-sky-100/70" : "text-white/45"}`}>{isUser ? "我" : "AI"}</div>
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
</div>
|
||||
{isUser ? (
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-400/30 bg-sky-500/18 text-sm font-semibold text-sky-50">
|
||||
我
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div ref={messageEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t border-white/8 px-5 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TOOLSET_CHIPS.map((chip) => (
|
||||
<div
|
||||
key={`${chip.id}-summary`}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-white/82">{chip.title}</span>
|
||||
<span className="text-xs text-white/42">{chip.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/68">
|
||||
最大步数
|
||||
<input
|
||||
className="w-14 rounded-md border border-white/10 bg-black/20 px-2 py-1 text-right text-white outline-none"
|
||||
type="number"
|
||||
min={MIN_PANEL_AGENT_STEPS}
|
||||
max={MAX_PANEL_AGENT_STEPS}
|
||||
step={1}
|
||||
value={maxSteps}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
setMaxSteps(clampStep(Math.floor(value)));
|
||||
}}
|
||||
disabled={running}
|
||||
/>
|
||||
</label>
|
||||
<Badge className="border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/70 hover:bg-white/[0.04]" variant="outline">
|
||||
{running ? "状态:生成中…" : "状态:等待输入"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[140px] rounded-[24px] border-white/10 bg-white/[0.03] px-4 py-4 pb-16 pr-40 text-sm leading-7 text-white placeholder:text-white/30"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-4 left-4 text-xs text-white/38">Enter 发送,Shift+Enter 换行</div>
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="absolute bottom-4 right-16 rounded-xl border border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.12]"
|
||||
onClick={stop}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canSend}
|
||||
aria-label="发送消息"
|
||||
title="发送"
|
||||
className="absolute bottom-4 right-4 h-10 w-10 rounded-full bg-sky-500 p-0 text-white hover:bg-sky-400"
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showLogs ? (
|
||||
<aside className="flex min-h-0 min-w-0 flex-col border-t border-white/8 bg-white/[0.02] xl:border-l xl:border-t-0">
|
||||
<div className="border-b border-white/8 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">本轮活动</div>
|
||||
<div className="mt-1 text-xs text-white/48">{running ? "AI 正在调度工具与生成回答" : "等待发起下一轮任务"}</div>
|
||||
</div>
|
||||
<Badge className="border-white/10 bg-white/[0.04] text-white/72 hover:bg-white/[0.04]" variant="outline">
|
||||
{logs.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-3 p-4">
|
||||
{logs.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm leading-7 text-white/45">
|
||||
还没有工具活动。发送问题后,这里会展示工具请求、执行结果和错误信息。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{logs.map((log, index) => (
|
||||
<div key={`${log.type}-${index}`} className={`rounded-2xl border p-3 ${getLogToneClass(log)}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-white/42">{getLogLabel(log)}</div>
|
||||
{"id" in log ? <div className="text-xs text-white/35">{log.id || "no-id"}</div> : null}
|
||||
</div>
|
||||
|
||||
{log.type === "error" ? (
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-red-100/90">{log.message}</div>
|
||||
) : null}
|
||||
|
||||
{log.type === "tool_call" ? (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Search className="h-4 w-4 text-sky-200/80" />
|
||||
{log.tool}
|
||||
</div>
|
||||
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
|
||||
{formatJson(log.args)}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{log.type === "tool_result" ? (
|
||||
<>
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Search className="h-4 w-4 text-emerald-200/80" />
|
||||
{log.tool}
|
||||
</div>
|
||||
<div className="text-xs text-white/45">{log.ms}ms</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-auto rounded-xl border border-white/10 bg-black/20 p-3 text-xs leading-6 text-white/70">
|
||||
{formatJson(log.result)}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
import { AiAgentPanel } from "./AiAgentPanel";
|
||||
|
||||
export function GlobalAiAgentHost() {
|
||||
const open = useAiAgentUiStore((s) => s.globalAgentOpen);
|
||||
const setOpen = useAiAgentUiStore((s) => s.setGlobalAgentOpen);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-[min(1500px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
|
||||
>
|
||||
<SheetTitle className="sr-only">MNOTE 全局 AI</SheetTitle>
|
||||
<AiAgentPanel onClose={() => setOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
"use client";
|
||||
|
||||
import { MessageCircle, Sparkles } from "lucide-react";
|
||||
import { useBackendHealth } from "@/hooks/use-backend-health";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
|
||||
|
||||
export function BottomToolbar() {
|
||||
const status = useBackendHealth();
|
||||
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
||||
@@ -15,10 +15,10 @@ export function BottomToolbar() {
|
||||
: status === "error"
|
||||
? "bg-red-500"
|
||||
: "bg-gray-300";
|
||||
|
||||
return (
|
||||
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
|
||||
return (
|
||||
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
|
||||
后端连接:
|
||||
{status === "ok"
|
||||
|
||||
@@ -26,6 +26,8 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
||||
const backendStatus = useBackendHealth();
|
||||
const globalAgentOpen = useAiAgentUiStore((s) => s.globalAgentOpen);
|
||||
const toggleGlobalAgentOpen = useAiAgentUiStore((s) => s.toggleGlobalAgentOpen);
|
||||
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
||||
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
|
||||
const isStarred = useQuery(
|
||||
@@ -82,6 +84,20 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
}`}
|
||||
aria-label="后端连接状态"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-sm transition-colors",
|
||||
globalAgentOpen
|
||||
? "bg-[#2563eb] text-white hover:bg-[#1d4ed8]"
|
||||
: "hover:bg-wolai-bg-hover hover:text-wolai-text-primary",
|
||||
)}
|
||||
onClick={() => toggleGlobalAgentOpen()}
|
||||
title="打开全局 AI"
|
||||
>
|
||||
<Sparkles className="mr-1 inline h-4 w-4" />
|
||||
全局AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
||||
@@ -4,18 +4,18 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
interface TaskResponse {
|
||||
task_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
|
||||
interface TaskResponse {
|
||||
task_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export function DocumentTaskPanel({ documentId }: Props) {
|
||||
const [task, setTask] = useState<TaskResponse | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
@@ -48,11 +48,11 @@ export function DocumentTaskPanel({ documentId }: Props) {
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
}, [backendUrl, task?.task_id, useConvex]);
|
||||
|
||||
return (
|
||||
<Card className="mt-4 bg-white shadow-sm">
|
||||
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
|
||||
<div>
|
||||
|
||||
return (
|
||||
<Card className="mt-4 bg-white shadow-sm">
|
||||
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">后端 OCR 流程</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
状态:{task ? task.status : "未开始"} · 进度:{task ? `${task.progress}%` : "0%"}
|
||||
|
||||
@@ -19,6 +19,19 @@ import {
|
||||
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
const extractCodexMode = (text: string): CodexMode => {
|
||||
const s = String(text ?? "");
|
||||
const m = s.match(/^\s*#(chat|test|dev)\b/i);
|
||||
if (!m) return "chat";
|
||||
const mode = String(m[1] ?? "").toLowerCase();
|
||||
if (mode === "dev" || mode === "test" || mode === "chat") return mode;
|
||||
return "chat";
|
||||
};
|
||||
|
||||
const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
|
||||
{
|
||||
@@ -68,6 +81,7 @@ const DEFAULT_TOOLS: ToolName[] = [
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type ChatSession = {
|
||||
@@ -77,6 +91,8 @@ type ChatSession = {
|
||||
updatedAt: number;
|
||||
messages: AgentMessage[];
|
||||
toolLogs: ToolLog[];
|
||||
codexSessionId?: string | null;
|
||||
codexMode?: CodexMode | null;
|
||||
};
|
||||
|
||||
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
|
||||
@@ -164,7 +180,7 @@ export function DocumentAiAgentPanel({
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
@@ -204,7 +220,7 @@ export function DocumentAiAgentPanel({
|
||||
if (Number.isFinite(parsed) && parsed >= MIN_AGENT_STEPS) {
|
||||
setMaxSteps(clamp(Math.floor(parsed), MIN_AGENT_STEPS, MAX_AGENT_STEPS));
|
||||
}
|
||||
if (p === "local" || p === "online") setAiProvider(p);
|
||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -241,6 +257,8 @@ export function DocumentAiAgentPanel({
|
||||
updatedAt: now,
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
};
|
||||
setSessions([session]);
|
||||
setActiveSessionId(id);
|
||||
@@ -263,7 +281,10 @@ export function DocumentAiAgentPanel({
|
||||
const title = String((x as any)?.title ?? "").trim() || "历史会话";
|
||||
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
|
||||
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
|
||||
return { id, title, createdAt, updatedAt, messages, toolLogs } as ChatSession;
|
||||
const codexSessionId = String((x as any)?.codexSessionId ?? "").trim() || null;
|
||||
const codexModeRaw = String((x as any)?.codexMode ?? "").trim();
|
||||
const codexMode = codexModeRaw === "chat" || codexModeRaw === "test" || codexModeRaw === "dev" ? (codexModeRaw as CodexMode) : null;
|
||||
return { id, title, createdAt, updatedAt, messages, toolLogs, codexSessionId, codexMode } as ChatSession;
|
||||
})
|
||||
.filter((s) => s.id),
|
||||
);
|
||||
@@ -298,6 +319,8 @@ export function DocumentAiAgentPanel({
|
||||
updatedAt: now,
|
||||
messages,
|
||||
toolLogs,
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
@@ -323,6 +346,12 @@ export function DocumentAiAgentPanel({
|
||||
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
|
||||
const currentSessionTitle = currentSession?.title || "新会话";
|
||||
|
||||
const [codexSessionDraft, setCodexSessionDraft] = useState("");
|
||||
useEffect(() => {
|
||||
if (aiProvider !== "codex") return;
|
||||
setCodexSessionDraft(String(currentSession?.codexSessionId ?? "").trim());
|
||||
}, [aiProvider, currentSession?.codexSessionId]);
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
switch (page) {
|
||||
case "tools":
|
||||
@@ -349,6 +378,8 @@ export function DocumentAiAgentPanel({
|
||||
updatedAt: now,
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
};
|
||||
setSessions((prev) => normalizeSessions([next, ...prev]));
|
||||
setActiveSessionId(id);
|
||||
@@ -400,7 +431,17 @@ export function DocumentAiAgentPanel({
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], updatedAt: Date.now(), title: s.title || "当前会话" } : s,
|
||||
s.id === activeSessionId
|
||||
? {
|
||||
...s,
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
updatedAt: Date.now(),
|
||||
title: s.title || "当前会话",
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -431,11 +472,17 @@ export function DocumentAiAgentPanel({
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
const hasExplicitCodexMode = /^\s*#(chat|test|dev)\b/i.test(content);
|
||||
const intendedCodexMode: CodexMode =
|
||||
aiProvider === "codex" ? (hasExplicitCodexMode ? extractCodexMode(content) : "chat") : "chat";
|
||||
setToolLogs([]);
|
||||
if (activeSessionId && currentSessionTitle === "新会话") {
|
||||
const title = content.length > 18 ? `${content.slice(0, 18)}…` : content;
|
||||
@@ -460,10 +507,25 @@ export function DocumentAiAgentPanel({
|
||||
(m, idx) => !(idx === 0 && m.role === "assistant" && /页面 AI Agent/.test(m.content)),
|
||||
);
|
||||
|
||||
const payloadMessagesForRequest = payloadMessages;
|
||||
|
||||
const blocks = getLatestBlocks();
|
||||
const blocksJson = blocks ? safeJsonStringify(blocks) : "";
|
||||
const shouldSendBlocks = blocksJson && blocksJson.length <= 500_000;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
if (aiProvider === "codex" && activeSessionId) {
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
@@ -473,7 +535,7 @@ export function DocumentAiAgentPanel({
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "document",
|
||||
messages: payloadMessages.slice(-24),
|
||||
messages: payloadMessagesForRequest.slice(-24),
|
||||
toolChoice: toolAuto
|
||||
? {
|
||||
mode: "auto",
|
||||
@@ -489,7 +551,14 @@ export function DocumentAiAgentPanel({
|
||||
}
|
||||
: { mode: "manual", tools: selectedTools },
|
||||
context: { documentId, documentBlocks: shouldSendBlocks ? blocks : null },
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -499,6 +568,25 @@ export function DocumentAiAgentPanel({
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
const sessionId = String(obj.sessionId ?? "").trim();
|
||||
if (sessionId && activeSessionId) {
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexSessionId: sessionId, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
@@ -564,6 +652,7 @@ export function DocumentAiAgentPanel({
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const message =
|
||||
@@ -576,6 +665,7 @@ export function DocumentAiAgentPanel({
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
@@ -711,18 +801,24 @@ export function DocumentAiAgentPanel({
|
||||
<select
|
||||
className="h-8 rounded border bg-white px-2 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
<input
|
||||
className="h-8 w-[180px] rounded border px-2 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="model(可选)"
|
||||
disabled={loading}
|
||||
placeholder={aiProvider === "codex" ? "Codex 无需 model" : aiProvider === "ollama" ? `默认:${OLLAMA_QWEN3_30B}` : "model(可选)"}
|
||||
disabled={loading || aiProvider === "codex"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -782,6 +878,13 @@ export function DocumentAiAgentPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<details key={idx} className="rounded border p-2">
|
||||
@@ -829,7 +932,7 @@ export function DocumentAiAgentPanel({
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
停止
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -959,23 +1062,123 @@ export function DocumentAiAgentPanel({
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
<label className="ml-2 text-xs text-muted-foreground">模型(可选)</label>
|
||||
<input
|
||||
className="h-9 w-[260px] rounded border px-2 text-sm"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="例如 gemini-2.5-pro"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="ml-2 space-y-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
使用本地 Codex 配置(<code className="rounded bg-muted px-1 py-0.5">~/.codex/config.toml</code>)。
|
||||
在消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>Codex Session</span>
|
||||
<input
|
||||
className="h-8 w-[360px] rounded border bg-white px-2 text-xs"
|
||||
value={codexSessionDraft}
|
||||
onChange={(e) => setCodexSessionDraft(e.target.value)}
|
||||
placeholder="留空=本会话自动创建;也可粘贴 VSCode/Codex CLI 的 thread_id 续聊"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={loading || !activeSessionId}
|
||||
onClick={() => {
|
||||
const nextId = codexSessionDraft.trim() || null;
|
||||
if (!activeSessionId) return;
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexSessionId: nextId, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
绑定
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={loading || !activeSessionId}
|
||||
onClick={() => {
|
||||
if (!activeSessionId) return;
|
||||
setCodexSessionDraft("");
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexSessionId: null, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={loading || !String(currentSession?.codexSessionId ?? "").trim()}
|
||||
onClick={() => {
|
||||
const sid = String(currentSession?.codexSessionId ?? "").trim();
|
||||
if (!sid) return;
|
||||
void navigator.clipboard?.writeText(sid).catch(() => null);
|
||||
}}
|
||||
>
|
||||
复制当前
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
提示:你可以在 VSCode 里继续用 Codex CLI 编辑,同时在这里用 <code className="rounded bg-muted px-1 py-0.5">#dev</code> 进行编辑;若要共享上下文,请绑定同一个 SessionId。避免两边同时改同一文件以免冲突。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="ml-2 text-xs text-muted-foreground">模型(可选)</label>
|
||||
{aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
list="doc-ai-model-suggestions"
|
||||
className="h-9 w-[260px] rounded border px-2 text-sm"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="例如 gemini-2.5-pro"
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
<datalist id="doc-ai-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
说明:在线/本地的 BaseURL 与 Key 仍由配置文件/环境变量控制;这里仅做 provider 与 model 覆盖。
|
||||
说明:在线/本地的 BaseURL 与 Key 仍由配置文件/环境变量控制;这里仅做 provider 与 model 覆盖。Ollama 默认使用 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -1147,23 +1350,56 @@ export function DocumentAiAgentPanel({
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
<label className="ml-2 text-xs text-muted-foreground">模型(可选)</label>
|
||||
<input
|
||||
className="h-9 w-[260px] rounded border px-2 text-sm"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="例如 gemini-2.5-pro"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="ml-2 text-xs text-muted-foreground">
|
||||
消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="ml-2 text-xs text-muted-foreground">模型(可选)</label>
|
||||
{aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
list="doc-ai-model-suggestions-dialog"
|
||||
className="h-9 w-[260px] rounded border px-2 text-sm"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="例如 gemini-2.5-pro"
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
<datalist id="doc-ai-model-suggestions-dialog">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
说明:在线/本地的 BaseURL 与 Key 仍由配置文件/环境变量控制;这里仅做 provider 与 model 覆盖。
|
||||
说明:在线/本地的 BaseURL 与 Key 仍由配置文件/环境变量控制;这里仅做 provider 与 model 覆盖。Ollama 默认使用 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type JSX,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||||
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type JSX,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||||
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind } from "@/types/media";
|
||||
@@ -25,59 +25,59 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
file: "文件",
|
||||
};
|
||||
|
||||
const deriveFileName = (value?: string) => {
|
||||
if (!value) {
|
||||
return "未命名资源";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const last = url.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
const segments = value.split("?")[0]?.split("/") ?? [];
|
||||
const last = segments.pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
}
|
||||
return "未命名资源";
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number | null) => {
|
||||
if (!size || size <= 0) {
|
||||
return "未知大小";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let idx = 0;
|
||||
let current = size;
|
||||
while (current >= 1024 && idx < units.length - 1) {
|
||||
current /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
file: "文件",
|
||||
};
|
||||
|
||||
const deriveFileName = (value?: string) => {
|
||||
if (!value) {
|
||||
return "未命名资源";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const last = url.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
const segments = value.split("?")[0]?.split("/") ?? [];
|
||||
const last = segments.pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
}
|
||||
return "未命名资源";
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number | null) => {
|
||||
if (!size || size <= 0) {
|
||||
return "未知大小";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let idx = 0;
|
||||
let current = size;
|
||||
while (current >= 1024 && idx < units.length - 1) {
|
||||
current /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const { openPicker } = useImagePicker();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -93,144 +93,144 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
|
||||
? (rawAssetType as MediaKind)
|
||||
: "image";
|
||||
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
|
||||
const canAlign = assetType === "image" || assetType === "video";
|
||||
const canToggleBorder = assetType === "image";
|
||||
const canTriggerOcr = assetType === "image";
|
||||
const canResize = assetType === "image" || assetType === "video";
|
||||
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
|
||||
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestWidthRef = useRef(localWidth);
|
||||
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
|
||||
const captionRef = useRef<HTMLInputElement | null>(null);
|
||||
const [captionEditing, setCaptionEditing] = useState(false);
|
||||
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
const resolveDocumentId = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const [, tail] = window.location.pathname.split("/documents/");
|
||||
if (tail) {
|
||||
const id = tail.split(/[/?#]/)[0];
|
||||
if (id) return id;
|
||||
}
|
||||
}
|
||||
return (block.props as { documentId?: string })?.documentId || "";
|
||||
}, [block.props]);
|
||||
const extension = useMemo(() => {
|
||||
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
||||
const match = /\.([a-z0-9]+)$/.exec(name);
|
||||
return match?.[1] ?? "";
|
||||
}, [block.props.fileName, fileUrl]);
|
||||
const isOfficeDoc = useMemo(
|
||||
() =>
|
||||
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
|
||||
extension,
|
||||
),
|
||||
[extension],
|
||||
);
|
||||
|
||||
const handleChoose = () => {
|
||||
openPicker({
|
||||
defaultTab: fileUrl ? "recent" : "upload",
|
||||
mediaType: assetType,
|
||||
onSelect: (selection) => {
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? rawAssetType,
|
||||
fileName: selection.fileName ?? block.props.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
documentId: resolveDocumentId(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBorder = () => {
|
||||
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
|
||||
};
|
||||
|
||||
const setAlign = (align: MediaAlign) => {
|
||||
editor.updateBlock(block, { props: { captionAlign: align } });
|
||||
};
|
||||
|
||||
const handleCaptionChange = (value: string) => {
|
||||
editor.updateBlock(block, { props: { caption: value } });
|
||||
};
|
||||
|
||||
const enableCaptionEdit = () => {
|
||||
setCaptionEditing(true);
|
||||
setTimeout(() => captionRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowCaption && captionEditing) {
|
||||
setCaptionEditing(false);
|
||||
}
|
||||
}, [captionEditing, shouldShowCaption]);
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
|
||||
}
|
||||
}, [block.props.width, dragging]);
|
||||
useEffect(() => {
|
||||
latestWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const resolvedWidth = useMemo(() => {
|
||||
if (!canResize) return 0;
|
||||
if (localWidth > 0) return clampWidth(localWidth);
|
||||
if (block.props.width && Number(block.props.width) > 0) {
|
||||
return clampWidth(Number(block.props.width));
|
||||
}
|
||||
return 0;
|
||||
}, [block.props.width, canResize, localWidth]);
|
||||
|
||||
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
|
||||
if (!canResize) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
|
||||
if (!canvasWidth) {
|
||||
return;
|
||||
}
|
||||
setDragging({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
startWidth: canvasWidth,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return undefined;
|
||||
}
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const delta = event.clientX - dragging.startX;
|
||||
const adjusted = dragging.side === "left" ? -delta : delta;
|
||||
const next = clampWidth(dragging.startWidth + adjusted);
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const handleUp = () => {
|
||||
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
|
||||
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragging, editor, block]);
|
||||
|
||||
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
|
||||
const canAlign = assetType === "image" || assetType === "video";
|
||||
const canToggleBorder = assetType === "image";
|
||||
const canTriggerOcr = assetType === "image";
|
||||
const canResize = assetType === "image" || assetType === "video";
|
||||
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
|
||||
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestWidthRef = useRef(localWidth);
|
||||
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
|
||||
const captionRef = useRef<HTMLInputElement | null>(null);
|
||||
const [captionEditing, setCaptionEditing] = useState(false);
|
||||
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
const resolveDocumentId = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const [, tail] = window.location.pathname.split("/documents/");
|
||||
if (tail) {
|
||||
const id = tail.split(/[/?#]/)[0];
|
||||
if (id) return id;
|
||||
}
|
||||
}
|
||||
return (block.props as { documentId?: string })?.documentId || "";
|
||||
}, [block.props]);
|
||||
const extension = useMemo(() => {
|
||||
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
||||
const match = /\.([a-z0-9]+)$/.exec(name);
|
||||
return match?.[1] ?? "";
|
||||
}, [block.props.fileName, fileUrl]);
|
||||
const isOfficeDoc = useMemo(
|
||||
() =>
|
||||
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
|
||||
extension,
|
||||
),
|
||||
[extension],
|
||||
);
|
||||
|
||||
const handleChoose = () => {
|
||||
openPicker({
|
||||
defaultTab: fileUrl ? "recent" : "upload",
|
||||
mediaType: assetType,
|
||||
onSelect: (selection) => {
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? rawAssetType,
|
||||
fileName: selection.fileName ?? block.props.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
documentId: resolveDocumentId(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBorder = () => {
|
||||
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
|
||||
};
|
||||
|
||||
const setAlign = (align: MediaAlign) => {
|
||||
editor.updateBlock(block, { props: { captionAlign: align } });
|
||||
};
|
||||
|
||||
const handleCaptionChange = (value: string) => {
|
||||
editor.updateBlock(block, { props: { caption: value } });
|
||||
};
|
||||
|
||||
const enableCaptionEdit = () => {
|
||||
setCaptionEditing(true);
|
||||
setTimeout(() => captionRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowCaption && captionEditing) {
|
||||
setCaptionEditing(false);
|
||||
}
|
||||
}, [captionEditing, shouldShowCaption]);
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
|
||||
}
|
||||
}, [block.props.width, dragging]);
|
||||
useEffect(() => {
|
||||
latestWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const resolvedWidth = useMemo(() => {
|
||||
if (!canResize) return 0;
|
||||
if (localWidth > 0) return clampWidth(localWidth);
|
||||
if (block.props.width && Number(block.props.width) > 0) {
|
||||
return clampWidth(Number(block.props.width));
|
||||
}
|
||||
return 0;
|
||||
}, [block.props.width, canResize, localWidth]);
|
||||
|
||||
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
|
||||
if (!canResize) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
|
||||
if (!canvasWidth) {
|
||||
return;
|
||||
}
|
||||
setDragging({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
startWidth: canvasWidth,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return undefined;
|
||||
}
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const delta = event.clientX - dragging.startX;
|
||||
const adjusted = dragging.side === "left" ? -delta : delta;
|
||||
const next = clampWidth(dragging.startWidth + adjusted);
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const handleUp = () => {
|
||||
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
|
||||
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragging, editor, block]);
|
||||
|
||||
const handleLink = () => {
|
||||
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
|
||||
if (next === null) return;
|
||||
@@ -282,7 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
|
||||
const openWithOnlyOffice = async () => {
|
||||
if (!fileUrl) return;
|
||||
if (!officeBase) {
|
||||
@@ -342,65 +342,65 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
||||
if (!assetId) {
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const docId = resolveDocumentId();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除附件失败");
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
emitAssetsChanged(docId);
|
||||
};
|
||||
|
||||
const triggerOcr = async () => {
|
||||
if (!block.props.assetId) {
|
||||
window.alert("请先上传图片后再执行 OCR");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/media/ocr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId: block.props.assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "触发 OCR 失败");
|
||||
}
|
||||
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
|
||||
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fileUrl) {
|
||||
return (
|
||||
<div className="wolai-media wolai-media--empty">
|
||||
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
选择或上传{typeLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">支持上传、最近及外链插入</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
||||
if (!assetId) {
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const docId = resolveDocumentId();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除附件失败");
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
emitAssetsChanged(docId);
|
||||
};
|
||||
|
||||
const triggerOcr = async () => {
|
||||
if (!block.props.assetId) {
|
||||
window.alert("请先上传图片后再执行 OCR");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/media/ocr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId: block.props.assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "触发 OCR 失败");
|
||||
}
|
||||
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
|
||||
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fileUrl) {
|
||||
return (
|
||||
<div className="wolai-media wolai-media--empty">
|
||||
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
选择或上传{typeLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">支持上传、最近及外链插入</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPreviewContent = () => {
|
||||
if (assetType === "video") {
|
||||
return (
|
||||
@@ -424,17 +424,17 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (assetType === "file") {
|
||||
// 根据文件扩展名确定图标颜色
|
||||
const getIconColor = () => {
|
||||
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
|
||||
if (ext === "pdf") return "text-red-500";
|
||||
if (["doc", "docx"].includes(ext)) return "text-blue-600";
|
||||
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
|
||||
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
if (assetType === "file") {
|
||||
// 根据文件扩展名确定图标颜色
|
||||
const getIconColor = () => {
|
||||
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
|
||||
if (ext === "pdf") return "text-red-500";
|
||||
if (["doc", "docx"].includes(ext)) return "text-blue-600";
|
||||
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
|
||||
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -548,62 +548,62 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const figure = (
|
||||
<figure
|
||||
className={cn(
|
||||
"wolai-media__figure",
|
||||
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
|
||||
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
|
||||
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
|
||||
)}
|
||||
>
|
||||
<div className="wolai-media__preview">{renderPreviewContent()}</div>
|
||||
{shouldShowCaption && (
|
||||
<figcaption>
|
||||
<input
|
||||
ref={captionRef}
|
||||
value={block.props.caption ?? ""}
|
||||
onChange={(event) => handleCaptionChange(event.target.value)}
|
||||
onBlur={() => setCaptionEditing(false)}
|
||||
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
|
||||
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
|
||||
/>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
|
||||
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
key: "replace",
|
||||
label: `替换${typeLabel}`,
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
onClick: handleChoose,
|
||||
},
|
||||
canToggleBorder
|
||||
? {
|
||||
key: "border",
|
||||
label: block.props.hasBorder ? "取消边框" : "显示边框",
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
onClick: toggleBorder,
|
||||
}
|
||||
: null,
|
||||
!shouldShowCaption
|
||||
? {
|
||||
key: "caption",
|
||||
label: "添加说明",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
onClick: enableCaptionEdit,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "link",
|
||||
label: block.props.linkUrl ? "编辑链接" : "添加链接",
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
|
||||
const figure = (
|
||||
<figure
|
||||
className={cn(
|
||||
"wolai-media__figure",
|
||||
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
|
||||
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
|
||||
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
|
||||
)}
|
||||
>
|
||||
<div className="wolai-media__preview">{renderPreviewContent()}</div>
|
||||
{shouldShowCaption && (
|
||||
<figcaption>
|
||||
<input
|
||||
ref={captionRef}
|
||||
value={block.props.caption ?? ""}
|
||||
onChange={(event) => handleCaptionChange(event.target.value)}
|
||||
onBlur={() => setCaptionEditing(false)}
|
||||
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
|
||||
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
|
||||
/>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
|
||||
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
key: "replace",
|
||||
label: `替换${typeLabel}`,
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
onClick: handleChoose,
|
||||
},
|
||||
canToggleBorder
|
||||
? {
|
||||
key: "border",
|
||||
label: block.props.hasBorder ? "取消边框" : "显示边框",
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
onClick: toggleBorder,
|
||||
}
|
||||
: null,
|
||||
!shouldShowCaption
|
||||
? {
|
||||
key: "caption",
|
||||
label: "添加说明",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
onClick: enableCaptionEdit,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "link",
|
||||
label: block.props.linkUrl ? "编辑链接" : "添加链接",
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
!downloadDisabled
|
||||
? {
|
||||
key: "download",
|
||||
@@ -614,16 +614,16 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <Trash className="h-4 w-4" />,
|
||||
onClick: handleDeleteAsset,
|
||||
},
|
||||
].filter((action): action is QuickAction => Boolean(action));
|
||||
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <Trash className="h-4 w-4" />,
|
||||
onClick: handleDeleteAsset,
|
||||
},
|
||||
].filter((action): action is QuickAction => Boolean(action));
|
||||
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
return (
|
||||
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
|
||||
<div
|
||||
@@ -636,11 +636,11 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
void viewOriginal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{figure}
|
||||
</a>
|
||||
>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{figure}
|
||||
</a>
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
@@ -651,38 +651,38 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
key={action.key}
|
||||
type="button"
|
||||
className="wolai-media__quickbutton"
|
||||
onClick={action.onClick}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && (
|
||||
<DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>
|
||||
)}
|
||||
{canToggleBorder && (
|
||||
<DropdownMenuItem onClick={toggleBorder}>
|
||||
{block.props.hasBorder ? "取消边框" : "显示边框"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAlign && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-gray-400">说明对齐</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setAlign("left")}>左对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("center")}>居中</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("right")}>右对齐</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && (
|
||||
<DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>
|
||||
)}
|
||||
{canToggleBorder && (
|
||||
<DropdownMenuItem onClick={toggleBorder}>
|
||||
{block.props.hasBorder ? "取消边框" : "显示边框"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAlign && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-gray-400">说明对齐</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setAlign("left")}>左对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("center")}>居中</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("right")}>右对齐</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -700,14 +700,14 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
>
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
|
||||
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
|
||||
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -717,73 +717,73 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
|
||||
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "media",
|
||||
propSchema: {
|
||||
fileUrl: { default: "", type: "string" },
|
||||
thumbnailUrl: { default: "", type: "string" },
|
||||
caption: { default: "", type: "string" },
|
||||
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
|
||||
hasBorder: { default: true, type: "boolean" },
|
||||
linkUrl: { default: "", type: "string" },
|
||||
assetId: { default: "", type: "string" },
|
||||
assetType: { default: "image", type: "string" },
|
||||
fileName: { default: "", type: "string" },
|
||||
fileSize: { default: 0, type: "number" },
|
||||
mimeType: { default: "", type: "string" },
|
||||
width: { default: 0, type: "number" },
|
||||
ocrStatus: { default: "idle", type: "string" },
|
||||
documentId: { default: "", type: "string" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => <MediaBlockContent {...props} />,
|
||||
},
|
||||
)();
|
||||
const handleCopyLink = async (targetUrl: string | null) => {
|
||||
if (!targetUrl) return;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(targetUrl);
|
||||
window.alert("链接已复制");
|
||||
} else {
|
||||
throw new Error("no clipboard");
|
||||
}
|
||||
} catch {
|
||||
window.prompt("请复制以下链接", targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const ResizeHandle = ({
|
||||
side,
|
||||
onMouseDown,
|
||||
dragging,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
dragging: boolean;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
|
||||
}) => (
|
||||
<span
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
|
||||
/>
|
||||
);
|
||||
|
||||
const clampWidth = (value: number) => {
|
||||
const min = 240;
|
||||
const max = 960;
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
)}
|
||||
</div>
|
||||
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "media",
|
||||
propSchema: {
|
||||
fileUrl: { default: "", type: "string" },
|
||||
thumbnailUrl: { default: "", type: "string" },
|
||||
caption: { default: "", type: "string" },
|
||||
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
|
||||
hasBorder: { default: true, type: "boolean" },
|
||||
linkUrl: { default: "", type: "string" },
|
||||
assetId: { default: "", type: "string" },
|
||||
assetType: { default: "image", type: "string" },
|
||||
fileName: { default: "", type: "string" },
|
||||
fileSize: { default: 0, type: "number" },
|
||||
mimeType: { default: "", type: "string" },
|
||||
width: { default: 0, type: "number" },
|
||||
ocrStatus: { default: "idle", type: "string" },
|
||||
documentId: { default: "", type: "string" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => <MediaBlockContent {...props} />,
|
||||
},
|
||||
)();
|
||||
const handleCopyLink = async (targetUrl: string | null) => {
|
||||
if (!targetUrl) return;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(targetUrl);
|
||||
window.alert("链接已复制");
|
||||
} else {
|
||||
throw new Error("no clipboard");
|
||||
}
|
||||
} catch {
|
||||
window.prompt("请复制以下链接", targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const ResizeHandle = ({
|
||||
side,
|
||||
onMouseDown,
|
||||
dragging,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
dragging: boolean;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
|
||||
}) => (
|
||||
<span
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
|
||||
/>
|
||||
);
|
||||
|
||||
const clampWidth = (value: number) => {
|
||||
const min = 240;
|
||||
const max = 960;
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
|
||||
@@ -18,6 +18,19 @@ type AgentAssetItem = {
|
||||
};
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
const extractCodexMode = (text: string): CodexMode => {
|
||||
const s = String(text ?? "");
|
||||
const m = s.match(/^\s*#(chat|test|dev)\b/i);
|
||||
if (!m) return "chat";
|
||||
const mode = String(m[1] ?? "").toLowerCase();
|
||||
if (mode === "dev" || mode === "test" || mode === "chat") return mode;
|
||||
return "chat";
|
||||
};
|
||||
|
||||
type MindmapInstanceLike = {
|
||||
setData?: (data: unknown) => void;
|
||||
@@ -35,6 +48,7 @@ const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type ChatSession = {
|
||||
@@ -45,6 +59,8 @@ type ChatSession = {
|
||||
messages: AgentMessage[];
|
||||
toolLogs: ToolLog[];
|
||||
attachments: AgentAssetItem[];
|
||||
codexSessionId?: string | null;
|
||||
codexMode?: CodexMode | null;
|
||||
};
|
||||
|
||||
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
|
||||
@@ -169,7 +185,7 @@ export function MindmapAiAgentPanel({
|
||||
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
@@ -198,7 +214,7 @@ export function MindmapAiAgentPanel({
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
||||
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
|
||||
if (p === "local" || p === "online") setAiProvider(p);
|
||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
const parsed = Number(stepsRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||
@@ -240,6 +256,8 @@ export function MindmapAiAgentPanel({
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
attachments: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
};
|
||||
setSessions([session]);
|
||||
setActiveSessionId(id);
|
||||
@@ -267,7 +285,10 @@ export function MindmapAiAgentPanel({
|
||||
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
|
||||
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
|
||||
const attachments = Array.isArray((x as any)?.attachments) ? (x as any).attachments : [];
|
||||
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments } as ChatSession;
|
||||
const codexSessionId = String((x as any)?.codexSessionId ?? "").trim() || null;
|
||||
const codexModeRaw = String((x as any)?.codexMode ?? "").trim();
|
||||
const codexMode = codexModeRaw === "chat" || codexModeRaw === "test" || codexModeRaw === "dev" ? (codexModeRaw as CodexMode) : null;
|
||||
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments, codexSessionId, codexMode } as ChatSession;
|
||||
})
|
||||
.filter((s) => s.id),
|
||||
);
|
||||
@@ -303,6 +324,8 @@ export function MindmapAiAgentPanel({
|
||||
messages,
|
||||
toolLogs,
|
||||
attachments,
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
@@ -625,6 +648,8 @@ export function MindmapAiAgentPanel({
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
attachments: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
};
|
||||
setSessions((prev) => normalizeSessions([next, ...prev]));
|
||||
setActiveSessionId(id);
|
||||
@@ -659,7 +684,16 @@ export function MindmapAiAgentPanel({
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId
|
||||
? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], attachments: [], updatedAt: Date.now(), title: s.title || "当前会话" }
|
||||
? {
|
||||
...s,
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
attachments: [],
|
||||
updatedAt: Date.now(),
|
||||
title: s.title || "当前会话",
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
),
|
||||
@@ -719,12 +753,18 @@ export function MindmapAiAgentPanel({
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
const hasExplicitCodexMode = /^\s*#(chat|test|dev)\b/i.test(content);
|
||||
const intendedCodexMode: CodexMode =
|
||||
aiProvider === "codex" ? (hasExplicitCodexMode ? extractCodexMode(content) : "chat") : "chat";
|
||||
setDebug("");
|
||||
setToolLogs([]);
|
||||
if (activeSessionId && currentSessionTitle === "新会话") {
|
||||
@@ -737,9 +777,10 @@ export function MindmapAiAgentPanel({
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
// 不把面板的“欢迎语”当作对话历史发送给服务端,避免影响任务执行
|
||||
@@ -747,6 +788,21 @@ export function MindmapAiAgentPanel({
|
||||
(m, idx) => !(idx === 0 && m.role === "assistant" && /思维导图 AI Agent/.test(m.content)),
|
||||
);
|
||||
|
||||
const payloadMessagesForRequest = payloadMessages;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
if (aiProvider === "codex" && activeSessionId) {
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -755,7 +811,7 @@ export function MindmapAiAgentPanel({
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "mindmap",
|
||||
messages: payloadMessages.slice(-24),
|
||||
messages: payloadMessagesForRequest.slice(-24),
|
||||
toolChoice: toolAuto
|
||||
? {
|
||||
mode: "auto",
|
||||
@@ -775,7 +831,14 @@ export function MindmapAiAgentPanel({
|
||||
fileUrl: a.fileUrl,
|
||||
mimeType: a.mimeType ?? null,
|
||||
})),
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -788,6 +851,26 @@ export function MindmapAiAgentPanel({
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
rawEvents.push({ event, dataText });
|
||||
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
const sessionId = String(obj.sessionId ?? "").trim();
|
||||
if (sessionId && activeSessionId) {
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexSessionId: sessionId, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
@@ -870,6 +953,7 @@ export function MindmapAiAgentPanel({
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller?.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const msg =
|
||||
@@ -884,6 +968,7 @@ export function MindmapAiAgentPanel({
|
||||
|
||||
setDebug(JSON.stringify(rawEvents.slice(-120), null, 2));
|
||||
} catch (e) {
|
||||
if (controller?.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
@@ -977,6 +1062,13 @@ export function MindmapAiAgentPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<details key={idx} className="rounded border p-2">
|
||||
@@ -1098,9 +1190,14 @@ export function MindmapAiAgentPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop} title="停止本次执行">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!loading}
|
||||
onClick={stop}
|
||||
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC)" : "停止本次执行"}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
停止
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
<Button disabled={!canSend} onClick={() => void send()}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
@@ -1222,18 +1319,30 @@ export function MindmapAiAgentPanel({
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
<label className="ml-2 text-xs text-muted-foreground">模型</label>
|
||||
{aiProvider === "online" ? (
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
使用本地 Codex 配置。消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiModel}
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -1243,6 +1352,16 @@ export function MindmapAiAgentPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
@@ -1251,6 +1370,7 @@ export function MindmapAiAgentPanel({
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="mindmap-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1258,7 +1378,14 @@ export function MindmapAiAgentPanel({
|
||||
<div className="text-xs text-muted-foreground">
|
||||
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`。
|
||||
</div>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ollama 默认使用 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>(可用 <code className="rounded bg-muted px-1 py-0.5">OLLAMA_BASE_URL</code> 覆盖)。
|
||||
</div>
|
||||
) : null}
|
||||
<datalist id="mindmap-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
@@ -1438,13 +1565,37 @@ export function MindmapAiAgentPanel({
|
||||
/>
|
||||
本地
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
checked={aiProvider === "ollama"}
|
||||
onChange={() => setAiProvider("ollama")}
|
||||
/>
|
||||
Ollama
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
checked={aiProvider === "codex"}
|
||||
onChange={() => setAiProvider("codex")}
|
||||
/>
|
||||
Codex
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">模型:</div>
|
||||
{aiProvider === "online" ? (
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-[11px] text-gray-500">
|
||||
消息开头加 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
@@ -1453,6 +1604,15 @@ export function MindmapAiAgentPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
@@ -1460,8 +1620,12 @@ export function MindmapAiAgentPanel({
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
list="mindmap-local-model-suggestions-bottom"
|
||||
/>
|
||||
)}
|
||||
<datalist id="mindmap-local-model-suggestions-bottom">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">最大步数:</div>
|
||||
@@ -1572,10 +1736,10 @@ export function MindmapAiAgentPanel({
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={!loading}
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
title="停止本次执行"
|
||||
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC)" : "停止本次执行"}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
停止
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1602,6 +1766,13 @@ export function MindmapAiAgentPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,171 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
|
||||
// 菜单项配置
|
||||
interface ContextMenuItem {
|
||||
key?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
show?: (node: MindMapNode | null) => boolean;
|
||||
}
|
||||
|
||||
// 节点右键菜单配置
|
||||
const NODE_MENU_ITEMS: ContextMenuItem[] = [
|
||||
{
|
||||
key: "INSERT_NODE",
|
||||
label: "插入同级节点",
|
||||
shortcut: "Enter",
|
||||
},
|
||||
{
|
||||
key: "INSERT_CHILD_NODE",
|
||||
label: "插入子级节点",
|
||||
shortcut: "Tab",
|
||||
},
|
||||
{
|
||||
key: "INSERT_PARENT_NODE",
|
||||
label: "插入父节点",
|
||||
shortcut: "Shift + Tab",
|
||||
},
|
||||
{
|
||||
key: "ADD_GENERALIZATION",
|
||||
label: "插入概要",
|
||||
shortcut: "Ctrl + G",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "UP_NODE",
|
||||
label: "上移节点",
|
||||
shortcut: "Ctrl + ↑",
|
||||
},
|
||||
{
|
||||
key: "DOWN_NODE",
|
||||
label: "下移节点",
|
||||
shortcut: "Ctrl + ↓",
|
||||
},
|
||||
{
|
||||
key: "UNEXPAND_ALL",
|
||||
label: "收起所有下级节点",
|
||||
},
|
||||
{
|
||||
key: "EXPAND_ALL",
|
||||
label: "展开所有下级节点",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_NODE",
|
||||
label: "删除节点",
|
||||
shortcut: "Delete",
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CURRENT_NODE",
|
||||
label: "仅删除当前节点",
|
||||
shortcut: "Shift + Backspace",
|
||||
danger: true,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "COPY_NODE",
|
||||
label: "复制节点",
|
||||
shortcut: "Ctrl + C",
|
||||
},
|
||||
{
|
||||
key: "CUT_NODE",
|
||||
label: "剪切节点",
|
||||
shortcut: "Ctrl + X",
|
||||
},
|
||||
{
|
||||
key: "PASTE_NODE",
|
||||
label: "粘贴节点",
|
||||
shortcut: "Ctrl + V",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_HYPERLINK",
|
||||
label: "移除超链接",
|
||||
show: (node) => !!node?.getData?.("hyperlink"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_NOTE",
|
||||
label: "移除备注",
|
||||
show: (node) => !!node?.getData?.("note"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CUSTOM_STYLES",
|
||||
label: "一键去除自定义样式",
|
||||
},
|
||||
{
|
||||
key: "EXPORT_CUR_NODE_TO_PNG",
|
||||
label: "导出该节点为图片",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "AI_CONTINUE",
|
||||
label: "AI续写",
|
||||
},
|
||||
];
|
||||
|
||||
interface MindmapContextMenuProps {
|
||||
mindmap: any | null;
|
||||
}
|
||||
|
||||
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
|
||||
|
||||
// 判断是否禁用某个菜单项
|
||||
const isItemDisabled = useCallback(
|
||||
(item: ContextMenuItem): boolean => {
|
||||
if (!targetNode) return false;
|
||||
|
||||
const isRoot = (targetNode as any).isRoot === true;
|
||||
const isGeneralization = (targetNode as any).isGeneralization === true;
|
||||
|
||||
switch (item.key) {
|
||||
case "INSERT_NODE":
|
||||
case "INSERT_PARENT_NODE":
|
||||
case "ADD_GENERALIZATION":
|
||||
return isRoot || isGeneralization;
|
||||
|
||||
case "INSERT_CHILD_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "COPY_NODE":
|
||||
case "CUT_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "UP_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
|
||||
}
|
||||
|
||||
case "DOWN_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
const children = parent.children;
|
||||
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[targetNode]
|
||||
);
|
||||
|
||||
// 过滤显示的菜单项
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
|
||||
// 菜单项配置
|
||||
interface ContextMenuItem {
|
||||
key?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
show?: (node: MindMapNode | null) => boolean;
|
||||
}
|
||||
|
||||
// 节点右键菜单配置
|
||||
const NODE_MENU_ITEMS: ContextMenuItem[] = [
|
||||
{
|
||||
key: "INSERT_NODE",
|
||||
label: "插入同级节点",
|
||||
shortcut: "Enter",
|
||||
},
|
||||
{
|
||||
key: "INSERT_CHILD_NODE",
|
||||
label: "插入子级节点",
|
||||
shortcut: "Tab",
|
||||
},
|
||||
{
|
||||
key: "INSERT_PARENT_NODE",
|
||||
label: "插入父节点",
|
||||
shortcut: "Shift + Tab",
|
||||
},
|
||||
{
|
||||
key: "ADD_GENERALIZATION",
|
||||
label: "插入概要",
|
||||
shortcut: "Ctrl + G",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "UP_NODE",
|
||||
label: "上移节点",
|
||||
shortcut: "Ctrl + ↑",
|
||||
},
|
||||
{
|
||||
key: "DOWN_NODE",
|
||||
label: "下移节点",
|
||||
shortcut: "Ctrl + ↓",
|
||||
},
|
||||
{
|
||||
key: "UNEXPAND_ALL",
|
||||
label: "收起所有下级节点",
|
||||
},
|
||||
{
|
||||
key: "EXPAND_ALL",
|
||||
label: "展开所有下级节点",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_NODE",
|
||||
label: "删除节点",
|
||||
shortcut: "Delete",
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CURRENT_NODE",
|
||||
label: "仅删除当前节点",
|
||||
shortcut: "Shift + Backspace",
|
||||
danger: true,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "COPY_NODE",
|
||||
label: "复制节点",
|
||||
shortcut: "Ctrl + C",
|
||||
},
|
||||
{
|
||||
key: "CUT_NODE",
|
||||
label: "剪切节点",
|
||||
shortcut: "Ctrl + X",
|
||||
},
|
||||
{
|
||||
key: "PASTE_NODE",
|
||||
label: "粘贴节点",
|
||||
shortcut: "Ctrl + V",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_HYPERLINK",
|
||||
label: "移除超链接",
|
||||
show: (node) => !!node?.getData?.("hyperlink"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_NOTE",
|
||||
label: "移除备注",
|
||||
show: (node) => !!node?.getData?.("note"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CUSTOM_STYLES",
|
||||
label: "一键去除自定义样式",
|
||||
},
|
||||
{
|
||||
key: "EXPORT_CUR_NODE_TO_PNG",
|
||||
label: "导出该节点为图片",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "AI_CONTINUE",
|
||||
label: "AI续写",
|
||||
},
|
||||
];
|
||||
|
||||
interface MindmapContextMenuProps {
|
||||
mindmap: any | null;
|
||||
}
|
||||
|
||||
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
|
||||
|
||||
// 判断是否禁用某个菜单项
|
||||
const isItemDisabled = useCallback(
|
||||
(item: ContextMenuItem): boolean => {
|
||||
if (!targetNode) return false;
|
||||
|
||||
const isRoot = (targetNode as any).isRoot === true;
|
||||
const isGeneralization = (targetNode as any).isGeneralization === true;
|
||||
|
||||
switch (item.key) {
|
||||
case "INSERT_NODE":
|
||||
case "INSERT_PARENT_NODE":
|
||||
case "ADD_GENERALIZATION":
|
||||
return isRoot || isGeneralization;
|
||||
|
||||
case "INSERT_CHILD_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "COPY_NODE":
|
||||
case "CUT_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "UP_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
|
||||
}
|
||||
|
||||
case "DOWN_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
const children = parent.children;
|
||||
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[targetNode]
|
||||
);
|
||||
|
||||
// 过滤显示的菜单项
|
||||
const getVisibleItems = useCallback((): ContextMenuItem[] => {
|
||||
return NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
@@ -186,57 +186,57 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const executeCommand = useCallback(
|
||||
(key: string) => {
|
||||
if (!mindmap || !targetNode) return;
|
||||
|
||||
switch (key) {
|
||||
case "COPY_NODE":
|
||||
mindmap.renderer?.copy?.();
|
||||
break;
|
||||
case "CUT_NODE":
|
||||
mindmap.renderer?.cut?.();
|
||||
break;
|
||||
case "PASTE_NODE":
|
||||
mindmap.renderer?.paste?.();
|
||||
break;
|
||||
case "REMOVE_HYPERLINK":
|
||||
if (typeof (targetNode as any).setHyperlink === "function") {
|
||||
(targetNode as any).setHyperlink("", "");
|
||||
}
|
||||
break;
|
||||
case "REMOVE_NOTE":
|
||||
if (typeof (targetNode as any).setNote === "function") {
|
||||
(targetNode as any).setNote("");
|
||||
}
|
||||
break;
|
||||
case "EXPORT_CUR_NODE_TO_PNG": {
|
||||
const getTextFromHtml = (html: string) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
const nodeText = targetNode.getData?.("text") || "";
|
||||
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
|
||||
break;
|
||||
}
|
||||
case "UNEXPAND_ALL":
|
||||
mindmap.execCommand?.(key, false, targetNode);
|
||||
break;
|
||||
case "EXPAND_ALL":
|
||||
mindmap.execCommand?.(key, (targetNode as any).uid || "");
|
||||
break;
|
||||
case "AI_CONTINUE":
|
||||
// 触发 AI 续写
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("mindmap-ai-continue", {
|
||||
detail: { node: targetNode },
|
||||
})
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
mindmap.execCommand?.(key);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "COPY_NODE":
|
||||
mindmap.renderer?.copy?.();
|
||||
break;
|
||||
case "CUT_NODE":
|
||||
mindmap.renderer?.cut?.();
|
||||
break;
|
||||
case "PASTE_NODE":
|
||||
mindmap.renderer?.paste?.();
|
||||
break;
|
||||
case "REMOVE_HYPERLINK":
|
||||
if (typeof (targetNode as any).setHyperlink === "function") {
|
||||
(targetNode as any).setHyperlink("", "");
|
||||
}
|
||||
break;
|
||||
case "REMOVE_NOTE":
|
||||
if (typeof (targetNode as any).setNote === "function") {
|
||||
(targetNode as any).setNote("");
|
||||
}
|
||||
break;
|
||||
case "EXPORT_CUR_NODE_TO_PNG": {
|
||||
const getTextFromHtml = (html: string) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
const nodeText = targetNode.getData?.("text") || "";
|
||||
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
|
||||
break;
|
||||
}
|
||||
case "UNEXPAND_ALL":
|
||||
mindmap.execCommand?.(key, false, targetNode);
|
||||
break;
|
||||
case "EXPAND_ALL":
|
||||
mindmap.execCommand?.(key, (targetNode as any).uid || "");
|
||||
break;
|
||||
case "AI_CONTINUE":
|
||||
// 触发 AI 续写
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("mindmap-ai-continue", {
|
||||
detail: { node: targetNode },
|
||||
})
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
mindmap.execCommand?.(key);
|
||||
break;
|
||||
}
|
||||
|
||||
hide();
|
||||
},
|
||||
@@ -246,275 +246,275 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
// 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
|
||||
const show = useCallback((x: number, y: number, node: MindMapNode) => {
|
||||
setTargetNode(node);
|
||||
|
||||
// 计算可见菜单项数量
|
||||
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
if (item.show && !item.show(node)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
|
||||
const itemHeight = 40;
|
||||
const dividerHeight = 10;
|
||||
const estimatedHeight = visibleItems.reduce((acc, item) => {
|
||||
return acc + (item.divider ? dividerHeight : itemHeight);
|
||||
}, 0) + 16; // +16 是上下 padding
|
||||
|
||||
const menuWidth = 250;
|
||||
const menuHeight = estimatedHeight + 20; // 额外的安全边距
|
||||
|
||||
// 初始位置:鼠标右侧下方
|
||||
let posX = x + 10;
|
||||
let posY = y + 10;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (posX + menuWidth > window.innerWidth) {
|
||||
posX = x - menuWidth - 20;
|
||||
}
|
||||
|
||||
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
|
||||
if (posY + menuHeight > window.innerHeight) {
|
||||
posY = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
// 确保不会超出左边界
|
||||
if (posX < 10) {
|
||||
posX = 10;
|
||||
}
|
||||
|
||||
// 确保菜单顶部不会超出窗口
|
||||
if (posY < 10) {
|
||||
posY = 10;
|
||||
}
|
||||
|
||||
setPosition({ x: posX, y: posY });
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// 监听右键事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const handleContextMenu = (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
|
||||
// 检查是否点击在节点上
|
||||
const target = mouseEvent.target as HTMLElement | SVGElement;
|
||||
|
||||
// simple-mind-map 的节点结构
|
||||
// 尝试多种选择器
|
||||
const nodeSelectors = [
|
||||
".smm-node", // 主节点容器
|
||||
".smm-node-light", // 亮色主题节点
|
||||
"g[role='node']", // 带 role 属性的 g 元素
|
||||
"g.smooth-smooth", // 特定样式的 g 元素
|
||||
];
|
||||
|
||||
let clickedNodeEl: Element | null = null;
|
||||
for (const selector of nodeSelectors) {
|
||||
clickedNodeEl = target.closest?.(selector) || null;
|
||||
if (clickedNodeEl) break;
|
||||
}
|
||||
|
||||
// 如果没找到节点选择器,尝试查找包含 text 的元素
|
||||
if (!clickedNodeEl) {
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
// 检查父元素是否包含文本内容
|
||||
const textContainer = parent.querySelector("text");
|
||||
if (textContainer) {
|
||||
clickedNodeEl = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clickedNodeEl) return;
|
||||
|
||||
// 阻止默认右键菜单
|
||||
mouseEvent.preventDefault();
|
||||
mouseEvent.stopPropagation();
|
||||
|
||||
// 获取当前激活的节点作为右键点击的节点
|
||||
const renderer = mindmap.renderer;
|
||||
if (!renderer) return;
|
||||
|
||||
// 使用 activeNodeList 或 lastActiveNodeList
|
||||
const activeList = renderer.activeNodeList ?? [];
|
||||
const lastActiveList = renderer.lastActiveNodeList ?? [];
|
||||
|
||||
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
|
||||
|
||||
if (node) {
|
||||
show(mouseEvent.clientX, mouseEvent.clientY, node);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟查找容器,确保 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
};
|
||||
}, [mindmap, show]);
|
||||
|
||||
// 监听画布点击事件隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const hideMenu = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
mindmap.on?.("draw_click", hideMenu);
|
||||
mindmap.on?.("node_click", hideMenu);
|
||||
mindmap.on?.("expand_btn_click", hideMenu);
|
||||
|
||||
return () => {
|
||||
mindmap.off?.("draw_click", hideMenu);
|
||||
mindmap.off?.("node_click", hideMenu);
|
||||
mindmap.off?.("expand_btn_click", hideMenu);
|
||||
};
|
||||
}, [mindmap, hide]);
|
||||
|
||||
// 点击外部隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visible, hide]);
|
||||
|
||||
// 渲染菜单
|
||||
const renderMenu = () => {
|
||||
const visibleItems = getVisibleItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mindmap-contextmenu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
maxWidth: "280px",
|
||||
background: "#ffffff",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
||||
padding: "8px 0",
|
||||
fontSize: "14px",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<div
|
||||
key={`divider-${index}`}
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "#e5e7eb",
|
||||
margin: "4px 12px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isItemDisabled(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key || `item-${index}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (!item.key) return;
|
||||
executeCommand(item.key);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
|
||||
background: "transparent",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.background = "#f3f4f6";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#9ca3af",
|
||||
marginLeft: "24px",
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof document === "undefined" || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(renderMenu(), document.body);
|
||||
}
|
||||
|
||||
// 计算可见菜单项数量
|
||||
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
if (item.show && !item.show(node)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
|
||||
const itemHeight = 40;
|
||||
const dividerHeight = 10;
|
||||
const estimatedHeight = visibleItems.reduce((acc, item) => {
|
||||
return acc + (item.divider ? dividerHeight : itemHeight);
|
||||
}, 0) + 16; // +16 是上下 padding
|
||||
|
||||
const menuWidth = 250;
|
||||
const menuHeight = estimatedHeight + 20; // 额外的安全边距
|
||||
|
||||
// 初始位置:鼠标右侧下方
|
||||
let posX = x + 10;
|
||||
let posY = y + 10;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (posX + menuWidth > window.innerWidth) {
|
||||
posX = x - menuWidth - 20;
|
||||
}
|
||||
|
||||
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
|
||||
if (posY + menuHeight > window.innerHeight) {
|
||||
posY = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
// 确保不会超出左边界
|
||||
if (posX < 10) {
|
||||
posX = 10;
|
||||
}
|
||||
|
||||
// 确保菜单顶部不会超出窗口
|
||||
if (posY < 10) {
|
||||
posY = 10;
|
||||
}
|
||||
|
||||
setPosition({ x: posX, y: posY });
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// 监听右键事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const handleContextMenu = (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
|
||||
// 检查是否点击在节点上
|
||||
const target = mouseEvent.target as HTMLElement | SVGElement;
|
||||
|
||||
// simple-mind-map 的节点结构
|
||||
// 尝试多种选择器
|
||||
const nodeSelectors = [
|
||||
".smm-node", // 主节点容器
|
||||
".smm-node-light", // 亮色主题节点
|
||||
"g[role='node']", // 带 role 属性的 g 元素
|
||||
"g.smooth-smooth", // 特定样式的 g 元素
|
||||
];
|
||||
|
||||
let clickedNodeEl: Element | null = null;
|
||||
for (const selector of nodeSelectors) {
|
||||
clickedNodeEl = target.closest?.(selector) || null;
|
||||
if (clickedNodeEl) break;
|
||||
}
|
||||
|
||||
// 如果没找到节点选择器,尝试查找包含 text 的元素
|
||||
if (!clickedNodeEl) {
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
// 检查父元素是否包含文本内容
|
||||
const textContainer = parent.querySelector("text");
|
||||
if (textContainer) {
|
||||
clickedNodeEl = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clickedNodeEl) return;
|
||||
|
||||
// 阻止默认右键菜单
|
||||
mouseEvent.preventDefault();
|
||||
mouseEvent.stopPropagation();
|
||||
|
||||
// 获取当前激活的节点作为右键点击的节点
|
||||
const renderer = mindmap.renderer;
|
||||
if (!renderer) return;
|
||||
|
||||
// 使用 activeNodeList 或 lastActiveNodeList
|
||||
const activeList = renderer.activeNodeList ?? [];
|
||||
const lastActiveList = renderer.lastActiveNodeList ?? [];
|
||||
|
||||
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
|
||||
|
||||
if (node) {
|
||||
show(mouseEvent.clientX, mouseEvent.clientY, node);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟查找容器,确保 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
};
|
||||
}, [mindmap, show]);
|
||||
|
||||
// 监听画布点击事件隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const hideMenu = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
mindmap.on?.("draw_click", hideMenu);
|
||||
mindmap.on?.("node_click", hideMenu);
|
||||
mindmap.on?.("expand_btn_click", hideMenu);
|
||||
|
||||
return () => {
|
||||
mindmap.off?.("draw_click", hideMenu);
|
||||
mindmap.off?.("node_click", hideMenu);
|
||||
mindmap.off?.("expand_btn_click", hideMenu);
|
||||
};
|
||||
}, [mindmap, hide]);
|
||||
|
||||
// 点击外部隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visible, hide]);
|
||||
|
||||
// 渲染菜单
|
||||
const renderMenu = () => {
|
||||
const visibleItems = getVisibleItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mindmap-contextmenu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
maxWidth: "280px",
|
||||
background: "#ffffff",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
||||
padding: "8px 0",
|
||||
fontSize: "14px",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<div
|
||||
key={`divider-${index}`}
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "#e5e7eb",
|
||||
margin: "4px 12px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isItemDisabled(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key || `item-${index}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (!item.key) return;
|
||||
executeCommand(item.key);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
|
||||
background: "transparent",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.background = "#f3f4f6";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#9ca3af",
|
||||
marginLeft: "24px",
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof document === "undefined" || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(renderMenu(), document.body);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,211 +1,211 @@
|
||||
import React from "react";
|
||||
import {
|
||||
fileToolbarMeta,
|
||||
fileToolbarOrder,
|
||||
nodeToolbarMeta,
|
||||
nodeToolbarOrder,
|
||||
type FileToolbarKey,
|
||||
type NodeToolbarKey,
|
||||
} from "./mindmapToolbarConfig";
|
||||
|
||||
const stopEditorEvent = (e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
type ToolbarProps = {
|
||||
canBack: boolean;
|
||||
canForward: boolean;
|
||||
painterMode: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPainter: () => void;
|
||||
onSibling: () => void;
|
||||
onChild: () => void;
|
||||
onDelete: () => void;
|
||||
onImage: () => void;
|
||||
onIcon: () => void;
|
||||
onLink: () => void;
|
||||
onNote: () => void;
|
||||
onTag: () => void;
|
||||
onSummary: () => void;
|
||||
onAssociativeLine: () => void;
|
||||
onFormula: () => void;
|
||||
onAttachment: () => void;
|
||||
onOuterFrame: () => void;
|
||||
onAnnotation?: () => void;
|
||||
onAi: () => void;
|
||||
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onNew: () => void;
|
||||
onOpenDirectory: () => void;
|
||||
onSaveAs: () => void;
|
||||
onDeleteMindmap: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportSvg: () => void;
|
||||
onExportPdf: () => void;
|
||||
onExportMd: () => void;
|
||||
onExportTxt: () => void;
|
||||
onExportXmind: () => void;
|
||||
import React from "react";
|
||||
import {
|
||||
fileToolbarMeta,
|
||||
fileToolbarOrder,
|
||||
nodeToolbarMeta,
|
||||
nodeToolbarOrder,
|
||||
type FileToolbarKey,
|
||||
type NodeToolbarKey,
|
||||
} from "./mindmapToolbarConfig";
|
||||
|
||||
const stopEditorEvent = (e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
type ToolbarProps = {
|
||||
canBack: boolean;
|
||||
canForward: boolean;
|
||||
painterMode: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPainter: () => void;
|
||||
onSibling: () => void;
|
||||
onChild: () => void;
|
||||
onDelete: () => void;
|
||||
onImage: () => void;
|
||||
onIcon: () => void;
|
||||
onLink: () => void;
|
||||
onNote: () => void;
|
||||
onTag: () => void;
|
||||
onSummary: () => void;
|
||||
onAssociativeLine: () => void;
|
||||
onFormula: () => void;
|
||||
onAttachment: () => void;
|
||||
onOuterFrame: () => void;
|
||||
onAnnotation?: () => void;
|
||||
onAi: () => void;
|
||||
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onNew: () => void;
|
||||
onOpenDirectory: () => void;
|
||||
onSaveAs: () => void;
|
||||
onDeleteMindmap: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportSvg: () => void;
|
||||
onExportPdf: () => void;
|
||||
onExportMd: () => void;
|
||||
onExportTxt: () => void;
|
||||
onExportXmind: () => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
const ToolbarButton = ({
|
||||
iconClass,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
className = "",
|
||||
}: {
|
||||
iconClass: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
|
||||
} ${className}`}
|
||||
title={label}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
|
||||
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const MindmapToolbar = ({
|
||||
canBack,
|
||||
canForward,
|
||||
painterMode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPainter,
|
||||
onSibling,
|
||||
onChild,
|
||||
onDelete,
|
||||
onImage,
|
||||
onIcon,
|
||||
onLink,
|
||||
onNote,
|
||||
onTag,
|
||||
onSummary,
|
||||
onAssociativeLine,
|
||||
onFormula,
|
||||
onAttachment,
|
||||
onOuterFrame,
|
||||
onAnnotation,
|
||||
onAi,
|
||||
onImport,
|
||||
onNew,
|
||||
onOpenDirectory,
|
||||
onSaveAs,
|
||||
onDeleteMindmap,
|
||||
onExportJson,
|
||||
onExportPng,
|
||||
onExportSvg,
|
||||
onExportPdf,
|
||||
onExportMd,
|
||||
onExportTxt,
|
||||
onExportXmind,
|
||||
fileInputRef,
|
||||
}: ToolbarProps) => {
|
||||
const [showExport, setShowExport] = React.useState(false);
|
||||
|
||||
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
|
||||
back: onUndo,
|
||||
forward: onRedo,
|
||||
painter: onPainter,
|
||||
siblingNode: onSibling,
|
||||
childNode: onChild,
|
||||
deleteNode: onDelete,
|
||||
image: onImage,
|
||||
icon: onIcon,
|
||||
link: onLink,
|
||||
note: onNote,
|
||||
tag: onTag,
|
||||
summary: onSummary,
|
||||
associativeLine: onAssociativeLine,
|
||||
formula: onFormula,
|
||||
attachment: onAttachment,
|
||||
outerFrame: onOuterFrame,
|
||||
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
|
||||
ai: onAi,
|
||||
};
|
||||
|
||||
const fileHandlers: Record<FileToolbarKey, () => void> = {
|
||||
directory: onOpenDirectory,
|
||||
newFile: onNew,
|
||||
openFile: () => fileInputRef.current?.click(),
|
||||
import: () => fileInputRef.current?.click(),
|
||||
saveAs: onSaveAs,
|
||||
deleteFile: onDeleteMindmap,
|
||||
exportMenu: () => setShowExport((v) => !v),
|
||||
};
|
||||
|
||||
const getNodeDisabled = (key: NodeToolbarKey) => {
|
||||
if (key === "back") return !canBack;
|
||||
if (key === "forward") return !canForward;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
contentEditable={false}
|
||||
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Left Section: Edit & Node Operations */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{nodeToolbarOrder.map((key) => {
|
||||
const meta = nodeToolbarMeta[key];
|
||||
const onClick = nodeHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
disabled={getNodeDisabled(key)}
|
||||
active={key === "painter" ? painterMode : false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Section: File & Export Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{fileToolbarOrder.map((key) => {
|
||||
const meta = fileToolbarMeta[key];
|
||||
const onClick = fileHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
const ToolbarButton = ({
|
||||
iconClass,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
className = "",
|
||||
}: {
|
||||
iconClass: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
|
||||
} ${className}`}
|
||||
title={label}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
|
||||
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const MindmapToolbar = ({
|
||||
canBack,
|
||||
canForward,
|
||||
painterMode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPainter,
|
||||
onSibling,
|
||||
onChild,
|
||||
onDelete,
|
||||
onImage,
|
||||
onIcon,
|
||||
onLink,
|
||||
onNote,
|
||||
onTag,
|
||||
onSummary,
|
||||
onAssociativeLine,
|
||||
onFormula,
|
||||
onAttachment,
|
||||
onOuterFrame,
|
||||
onAnnotation,
|
||||
onAi,
|
||||
onImport,
|
||||
onNew,
|
||||
onOpenDirectory,
|
||||
onSaveAs,
|
||||
onDeleteMindmap,
|
||||
onExportJson,
|
||||
onExportPng,
|
||||
onExportSvg,
|
||||
onExportPdf,
|
||||
onExportMd,
|
||||
onExportTxt,
|
||||
onExportXmind,
|
||||
fileInputRef,
|
||||
}: ToolbarProps) => {
|
||||
const [showExport, setShowExport] = React.useState(false);
|
||||
|
||||
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
|
||||
back: onUndo,
|
||||
forward: onRedo,
|
||||
painter: onPainter,
|
||||
siblingNode: onSibling,
|
||||
childNode: onChild,
|
||||
deleteNode: onDelete,
|
||||
image: onImage,
|
||||
icon: onIcon,
|
||||
link: onLink,
|
||||
note: onNote,
|
||||
tag: onTag,
|
||||
summary: onSummary,
|
||||
associativeLine: onAssociativeLine,
|
||||
formula: onFormula,
|
||||
attachment: onAttachment,
|
||||
outerFrame: onOuterFrame,
|
||||
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
|
||||
ai: onAi,
|
||||
};
|
||||
|
||||
const fileHandlers: Record<FileToolbarKey, () => void> = {
|
||||
directory: onOpenDirectory,
|
||||
newFile: onNew,
|
||||
openFile: () => fileInputRef.current?.click(),
|
||||
import: () => fileInputRef.current?.click(),
|
||||
saveAs: onSaveAs,
|
||||
deleteFile: onDeleteMindmap,
|
||||
exportMenu: () => setShowExport((v) => !v),
|
||||
};
|
||||
|
||||
const getNodeDisabled = (key: NodeToolbarKey) => {
|
||||
if (key === "back") return !canBack;
|
||||
if (key === "forward") return !canForward;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
contentEditable={false}
|
||||
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Left Section: Edit & Node Operations */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{nodeToolbarOrder.map((key) => {
|
||||
const meta = nodeToolbarMeta[key];
|
||||
const onClick = nodeHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
disabled={getNodeDisabled(key)}
|
||||
active={key === "painter" ? painterMode : false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Section: File & Export Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{fileToolbarOrder.map((key) => {
|
||||
const meta = fileToolbarMeta[key];
|
||||
const onClick = fileHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -213,37 +213,37 @@ export const MindmapToolbar = ({
|
||||
className="hidden"
|
||||
onChange={onImport}
|
||||
/>
|
||||
{showExport ? (
|
||||
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
{[
|
||||
{ label: "JSON", onClick: onExportJson },
|
||||
{ label: "PNG", onClick: onExportPng },
|
||||
{ label: "SVG", onClick: onExportSvg },
|
||||
{ label: "PDF", onClick: onExportPdf },
|
||||
{ label: "Markdown", onClick: onExportMd },
|
||||
{ label: "TXT", onClick: onExportTxt },
|
||||
{ label: "XMind", onClick: onExportXmind },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
setShowExport(false);
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<i className="iconfont iconexport text-[12px]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{showExport ? (
|
||||
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
{[
|
||||
{ label: "JSON", onClick: onExportJson },
|
||||
{ label: "PNG", onClick: onExportPng },
|
||||
{ label: "SVG", onClick: onExportSvg },
|
||||
{ label: "PDF", onClick: onExportPdf },
|
||||
{ label: "Markdown", onClick: onExportMd },
|
||||
{ label: "TXT", onClick: onExportTxt },
|
||||
{ label: "XMind", onClick: onExportXmind },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
setShowExport(false);
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<i className="iconfont iconexport text-[12px]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
"use client";
|
||||
|
||||
"use client";
|
||||
|
||||
import { BlockNoteEditor, Block } from "@blocknote/core";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
|
||||
const DEFAULT_WIDTH = 960;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const MIN_WIDTH = 420;
|
||||
const MAX_WIDTH = 1400;
|
||||
const MIN_HEIGHT = 320;
|
||||
const MAX_HEIGHT = 900;
|
||||
|
||||
type ResizeHandle =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
const handleMapping: Record<
|
||||
ResizeHandle,
|
||||
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
|
||||
> = {
|
||||
left: { horizontal: "left" },
|
||||
right: { horizontal: "right" },
|
||||
top: { vertical: "top" },
|
||||
bottom: { vertical: "bottom" },
|
||||
"top-left": { horizontal: "left", vertical: "top" },
|
||||
"top-right": { horizontal: "right", vertical: "top" },
|
||||
"bottom-left": { horizontal: "left", vertical: "bottom" },
|
||||
"bottom-right": { horizontal: "right", vertical: "bottom" },
|
||||
};
|
||||
|
||||
// 占位符组件:在紧凑模式下渲染表格块
|
||||
|
||||
const DEFAULT_WIDTH = 960;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const MIN_WIDTH = 420;
|
||||
const MAX_WIDTH = 1400;
|
||||
const MIN_HEIGHT = 320;
|
||||
const MAX_HEIGHT = 900;
|
||||
|
||||
type ResizeHandle =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
const handleMapping: Record<
|
||||
ResizeHandle,
|
||||
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
|
||||
> = {
|
||||
left: { horizontal: "left" },
|
||||
right: { horizontal: "right" },
|
||||
top: { vertical: "top" },
|
||||
bottom: { vertical: "bottom" },
|
||||
"top-left": { horizontal: "left", vertical: "top" },
|
||||
"top-right": { horizontal: "right", vertical: "top" },
|
||||
"bottom-left": { horizontal: "left", vertical: "bottom" },
|
||||
"bottom-right": { horizontal: "right", vertical: "bottom" },
|
||||
};
|
||||
|
||||
// 占位符组件:在紧凑模式下渲染表格块
|
||||
const OnlineTableBlockComponent = ({
|
||||
block,
|
||||
editor,
|
||||
}: any) => {
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
|
||||
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
|
||||
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
|
||||
@@ -67,27 +67,27 @@ const OnlineTableBlockComponent = ({
|
||||
(next: { width: number; height: number }) => {
|
||||
setDraftSize(next);
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
...block.props,
|
||||
width: next.width,
|
||||
props: {
|
||||
...block.props,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
},
|
||||
});
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
// 阶段三:实现双击/按钮进入全屏编辑
|
||||
const handleFullScreen = () => {
|
||||
if (openTableFullScreen) {
|
||||
openTableFullScreen(tableId);
|
||||
} else {
|
||||
console.error("Editor bridge not ready or openTableFullScreen missing.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.removeBlocks([block.id]);
|
||||
|
||||
// 阶段三:实现双击/按钮进入全屏编辑
|
||||
const handleFullScreen = () => {
|
||||
if (openTableFullScreen) {
|
||||
openTableFullScreen(tableId);
|
||||
} else {
|
||||
console.error("Editor bridge not ready or openTableFullScreen missing.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.removeBlocks([block.id]);
|
||||
}, [block.id, editor]);
|
||||
|
||||
const startResize = useCallback(
|
||||
@@ -107,53 +107,53 @@ const OnlineTableBlockComponent = ({
|
||||
const cursor =
|
||||
axes.horizontal && axes.vertical
|
||||
? axes.horizontal === "left"
|
||||
? axes.vertical === "top"
|
||||
? "nwse-resize"
|
||||
: "nesw-resize"
|
||||
: axes.vertical === "top"
|
||||
? "nesw-resize"
|
||||
: "nwse-resize"
|
||||
: axes.horizontal
|
||||
? "ew-resize"
|
||||
: "ns-resize";
|
||||
document.body.style.cursor = cursor;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
if (axes.horizontal === "left") {
|
||||
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else if (axes.horizontal === "right") {
|
||||
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else {
|
||||
nextWidth = startWidth;
|
||||
}
|
||||
|
||||
if (axes.vertical === "top") {
|
||||
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else if (axes.vertical === "bottom") {
|
||||
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else {
|
||||
nextHeight = startHeight;
|
||||
}
|
||||
setDraftSize({
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
setActiveHandle(null);
|
||||
commitSize({
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight),
|
||||
});
|
||||
};
|
||||
|
||||
? axes.vertical === "top"
|
||||
? "nwse-resize"
|
||||
: "nesw-resize"
|
||||
: axes.vertical === "top"
|
||||
? "nesw-resize"
|
||||
: "nwse-resize"
|
||||
: axes.horizontal
|
||||
? "ew-resize"
|
||||
: "ns-resize";
|
||||
document.body.style.cursor = cursor;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
if (axes.horizontal === "left") {
|
||||
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else if (axes.horizontal === "right") {
|
||||
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else {
|
||||
nextWidth = startWidth;
|
||||
}
|
||||
|
||||
if (axes.vertical === "top") {
|
||||
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else if (axes.vertical === "bottom") {
|
||||
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else {
|
||||
nextHeight = startHeight;
|
||||
}
|
||||
setDraftSize({
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
setActiveHandle(null);
|
||||
commitSize({
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight),
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
@@ -167,90 +167,90 @@ const OnlineTableBlockComponent = ({
|
||||
height: clamp(src.height, MIN_HEIGHT, MAX_HEIGHT),
|
||||
};
|
||||
}, [activeHandle, committedSize, draftSize]);
|
||||
|
||||
const handleClass = (handle: ResizeHandle) =>
|
||||
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
|
||||
activeHandle === handle ? "is-dragging" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto" contentEditable={false}>
|
||||
<div
|
||||
className="online-table-block group relative mx-auto"
|
||||
style={{ width: size.width, minWidth: MIN_WIDTH }}
|
||||
>
|
||||
<CompactTablePreview
|
||||
tableId={tableId}
|
||||
onFullScreen={handleFullScreen}
|
||||
onDelete={handleDelete}
|
||||
height={size.height}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向左拖拽以调整宽度"
|
||||
className={handleClass("left")}
|
||||
onMouseDown={startResize("left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向右拖拽以调整宽度"
|
||||
className={handleClass("right")}
|
||||
onMouseDown={startResize("right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向上拖拽以调整高度"
|
||||
className={handleClass("top")}
|
||||
onMouseDown={startResize("top")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向下拖拽以调整高度"
|
||||
className={handleClass("bottom")}
|
||||
onMouseDown={startResize("bottom")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-left")}
|
||||
onMouseDown={startResize("top-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-right")}
|
||||
onMouseDown={startResize("top-right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-left")}
|
||||
onMouseDown={startResize("bottom-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-right")}
|
||||
onMouseDown={startResize("bottom-right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Block Spec 定义
|
||||
export const onlineTableBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "onlineTable",
|
||||
propSchema: {
|
||||
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
|
||||
title: { default: "未命名表格" },
|
||||
width: { default: DEFAULT_WIDTH },
|
||||
height: { default: DEFAULT_HEIGHT },
|
||||
},
|
||||
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
|
||||
},
|
||||
{
|
||||
render: (props) => <OnlineTableBlockComponent {...props} />,
|
||||
}
|
||||
);
|
||||
|
||||
const handleClass = (handle: ResizeHandle) =>
|
||||
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
|
||||
activeHandle === handle ? "is-dragging" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto" contentEditable={false}>
|
||||
<div
|
||||
className="online-table-block group relative mx-auto"
|
||||
style={{ width: size.width, minWidth: MIN_WIDTH }}
|
||||
>
|
||||
<CompactTablePreview
|
||||
tableId={tableId}
|
||||
onFullScreen={handleFullScreen}
|
||||
onDelete={handleDelete}
|
||||
height={size.height}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向左拖拽以调整宽度"
|
||||
className={handleClass("left")}
|
||||
onMouseDown={startResize("left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向右拖拽以调整宽度"
|
||||
className={handleClass("right")}
|
||||
onMouseDown={startResize("right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向上拖拽以调整高度"
|
||||
className={handleClass("top")}
|
||||
onMouseDown={startResize("top")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向下拖拽以调整高度"
|
||||
className={handleClass("bottom")}
|
||||
onMouseDown={startResize("bottom")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-left")}
|
||||
onMouseDown={startResize("top-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-right")}
|
||||
onMouseDown={startResize("top-right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-left")}
|
||||
onMouseDown={startResize("bottom-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-right")}
|
||||
onMouseDown={startResize("bottom-right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Block Spec 定义
|
||||
export const onlineTableBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "onlineTable",
|
||||
propSchema: {
|
||||
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
|
||||
title: { default: "未命名表格" },
|
||||
width: { default: DEFAULT_WIDTH },
|
||||
height: { default: DEFAULT_HEIGHT },
|
||||
},
|
||||
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
|
||||
},
|
||||
{
|
||||
render: (props) => <OnlineTableBlockComponent {...props} />,
|
||||
}
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,10 @@ import {
|
||||
export interface TocEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
numbering: string;
|
||||
}
|
||||
|
||||
level: number;
|
||||
numbering: string;
|
||||
}
|
||||
|
||||
interface DocumentTocProps {
|
||||
entries: TocEntry[];
|
||||
visible: boolean;
|
||||
@@ -85,12 +85,12 @@ export function DocumentToc({ entries, visible, onJump, onClose }: DocumentTocPr
|
||||
onClick={() => onJump(entry.id)}
|
||||
>
|
||||
<span className="mr-2 font-mono text-[10px] text-gray-400">{entry.numbering}</span>
|
||||
{entry.title || "未命名"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{entry.title || "未命名"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -682,39 +682,42 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
) : null}
|
||||
{!showEmptyPlus ? (
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.SideMenu.Button
|
||||
label="块操作"
|
||||
draggable={true}
|
||||
onDragStart={(e) => blockDragStart(e, block)}
|
||||
onDragEnd={blockDragEnd}
|
||||
onPointerEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave;
|
||||
// 这里用 :hover 兜底,避免插入按钮一闪而过。
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering && !menuOpen) {
|
||||
setHovering(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
|
||||
icon={
|
||||
<span className="relative inline-flex">
|
||||
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
|
||||
{unresolvedCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
|
||||
{unresolvedCount > 9 ? "9+" : unresolvedCount}
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave;
|
||||
// 这里用 :hover 兜底,避免插入按钮一闪而过。
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering && !menuOpen) {
|
||||
setHovering(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Components.SideMenu.Button
|
||||
label="块操作"
|
||||
draggable={true}
|
||||
onDragStart={(e) => blockDragStart(e, block)}
|
||||
onDragEnd={blockDragEnd}
|
||||
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
|
||||
icon={
|
||||
<span className="relative inline-flex">
|
||||
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
|
||||
{unresolvedCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
|
||||
{unresolvedCount > 9 ? "9+" : unresolvedCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { JSX } from "react";
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
SuggestionMenuController,
|
||||
getDefaultReactSlashMenuItems,
|
||||
@@ -9,34 +9,34 @@ import {
|
||||
} from "@blocknote/react";
|
||||
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileImage,
|
||||
FilePlus2,
|
||||
FileVideo,
|
||||
ListTree,
|
||||
Music,
|
||||
Paperclip,
|
||||
PilcrowSquare,
|
||||
Play,
|
||||
Spline,
|
||||
Sparkles,
|
||||
SquareCheckBig,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind, MediaSelection } from "@/types/media";
|
||||
import { createOnlineTable } from "@/lib/online-table";
|
||||
|
||||
type Props = {
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
currentDocumentId: string;
|
||||
};
|
||||
|
||||
import {
|
||||
FileImage,
|
||||
FilePlus2,
|
||||
FileVideo,
|
||||
ListTree,
|
||||
Music,
|
||||
Paperclip,
|
||||
PilcrowSquare,
|
||||
Play,
|
||||
Spline,
|
||||
Sparkles,
|
||||
SquareCheckBig,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind, MediaSelection } from "@/types/media";
|
||||
import { createOnlineTable } from "@/lib/online-table";
|
||||
|
||||
type Props = {
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
currentDocumentId: string;
|
||||
};
|
||||
|
||||
const matchKeywords = (query: string, aliases: string[]) => {
|
||||
const lower = query.trim().toLowerCase();
|
||||
if (!lower) return true;
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(lower));
|
||||
const lower = query.trim().toLowerCase();
|
||||
if (!lower) return true;
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
function insertOrUpdateBlockForSlashMenuCompat(
|
||||
@@ -100,95 +100,95 @@ function insertOrUpdateBlockForSlashMenuCompat(
|
||||
|
||||
editor.insertBlocks([partialBlock as never], referenceBlock, "after");
|
||||
}
|
||||
|
||||
const GROUP_TRANSLATIONS: Record<string, string> = {
|
||||
"Headings": "标题",
|
||||
"Subheadings": "副标题",
|
||||
"Basic blocks": "基础块",
|
||||
"Advanced": "高级",
|
||||
"Media": "媒体",
|
||||
"Others": "其他",
|
||||
};
|
||||
|
||||
const DEFAULT_ITEM_TRANSLATIONS: Record<
|
||||
string,
|
||||
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
|
||||
> = {
|
||||
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
|
||||
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
|
||||
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
|
||||
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
|
||||
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
|
||||
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
|
||||
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
|
||||
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
|
||||
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
|
||||
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
|
||||
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
|
||||
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
|
||||
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
|
||||
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
|
||||
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
|
||||
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
|
||||
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
|
||||
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
|
||||
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
|
||||
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
|
||||
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
|
||||
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
|
||||
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
|
||||
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
|
||||
};
|
||||
|
||||
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
|
||||
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
|
||||
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
|
||||
audio: <Music className="h-4 w-4 text-[#10b981]" />,
|
||||
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
|
||||
};
|
||||
|
||||
const HEADING_PRESETS = [
|
||||
{
|
||||
level: 1,
|
||||
title: "主标题",
|
||||
subtext: "适合页面名称/顶层章节",
|
||||
aliases: ["biaoti1", "h1", "level1"],
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
title: "大标题",
|
||||
subtext: "用于章节逻辑层",
|
||||
aliases: ["biaoti2", "h2", "level2"],
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
title: "中标题",
|
||||
subtext: "用于小节和段落",
|
||||
aliases: ["biaoti3", "h3", "level3"],
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
title: "小标题",
|
||||
subtext: "更细的结构说明",
|
||||
aliases: ["biaoti4", "h4", "level4"],
|
||||
},
|
||||
{
|
||||
level: 5,
|
||||
title: "极小标题",
|
||||
subtext: "适合脚注/补充说明",
|
||||
aliases: ["biaoti5", "h5", "level5"],
|
||||
},
|
||||
];
|
||||
|
||||
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
|
||||
const maybeKey = (item as { key?: string }).key ?? "";
|
||||
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
|
||||
return true;
|
||||
}
|
||||
const title = item.title ?? "";
|
||||
return title.includes("标题");
|
||||
};
|
||||
|
||||
|
||||
const GROUP_TRANSLATIONS: Record<string, string> = {
|
||||
"Headings": "标题",
|
||||
"Subheadings": "副标题",
|
||||
"Basic blocks": "基础块",
|
||||
"Advanced": "高级",
|
||||
"Media": "媒体",
|
||||
"Others": "其他",
|
||||
};
|
||||
|
||||
const DEFAULT_ITEM_TRANSLATIONS: Record<
|
||||
string,
|
||||
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
|
||||
> = {
|
||||
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
|
||||
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
|
||||
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
|
||||
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
|
||||
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
|
||||
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
|
||||
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
|
||||
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
|
||||
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
|
||||
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
|
||||
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
|
||||
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
|
||||
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
|
||||
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
|
||||
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
|
||||
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
|
||||
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
|
||||
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
|
||||
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
|
||||
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
|
||||
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
|
||||
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
|
||||
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
|
||||
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
|
||||
};
|
||||
|
||||
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
|
||||
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
|
||||
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
|
||||
audio: <Music className="h-4 w-4 text-[#10b981]" />,
|
||||
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
|
||||
};
|
||||
|
||||
const HEADING_PRESETS = [
|
||||
{
|
||||
level: 1,
|
||||
title: "主标题",
|
||||
subtext: "适合页面名称/顶层章节",
|
||||
aliases: ["biaoti1", "h1", "level1"],
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
title: "大标题",
|
||||
subtext: "用于章节逻辑层",
|
||||
aliases: ["biaoti2", "h2", "level2"],
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
title: "中标题",
|
||||
subtext: "用于小节和段落",
|
||||
aliases: ["biaoti3", "h3", "level3"],
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
title: "小标题",
|
||||
subtext: "更细的结构说明",
|
||||
aliases: ["biaoti4", "h4", "level4"],
|
||||
},
|
||||
{
|
||||
level: 5,
|
||||
title: "极小标题",
|
||||
subtext: "适合脚注/补充说明",
|
||||
aliases: ["biaoti5", "h5", "level5"],
|
||||
},
|
||||
];
|
||||
|
||||
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
|
||||
const maybeKey = (item as { key?: string }).key ?? "";
|
||||
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
|
||||
return true;
|
||||
}
|
||||
const title = item.title ?? "";
|
||||
return title.includes("标题");
|
||||
};
|
||||
|
||||
export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]);
|
||||
const router = useRouter();
|
||||
@@ -220,7 +220,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const createMindmapItem: DefaultReactSuggestionItem = {
|
||||
title: "思维导图",
|
||||
group: "高级",
|
||||
@@ -235,7 +235,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const createPageItem: DefaultReactSuggestionItem = {
|
||||
title: "嵌入页面",
|
||||
group: "嵌入",
|
||||
@@ -268,12 +268,12 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
router.refresh();
|
||||
},
|
||||
};
|
||||
|
||||
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
|
||||
title: preset.title,
|
||||
group: "标题",
|
||||
subtext: preset.subtext,
|
||||
aliases: preset.aliases,
|
||||
|
||||
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
|
||||
title: preset.title,
|
||||
group: "标题",
|
||||
subtext: preset.subtext,
|
||||
aliases: preset.aliases,
|
||||
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
@@ -283,10 +283,10 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const foldHeading: DefaultReactSuggestionItem = {
|
||||
title: "折叠标题",
|
||||
group: "标题",
|
||||
|
||||
const foldHeading: DefaultReactSuggestionItem = {
|
||||
title: "折叠标题",
|
||||
group: "标题",
|
||||
aliases: ["toggle", "zd", "fold"],
|
||||
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
@@ -297,12 +297,12 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const advancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "高级待办",
|
||||
group: "待办",
|
||||
subtext: "四态状态 · Alt 直接取消",
|
||||
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
|
||||
|
||||
const advancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "高级待办",
|
||||
group: "待办",
|
||||
subtext: "四态状态 · Alt 直接取消",
|
||||
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
|
||||
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
@@ -312,12 +312,12 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const progressMeter: DefaultReactSuggestionItem = {
|
||||
title: "进度条",
|
||||
group: "进度",
|
||||
subtext: "自动读取下方待办完成度",
|
||||
aliases: ["jdt", "progress", "jindu"],
|
||||
|
||||
const progressMeter: DefaultReactSuggestionItem = {
|
||||
title: "进度条",
|
||||
group: "进度",
|
||||
subtext: "自动读取下方待办完成度",
|
||||
aliases: ["jdt", "progress", "jindu"],
|
||||
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
@@ -327,10 +327,10 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const foldAdvancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "折叠高级待办",
|
||||
group: "待办",
|
||||
|
||||
const foldAdvancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "折叠高级待办",
|
||||
group: "待办",
|
||||
aliases: ["zdgjdb", "foldtodo"],
|
||||
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
|
||||
onItemClick: () => {
|
||||
@@ -341,18 +341,18 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const customItems = [
|
||||
...headingItems,
|
||||
foldHeading,
|
||||
createPageItem,
|
||||
createTableItem,
|
||||
createMindmapItem,
|
||||
advancedTodo,
|
||||
foldAdvancedTodo,
|
||||
progressMeter,
|
||||
].filter((item) => matchKeywords(query, item.aliases ?? []));
|
||||
|
||||
|
||||
const customItems = [
|
||||
...headingItems,
|
||||
foldHeading,
|
||||
createPageItem,
|
||||
createTableItem,
|
||||
createMindmapItem,
|
||||
advancedTodo,
|
||||
foldAdvancedTodo,
|
||||
progressMeter,
|
||||
].filter((item) => matchKeywords(query, item.aliases ?? []));
|
||||
|
||||
const insertMediaSelection = (selection: MediaSelection) => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "media",
|
||||
@@ -369,7 +369,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
content: [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleMediaPick = (mediaType: MediaKind) => {
|
||||
openPicker({
|
||||
mediaType,
|
||||
@@ -379,41 +379,41 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
...selection,
|
||||
assetType: selection.assetType ?? mediaType,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const localizedDefaults = defaultItems.map((item) => {
|
||||
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
|
||||
const next: DefaultReactSuggestionItem = { ...item };
|
||||
if (translation?.title) next.title = translation.title;
|
||||
if (translation?.subtext) next.subtext = translation.subtext;
|
||||
if (translation?.aliases) next.aliases = translation.aliases;
|
||||
if (translation?.group) {
|
||||
next.group = translation.group;
|
||||
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
|
||||
next.group = GROUP_TRANSLATIONS[item.group];
|
||||
}
|
||||
|
||||
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
|
||||
const mediaType = item.title.toLowerCase() as MediaKind;
|
||||
next.icon = MEDIA_ICONS[mediaType];
|
||||
next.group = translation?.group ?? "媒体";
|
||||
next.subtext = translation?.subtext ?? next.subtext;
|
||||
next.aliases = translation?.aliases ?? next.aliases;
|
||||
next.onItemClick = () => handleMediaPick(mediaType);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const sanitizedDefaults = localizedDefaults.filter(
|
||||
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
|
||||
);
|
||||
const merged = [...customItems, ...sanitizedDefaults];
|
||||
return filterSuggestionItems(merged, query);
|
||||
},
|
||||
[currentDocumentId, defaultItems, editor, openPicker, router],
|
||||
);
|
||||
|
||||
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const localizedDefaults = defaultItems.map((item) => {
|
||||
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
|
||||
const next: DefaultReactSuggestionItem = { ...item };
|
||||
if (translation?.title) next.title = translation.title;
|
||||
if (translation?.subtext) next.subtext = translation.subtext;
|
||||
if (translation?.aliases) next.aliases = translation.aliases;
|
||||
if (translation?.group) {
|
||||
next.group = translation.group;
|
||||
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
|
||||
next.group = GROUP_TRANSLATIONS[item.group];
|
||||
}
|
||||
|
||||
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
|
||||
const mediaType = item.title.toLowerCase() as MediaKind;
|
||||
next.icon = MEDIA_ICONS[mediaType];
|
||||
next.group = translation?.group ?? "媒体";
|
||||
next.subtext = translation?.subtext ?? next.subtext;
|
||||
next.aliases = translation?.aliases ?? next.aliases;
|
||||
next.onItemClick = () => handleMediaPick(mediaType);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const sanitizedDefaults = localizedDefaults.filter(
|
||||
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
|
||||
);
|
||||
const merged = [...customItems, ...sanitizedDefaults];
|
||||
return filterSuggestionItems(merged, query);
|
||||
},
|
||||
[currentDocumentId, defaultItems, editor, openPicker, router],
|
||||
);
|
||||
|
||||
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
createHeadingBlockSpec,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
defaultStyleSpecs,
|
||||
} from "@blocknote/core";
|
||||
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
createHeadingBlockSpec,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
defaultStyleSpecs,
|
||||
} from "@blocknote/core";
|
||||
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
|
||||
import { advancedTodoBlock } from "./blocks/AdvancedTodoBlock";
|
||||
import { progressBlock } from "./blocks/ProgressBlock";
|
||||
import { mediaBlock } from "./blocks/MediaBlock";
|
||||
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
|
||||
import { mindmapBlock } from "./blocks/MindmapBlock";
|
||||
import { blockReferenceBlock } from "./blocks/BlockReferenceBlock";
|
||||
|
||||
const headingSpec = createHeadingBlockSpec({
|
||||
levels: [1, 2, 3, 4, 5],
|
||||
allowToggleHeadings: true,
|
||||
});
|
||||
|
||||
export const customBlockSchema = BlockNoteSchema.create({
|
||||
|
||||
const headingSpec = createHeadingBlockSpec({
|
||||
levels: [1, 2, 3, 4, 5],
|
||||
allowToggleHeadings: true,
|
||||
});
|
||||
|
||||
export const customBlockSchema = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
heading: headingSpec,
|
||||
@@ -35,5 +35,5 @@ export const customBlockSchema = BlockNoteSchema.create({
|
||||
inlineContentSpecs: defaultInlineContentSpecs,
|
||||
styleSpecs: defaultStyleSpecs,
|
||||
});
|
||||
|
||||
export type CustomBlockSchema = typeof customBlockSchema.blockSchema;
|
||||
|
||||
export type CustomBlockSchema = typeof customBlockSchema.blockSchema;
|
||||
|
||||
@@ -60,19 +60,19 @@ const useTableData = (tableId: string) => {
|
||||
.then((data) => {
|
||||
if (!canceled) {
|
||||
setTable({ ...data, title: data.title || "未命名表格" });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load table:", error);
|
||||
if (!canceled) {
|
||||
setTable(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load table:", error);
|
||||
if (!canceled) {
|
||||
setTable(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
@@ -87,254 +87,254 @@ const CompactTablePreviewInner: React.FC<CompactTablePreviewProps> = ({
|
||||
onDelete,
|
||||
height,
|
||||
}) => {
|
||||
const { table, isLoading, refresh } = useTableData(tableId);
|
||||
const [iframeVersion, setIframeVersion] = useState(0);
|
||||
const [iframeLoading, setIframeLoading] = useState(true);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [isSavingTitle, setIsSavingTitle] = useState(false);
|
||||
const fixedViewerHeight = 320; // 默认视窗高度
|
||||
const minEmbedHeight = 260;
|
||||
const maxEmbedHeight = 440;
|
||||
const rowHeight = 26; // 预估单行高度,便于动态收缩高度
|
||||
|
||||
const iframeSrc = useMemo(
|
||||
() => `/tables/${tableId}/view?embed=1&v=${iframeVersion}`,
|
||||
[tableId, iframeVersion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaved = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
if (detail?.tableId === tableId) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
const handleDeleted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
if (detail?.tableId === tableId) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener("online-table-saved", handleSaved as EventListener);
|
||||
window.addEventListener("online-table-deleted", handleDeleted as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("online-table-saved", handleSaved as EventListener);
|
||||
window.removeEventListener("online-table-deleted", handleDeleted as EventListener);
|
||||
};
|
||||
}, [refresh, tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (table?.title !== undefined) {
|
||||
setRenameValue(table.title ?? "");
|
||||
}
|
||||
}, [table?.title]);
|
||||
|
||||
const suppressEditorEvents = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const { table, isLoading, refresh } = useTableData(tableId);
|
||||
const [iframeVersion, setIframeVersion] = useState(0);
|
||||
const [iframeLoading, setIframeLoading] = useState(true);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [isSavingTitle, setIsSavingTitle] = useState(false);
|
||||
const fixedViewerHeight = 320; // 默认视窗高度
|
||||
const minEmbedHeight = 260;
|
||||
const maxEmbedHeight = 440;
|
||||
const rowHeight = 26; // 预估单行高度,便于动态收缩高度
|
||||
|
||||
const iframeSrc = useMemo(
|
||||
() => `/tables/${tableId}/view?embed=1&v=${iframeVersion}`,
|
||||
[tableId, iframeVersion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaved = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
if (detail?.tableId === tableId) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
const handleDeleted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
if (detail?.tableId === tableId) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener("online-table-saved", handleSaved as EventListener);
|
||||
window.addEventListener("online-table-deleted", handleDeleted as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("online-table-saved", handleSaved as EventListener);
|
||||
window.removeEventListener("online-table-deleted", handleDeleted as EventListener);
|
||||
};
|
||||
}, [refresh, tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (table?.title !== undefined) {
|
||||
setRenameValue(table.title ?? "");
|
||||
}
|
||||
}, [table?.title]);
|
||||
|
||||
const suppressEditorEvents = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDeleteTable = useCallback(async () => {
|
||||
const confirmed = window.confirm("删除表格将同步移除在线表格记录,确认继续?");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await deleteOnlineTable(tableId);
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
||||
onDelete?.();
|
||||
} catch (error) {
|
||||
console.error("删除表格失败", error);
|
||||
window.alert("删除失败,请稍后重试");
|
||||
}
|
||||
}, [onDelete, tableId]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIframeVersion((value) => value + 1);
|
||||
setIframeLoading(true);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
const estimatedRows = useMemo(() => {
|
||||
const rowsBySnapshot =
|
||||
Array.isArray(table?.snapshot?.rows) && table.snapshot?.rows
|
||||
? table.snapshot.rows.length
|
||||
: 0;
|
||||
const celldata = table?.snapshot?.luckysheet?.[0]?.celldata;
|
||||
const rowsByCells =
|
||||
Array.isArray(celldata) && celldata.length > 0
|
||||
? Math.max(
|
||||
...celldata.map((cell) =>
|
||||
typeof cell?.r === "number" ? cell.r : -1,
|
||||
),
|
||||
) + 1
|
||||
: 0;
|
||||
const fallbackRows = 10;
|
||||
return Math.max(rowsBySnapshot, rowsByCells, fallbackRows);
|
||||
}, [table?.snapshot]);
|
||||
|
||||
const clampHeight = useCallback(
|
||||
(value: number) => Math.min(maxEmbedHeight, Math.max(minEmbedHeight, value)),
|
||||
[maxEmbedHeight, minEmbedHeight],
|
||||
);
|
||||
const autoHeight = clampHeight(estimatedRows * rowHeight);
|
||||
const effectiveHeight = clampHeight(height ?? autoHeight ?? fixedViewerHeight);
|
||||
const effectiveWidth: number | string = "100%";
|
||||
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = (renameValue || "").trim() || "未命名表格";
|
||||
if (nextTitle === table.title) {
|
||||
setIsRenaming(false);
|
||||
return;
|
||||
}
|
||||
setIsSavingTitle(true);
|
||||
try {
|
||||
const updated = await saveOnlineTable(tableId, { title: nextTitle });
|
||||
const finalTitle = updated.title ?? nextTitle;
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
refresh();
|
||||
setRenameValue(finalTitle);
|
||||
} catch (error) {
|
||||
console.error("重命名表格失败", error);
|
||||
setRenameValue(table.title ?? "");
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
setIsSavingTitle(false);
|
||||
}
|
||||
}, [refresh, renameValue, table, tableId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-center rounded-md border border-dashed bg-gray-50">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!table) {
|
||||
return (
|
||||
<div className="flex h-24 items-center justify-between rounded-md border border-red-200 bg-red-50 px-4 py-2 text-red-600">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<TableIcon className="h-5 w-5" />
|
||||
<span>表格加载失败或不存在</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 rounded-md border border-red-200 px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
onDoubleClick={onFullScreen}
|
||||
contentEditable={false}
|
||||
onMouseDown={suppressEditorEvents}
|
||||
onMouseUp={suppressEditorEvents}
|
||||
onDelete?.();
|
||||
} catch (error) {
|
||||
console.error("删除表格失败", error);
|
||||
window.alert("删除失败,请稍后重试");
|
||||
}
|
||||
}, [onDelete, tableId]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIframeVersion((value) => value + 1);
|
||||
setIframeLoading(true);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
const estimatedRows = useMemo(() => {
|
||||
const rowsBySnapshot =
|
||||
Array.isArray(table?.snapshot?.rows) && table.snapshot?.rows
|
||||
? table.snapshot.rows.length
|
||||
: 0;
|
||||
const celldata = table?.snapshot?.luckysheet?.[0]?.celldata;
|
||||
const rowsByCells =
|
||||
Array.isArray(celldata) && celldata.length > 0
|
||||
? Math.max(
|
||||
...celldata.map((cell) =>
|
||||
typeof cell?.r === "number" ? cell.r : -1,
|
||||
),
|
||||
) + 1
|
||||
: 0;
|
||||
const fallbackRows = 10;
|
||||
return Math.max(rowsBySnapshot, rowsByCells, fallbackRows);
|
||||
}, [table?.snapshot]);
|
||||
|
||||
const clampHeight = useCallback(
|
||||
(value: number) => Math.min(maxEmbedHeight, Math.max(minEmbedHeight, value)),
|
||||
[maxEmbedHeight, minEmbedHeight],
|
||||
);
|
||||
const autoHeight = clampHeight(estimatedRows * rowHeight);
|
||||
const effectiveHeight = clampHeight(height ?? autoHeight ?? fixedViewerHeight);
|
||||
const effectiveWidth: number | string = "100%";
|
||||
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = (renameValue || "").trim() || "未命名表格";
|
||||
if (nextTitle === table.title) {
|
||||
setIsRenaming(false);
|
||||
return;
|
||||
}
|
||||
setIsSavingTitle(true);
|
||||
try {
|
||||
const updated = await saveOnlineTable(tableId, { title: nextTitle });
|
||||
const finalTitle = updated.title ?? nextTitle;
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
refresh();
|
||||
setRenameValue(finalTitle);
|
||||
} catch (error) {
|
||||
console.error("重命名表格失败", error);
|
||||
setRenameValue(table.title ?? "");
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
setIsSavingTitle(false);
|
||||
}
|
||||
}, [refresh, renameValue, table, tableId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-center rounded-md border border-dashed bg-gray-50">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!table) {
|
||||
return (
|
||||
<div className="flex h-24 items-center justify-between rounded-md border border-red-200 bg-red-50 px-4 py-2 text-red-600">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<TableIcon className="h-5 w-5" />
|
||||
<span>表格加载失败或不存在</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 rounded-md border border-red-200 px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
onDoubleClick={onFullScreen}
|
||||
contentEditable={false}
|
||||
onMouseDown={suppressEditorEvents}
|
||||
onMouseUp={suppressEditorEvents}
|
||||
onMouseMove={suppressEditorEvents}
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white/90 shadow-[0_10px_36px_rgba(15,23,42,0.05)] transition-all hover:shadow-[0_14px_44px_rgba(15,23,42,0.08)]"
|
||||
style={{
|
||||
width: effectiveWidth,
|
||||
maxWidth: "100%",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 bg-white/80 px-4 py-2 backdrop-blur-sm">
|
||||
<div className="flex flex-col">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-48 rounded border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-700 focus:border-emerald-500 focus:outline-none"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleRenameSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleRenameSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setRenameValue(table.title ?? "");
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-left text-sm font-semibold text-gray-700 hover:text-emerald-600"
|
||||
title="点击重命名表格"
|
||||
onClick={() => setIsRenaming(true)}
|
||||
>
|
||||
<span className="truncate max-w-xs">{table.title}</span>
|
||||
{isSavingTitle && <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />}
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">嵌入视图 · 双击进入全屏编辑</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
||||
title="刷新嵌入视图"
|
||||
type="button"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteTable}
|
||||
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
title="删除表格"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onFullScreen}
|
||||
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
title="进入全屏编辑"
|
||||
type="button"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-hidden bg-white select-none px-4 pb-4 pt-3"
|
||||
style={{ height: effectiveHeight, minHeight: minEmbedHeight }}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
|
||||
<iframe
|
||||
key={`${tableId}-${iframeVersion}`}
|
||||
src={iframeSrc}
|
||||
title={`online-table-${tableId}`}
|
||||
className="block h-full w-full border-0"
|
||||
loading="lazy"
|
||||
onLoad={() => setIframeLoading(false)}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
{iframeLoading && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-white/90">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
|
||||
<span className="text-xs text-gray-500">正在载入 Luckysheet 视图...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white/90 shadow-[0_10px_36px_rgba(15,23,42,0.05)] transition-all hover:shadow-[0_14px_44px_rgba(15,23,42,0.08)]"
|
||||
style={{
|
||||
width: effectiveWidth,
|
||||
maxWidth: "100%",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 bg-white/80 px-4 py-2 backdrop-blur-sm">
|
||||
<div className="flex flex-col">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-48 rounded border border-gray-200 px-2 py-1 text-sm font-semibold text-gray-700 focus:border-emerald-500 focus:outline-none"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleRenameSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleRenameSubmit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setRenameValue(table.title ?? "");
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-left text-sm font-semibold text-gray-700 hover:text-emerald-600"
|
||||
title="点击重命名表格"
|
||||
onClick={() => setIsRenaming(true)}
|
||||
>
|
||||
<span className="truncate max-w-xs">{table.title}</span>
|
||||
{isSavingTitle && <Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />}
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">嵌入视图 · 双击进入全屏编辑</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
||||
title="刷新嵌入视图"
|
||||
type="button"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteTable}
|
||||
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
|
||||
title="删除表格"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onFullScreen}
|
||||
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
title="进入全屏编辑"
|
||||
type="button"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-hidden bg-white select-none px-4 pb-4 pt-3"
|
||||
style={{ height: effectiveHeight, minHeight: minEmbedHeight }}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
|
||||
<iframe
|
||||
key={`${tableId}-${iframeVersion}`}
|
||||
src={iframeSrc}
|
||||
title={`online-table-${tableId}`}
|
||||
className="block h-full w-full border-0"
|
||||
loading="lazy"
|
||||
onLoad={() => setIframeLoading(false)}
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
{iframeLoading && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-white/90">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
|
||||
<span className="text-xs text-gray-500">正在载入 Luckysheet 视图...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,39 +17,39 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { extractRowsForPreview } from "@/components/online-table/utils";
|
||||
|
||||
type LuckysheetSelection =
|
||||
| {
|
||||
row?: [number, number];
|
||||
column?: [number, number];
|
||||
row_focus?: number;
|
||||
column_focus?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const isPrintableKey = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return false;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
|
||||
if (event.key.length === 1) return true;
|
||||
return event.key === "Process" || event.key === "Unidentified";
|
||||
};
|
||||
|
||||
const isInlineEditorVisible = () => {
|
||||
const inputBox = document.getElementById("luckysheet-input-box");
|
||||
if (!inputBox) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(inputBox);
|
||||
return style.top !== "-10000px" && style.display !== "none";
|
||||
};
|
||||
|
||||
interface HeadlessTableViewerProps {
|
||||
tableId: string;
|
||||
embed?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
|
||||
type LuckysheetSelection =
|
||||
| {
|
||||
row?: [number, number];
|
||||
column?: [number, number];
|
||||
row_focus?: number;
|
||||
column_focus?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const isPrintableKey = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return false;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
|
||||
if (event.key.length === 1) return true;
|
||||
return event.key === "Process" || event.key === "Unidentified";
|
||||
};
|
||||
|
||||
const isInlineEditorVisible = () => {
|
||||
const inputBox = document.getElementById("luckysheet-input-box");
|
||||
if (!inputBox) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(inputBox);
|
||||
return style.top !== "-10000px" && style.display !== "none";
|
||||
};
|
||||
|
||||
interface HeadlessTableViewerProps {
|
||||
tableId: string;
|
||||
embed?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
|
||||
|
||||
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
|
||||
@@ -87,15 +87,15 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
if (savingHintTimerRef.current !== null) return;
|
||||
savingHintTimerRef.current = window.setTimeout(() => {
|
||||
setShowSavingHint(true);
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
const stopSavingHint = useCallback(() => {
|
||||
if (savingHintTimerRef.current !== null) {
|
||||
clearTimeout(savingHintTimerRef.current);
|
||||
savingHintTimerRef.current = null;
|
||||
}
|
||||
setShowSavingHint(false);
|
||||
}, 700);
|
||||
}, []);
|
||||
|
||||
const stopSavingHint = useCallback(() => {
|
||||
if (savingHintTimerRef.current !== null) {
|
||||
clearTimeout(savingHintTimerRef.current);
|
||||
savingHintTimerRef.current = null;
|
||||
}
|
||||
setShowSavingHint(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,71 +154,71 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
canceled = true;
|
||||
};
|
||||
}, [allowInlineEdit, convexEnabled, reloadVersion, tableFromConvex, tableId]);
|
||||
|
||||
|
||||
|
||||
|
||||
const focusLuckysheetEditor = useCallback(() => {
|
||||
const applyFocus = () => {
|
||||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||||
if (editor && typeof editor.focus === "function") {
|
||||
editor.focus();
|
||||
const selection = window.getSelection();
|
||||
if (selection && editor.childNodes.length > 0) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
applyFocus();
|
||||
setTimeout(applyFocus, 0);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
|
||||
if (!Array.isArray(range) || range.length !== 1) return false;
|
||||
const target = range[0];
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
|
||||
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
|
||||
if (!rowRange || !columnRange) {
|
||||
return false;
|
||||
}
|
||||
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
|
||||
}, []);
|
||||
|
||||
const tryEnterInlineEdit = useCallback(() => {
|
||||
if (!allowInlineEdit) {
|
||||
return false;
|
||||
}
|
||||
const luckysheetInstance = window.luckysheet;
|
||||
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
|
||||
return false;
|
||||
}
|
||||
const selection = luckysheetInstance.getluckysheet_select_save?.();
|
||||
const normalized = Array.isArray(selection)
|
||||
? (selection as LuckysheetSelection[])
|
||||
: selection
|
||||
? [selection as LuckysheetSelection]
|
||||
: undefined;
|
||||
if (!isSingleCellSelection(normalized)) {
|
||||
return false;
|
||||
}
|
||||
setTimeout(() => {
|
||||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||||
const selection = window.getSelection();
|
||||
if (selection && editor.childNodes.length > 0) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
applyFocus();
|
||||
setTimeout(applyFocus, 0);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
|
||||
if (!Array.isArray(range) || range.length !== 1) return false;
|
||||
const target = range[0];
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
|
||||
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
|
||||
if (!rowRange || !columnRange) {
|
||||
return false;
|
||||
}
|
||||
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
|
||||
}, []);
|
||||
|
||||
const tryEnterInlineEdit = useCallback(() => {
|
||||
if (!allowInlineEdit) {
|
||||
return false;
|
||||
}
|
||||
const luckysheetInstance = window.luckysheet;
|
||||
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
|
||||
return false;
|
||||
}
|
||||
const selection = luckysheetInstance.getluckysheet_select_save?.();
|
||||
const normalized = Array.isArray(selection)
|
||||
? (selection as LuckysheetSelection[])
|
||||
: selection
|
||||
? [selection as LuckysheetSelection]
|
||||
: undefined;
|
||||
if (!isSingleCellSelection(normalized)) {
|
||||
return false;
|
||||
}
|
||||
setTimeout(() => {
|
||||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||||
if (editor && document.activeElement === editor && isInlineEditorVisible()) {
|
||||
return;
|
||||
}
|
||||
luckysheetInstance.enterEditMode?.();
|
||||
focusLuckysheetEditor();
|
||||
}, 0);
|
||||
return true;
|
||||
}, [allowInlineEdit, focusLuckysheetEditor, isSingleCellSelection]);
|
||||
|
||||
return true;
|
||||
}, [allowInlineEdit, focusLuckysheetEditor, isSingleCellSelection]);
|
||||
|
||||
const persistSnapshot = useCallback(async () => {
|
||||
if (!allowInlineEdit || !table || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") {
|
||||
return;
|
||||
@@ -262,60 +262,60 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [allowInlineEdit, convexEnabled, startSavingHint, stopSavingHint, table, tableId, updateTable, userId]);
|
||||
|
||||
const debouncedPersist = useDebouncedCallback(() => {
|
||||
void persistSnapshot();
|
||||
}, 1200);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedPersist.cancel();
|
||||
stopSavingHint();
|
||||
};
|
||||
}, [debouncedPersist, stopSavingHint]);
|
||||
|
||||
|
||||
const debouncedPersist = useDebouncedCallback(() => {
|
||||
void persistSnapshot();
|
||||
}, 1200);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedPersist.cancel();
|
||||
stopSavingHint();
|
||||
};
|
||||
}, [debouncedPersist, stopSavingHint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableId) return;
|
||||
// Supabase 已移除:实时订阅由 Convex useQuery 承担(见上方 tableFromConvex)
|
||||
}, [tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.luckysheet.destroy === "function") {
|
||||
window.luckysheet.destroy(containerId);
|
||||
}
|
||||
containerRef.current.innerHTML = "";
|
||||
|
||||
const sheets =
|
||||
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
|
||||
? table.snapshot.luckysheet
|
||||
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.luckysheet.destroy === "function") {
|
||||
window.luckysheet.destroy(containerId);
|
||||
}
|
||||
containerRef.current.innerHTML = "";
|
||||
|
||||
const sheets =
|
||||
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
|
||||
? table.snapshot.luckysheet
|
||||
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
|
||||
|
||||
window.luckysheet?.create?.({
|
||||
container: containerId,
|
||||
title: table.title ?? tableId,
|
||||
lang: "zh",
|
||||
showinfobar: false,
|
||||
showtoolbar: false,
|
||||
showsheetbar: sheets.length > 1,
|
||||
showstatisticBar: false,
|
||||
allowEdit: allowInlineEdit,
|
||||
allowUpdate: false,
|
||||
enableAddBackTop: false,
|
||||
enableAddRow: false,
|
||||
row: DEFAULT_TABLE_ROWS,
|
||||
column: DEFAULT_TABLE_COLUMNS,
|
||||
data: sheets,
|
||||
pointEdit: allowInlineEdit,
|
||||
pointEditZoom: window.devicePixelRatio ?? 1,
|
||||
pointEditUpdate: allowInlineEdit
|
||||
? () => {
|
||||
debouncedPersist();
|
||||
}
|
||||
: undefined,
|
||||
showsheetbar: sheets.length > 1,
|
||||
showstatisticBar: false,
|
||||
allowEdit: allowInlineEdit,
|
||||
allowUpdate: false,
|
||||
enableAddBackTop: false,
|
||||
enableAddRow: false,
|
||||
row: DEFAULT_TABLE_ROWS,
|
||||
column: DEFAULT_TABLE_COLUMNS,
|
||||
data: sheets,
|
||||
pointEdit: allowInlineEdit,
|
||||
pointEditZoom: window.devicePixelRatio ?? 1,
|
||||
pointEditUpdate: allowInlineEdit
|
||||
? () => {
|
||||
debouncedPersist();
|
||||
}
|
||||
: undefined,
|
||||
hook: allowInlineEdit
|
||||
? {
|
||||
updated: () => {
|
||||
@@ -324,22 +324,22 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
}
|
||||
: undefined,
|
||||
} as any);
|
||||
|
||||
return () => {
|
||||
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
|
||||
window.luckysheet.destroy(containerId);
|
||||
}
|
||||
};
|
||||
}, [allowInlineEdit, containerId, debouncedPersist, isLuckysheetReady, table, tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowInlineEdit || !isLuckysheetReady) {
|
||||
return;
|
||||
}
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
|
||||
window.luckysheet.destroy(containerId);
|
||||
}
|
||||
};
|
||||
}, [allowInlineEdit, containerId, debouncedPersist, isLuckysheetReady, table, tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowInlineEdit || !isLuckysheetReady) {
|
||||
return;
|
||||
}
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const handlePointerUp: EventListener = (event) => {
|
||||
const target = event.target instanceof Node ? event.target : null;
|
||||
if (target && !container.contains(target)) {
|
||||
@@ -352,100 +352,100 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
return () => {
|
||||
events.forEach((eventName) => container.removeEventListener(eventName, handlePointerUp, true));
|
||||
};
|
||||
}, [allowInlineEdit, containerId, isLuckysheetReady, tryEnterInlineEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowInlineEdit || !isLuckysheetReady) {
|
||||
return;
|
||||
}
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isPrintableKey(event)) {
|
||||
return;
|
||||
}
|
||||
tryEnterInlineEdit();
|
||||
};
|
||||
const handleCompositionStart = () => {
|
||||
tryEnterInlineEdit();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
window.addEventListener("compositionstart", handleCompositionStart, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown, true);
|
||||
window.removeEventListener("compositionstart", handleCompositionStart, true);
|
||||
};
|
||||
}, [allowInlineEdit, isLuckysheetReady, tryEnterInlineEdit]);
|
||||
|
||||
const overlayVisible = isLoading || !isLuckysheetReady;
|
||||
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
|
||||
|
||||
const embedContainerStyle = embed
|
||||
? { height: "360px", minHeight: "360px", width: "100%", overflow: "hidden" as const }
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed) return;
|
||||
const prevDocOverflow = document.documentElement.style.overflow;
|
||||
const prevBodyOverflow = document.body.style.overflow;
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.documentElement.style.overflow = prevDocOverflow;
|
||||
document.body.style.overflow = prevBodyOverflow;
|
||||
};
|
||||
}, [embed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embed ? "h-full w-full bg-transparent overflow-hidden" : "min-h-screen w-full bg-white"
|
||||
}
|
||||
style={embedContainerStyle}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
embed ? "relative h-full w-full overflow-hidden" : "relative h-[calc(100vh-64px)] w-full"
|
||||
}
|
||||
style={embedContainerStyle}
|
||||
>
|
||||
<div
|
||||
id={containerId}
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{overlayVisible && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
|
||||
<span className="text-sm text-gray-500">{overlayText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !overlayVisible && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
|
||||
<span className="text-sm">{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
|
||||
onClick={() => setReloadVersion((value) => value + 1)}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
|
||||
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
|
||||
{isSaving && showSavingHint && (
|
||||
<span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">同步中...</span>
|
||||
)}
|
||||
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeadlessTableViewer;
|
||||
|
||||
}, [allowInlineEdit, containerId, isLuckysheetReady, tryEnterInlineEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowInlineEdit || !isLuckysheetReady) {
|
||||
return;
|
||||
}
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isPrintableKey(event)) {
|
||||
return;
|
||||
}
|
||||
tryEnterInlineEdit();
|
||||
};
|
||||
const handleCompositionStart = () => {
|
||||
tryEnterInlineEdit();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
window.addEventListener("compositionstart", handleCompositionStart, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown, true);
|
||||
window.removeEventListener("compositionstart", handleCompositionStart, true);
|
||||
};
|
||||
}, [allowInlineEdit, isLuckysheetReady, tryEnterInlineEdit]);
|
||||
|
||||
const overlayVisible = isLoading || !isLuckysheetReady;
|
||||
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
|
||||
|
||||
const embedContainerStyle = embed
|
||||
? { height: "360px", minHeight: "360px", width: "100%", overflow: "hidden" as const }
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed) return;
|
||||
const prevDocOverflow = document.documentElement.style.overflow;
|
||||
const prevBodyOverflow = document.body.style.overflow;
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.documentElement.style.overflow = prevDocOverflow;
|
||||
document.body.style.overflow = prevBodyOverflow;
|
||||
};
|
||||
}, [embed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embed ? "h-full w-full bg-transparent overflow-hidden" : "min-h-screen w-full bg-white"
|
||||
}
|
||||
style={embedContainerStyle}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
embed ? "relative h-full w-full overflow-hidden" : "relative h-[calc(100vh-64px)] w-full"
|
||||
}
|
||||
style={embedContainerStyle}
|
||||
>
|
||||
<div
|
||||
id={containerId}
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{overlayVisible && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
|
||||
<span className="text-sm text-gray-500">{overlayText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !overlayVisible && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
|
||||
<span className="text-sm">{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
|
||||
onClick={() => setReloadVersion((value) => value + 1)}
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
|
||||
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
|
||||
{isSaving && showSavingHint && (
|
||||
<span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">同步中...</span>
|
||||
)}
|
||||
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeadlessTableViewer;
|
||||
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import type { TableRowData } from "@/types/online-table";
|
||||
import { DEFAULT_TABLE_COLUMNS } from "@/lib/online-table";
|
||||
|
||||
const pickCellValue = (cell: any) => {
|
||||
if (!cell) return undefined;
|
||||
if (cell.m != null) return cell.m;
|
||||
if (cell.v?.m != null) return cell.v.m;
|
||||
if (cell.v?.v != null) return cell.v.v;
|
||||
if (cell.v != null && typeof cell.v !== "object") return cell.v;
|
||||
if (cell.w != null) return cell.w;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const extractRowsForPreview = (
|
||||
luckysheetData: any,
|
||||
columns: Array<{ id: string }>,
|
||||
): TableRowData[] => {
|
||||
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
|
||||
if (!sheet) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columnIds =
|
||||
columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
|
||||
|
||||
const rows: TableRowData[] = [];
|
||||
const grid = Array.isArray(sheet.data) ? sheet.data : [];
|
||||
|
||||
grid.forEach((row: any[], rowIndex: number) => {
|
||||
if (!Array.isArray(row)) return;
|
||||
const rowObj: TableRowData = {};
|
||||
let hasValue = false;
|
||||
columnIds.forEach((colId, colIndex) => {
|
||||
const value = pickCellValue(row[colIndex]);
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
rowObj[colId] = value;
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
if (hasValue) {
|
||||
rows.push(rowObj);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
|
||||
const map = new Map<number, TableRowData>();
|
||||
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
|
||||
const value = pickCellValue(cell?.v ?? cell);
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
const existing = map.get(cell.r) ?? {};
|
||||
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
|
||||
map.set(cell.r, existing);
|
||||
});
|
||||
Array.from(map.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.forEach(([, row]) => rows.push(row));
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
import type { TableRowData } from "@/types/online-table";
|
||||
import { DEFAULT_TABLE_COLUMNS } from "@/lib/online-table";
|
||||
|
||||
const pickCellValue = (cell: any) => {
|
||||
if (!cell) return undefined;
|
||||
if (cell.m != null) return cell.m;
|
||||
if (cell.v?.m != null) return cell.v.m;
|
||||
if (cell.v?.v != null) return cell.v.v;
|
||||
if (cell.v != null && typeof cell.v !== "object") return cell.v;
|
||||
if (cell.w != null) return cell.w;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const extractRowsForPreview = (
|
||||
luckysheetData: any,
|
||||
columns: Array<{ id: string }>,
|
||||
): TableRowData[] => {
|
||||
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
|
||||
if (!sheet) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columnIds =
|
||||
columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
|
||||
|
||||
const rows: TableRowData[] = [];
|
||||
const grid = Array.isArray(sheet.data) ? sheet.data : [];
|
||||
|
||||
grid.forEach((row: any[], rowIndex: number) => {
|
||||
if (!Array.isArray(row)) return;
|
||||
const rowObj: TableRowData = {};
|
||||
let hasValue = false;
|
||||
columnIds.forEach((colId, colIndex) => {
|
||||
const value = pickCellValue(row[colIndex]);
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
rowObj[colId] = value;
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
if (hasValue) {
|
||||
rows.push(rowObj);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
|
||||
const map = new Map<number, TableRowData>();
|
||||
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
|
||||
const value = pickCellValue(cell?.v ?? cell);
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
const existing = map.get(cell.r) ?? {};
|
||||
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
|
||||
map.set(cell.r, existing);
|
||||
});
|
||||
Array.from(map.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.forEach(([, row]) => rows.push(row));
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ import { clamp } from "@/lib/constants";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type PanelPage = "chat" | "tools" | "settings";
|
||||
@@ -37,6 +39,8 @@ const ONLINE_MODELS = [
|
||||
"gemini-3-flash-preview",
|
||||
] as const;
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
@@ -95,10 +99,13 @@ export function OnlyOfficeAiAgentPanel({
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
|
||||
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
|
||||
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
|
||||
|
||||
const [pluginReady, setPluginReady] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -131,7 +138,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
const providerRaw = (window.localStorage.getItem("onlyoffice_ai_provider") || "").trim();
|
||||
const modelRaw = window.localStorage.getItem("onlyoffice_ai_model") || "";
|
||||
const parsed = Number(stepsRaw);
|
||||
if (providerRaw === "online" || providerRaw === "local") setAiProvider(providerRaw);
|
||||
if (providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex") setAiProvider(providerRaw);
|
||||
if (typeof modelRaw === "string") setAiModel(modelRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) setMaxSteps(clamp(Math.floor(parsed), 1, 24));
|
||||
} catch {
|
||||
@@ -281,6 +288,9 @@ export function OnlyOfficeAiAgentPanel({
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
@@ -288,6 +298,9 @@ export function OnlyOfficeAiAgentPanel({
|
||||
if (!text) return;
|
||||
if (loading) return;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
@@ -318,7 +331,14 @@ export function OnlyOfficeAiAgentPanel({
|
||||
"toolset.onlyoffice_editor",
|
||||
],
|
||||
},
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel } },
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(aiModel || "").trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -329,6 +349,19 @@ export function OnlyOfficeAiAgentPanel({
|
||||
}
|
||||
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
|
||||
const sid = String(obj.sessionId ?? "").trim();
|
||||
if (sid) {
|
||||
setCodexSessionId(sid);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === "assistant_message") {
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
@@ -386,6 +419,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const d = JSON.parse(dataText || "null") as unknown;
|
||||
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
|
||||
@@ -397,6 +431,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
} finally {
|
||||
@@ -495,6 +530,13 @@ export function OnlyOfficeAiAgentPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
@@ -544,29 +586,65 @@ export function OnlyOfficeAiAgentPanel({
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">模型</span>
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading || aiProvider !== "online"}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
className="w-[220px] rounded border px-2 py-1 text-xs"
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "默认"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="w-[320px] rounded border px-2 py-1 text-xs"
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="oo-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<datalist id="oo-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -602,7 +680,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
发送
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop}>
|
||||
停止
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AssetContextMenuProps {
|
||||
asset: MediaAsset;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
onOpen: (asset: MediaAsset) => void;
|
||||
onCopyLink: (asset: MediaAsset) => void;
|
||||
onCopyPath: (asset: MediaAsset) => void;
|
||||
onRename: (asset: MediaAsset) => void;
|
||||
onMove: (asset: MediaAsset) => void;
|
||||
onDelete: (assetIds: string[]) => void;
|
||||
onDownload: (asset: MediaAsset) => void;
|
||||
}
|
||||
|
||||
export function AssetContextMenu({
|
||||
asset,
|
||||
position,
|
||||
onClose,
|
||||
onOpen,
|
||||
onCopyLink,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMove,
|
||||
onDelete,
|
||||
onDownload,
|
||||
}: AssetContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AssetContextMenuProps {
|
||||
asset: MediaAsset;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
onOpen: (asset: MediaAsset) => void;
|
||||
onCopyLink: (asset: MediaAsset) => void;
|
||||
onCopyPath: (asset: MediaAsset) => void;
|
||||
onRename: (asset: MediaAsset) => void;
|
||||
onMove: (asset: MediaAsset) => void;
|
||||
onDelete: (assetIds: string[]) => void;
|
||||
onDownload: (asset: MediaAsset) => void;
|
||||
}
|
||||
|
||||
export function AssetContextMenu({
|
||||
asset,
|
||||
position,
|
||||
onClose,
|
||||
onOpen,
|
||||
onCopyLink,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMove,
|
||||
onDelete,
|
||||
onDownload,
|
||||
}: AssetContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState(position);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const clampPosition = () => {
|
||||
const element = menuRef.current;
|
||||
if (!element) {
|
||||
setPos(position);
|
||||
return;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const padding = 12;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const clampPosition = () => {
|
||||
const element = menuRef.current;
|
||||
if (!element) {
|
||||
setPos(position);
|
||||
return;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const padding = 12;
|
||||
const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
|
||||
const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
|
||||
const left = Math.min(Math.max(padding, position.x), maxLeft);
|
||||
const top = Math.min(Math.max(padding, position.y), maxTop);
|
||||
setPos({ x: left, y: top });
|
||||
};
|
||||
clampPosition();
|
||||
window.addEventListener("resize", clampPosition);
|
||||
return () => window.removeEventListener("resize", clampPosition);
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = () => onClose();
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, [onClose]);
|
||||
|
||||
const buttonClass =
|
||||
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
|
||||
clampPosition();
|
||||
window.addEventListener("resize", clampPosition);
|
||||
return () => window.removeEventListener("resize", clampPosition);
|
||||
}, [position]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = () => onClose();
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, [onClose]);
|
||||
|
||||
const buttonClass =
|
||||
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
|
||||
style={{ top: pos.y, left: pos.x, minWidth: 200 }}
|
||||
>
|
||||
<button type="button" className={buttonClass} onClick={() => onOpen(asset)}>
|
||||
<LinkIcon className="h-4 w-4 text-[#2563eb]" />
|
||||
<span>在新标签打开</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onDownload(asset)}>
|
||||
<Download className="h-4 w-4 text-gray-500" />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onCopyLink(asset)}>
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onCopyPath(asset)}>
|
||||
<Hash className="h-4 w-4 text-gray-500" />
|
||||
<span>复制存储路径</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-[#f2f2f2]" />
|
||||
<button type="button" className={buttonClass} onClick={() => onRename(asset)}>
|
||||
<PenLine className="h-4 w-4 text-gray-500" />
|
||||
<span>重命名</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onMove(asset)}>
|
||||
<Move className="h-4 w-4 text-gray-500" />
|
||||
<span>移动到...</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
|
||||
onClick={() => onDelete([asset.id])}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
<button type="button" className={buttonClass} onClick={() => onOpen(asset)}>
|
||||
<LinkIcon className="h-4 w-4 text-[#2563eb]" />
|
||||
<span>在新标签打开</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onDownload(asset)}>
|
||||
<Download className="h-4 w-4 text-gray-500" />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onCopyLink(asset)}>
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
<span>复制链接</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onCopyPath(asset)}>
|
||||
<Hash className="h-4 w-4 text-gray-500" />
|
||||
<span>复制存储路径</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-[#f2f2f2]" />
|
||||
<button type="button" className={buttonClass} onClick={() => onRename(asset)}>
|
||||
<PenLine className="h-4 w-4 text-gray-500" />
|
||||
<span>重命名</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => onMove(asset)}>
|
||||
<Move className="h-4 w-4 text-gray-500" />
|
||||
<span>移动到...</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
|
||||
onClick={() => onDelete([asset.id])}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
||||
|
||||
export interface TrashRecord {
|
||||
id: string;
|
||||
title: string | null;
|
||||
deleted_at: string;
|
||||
parent_id: string | null;
|
||||
access_scope: DocumentRecord["access_scope"];
|
||||
}
|
||||
|
||||
|
||||
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
|
||||
|
||||
export interface TrashRecord {
|
||||
id: string;
|
||||
title: string | null;
|
||||
deleted_at: string;
|
||||
parent_id: string | null;
|
||||
access_scope: DocumentRecord["access_scope"];
|
||||
}
|
||||
|
||||
export interface SidebarInitialData {
|
||||
activeWorkspaceId: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
@@ -20,15 +20,15 @@ export function useBackendHealth() {
|
||||
} catch {
|
||||
if (!destroyed) {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
check();
|
||||
return () => {
|
||||
destroyed = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
check();
|
||||
return () => {
|
||||
destroyed = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
* 使用 Convex 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
*
|
||||
* 注意:此 hook 仅应在 Convex 模式下使用
|
||||
* 后端从 ctx.auth.getUserIdentity() 获取用户身份,前端无需传入 userId
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
* 使用 Convex 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
*
|
||||
* 注意:此 hook 仅应在 Convex 模式下使用
|
||||
* 后端从 ctx.auth.getUserIdentity() 获取用户身份,前端无需传入 userId
|
||||
*/
|
||||
export function useConvexSidebarData(workspaceId: string): {
|
||||
data: SidebarInitialData | null;
|
||||
isLoading: boolean;
|
||||
@@ -89,57 +89,57 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
|
||||
const activeMindmaps = (mindmaps ?? []).filter((r) => !r.deleted_at);
|
||||
const trashedMindmaps = (mindmaps ?? []).filter((r) => !!r.deleted_at);
|
||||
|
||||
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||
|
||||
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? workspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||
|
||||
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? workspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? workspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: r.deleted_at ?? null,
|
||||
deleted_by: r.deleted_by ?? null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? workspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: r.deleted_at ?? null,
|
||||
deleted_by: r.deleted_by ?? null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -239,13 +239,13 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
workspacesResult === undefined
|
||||
);
|
||||
const error = null;
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 是空操作
|
||||
// Convex 的 useQuery 会自动实时同步,无需手动 refetch
|
||||
// 这个方法只是为了保持与 useSidebarData 的接口兼容
|
||||
const refetch = async () => {
|
||||
// 空操作 - Convex 会自动同步数据
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
}
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 是空操作
|
||||
// Convex 的 useQuery 会自动实时同步,无需手动 refetch
|
||||
// 这个方法只是为了保持与 useSidebarData 的接口兼容
|
||||
const refetch = async () => {
|
||||
// 空操作 - Convex 会自动同步数据
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@ import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@
|
||||
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
|
||||
|
||||
export type AgentChatFn = (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
cfg: OpenAiCompatibleChatOptions,
|
||||
) => Promise<{ text: string; raw: unknown }>;
|
||||
|
||||
export type RunAiAgentArgs = {
|
||||
userMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
cfg: OpenAiCompatibleChatOptions;
|
||||
chat?: AgentChatFn;
|
||||
allowedToolIds: Set<string>;
|
||||
runTool: (toolId: string, toolArgs: Record<string, unknown>) => Promise<unknown>;
|
||||
maxSteps?: number;
|
||||
@@ -246,6 +252,7 @@ export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text
|
||||
const maxSteps = Math.max(1, Math.min(24, Math.floor(args.maxSteps ?? DEFAULT_MAX_STEPS)));
|
||||
const allowedToolIds = args.allowedToolIds;
|
||||
const allowedToolNames = new Set<string>(Array.from(allowedToolIds));
|
||||
const chat = args.chat ?? openAiCompatibleChat;
|
||||
|
||||
const messages: OpenAiCompatibleChatMessage[] = [
|
||||
{ role: "system", content: buildSystemPrompt(allowedToolIds, args.systemContextText) },
|
||||
@@ -288,7 +295,7 @@ export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text
|
||||
};
|
||||
|
||||
for (; steps < maxSteps; steps += 1) {
|
||||
const { text } = await openAiCompatibleChat(messages, args.cfg);
|
||||
const { text } = await chat(messages, args.cfg);
|
||||
const parsed = parseToolTagCalls(text, allowedToolNames);
|
||||
|
||||
if (parsed.calls.length === 0) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { spawn } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type CodexSandbox = "read-only" | "workspace-write";
|
||||
|
||||
export type CodexExecJsonLine = {
|
||||
type: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const getCodexBin = () => (process.platform === "win32" ? "codex.cmd" : "codex");
|
||||
|
||||
const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
|
||||
|
||||
const safeKill = (child: ReturnType<typeof spawn> | null | undefined) => {
|
||||
if (!child) return;
|
||||
try {
|
||||
if (child.exitCode !== null) return;
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 startDir 向上查找 workspace 根目录。
|
||||
* - 优先以 `AGENTS.md` 作为锚点(本仓库协作约定)
|
||||
* - 若找不到,则退化为 `startDir`
|
||||
*/
|
||||
export const findWorkspaceRoot = async (startDir: string): Promise<string> => {
|
||||
let dir = path.resolve(startDir || process.cwd());
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const agents = path.join(dir, "AGENTS.md");
|
||||
try {
|
||||
const st = await fs.stat(agents);
|
||||
if (st.isFile()) return dir;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.resolve(startDir || process.cwd());
|
||||
};
|
||||
|
||||
const buildCodexPromptFromMessages = (
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
|
||||
): string => {
|
||||
const lines: string[] = [];
|
||||
for (const m of messages) {
|
||||
const role = m.role === "system" ? "系统" : m.role === "user" ? "用户" : "助手";
|
||||
lines.push(`【${role}】`);
|
||||
lines.push(String(m.content ?? "").trimEnd());
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
};
|
||||
|
||||
export const codexExecToLastMessage = async ({
|
||||
cwd,
|
||||
sandbox,
|
||||
prompt,
|
||||
model,
|
||||
}: {
|
||||
cwd: string;
|
||||
sandbox: CodexSandbox;
|
||||
prompt: string;
|
||||
model?: string | null;
|
||||
}): Promise<{ text: string; rawLines: CodexExecJsonLine[] }> => {
|
||||
const bin = getCodexBin();
|
||||
// 用 stdin 传入 prompt,避免 Windows 命令行长度限制
|
||||
const args = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
|
||||
if (isNonEmptyString(model)) args.push("-m", model.trim());
|
||||
args.push("-");
|
||||
|
||||
const rawLines: CodexExecJsonLine[] = [];
|
||||
let lastAgentText = "";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(bin, args, {
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin?.setDefaultEncoding("utf8");
|
||||
child.stdin?.write(String(prompt ?? ""));
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) break;
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const j = JSON.parse(line) as CodexExecJsonLine;
|
||||
rawLines.push(j);
|
||||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||||
lastAgentText = String(j.item.text ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore:非 JSON 行
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", () => {
|
||||
// ignore:codex --json 主要走 stdout
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (buf.trim()) {
|
||||
try {
|
||||
const j = JSON.parse(buf.trim()) as CodexExecJsonLine;
|
||||
rawLines.push(j);
|
||||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||||
lastAgentText = String(j.item.text ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (code && code !== 0) {
|
||||
reject(new Error(`Codex 执行失败:exit=${code}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return { text: lastAgentText.trim() ? lastAgentText.trim() : "(无输出)", rawLines };
|
||||
};
|
||||
|
||||
export const codexMessagesToPrompt = buildCodexPromptFromMessages;
|
||||
|
||||
export const startCodexJsonRun = ({
|
||||
cwd,
|
||||
sandbox,
|
||||
prompt,
|
||||
model,
|
||||
sessionId,
|
||||
onJsonLine,
|
||||
}: {
|
||||
cwd: string;
|
||||
sandbox: CodexSandbox;
|
||||
prompt: string;
|
||||
model?: string | null;
|
||||
sessionId?: string | null;
|
||||
onJsonLine?: (line: CodexExecJsonLine) => void;
|
||||
}) => {
|
||||
const bin = getCodexBin();
|
||||
|
||||
const args = (() => {
|
||||
// 新会话:可指定 -C / -s
|
||||
if (!isNonEmptyString(sessionId)) {
|
||||
const a = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
|
||||
if (isNonEmptyString(model)) a.push("-m", model.trim());
|
||||
a.push("-"); // stdin prompt
|
||||
return a;
|
||||
}
|
||||
|
||||
// 续聊:用 sessionId 恢复;resume 子命令不支持 -C/-s(沿用会话配置)
|
||||
const a = ["exec", "resume", sessionId.trim(), "--json"];
|
||||
if (isNonEmptyString(model)) a.push("-m", model.trim());
|
||||
a.push("-"); // stdin prompt
|
||||
return a;
|
||||
})();
|
||||
|
||||
const child = spawn(bin, args, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin?.setDefaultEncoding("utf8");
|
||||
child.stdin?.write(String(prompt ?? ""));
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let threadId = "";
|
||||
let lastAgentText = "";
|
||||
|
||||
let buf = "";
|
||||
const parseLine = (line: string) => {
|
||||
const s = String(line ?? "").trim();
|
||||
if (!s) return;
|
||||
try {
|
||||
const j = JSON.parse(s) as CodexExecJsonLine;
|
||||
onJsonLine?.(j);
|
||||
if (j.type === "thread.started" && isNonEmptyString(j.thread_id)) {
|
||||
threadId = j.thread_id.trim();
|
||||
}
|
||||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||||
lastAgentText = String(j.item.text ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) break;
|
||||
const line = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + 1);
|
||||
parseLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on("data", () => {
|
||||
// ignore:--json 主要走 stdout
|
||||
});
|
||||
|
||||
const done = new Promise<{ ok: true; threadId: string; text: string } | { ok: false; error: string; threadId: string; text: string }>((resolve) => {
|
||||
child.on("close", (code) => {
|
||||
if (buf.trim()) parseLine(buf);
|
||||
const text = lastAgentText.trim() ? lastAgentText.trim() : "(无输出)";
|
||||
if (code && code !== 0) {
|
||||
resolve({ ok: false, error: `Codex 执行失败:exit=${code}`, threadId, text });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, threadId, text });
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
resolve({ ok: false, error: e instanceof Error ? e.message : String(e), threadId, text: lastAgentText.trim() || "(无输出)" });
|
||||
});
|
||||
});
|
||||
|
||||
return { child, done, kill: () => safeKill(child) };
|
||||
};
|
||||
+152
-152
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* API 工具函数
|
||||
* 提供统一的错误处理和响应解析
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 API 错误响应格式
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 API 错误响应
|
||||
* @param message 错误消息
|
||||
* @param status HTTP 状态码
|
||||
* @param details 额外的错误详情
|
||||
* @returns NextResponse
|
||||
*/
|
||||
/**
|
||||
* API 工具函数
|
||||
* 提供统一的错误处理和响应解析
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 API 错误响应格式
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 API 错误响应
|
||||
* @param message 错误消息
|
||||
* @param status HTTP 状态码
|
||||
* @param details 额外的错误详情
|
||||
* @returns NextResponse
|
||||
*/
|
||||
export function apiErrorResponse(
|
||||
message: string,
|
||||
status = 500,
|
||||
@@ -43,119 +43,119 @@ export function apiErrorResponse(
|
||||
typeof details === "undefined" ? { error: message } : { error: message, details };
|
||||
return NextResponse.json(payload, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用错误响应的快捷方法
|
||||
*/
|
||||
export const errorResponses = {
|
||||
/** 400 - 请求参数错误 */
|
||||
badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),
|
||||
|
||||
/** 401 - 未登录 */
|
||||
unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),
|
||||
|
||||
/** 403 - 无权限 */
|
||||
forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),
|
||||
|
||||
/** 404 - 资源不存在 */
|
||||
notFound: (message: string = "资源不存在") => apiErrorResponse(message, 404),
|
||||
|
||||
/** 500 - 服务器错误 */
|
||||
internalError: (message: string = "服务器错误") => apiErrorResponse(message, 500),
|
||||
|
||||
/** AI 配置错误 */
|
||||
aiConfigError: (provider: "online" | "local") =>
|
||||
apiErrorResponse(
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)",
|
||||
500,
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 处理 fetch 响应并解析 JSON
|
||||
* 如果响应不成功,抛出 ApiError
|
||||
* @param response Fetch Response 对象
|
||||
* @param defaultErrorMessage 默认错误消息
|
||||
* @returns 解析后的 JSON 数据
|
||||
*/
|
||||
export async function handleApiResponse<T>(
|
||||
response: Response,
|
||||
defaultErrorMessage: string = "请求失败",
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = defaultErrorMessage;
|
||||
let details: unknown;
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === "object") {
|
||||
if ("error" in payload && typeof payload.error === "string") {
|
||||
message = payload.error;
|
||||
}
|
||||
if ("details" in payload) {
|
||||
details = payload.details;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,使用默认消息
|
||||
}
|
||||
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地解析 JSON,失败时返回 null
|
||||
* @param raw JSON 字符串
|
||||
* @returns 解析后的对象或 null
|
||||
*/
|
||||
export function safeParseJson<T = unknown>(raw: string | null | undefined): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求体是否包含必需字段
|
||||
* @param body 请求体对象
|
||||
* @param requiredFields 必需字段列表
|
||||
* @returns 如果验证失败,返回错误响应;否则返回 null
|
||||
*/
|
||||
export function validateRequestBody<T extends Record<string, unknown>>(
|
||||
body: T | null,
|
||||
requiredFields: (keyof T)[],
|
||||
): NextResponse<ApiErrorResponse> | null {
|
||||
if (!body) {
|
||||
return apiErrorResponse("请求体为空", 400);
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in body) || body[field] === null || body[field] === undefined) {
|
||||
return apiErrorResponse(`缺少必需字段: ${String(field)}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中安全地获取 JSON
|
||||
* @param request Next.js Request 对象
|
||||
* @returns 解析后的 JSON 或 null
|
||||
*/
|
||||
export async function safeGetJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用错误响应的快捷方法
|
||||
*/
|
||||
export const errorResponses = {
|
||||
/** 400 - 请求参数错误 */
|
||||
badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),
|
||||
|
||||
/** 401 - 未登录 */
|
||||
unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),
|
||||
|
||||
/** 403 - 无权限 */
|
||||
forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),
|
||||
|
||||
/** 404 - 资源不存在 */
|
||||
notFound: (message: string = "资源不存在") => apiErrorResponse(message, 404),
|
||||
|
||||
/** 500 - 服务器错误 */
|
||||
internalError: (message: string = "服务器错误") => apiErrorResponse(message, 500),
|
||||
|
||||
/** AI 配置错误 */
|
||||
aiConfigError: (provider: "online" | "local") =>
|
||||
apiErrorResponse(
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)",
|
||||
500,
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 处理 fetch 响应并解析 JSON
|
||||
* 如果响应不成功,抛出 ApiError
|
||||
* @param response Fetch Response 对象
|
||||
* @param defaultErrorMessage 默认错误消息
|
||||
* @returns 解析后的 JSON 数据
|
||||
*/
|
||||
export async function handleApiResponse<T>(
|
||||
response: Response,
|
||||
defaultErrorMessage: string = "请求失败",
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = defaultErrorMessage;
|
||||
let details: unknown;
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === "object") {
|
||||
if ("error" in payload && typeof payload.error === "string") {
|
||||
message = payload.error;
|
||||
}
|
||||
if ("details" in payload) {
|
||||
details = payload.details;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,使用默认消息
|
||||
}
|
||||
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地解析 JSON,失败时返回 null
|
||||
* @param raw JSON 字符串
|
||||
* @returns 解析后的对象或 null
|
||||
*/
|
||||
export function safeParseJson<T = unknown>(raw: string | null | undefined): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求体是否包含必需字段
|
||||
* @param body 请求体对象
|
||||
* @param requiredFields 必需字段列表
|
||||
* @returns 如果验证失败,返回错误响应;否则返回 null
|
||||
*/
|
||||
export function validateRequestBody<T extends Record<string, unknown>>(
|
||||
body: T | null,
|
||||
requiredFields: (keyof T)[],
|
||||
): NextResponse<ApiErrorResponse> | null {
|
||||
if (!body) {
|
||||
return apiErrorResponse("请求体为空", 400);
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in body) || body[field] === null || body[field] === undefined) {
|
||||
return apiErrorResponse(`缺少必需字段: ${String(field)}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中安全地获取 JSON
|
||||
* @param request Next.js Request 对象
|
||||
* @returns 解析后的 JSON 或 null
|
||||
*/
|
||||
export async function safeGetJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+127
-127
@@ -1,127 +1,127 @@
|
||||
/**
|
||||
* 全局常量定义
|
||||
* 集中管理项目中的魔法数字和硬编码值
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// AI Agent 相关常量
|
||||
// ============================================
|
||||
|
||||
/** AI Agent 默认最大步数 */
|
||||
export const DEFAULT_AGENT_MAX_STEPS = 10;
|
||||
|
||||
/** AI Agent 最大步数限制 */
|
||||
export const MAX_AGENT_STEPS = 24;
|
||||
|
||||
/** AI Agent 最小步数 */
|
||||
export const MIN_AGENT_STEPS = 1;
|
||||
|
||||
/** 客户端工具默认超时时间(毫秒) */
|
||||
export const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// ============================================
|
||||
// 网络请求相关常量
|
||||
// ============================================
|
||||
|
||||
/** OpenAI 兼容 API 最小超时(毫秒) */
|
||||
export const MIN_API_TIMEOUT_MS = 500;
|
||||
|
||||
/** OpenAI 兼容 API 默认超时(毫秒) */
|
||||
export const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** OpenAI 兼容 API 最大超时(毫秒) */
|
||||
export const MAX_API_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OpenAI 最小输出 token 数 */
|
||||
export const MIN_COMPLETION_TOKENS = 64;
|
||||
|
||||
/** OpenAI 最大输出 token 数 */
|
||||
export const MAX_COMPLETION_TOKENS = 16_000;
|
||||
|
||||
// ============================================
|
||||
// 编辑器相关常量
|
||||
// ============================================
|
||||
|
||||
/** 内容加载延迟时间(毫秒) */
|
||||
export const CONTENT_LOADING_DELAY_MS = 200;
|
||||
|
||||
/** 编辑器块标题最小级别 */
|
||||
export const MIN_BLOCK_LEVEL = 1;
|
||||
|
||||
/** 编辑器块标题最大级别 */
|
||||
export const MAX_BLOCK_LEVEL = 5;
|
||||
|
||||
/** 零延迟 setTimeout(用于将任务推入事件循环) */
|
||||
export const ZERO_DELAY_MS = 0;
|
||||
|
||||
// ============================================
|
||||
// 思维导图相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图最大附件数量 */
|
||||
export const MAX_MINDMAP_ATTACHMENTS = 12;
|
||||
|
||||
/** 思维导图最大选中节点数 */
|
||||
export const MAX_SELECTED_NODES = 6;
|
||||
|
||||
// ============================================
|
||||
// RAG 搜索相关常量
|
||||
// ============================================
|
||||
|
||||
/** RAG 默认搜索结果数量 */
|
||||
export const DEFAULT_RAG_TOP_K = 12;
|
||||
|
||||
/** RAG 默认 chunk 结果数量 */
|
||||
export const DEFAULT_RAG_CHUNK_TOP_K = 12;
|
||||
|
||||
/** 文档搜索默认结果数量 */
|
||||
export const DEFAULT_DOCS_SEARCH_LIMIT = 12;
|
||||
|
||||
/** 文档读取默认最大字符数 */
|
||||
export const DEFAULT_DOCS_READ_MAX_CHARS = 2500;
|
||||
|
||||
/** 文档获取默认最大块数 */
|
||||
export const DEFAULT_DOC_GET_MAX_BLOCKS = 80;
|
||||
|
||||
/** 文档查找默认最大结果数 */
|
||||
export const DEFAULT_DOC_FIND_MAX_RESULTS = 8;
|
||||
|
||||
// ============================================
|
||||
// 资产/附件相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图从资产转换最大项目数 */
|
||||
export const DEFAULT_ASSET_TO_MINDMAP_MAX_ITEMS = 120;
|
||||
|
||||
/** 搜索默认结果数量 */
|
||||
export const DEFAULT_SEARCH_COUNT = 6;
|
||||
|
||||
// ============================================
|
||||
// UI 相关常量
|
||||
// ============================================
|
||||
|
||||
/** 表格嵌入预览最小高度(像素) */
|
||||
export const MIN_EMBED_HEIGHT = 120;
|
||||
|
||||
/** 表格嵌入预览最大高度(像素) */
|
||||
export const MAX_EMBED_HEIGHT = 600;
|
||||
|
||||
/** 表格嵌入预览默认高度(像素) */
|
||||
export const DEFAULT_EMBED_HEIGHT = 300;
|
||||
|
||||
/** 上下文菜单距离窗口边缘的最小内边距(像素) */
|
||||
export const CONTEXT_MENU_PADDING = 12;
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 将数值限制在指定范围内
|
||||
* @param n 输入值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 限制后的值
|
||||
*/
|
||||
export const clamp = (n: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, n));
|
||||
/**
|
||||
* 全局常量定义
|
||||
* 集中管理项目中的魔法数字和硬编码值
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// AI Agent 相关常量
|
||||
// ============================================
|
||||
|
||||
/** AI Agent 默认最大步数 */
|
||||
export const DEFAULT_AGENT_MAX_STEPS = 10;
|
||||
|
||||
/** AI Agent 最大步数限制 */
|
||||
export const MAX_AGENT_STEPS = 24;
|
||||
|
||||
/** AI Agent 最小步数 */
|
||||
export const MIN_AGENT_STEPS = 1;
|
||||
|
||||
/** 客户端工具默认超时时间(毫秒) */
|
||||
export const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// ============================================
|
||||
// 网络请求相关常量
|
||||
// ============================================
|
||||
|
||||
/** OpenAI 兼容 API 最小超时(毫秒) */
|
||||
export const MIN_API_TIMEOUT_MS = 500;
|
||||
|
||||
/** OpenAI 兼容 API 默认超时(毫秒) */
|
||||
export const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** OpenAI 兼容 API 最大超时(毫秒) */
|
||||
export const MAX_API_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OpenAI 最小输出 token 数 */
|
||||
export const MIN_COMPLETION_TOKENS = 64;
|
||||
|
||||
/** OpenAI 最大输出 token 数 */
|
||||
export const MAX_COMPLETION_TOKENS = 16_000;
|
||||
|
||||
// ============================================
|
||||
// 编辑器相关常量
|
||||
// ============================================
|
||||
|
||||
/** 内容加载延迟时间(毫秒) */
|
||||
export const CONTENT_LOADING_DELAY_MS = 200;
|
||||
|
||||
/** 编辑器块标题最小级别 */
|
||||
export const MIN_BLOCK_LEVEL = 1;
|
||||
|
||||
/** 编辑器块标题最大级别 */
|
||||
export const MAX_BLOCK_LEVEL = 5;
|
||||
|
||||
/** 零延迟 setTimeout(用于将任务推入事件循环) */
|
||||
export const ZERO_DELAY_MS = 0;
|
||||
|
||||
// ============================================
|
||||
// 思维导图相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图最大附件数量 */
|
||||
export const MAX_MINDMAP_ATTACHMENTS = 12;
|
||||
|
||||
/** 思维导图最大选中节点数 */
|
||||
export const MAX_SELECTED_NODES = 6;
|
||||
|
||||
// ============================================
|
||||
// RAG 搜索相关常量
|
||||
// ============================================
|
||||
|
||||
/** RAG 默认搜索结果数量 */
|
||||
export const DEFAULT_RAG_TOP_K = 12;
|
||||
|
||||
/** RAG 默认 chunk 结果数量 */
|
||||
export const DEFAULT_RAG_CHUNK_TOP_K = 12;
|
||||
|
||||
/** 文档搜索默认结果数量 */
|
||||
export const DEFAULT_DOCS_SEARCH_LIMIT = 12;
|
||||
|
||||
/** 文档读取默认最大字符数 */
|
||||
export const DEFAULT_DOCS_READ_MAX_CHARS = 2500;
|
||||
|
||||
/** 文档获取默认最大块数 */
|
||||
export const DEFAULT_DOC_GET_MAX_BLOCKS = 80;
|
||||
|
||||
/** 文档查找默认最大结果数 */
|
||||
export const DEFAULT_DOC_FIND_MAX_RESULTS = 8;
|
||||
|
||||
// ============================================
|
||||
// 资产/附件相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图从资产转换最大项目数 */
|
||||
export const DEFAULT_ASSET_TO_MINDMAP_MAX_ITEMS = 120;
|
||||
|
||||
/** 搜索默认结果数量 */
|
||||
export const DEFAULT_SEARCH_COUNT = 6;
|
||||
|
||||
// ============================================
|
||||
// UI 相关常量
|
||||
// ============================================
|
||||
|
||||
/** 表格嵌入预览最小高度(像素) */
|
||||
export const MIN_EMBED_HEIGHT = 120;
|
||||
|
||||
/** 表格嵌入预览最大高度(像素) */
|
||||
export const MAX_EMBED_HEIGHT = 600;
|
||||
|
||||
/** 表格嵌入预览默认高度(像素) */
|
||||
export const DEFAULT_EMBED_HEIGHT = 300;
|
||||
|
||||
/** 上下文菜单距离窗口边缘的最小内边距(像素) */
|
||||
export const CONTEXT_MENU_PADDING = 12;
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 将数值限制在指定范围内
|
||||
* @param n 输入值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 限制后的值
|
||||
*/
|
||||
export const clamp = (n: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, n));
|
||||
|
||||
@@ -27,4 +27,4 @@ export const composeContentWithBlocks = (content: unknown, blocks: Json[]): Json
|
||||
} as Json;
|
||||
}
|
||||
return { blocks } as Json;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,17 +50,17 @@ export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSn
|
||||
* @param documentId 当前文档的 ID
|
||||
* @param title 表格的初始标题
|
||||
* @returns 新创建的 DocumentTable
|
||||
*/
|
||||
export async function createOnlineTable(
|
||||
documentId: string,
|
||||
title: string = "未命名表格"
|
||||
): Promise<DocumentTable> {
|
||||
// 假设 Next.js API 路由 /api/tables/create 负责与 Supabase 交互
|
||||
const response = await fetch("/api/tables/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
*/
|
||||
export async function createOnlineTable(
|
||||
documentId: string,
|
||||
title: string = "未命名表格"
|
||||
): Promise<DocumentTable> {
|
||||
// 假设 Next.js API 路由 /api/tables/create 负责与 Supabase 交互
|
||||
const response = await fetch("/api/tables/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
title,
|
||||
@@ -68,28 +68,28 @@ export async function createOnlineTable(
|
||||
snapshot: createDefaultTableSnapshot(DEFAULT_TABLE_SCHEMA),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create online table.");
|
||||
}
|
||||
|
||||
const newTable: DocumentTable = await response.json();
|
||||
return newTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格元数据 (用于紧凑模式渲染)
|
||||
* 实际实现中,这可能需要一个更复杂的获取逻辑,例如同时获取前 N 行数据
|
||||
*/
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create online table.");
|
||||
}
|
||||
|
||||
const newTable: DocumentTable = await response.json();
|
||||
return newTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格元数据 (用于紧凑模式渲染)
|
||||
* 实际实现中,这可能需要一个更复杂的获取逻辑,例如同时获取前 N 行数据
|
||||
*/
|
||||
export async function getDocumentTable(tableId: string): Promise<DocumentTable> {
|
||||
const response = await fetch(`/api/tables/${tableId}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch document table.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch document table.");
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocumentTable>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
type RequestCookies = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
type RequestCookies = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
const decodeValue = (value?: string) => {
|
||||
if (!value) return value;
|
||||
let v = value;
|
||||
@@ -19,25 +19,25 @@ const decodeValue = (value?: string) => {
|
||||
}
|
||||
return v.startsWith("base64-") ? Buffer.from(v.slice(7), "base64").toString("utf8") : v;
|
||||
};
|
||||
|
||||
const encodeValue = (value: string) => {
|
||||
if (!value) return value;
|
||||
// Next.js 会自动 base64 编码,我们只需处理已有 base64 前缀的情况
|
||||
if (value.startsWith("base64-")) {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const wrapCookies = (store: RequestCookies) => {
|
||||
return {
|
||||
get: (name: string) => {
|
||||
const cookie = store.get(name);
|
||||
if (!cookie) return cookie;
|
||||
return { ...cookie, value: decodeValue(cookie.value) };
|
||||
},
|
||||
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
|
||||
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
|
||||
|
||||
const encodeValue = (value: string) => {
|
||||
if (!value) return value;
|
||||
// Next.js 会自动 base64 编码,我们只需处理已有 base64 前缀的情况
|
||||
if (value.startsWith("base64-")) {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const wrapCookies = (store: RequestCookies) => {
|
||||
return {
|
||||
get: (name: string) => {
|
||||
const cookie = store.get(name);
|
||||
if (!cookie) return cookie;
|
||||
return { ...cookie, value: decodeValue(cookie.value) };
|
||||
},
|
||||
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
|
||||
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
|
||||
set: (...args: Parameters<RequestCookies["set"]>) => {
|
||||
const [name, value, options] = args as unknown as [
|
||||
unknown,
|
||||
@@ -50,12 +50,12 @@ const wrapCookies = (store: RequestCookies) => {
|
||||
}
|
||||
(store as any).set(...(args as any));
|
||||
},
|
||||
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
|
||||
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDecodedCookies = async () => {
|
||||
const store = await cookies();
|
||||
return wrapCookies(store) as RequestCookies;
|
||||
};
|
||||
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
|
||||
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDecodedCookies = async () => {
|
||||
const store = await cookies();
|
||||
return wrapCookies(store) as RequestCookies;
|
||||
};
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
/**
|
||||
* 类型守卫和类型断言工具
|
||||
* 用于替代 `any` 类型,提供更安全的类型检查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查值是否为普通对象(非 null、非数组)
|
||||
*/
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为字符串
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数字(有限)
|
||||
*/
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数组
|
||||
*/
|
||||
export function isArray<T = unknown>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (itemGuard) {
|
||||
return value.every(itemGuard);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含指定的属性
|
||||
*/
|
||||
export function hasProperty<K extends string>(
|
||||
obj: unknown,
|
||||
key: K,
|
||||
): obj is Record<K, unknown> {
|
||||
return isPlainObject(obj) && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含多个指定的属性
|
||||
*/
|
||||
export function hasProperties<K extends string>(
|
||||
obj: unknown,
|
||||
keys: K[],
|
||||
): obj is Record<K, unknown> {
|
||||
if (!isPlainObject(obj)) return false;
|
||||
return keys.every(key => key in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取字符串属性
|
||||
*/
|
||||
export function getStringProperty(obj: unknown, key: string, defaultValue: string = ""): string {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isString(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数字属性
|
||||
*/
|
||||
export function getNumberProperty(obj: unknown, key: string, defaultValue: number = 0): number {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isFiniteNumber(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取布尔属性
|
||||
*/
|
||||
export function getBooleanProperty(obj: unknown, key: string, defaultValue: boolean = false): boolean {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return typeof value === "boolean" ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数组属性
|
||||
*/
|
||||
export function getArrayProperty<T = unknown>(
|
||||
obj: unknown,
|
||||
key: string,
|
||||
defaultValue: T[] = [],
|
||||
): T[] {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行对象(包含 id 属性)
|
||||
*/
|
||||
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
|
||||
return isPlainObject(value) && ("id" in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行数组
|
||||
*/
|
||||
export function isDatabaseRowArray(value: unknown): value is Array<{ id: string | number; [key: string]: unknown }> {
|
||||
return isArray(value) && value.every(isDatabaseRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型断言:确保值不为 null/undefined
|
||||
*/
|
||||
export function assertNotNullOrUndefined<T>(value: T | null | undefined, message?: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message ?? "值不能为 null 或 undefined");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 unknown 转换为 Record<string, unknown>,如果类型不匹配则返回空对象
|
||||
*/
|
||||
export function toRecord(value: unknown): Record<string, unknown> {
|
||||
return isPlainObject(value) ? value : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地访问嵌套对象属性
|
||||
* @example getNestedValue(obj, 'a.b.c') === obj?.a?.b?.c
|
||||
*/
|
||||
export function getNestedValue<T = unknown>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T,
|
||||
): T | undefined {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!isPlainObject(current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current as T ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查响应是否为错误响应
|
||||
*/
|
||||
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
|
||||
return isPlainObject(value) && isString(value.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 unknown 类型中提取工具参数
|
||||
* 用于 AI agent 工具调用时的类型安全
|
||||
*/
|
||||
export function getToolArgs(args: unknown): Record<string, unknown> {
|
||||
if (isPlainObject(args)) {
|
||||
return args;
|
||||
}
|
||||
// 如果是数组或其他类型,返回空对象
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息
|
||||
*/
|
||||
export function isAgentMessage(value: unknown): value is { role: "user" | "assistant"; content: string } {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
(value.role === "user" || value.role === "assistant") &&
|
||||
isString(value.content)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息数组
|
||||
*/
|
||||
export function isAgentMessageArray(value: unknown): value is Array<{ role: "user" | "assistant"; content: string }> {
|
||||
return isArray(value) && value.every(isAgentMessage);
|
||||
}
|
||||
/**
|
||||
* 类型守卫和类型断言工具
|
||||
* 用于替代 `any` 类型,提供更安全的类型检查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查值是否为普通对象(非 null、非数组)
|
||||
*/
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为字符串
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数字(有限)
|
||||
*/
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数组
|
||||
*/
|
||||
export function isArray<T = unknown>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (itemGuard) {
|
||||
return value.every(itemGuard);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含指定的属性
|
||||
*/
|
||||
export function hasProperty<K extends string>(
|
||||
obj: unknown,
|
||||
key: K,
|
||||
): obj is Record<K, unknown> {
|
||||
return isPlainObject(obj) && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含多个指定的属性
|
||||
*/
|
||||
export function hasProperties<K extends string>(
|
||||
obj: unknown,
|
||||
keys: K[],
|
||||
): obj is Record<K, unknown> {
|
||||
if (!isPlainObject(obj)) return false;
|
||||
return keys.every(key => key in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取字符串属性
|
||||
*/
|
||||
export function getStringProperty(obj: unknown, key: string, defaultValue: string = ""): string {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isString(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数字属性
|
||||
*/
|
||||
export function getNumberProperty(obj: unknown, key: string, defaultValue: number = 0): number {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isFiniteNumber(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取布尔属性
|
||||
*/
|
||||
export function getBooleanProperty(obj: unknown, key: string, defaultValue: boolean = false): boolean {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return typeof value === "boolean" ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数组属性
|
||||
*/
|
||||
export function getArrayProperty<T = unknown>(
|
||||
obj: unknown,
|
||||
key: string,
|
||||
defaultValue: T[] = [],
|
||||
): T[] {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行对象(包含 id 属性)
|
||||
*/
|
||||
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
|
||||
return isPlainObject(value) && ("id" in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行数组
|
||||
*/
|
||||
export function isDatabaseRowArray(value: unknown): value is Array<{ id: string | number; [key: string]: unknown }> {
|
||||
return isArray(value) && value.every(isDatabaseRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型断言:确保值不为 null/undefined
|
||||
*/
|
||||
export function assertNotNullOrUndefined<T>(value: T | null | undefined, message?: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message ?? "值不能为 null 或 undefined");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 unknown 转换为 Record<string, unknown>,如果类型不匹配则返回空对象
|
||||
*/
|
||||
export function toRecord(value: unknown): Record<string, unknown> {
|
||||
return isPlainObject(value) ? value : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地访问嵌套对象属性
|
||||
* @example getNestedValue(obj, 'a.b.c') === obj?.a?.b?.c
|
||||
*/
|
||||
export function getNestedValue<T = unknown>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T,
|
||||
): T | undefined {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!isPlainObject(current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current as T ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查响应是否为错误响应
|
||||
*/
|
||||
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
|
||||
return isPlainObject(value) && isString(value.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 unknown 类型中提取工具参数
|
||||
* 用于 AI agent 工具调用时的类型安全
|
||||
*/
|
||||
export function getToolArgs(args: unknown): Record<string, unknown> {
|
||||
if (isPlainObject(args)) {
|
||||
return args;
|
||||
}
|
||||
// 如果是数组或其他类型,返回空对象
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息
|
||||
*/
|
||||
export function isAgentMessage(value: unknown): value is { role: "user" | "assistant"; content: string } {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
(value.role === "user" || value.role === "assistant") &&
|
||||
isString(value.content)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息数组
|
||||
*/
|
||||
export function isAgentMessageArray(value: unknown): value is Array<{ role: "user" | "assistant"; content: string }> {
|
||||
return isArray(value) && value.every(isAgentMessage);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
type TypedClient = {
|
||||
from: (table: string) => any;
|
||||
};
|
||||
|
||||
|
||||
interface WorkspaceMembershipRow {
|
||||
workspace_id: string;
|
||||
is_default: boolean;
|
||||
@@ -20,76 +20,76 @@ interface WorkspaceMembershipRow {
|
||||
}>
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
iconUrl: string | null;
|
||||
memberCount: number;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export async function ensureDefaultWorkspace(client: TypedClient, userId: string, fallbackName: string): Promise<void> {
|
||||
const { data: memberships, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("user_id", userId)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取工作空间成员信息失败:${error.message}`);
|
||||
}
|
||||
|
||||
if (memberships && memberships.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceName = fallbackName.trim() ? `${fallbackName.trim()} 的空间` : "我的空间";
|
||||
|
||||
const { data: workspace, error: workspaceError } = await client
|
||||
.from("workspaces")
|
||||
.insert({
|
||||
name: workspaceName,
|
||||
type: "personal",
|
||||
created_by: userId,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (workspaceError || !workspace) {
|
||||
throw new Error(`创建默认工作空间失败:${workspaceError?.message ?? "未知错误"}`);
|
||||
}
|
||||
|
||||
const { error: memberError } = await client.from("workspace_members").insert({
|
||||
workspace_id: workspace.id,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
is_default: true,
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
throw new Error(`创建工作空间成员失败:${memberError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSummaries(
|
||||
client: TypedClient,
|
||||
userId: string,
|
||||
): Promise<{ workspaces: WorkspaceSummary[]; activeWorkspaceId: string }> {
|
||||
const { data: memberRows, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default,workspaces(id,name,type,icon_url)")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
throw new Error(`拉取工作空间列表失败:${error.message}`);
|
||||
}
|
||||
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
iconUrl: string | null;
|
||||
memberCount: number;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export async function ensureDefaultWorkspace(client: TypedClient, userId: string, fallbackName: string): Promise<void> {
|
||||
const { data: memberships, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("user_id", userId)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取工作空间成员信息失败:${error.message}`);
|
||||
}
|
||||
|
||||
if (memberships && memberships.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceName = fallbackName.trim() ? `${fallbackName.trim()} 的空间` : "我的空间";
|
||||
|
||||
const { data: workspace, error: workspaceError } = await client
|
||||
.from("workspaces")
|
||||
.insert({
|
||||
name: workspaceName,
|
||||
type: "personal",
|
||||
created_by: userId,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (workspaceError || !workspace) {
|
||||
throw new Error(`创建默认工作空间失败:${workspaceError?.message ?? "未知错误"}`);
|
||||
}
|
||||
|
||||
const { error: memberError } = await client.from("workspace_members").insert({
|
||||
workspace_id: workspace.id,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
is_default: true,
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
throw new Error(`创建工作空间成员失败:${memberError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSummaries(
|
||||
client: TypedClient,
|
||||
userId: string,
|
||||
): Promise<{ workspaces: WorkspaceSummary[]; activeWorkspaceId: string }> {
|
||||
const { data: memberRows, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default,workspaces(id,name,type,icon_url)")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
throw new Error(`拉取工作空间列表失败:${error.message}`);
|
||||
}
|
||||
|
||||
const rows: WorkspaceMembershipRow[] = (memberRows ?? []) as any;
|
||||
const workspaceIds = rows.map((row) => row.workspace_id);
|
||||
|
||||
|
||||
const memberCountMap: Record<string, number> = {};
|
||||
if (workspaceIds.length > 0) {
|
||||
const { data: memberCounts, error: countError } = await client
|
||||
@@ -107,7 +107,7 @@ export async function fetchWorkspaceSummaries(
|
||||
memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const summaries: WorkspaceSummary[] = rows
|
||||
.map((row) => {
|
||||
const workspace = Array.isArray(row.workspaces) ? row.workspaces[0] : row.workspaces;
|
||||
@@ -124,28 +124,28 @@ export async function fetchWorkspaceSummaries(
|
||||
} satisfies WorkspaceSummary;
|
||||
})
|
||||
.filter(Boolean) as WorkspaceSummary[];
|
||||
|
||||
const defaultWorkspace = summaries.find((workspace) => workspace.isDefault);
|
||||
const activeWorkspaceId = defaultWorkspace?.id ?? summaries[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
workspaces: summaries,
|
||||
activeWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveActiveWorkspaceId(client: TypedClient, userId: string): Promise<string> {
|
||||
const { data, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default")
|
||||
.eq("user_id", userId)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取当前工作空间失败:${error.message}`);
|
||||
}
|
||||
|
||||
return data?.[0]?.workspace_id ?? "";
|
||||
}
|
||||
|
||||
const defaultWorkspace = summaries.find((workspace) => workspace.isDefault);
|
||||
const activeWorkspaceId = defaultWorkspace?.id ?? summaries[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
workspaces: summaries,
|
||||
activeWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveActiveWorkspaceId(client: TypedClient, userId: string): Promise<string> {
|
||||
const { data, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default")
|
||||
.eq("user_id", userId)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取当前工作空间失败:${error.message}`);
|
||||
}
|
||||
|
||||
return data?.[0]?.workspace_id ?? "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useAiAgentUiStore } from "./ai-agent-ui";
|
||||
|
||||
describe("useAiAgentUiStore", () => {
|
||||
beforeEach(() => {
|
||||
useAiAgentUiStore.setState({
|
||||
documentAgentAvailable: false,
|
||||
documentAgentOpen: false,
|
||||
globalAgentOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("全局 AI 开关不依赖页面 AI 可用态", () => {
|
||||
const state = useAiAgentUiStore.getState();
|
||||
|
||||
state.toggleGlobalAgentOpen();
|
||||
expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(true);
|
||||
|
||||
state.toggleGlobalAgentOpen();
|
||||
expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,25 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type AiAgentUiState = {
|
||||
globalAgentOpen: boolean;
|
||||
documentAgentAvailable: boolean;
|
||||
documentAgentOpen: boolean;
|
||||
setGlobalAgentOpen: (open: boolean) => void;
|
||||
toggleGlobalAgentOpen: () => void;
|
||||
setDocumentAgentAvailable: (available: boolean) => void;
|
||||
setDocumentAgentOpen: (open: boolean) => void;
|
||||
toggleDocumentAgentOpen: () => void;
|
||||
};
|
||||
|
||||
export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({
|
||||
globalAgentOpen: false,
|
||||
documentAgentAvailable: false,
|
||||
documentAgentOpen: false,
|
||||
setGlobalAgentOpen: (open) => set({ globalAgentOpen: open }),
|
||||
toggleGlobalAgentOpen: () => {
|
||||
const s = get();
|
||||
set({ globalAgentOpen: !s.globalAgentOpen });
|
||||
},
|
||||
setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }),
|
||||
setDocumentAgentOpen: (open) => set({ documentAgentOpen: open }),
|
||||
toggleDocumentAgentOpen: () => {
|
||||
@@ -21,4 +30,3 @@ export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({
|
||||
set({ documentAgentOpen: !s.documentAgentOpen });
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { ReferenceTarget } from "@/types/search";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export interface EditorReferenceBridgeResult {
|
||||
blockId: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface EditorReferenceBridgeResult {
|
||||
blockId: string | null;
|
||||
}
|
||||
|
||||
export interface EditorReferenceBridge {
|
||||
insertInlineReference: (target: ReferenceTarget, alias?: string) => EditorReferenceBridgeResult;
|
||||
insertEmbedReference: (target: ReferenceTarget) => EditorReferenceBridgeResult;
|
||||
@@ -33,13 +33,13 @@ export interface EditorReferenceBridge {
|
||||
replaceWithSnapshot: (blocks: Json) => void;
|
||||
openTableFullScreen?: (tableId: string) => void;
|
||||
}
|
||||
|
||||
interface EditorBridgeState {
|
||||
bridge: EditorReferenceBridge | null;
|
||||
registerBridge: (bridge: EditorReferenceBridge | null) => void;
|
||||
}
|
||||
|
||||
export const useEditorBridgeStore = create<EditorBridgeState>((set) => ({
|
||||
bridge: null,
|
||||
registerBridge: (bridge) => set({ bridge }),
|
||||
}));
|
||||
|
||||
interface EditorBridgeState {
|
||||
bridge: EditorReferenceBridge | null;
|
||||
registerBridge: (bridge: EditorReferenceBridge | null) => void;
|
||||
}
|
||||
|
||||
export const useEditorBridgeStore = create<EditorBridgeState>((set) => ({
|
||||
bridge: null,
|
||||
registerBridge: (bridge) => set({ bridge }),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { SidebarSectionId } from "@/components/sidebar/types";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { SidebarSectionId } from "@/components/sidebar/types";
|
||||
|
||||
interface SidebarState {
|
||||
open: boolean;
|
||||
width: number;
|
||||
|
||||
@@ -22,15 +22,15 @@ export interface MediaAsset {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MediaSelection {
|
||||
assetId: string;
|
||||
fileUrl: string;
|
||||
thumbnailUrl?: string | null;
|
||||
assetType?: string;
|
||||
fileName?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
}
|
||||
|
||||
export type MediaKind = "image" | "video" | "audio" | "file";
|
||||
|
||||
export interface MediaSelection {
|
||||
assetId: string;
|
||||
fileUrl: string;
|
||||
thumbnailUrl?: string | null;
|
||||
assetType?: string;
|
||||
fileName?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
}
|
||||
|
||||
export type MediaKind = "image" | "video" | "audio" | "file";
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
export type ColumnType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "date"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "formula"
|
||||
| "reference";
|
||||
|
||||
export type ColumnType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "date"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "formula"
|
||||
| "reference";
|
||||
|
||||
export interface TableColumn {
|
||||
id: string; // Unique column ID
|
||||
name: string; // Column display name
|
||||
type: ColumnType;
|
||||
width: number; // Column width for display
|
||||
options?: { value: string; color: string }[]; // For 'select' type
|
||||
}
|
||||
|
||||
options?: { value: string; color: string }[]; // For 'select' type
|
||||
}
|
||||
|
||||
export interface TableSchema {
|
||||
columns: TableColumn[];
|
||||
frozenRowCount: number; // 冻结行数
|
||||
@@ -42,14 +42,14 @@ export interface DocumentTable {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Blocknote 块的数据结构
|
||||
export interface OnlineTableBlockProps {
|
||||
tableId: string;
|
||||
title: string; // 允许 Blocknote 渲染时显示表格名称
|
||||
}
|
||||
|
||||
// Global declarations for Luckysheet (minimal definition)
|
||||
|
||||
// Blocknote 块的数据结构
|
||||
export interface OnlineTableBlockProps {
|
||||
tableId: string;
|
||||
title: string; // 允许 Blocknote 渲染时显示表格名称
|
||||
}
|
||||
|
||||
// Global declarations for Luckysheet (minimal definition)
|
||||
declare global {
|
||||
interface Window {
|
||||
luckysheet?: {
|
||||
|
||||
@@ -1,285 +1,285 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[];
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
documents: {
|
||||
Row: {
|
||||
access_scope: "private" | "shared" | "public";
|
||||
content: Json | null;
|
||||
created_at: string | null;
|
||||
deleted_at: string | null;
|
||||
deleted_by: string | null;
|
||||
id: string;
|
||||
index_status: string | null;
|
||||
is_public: boolean | null;
|
||||
is_starred: boolean | null;
|
||||
is_template: boolean;
|
||||
mindmap_data: Json | null;
|
||||
parent_id: string | null;
|
||||
raw_text: string | null;
|
||||
sort_order: number;
|
||||
title: string | null;
|
||||
updated_at: string | null;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
wide_layout: boolean;
|
||||
use_small_text: boolean;
|
||||
show_heading_numbers: boolean;
|
||||
show_toc: boolean;
|
||||
show_structure: boolean;
|
||||
protect_editing: boolean;
|
||||
show_word_count: boolean;
|
||||
word_count: number;
|
||||
character_count: number;
|
||||
block_count: number;
|
||||
};
|
||||
Insert: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
content?: Json | null;
|
||||
created_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
id?: string;
|
||||
index_status?: string | null;
|
||||
is_public?: boolean | null;
|
||||
is_starred?: boolean | null;
|
||||
is_template?: boolean;
|
||||
mindmap_data?: Json | null;
|
||||
parent_id?: string | null;
|
||||
raw_text?: string | null;
|
||||
sort_order?: number;
|
||||
title?: string | null;
|
||||
updated_at?: string | null;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
wide_layout?: boolean;
|
||||
use_small_text?: boolean;
|
||||
show_heading_numbers?: boolean;
|
||||
show_toc?: boolean;
|
||||
show_structure?: boolean;
|
||||
protect_editing?: boolean;
|
||||
show_word_count?: boolean;
|
||||
word_count?: number;
|
||||
character_count?: number;
|
||||
block_count?: number;
|
||||
};
|
||||
Update: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
content?: Json | null;
|
||||
created_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
id?: string;
|
||||
index_status?: string | null;
|
||||
is_public?: boolean | null;
|
||||
is_starred?: boolean | null;
|
||||
is_template?: boolean;
|
||||
mindmap_data?: Json | null;
|
||||
parent_id?: string | null;
|
||||
raw_text?: string | null;
|
||||
sort_order?: number;
|
||||
title?: string | null;
|
||||
updated_at?: string | null;
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
wide_layout?: boolean;
|
||||
use_small_text?: boolean;
|
||||
show_heading_numbers?: boolean;
|
||||
show_toc?: boolean;
|
||||
show_structure?: boolean;
|
||||
protect_editing?: boolean;
|
||||
show_word_count?: boolean;
|
||||
word_count?: number;
|
||||
character_count?: number;
|
||||
block_count?: number;
|
||||
};
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[];
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
documents: {
|
||||
Row: {
|
||||
access_scope: "private" | "shared" | "public";
|
||||
content: Json | null;
|
||||
created_at: string | null;
|
||||
deleted_at: string | null;
|
||||
deleted_by: string | null;
|
||||
id: string;
|
||||
index_status: string | null;
|
||||
is_public: boolean | null;
|
||||
is_starred: boolean | null;
|
||||
is_template: boolean;
|
||||
mindmap_data: Json | null;
|
||||
parent_id: string | null;
|
||||
raw_text: string | null;
|
||||
sort_order: number;
|
||||
title: string | null;
|
||||
updated_at: string | null;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
wide_layout: boolean;
|
||||
use_small_text: boolean;
|
||||
show_heading_numbers: boolean;
|
||||
show_toc: boolean;
|
||||
show_structure: boolean;
|
||||
protect_editing: boolean;
|
||||
show_word_count: boolean;
|
||||
word_count: number;
|
||||
character_count: number;
|
||||
block_count: number;
|
||||
};
|
||||
Insert: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
content?: Json | null;
|
||||
created_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
id?: string;
|
||||
index_status?: string | null;
|
||||
is_public?: boolean | null;
|
||||
is_starred?: boolean | null;
|
||||
is_template?: boolean;
|
||||
mindmap_data?: Json | null;
|
||||
parent_id?: string | null;
|
||||
raw_text?: string | null;
|
||||
sort_order?: number;
|
||||
title?: string | null;
|
||||
updated_at?: string | null;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
wide_layout?: boolean;
|
||||
use_small_text?: boolean;
|
||||
show_heading_numbers?: boolean;
|
||||
show_toc?: boolean;
|
||||
show_structure?: boolean;
|
||||
protect_editing?: boolean;
|
||||
show_word_count?: boolean;
|
||||
word_count?: number;
|
||||
character_count?: number;
|
||||
block_count?: number;
|
||||
};
|
||||
Update: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
content?: Json | null;
|
||||
created_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
deleted_by?: string | null;
|
||||
id?: string;
|
||||
index_status?: string | null;
|
||||
is_public?: boolean | null;
|
||||
is_starred?: boolean | null;
|
||||
is_template?: boolean;
|
||||
mindmap_data?: Json | null;
|
||||
parent_id?: string | null;
|
||||
raw_text?: string | null;
|
||||
sort_order?: number;
|
||||
title?: string | null;
|
||||
updated_at?: string | null;
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
wide_layout?: boolean;
|
||||
use_small_text?: boolean;
|
||||
show_heading_numbers?: boolean;
|
||||
show_toc?: boolean;
|
||||
show_structure?: boolean;
|
||||
protect_editing?: boolean;
|
||||
show_word_count?: boolean;
|
||||
word_count?: number;
|
||||
character_count?: number;
|
||||
block_count?: number;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "documents_parent_id_fkey";
|
||||
columns: ["parent_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "documents_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "documents_deleted_by_fkey";
|
||||
columns: ["deleted_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
document_embeddings: {
|
||||
Row: {
|
||||
content_text: string;
|
||||
document_id: string | null;
|
||||
embedding: string | null;
|
||||
id: string;
|
||||
};
|
||||
Insert: {
|
||||
content_text: string;
|
||||
document_id?: string | null;
|
||||
embedding?: string | null;
|
||||
id?: string;
|
||||
};
|
||||
Update: {
|
||||
content_text?: string;
|
||||
document_id?: string | null;
|
||||
embedding?: string | null;
|
||||
id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "document_embeddings_document_id_fkey";
|
||||
columns: ["document_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
entities: {
|
||||
Row: {
|
||||
document_ids: string[] | null;
|
||||
id: string;
|
||||
name: string;
|
||||
properties: Json | null;
|
||||
type: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
document_ids?: string[] | null;
|
||||
id?: string;
|
||||
name: string;
|
||||
properties?: Json | null;
|
||||
type?: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
document_ids?: string[] | null;
|
||||
id?: string;
|
||||
name?: string;
|
||||
properties?: Json | null;
|
||||
type?: string | null;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
relationships: {
|
||||
Row: {
|
||||
document_ids: string[] | null;
|
||||
from_entity: string | null;
|
||||
id: string;
|
||||
to_entity: string | null;
|
||||
type: string | null;
|
||||
};
|
||||
Insert: {
|
||||
document_ids?: string[] | null;
|
||||
from_entity?: string | null;
|
||||
id?: string;
|
||||
to_entity?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
Update: {
|
||||
document_ids?: string[] | null;
|
||||
from_entity?: string | null;
|
||||
id?: string;
|
||||
to_entity?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "relationships_from_entity_fkey";
|
||||
columns: ["from_entity"];
|
||||
referencedRelation: "entities";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "relationships_to_entity_fkey";
|
||||
columns: ["to_entity"];
|
||||
referencedRelation: "entities";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
page_refs: {
|
||||
Row: {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
source_page_id: string;
|
||||
source_block_id: string | null;
|
||||
target_page_id: string;
|
||||
alias: string | null;
|
||||
display_mode: string;
|
||||
is_previewable: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
workspace_id: string;
|
||||
source_page_id: string;
|
||||
source_block_id?: string | null;
|
||||
target_page_id: string;
|
||||
alias?: string | null;
|
||||
display_mode?: string;
|
||||
is_previewable?: boolean;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
source_page_id?: string;
|
||||
source_block_id?: string | null;
|
||||
target_page_id?: string;
|
||||
alias?: string | null;
|
||||
display_mode?: string;
|
||||
is_previewable?: boolean;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "page_refs_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "page_refs_source_page_id_fkey";
|
||||
columns: ["source_page_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "page_refs_target_page_id_fkey";
|
||||
columns: ["target_page_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "page_refs_created_by_fkey";
|
||||
columns: ["created_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
{
|
||||
foreignKeyName: "documents_parent_id_fkey";
|
||||
columns: ["parent_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "documents_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "documents_deleted_by_fkey";
|
||||
columns: ["deleted_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
document_embeddings: {
|
||||
Row: {
|
||||
content_text: string;
|
||||
document_id: string | null;
|
||||
embedding: string | null;
|
||||
id: string;
|
||||
};
|
||||
Insert: {
|
||||
content_text: string;
|
||||
document_id?: string | null;
|
||||
embedding?: string | null;
|
||||
id?: string;
|
||||
};
|
||||
Update: {
|
||||
content_text?: string;
|
||||
document_id?: string | null;
|
||||
embedding?: string | null;
|
||||
id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "document_embeddings_document_id_fkey";
|
||||
columns: ["document_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
entities: {
|
||||
Row: {
|
||||
document_ids: string[] | null;
|
||||
id: string;
|
||||
name: string;
|
||||
properties: Json | null;
|
||||
type: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Insert: {
|
||||
document_ids?: string[] | null;
|
||||
id?: string;
|
||||
name: string;
|
||||
properties?: Json | null;
|
||||
type?: string | null;
|
||||
user_id: string;
|
||||
};
|
||||
Update: {
|
||||
document_ids?: string[] | null;
|
||||
id?: string;
|
||||
name?: string;
|
||||
properties?: Json | null;
|
||||
type?: string | null;
|
||||
user_id?: string;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
relationships: {
|
||||
Row: {
|
||||
document_ids: string[] | null;
|
||||
from_entity: string | null;
|
||||
id: string;
|
||||
to_entity: string | null;
|
||||
type: string | null;
|
||||
};
|
||||
Insert: {
|
||||
document_ids?: string[] | null;
|
||||
from_entity?: string | null;
|
||||
id?: string;
|
||||
to_entity?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
Update: {
|
||||
document_ids?: string[] | null;
|
||||
from_entity?: string | null;
|
||||
id?: string;
|
||||
to_entity?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "relationships_from_entity_fkey";
|
||||
columns: ["from_entity"];
|
||||
referencedRelation: "entities";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "relationships_to_entity_fkey";
|
||||
columns: ["to_entity"];
|
||||
referencedRelation: "entities";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
page_refs: {
|
||||
Row: {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
source_page_id: string;
|
||||
source_block_id: string | null;
|
||||
target_page_id: string;
|
||||
alias: string | null;
|
||||
display_mode: string;
|
||||
is_previewable: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
workspace_id: string;
|
||||
source_page_id: string;
|
||||
source_block_id?: string | null;
|
||||
target_page_id: string;
|
||||
alias?: string | null;
|
||||
display_mode?: string;
|
||||
is_previewable?: boolean;
|
||||
created_by: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
source_page_id?: string;
|
||||
source_block_id?: string | null;
|
||||
target_page_id?: string;
|
||||
alias?: string | null;
|
||||
display_mode?: string;
|
||||
is_previewable?: boolean;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "page_refs_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "page_refs_source_page_id_fkey";
|
||||
columns: ["source_page_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "page_refs_target_page_id_fkey";
|
||||
columns: ["target_page_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "page_refs_created_by_fkey";
|
||||
columns: ["created_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
media_assets: {
|
||||
Row: {
|
||||
id: string;
|
||||
@@ -377,47 +377,47 @@ export type Database = {
|
||||
},
|
||||
];
|
||||
};
|
||||
user_recent_pages: {
|
||||
Row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
last_accessed_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
last_accessed_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
document_id?: string;
|
||||
last_accessed_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "user_recent_pages_document_id_fkey";
|
||||
columns: ["document_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "user_recent_pages_user_id_fkey";
|
||||
columns: ["user_id"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "user_recent_pages_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
user_recent_pages: {
|
||||
Row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
last_accessed_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
last_accessed_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
document_id?: string;
|
||||
last_accessed_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "user_recent_pages_document_id_fkey";
|
||||
columns: ["document_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "user_recent_pages_user_id_fkey";
|
||||
columns: ["user_id"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "user_recent_pages_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
[key: string]: any;
|
||||
@@ -425,136 +425,136 @@ export type Database = {
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
get_documents_tree: {
|
||||
Args: {
|
||||
request_user_id: string;
|
||||
};
|
||||
Returns: {
|
||||
depth: number;
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
path: string[];
|
||||
sort_order: number;
|
||||
title: string | null;
|
||||
}[];
|
||||
};
|
||||
workspaces: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
icon_url: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
created_by: string;
|
||||
icon_url?: string | null;
|
||||
id?: string;
|
||||
name: string;
|
||||
type?: "personal" | "team";
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
icon_url?: string | null;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: "personal" | "team";
|
||||
updated_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "workspaces_created_by_fkey";
|
||||
columns: ["created_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
workspace_members: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
id: string;
|
||||
is_default: boolean;
|
||||
role: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
is_default?: boolean;
|
||||
role?: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
is_default?: boolean;
|
||||
role?: string;
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "workspace_members_user_id_fkey";
|
||||
columns: ["user_id"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "workspace_members_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
record_page_ref: {
|
||||
Args: {
|
||||
p_workspace_id: string;
|
||||
p_source_page_id: string;
|
||||
p_source_block_id?: string | null;
|
||||
p_target_page_id: string;
|
||||
p_alias?: string | null;
|
||||
p_display_mode?: string;
|
||||
p_is_previewable?: boolean;
|
||||
p_created_by?: string;
|
||||
};
|
||||
Returns: Database["public"]["Tables"]["page_refs"]["Row"];
|
||||
};
|
||||
list_backlinks: {
|
||||
Args: {
|
||||
p_workspace_id: string;
|
||||
p_target_page_id: string;
|
||||
p_limit?: number;
|
||||
p_offset?: number;
|
||||
};
|
||||
Returns: Array<{
|
||||
id: string;
|
||||
source_page_id: string;
|
||||
source_block_id: string | null;
|
||||
alias: string | null;
|
||||
display_mode: string;
|
||||
is_previewable: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
source_title: string | null;
|
||||
workspace_id: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
Functions: {
|
||||
get_documents_tree: {
|
||||
Args: {
|
||||
request_user_id: string;
|
||||
};
|
||||
Returns: {
|
||||
depth: number;
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
path: string[];
|
||||
sort_order: number;
|
||||
title: string | null;
|
||||
}[];
|
||||
};
|
||||
workspaces: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
icon_url: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
created_by: string;
|
||||
icon_url?: string | null;
|
||||
id?: string;
|
||||
name: string;
|
||||
type?: "personal" | "team";
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
icon_url?: string | null;
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: "personal" | "team";
|
||||
updated_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "workspaces_created_by_fkey";
|
||||
columns: ["created_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
workspace_members: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
id: string;
|
||||
is_default: boolean;
|
||||
role: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
is_default?: boolean;
|
||||
role?: string;
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
};
|
||||
Update: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
is_default?: boolean;
|
||||
role?: string;
|
||||
user_id?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "workspace_members_user_id_fkey";
|
||||
columns: ["user_id"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "workspace_members_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
record_page_ref: {
|
||||
Args: {
|
||||
p_workspace_id: string;
|
||||
p_source_page_id: string;
|
||||
p_source_block_id?: string | null;
|
||||
p_target_page_id: string;
|
||||
p_alias?: string | null;
|
||||
p_display_mode?: string;
|
||||
p_is_previewable?: boolean;
|
||||
p_created_by?: string;
|
||||
};
|
||||
Returns: Database["public"]["Tables"]["page_refs"]["Row"];
|
||||
};
|
||||
list_backlinks: {
|
||||
Args: {
|
||||
p_workspace_id: string;
|
||||
p_target_page_id: string;
|
||||
p_limit?: number;
|
||||
p_offset?: number;
|
||||
};
|
||||
Returns: Array<{
|
||||
id: string;
|
||||
source_page_id: string;
|
||||
source_block_id: string | null;
|
||||
alias: string | null;
|
||||
display_mode: string;
|
||||
is_previewable: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
source_title: string | null;
|
||||
workspace_id: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user