0.5 缩减重构

This commit is contained in:
lix-2026
2026-04-13 19:21:42 +08:00
parent af92c4b149
commit 71fb1aee7e
2023 changed files with 21113 additions and 394493 deletions
+47 -47
View File
@@ -1,47 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
#test
**/test/
pw-tests/**
pw-tests/
wolai-frontend/public/documents/**
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
#test
**/test/
pw-tests/**
pw-tests/
wolai-frontend/public/documents/**
+12
View File
@@ -15,8 +15,11 @@ import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
import type * as _utils_lightrag from "../_utils/lightrag.js";
import type * as _utils_text from "../_utils/text.js";
import type * as _utils_time from "../_utils/time.js";
import type * as agentActions from "../agentActions.js";
import type * as auth from "../auth.js";
import type * as blocks from "../blocks.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
import type * as documentGroupShares from "../documentGroupShares.js";
import type * as documentShares from "../documentShares.js";
import type * as documentStars from "../documentStars.js";
@@ -26,8 +29,11 @@ import type * as groupMembers from "../groupMembers.js";
import type * as groups from "../groups.js";
import type * as http from "../http.js";
import type * as jobs from "../jobs.js";
import type * as maintenance from "../maintenance.js";
import type * as mediaAssets from "../mediaAssets.js";
import type * as mindmaps from "../mindmaps.js";
import type * as mnoteNextBridge from "../mnoteNextBridge.js";
import type * as pages from "../pages.js";
import type * as ping from "../ping.js";
import type * as recents from "../recents.js";
import type * as references from "../references.js";
@@ -49,8 +55,11 @@ declare const fullApi: ApiFromModules<{
"_utils/lightrag": typeof _utils_lightrag;
"_utils/text": typeof _utils_text;
"_utils/time": typeof _utils_time;
agentActions: typeof agentActions;
auth: typeof auth;
blocks: typeof blocks;
comments: typeof comments;
crons: typeof crons;
documentGroupShares: typeof documentGroupShares;
documentShares: typeof documentShares;
documentStars: typeof documentStars;
@@ -60,8 +69,11 @@ declare const fullApi: ApiFromModules<{
groups: typeof groups;
http: typeof http;
jobs: typeof jobs;
maintenance: typeof maintenance;
mediaAssets: typeof mediaAssets;
mindmaps: typeof mindmaps;
mnoteNextBridge: typeof mnoteNextBridge;
pages: typeof pages;
ping: typeof ping;
recents: typeof recents;
references: typeof references;
+7 -7
View File
@@ -1,7 +1,7 @@
// 简单的 ID 生成器,用于生成类似 UUID 的字符串
export function generateId(): string {
// 使用时间戳 + 随机数生成唯一 ID
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 15);
return `${timestamp}${random}`;
}
// 简单的 ID 生成器,用于生成类似 UUID 的字符串
export function generateId(): string {
// 使用时间戳 + 随机数生成唯一 ID
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 15);
return `${timestamp}${random}`;
}
+131
View File
@@ -0,0 +1,131 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(
ctx: QueryCtx | MutationCtx,
userId: string,
workspaceId: string,
) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) =>
q.eq("workspace_id", workspaceId).eq("user_id", userId),
)
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
return membership;
}
export const getById = query({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("agent_actions")
.withIndex("by_action_id", (q) => q.eq("id", args.actionId))
.first();
},
});
export const listBySession = query({
args: {
userId: v.string(),
workspaceId: v.string(),
sessionId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("agent_actions")
.withIndex("by_workspace_session", (q) =>
q.eq("workspace_id", args.workspaceId).eq("session_id", args.sessionId),
)
.collect();
},
});
export const createStarted = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
sessionId: v.string(),
actionType: v.string(),
actor: v.union(v.literal("human"), v.literal("agent"), v.literal("automation")),
input: v.any(),
startedAt: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
await ctx.db.insert("agent_actions", {
id: args.actionId,
workspace_id: args.workspaceId,
session_id: args.sessionId,
action_type: args.actionType,
status: "running",
actor: args.actor,
input: args.input,
output: null,
error: null,
started_at: args.startedAt,
finished_at: null,
});
return { ok: true, actionId: args.actionId };
},
});
export const markSucceeded = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
output: v.any(),
finishedAt: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const row = await ctx.db
.query("agent_actions")
.withIndex("by_action_id", (q) => q.eq("id", args.actionId))
.first();
if (!row) throw new Error("action log not found");
await ctx.db.patch(row._id, {
status: "succeeded",
output: args.output,
finished_at: args.finishedAt,
});
return { ok: true };
},
});
export const markFailed = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
error: v.string(),
finishedAt: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const row = await ctx.db
.query("agent_actions")
.withIndex("by_action_id", (q) => q.eq("id", args.actionId))
.first();
if (!row) throw new Error("action log not found");
await ctx.db.patch(row._id, {
status: "failed",
error: args.error,
finished_at: args.finishedAt,
});
return { ok: true };
},
});
+4
View File
@@ -1,6 +1,10 @@
import { Password } from "@convex-dev/auth/providers/Password";
import { convexAuth } from "@convex-dev/auth/server";
if (!process.env.JWT_PRIVATE_KEY) {
process.env.JWT_PRIVATE_KEY = "-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1 gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d /CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU 6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9 /5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1 yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN 3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwQx3gZA39sa8 qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7 GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO 0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8 EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN 2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru 3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh 9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq AT5PRKihrWbg63/rXaSCxw== -----END PRIVATE KEY-----";
}
/**
* Convex Auth 配置
*
+168
View File
@@ -0,0 +1,168 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(
ctx: QueryCtx | MutationCtx,
userId: string,
workspaceId: string,
) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) =>
q.eq("workspace_id", workspaceId).eq("user_id", userId),
)
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
return membership;
}
export const getById = query({
args: {
userId: v.string(),
workspaceId: v.string(),
blockId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("blocks")
.withIndex("by_block_id", (q) => q.eq("id", args.blockId))
.first();
},
});
export const listByPage = query({
args: {
userId: v.string(),
workspaceId: v.string(),
pageId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("blocks")
.withIndex("by_page", (q) => q.eq("page_id", args.pageId))
.collect();
},
});
export const append = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
pageId: v.string(),
blocks: v.array(
v.object({
id: v.string(),
parentBlockId: v.union(v.string(), v.null()),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
orderKey: v.string(),
}),
),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const ts = nowIso();
for (const block of args.blocks) {
await ctx.db.insert("blocks", {
id: block.id,
workspace_id: args.workspaceId,
page_id: args.pageId,
parent_block_id: block.parentBlockId,
type: block.type,
props: block.props,
content: block.content,
child_block_ids: [],
created_by: args.userId,
created_at: ts,
updated_at: ts,
});
}
return { ok: true, count: args.blocks.length };
},
});
export const insertAfter = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
pageId: v.string(),
afterBlockId: v.string(),
block: v.object({
id: v.string(),
parentBlockId: v.union(v.string(), v.null()),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
orderKey: v.string(),
}),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const ts = nowIso();
await ctx.db.insert("blocks", {
id: args.block.id,
workspace_id: args.workspaceId,
page_id: args.pageId,
parent_block_id: args.block.parentBlockId,
type: args.block.type,
props: args.block.props,
content: args.block.content,
child_block_ids: [],
created_by: args.userId,
created_at: ts,
updated_at: ts,
});
return { ok: true, blockId: args.block.id, afterBlockId: args.afterBlockId };
},
});
export const replace = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
blockId: v.string(),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const block = await ctx.db
.query("blocks")
.withIndex("by_block_id", (q) => q.eq("id", args.blockId))
.first();
if (!block) throw new Error("块不存在");
await ctx.db.patch(block._id, {
type: args.type,
props: args.props,
content: args.content,
updated_at: nowIso(),
});
return { ok: true };
},
});
export const remove = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
blockId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const block = await ctx.db
.query("blocks")
.withIndex("by_block_id", (q) => q.eq("id", args.blockId))
.first();
if (!block) throw new Error("块不存在");
await ctx.db.delete(block._id);
return { ok: true };
},
});
+80 -3
View File
@@ -34,14 +34,78 @@ async function cleanupByCreationTime(ctx: any, table: string, cutoffMs: number,
return deleted;
}
async function cleanupOrphanConvexFiles(
ctx: any,
args: { cutoffMs: number; maxDeletes: number; dryRun: boolean },
): Promise<{ deleted: number; scanned: number; hitLimit: boolean }> {
const batchSize = 200;
let deleted = 0;
let scanned = 0;
let lastCreationTime: number | null = null;
let lastId: any = null;
while (deleted < args.maxDeletes) {
let q = ctx.db.system.query("_storage").order("asc");
if (lastCreationTime != null && lastId != null) {
// 说明:Convex 的 `paginate` 每个函数只能调用一次,这里用“游标过滤 + take”模拟分页。
// 使用 (_creationTime, _id) 作为游标,避免同一毫秒内多条记录导致遗漏/死循环。
q = q.filter((qb: any) =>
qb.or(
qb.gt(qb.field("_creationTime"), lastCreationTime),
qb.and(qb.eq(qb.field("_creationTime"), lastCreationTime), qb.gt(qb.field("_id"), lastId)),
),
);
}
const rows = await q.take(batchSize);
if (!rows.length) break;
let reachedNewer = false;
for (const row of rows) {
scanned += 1;
const created = typeof row._creationTime === "number" ? row._creationTime : 0;
lastCreationTime = created;
lastId = row._id;
if (created >= args.cutoffMs) {
reachedNewer = true;
break;
}
// 说明:当前仓库里,Convex Files 仅用于 media_assets.storage_id。
// 若未来新增其它引用表,需要把引用检查一并补齐,避免误删仍被使用的文件。
const ref = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", row._id))
.first();
if (ref) continue;
if (!args.dryRun) {
try {
await ctx.storage.delete(row._id);
} catch {
// ignore
}
}
deleted += 1;
if (deleted >= args.maxDeletes) break;
}
if (reachedNewer) break;
}
return { deleted, scanned, hitLimit: deleted >= args.maxDeletes };
}
export const cleanupWeekly = internalMutation({
args: {
dryRun: v.optional(v.boolean()),
maxDeletes: v.optional(v.number()),
maxFileDeletes: v.optional(v.number()),
},
handler: async (ctx, args) => {
const dryRun = Boolean(args.dryRun);
const maxDeletes = Math.max(100, Math.min(50_000, Math.floor(args.maxDeletes ?? 20_000)));
const maxFileDeletes = Math.max(100, Math.min(50_000, Math.floor(args.maxFileDeletes ?? 10_000)));
const cutoffMs = Date.now() - WEEK_MS;
@@ -60,11 +124,21 @@ export const cleanupWeekly = internalMutation({
const deletedAuthSessions = await cleanupByCreationTime(ctx, "authSessions", cutoffMs, perTable, dryRun);
const deletedJobs = await cleanupByCreationTime(ctx, "jobs", cutoffMs, maxDeletes - perTable * 2, dryRun);
// 说明:清理 Convex Files 孤儿数据(_storage 里“7 天前且无引用”的文件)。
const files = await cleanupOrphanConvexFiles(ctx, { cutoffMs, maxDeletes: maxFileDeletes, dryRun });
// 若达到上限,兜底再跑一轮(避免一次删太多导致超时)
const hitLimit =
deletedAuthRefreshTokens >= perTable || deletedAuthSessions >= perTable || deletedJobs >= maxDeletes - perTable * 2;
deletedAuthRefreshTokens >= perTable ||
deletedAuthSessions >= perTable ||
deletedJobs >= maxDeletes - perTable * 2 ||
files.hitLimit;
if (!dryRun && hitLimit) {
await ctx.scheduler.runAfter(60_000, (internal as any).maintenance.cleanupWeekly, { dryRun: false, maxDeletes });
await ctx.scheduler.runAfter(60_000, (internal as any).maintenance.cleanupWeekly, {
dryRun: false,
maxDeletes,
maxFileDeletes,
});
}
return {
@@ -75,9 +149,12 @@ export const cleanupWeekly = internalMutation({
authRefreshTokens: deletedAuthRefreshTokens,
authSessions: deletedAuthSessions,
jobs: deletedJobs,
convexFiles: files.deleted,
},
scanned: {
convexFiles: files.scanned,
},
rescheduled: !dryRun && hitLimit,
};
},
});
+438
View File
@@ -0,0 +1,438 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(
ctx: QueryCtx | MutationCtx,
userId: string,
workspaceId: string,
) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) =>
q.eq("workspace_id", workspaceId).eq("user_id", userId),
)
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
return membership;
}
export const listWorkspacesByUser = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
const memberships = await ctx.db
.query("workspace_members")
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
.collect();
const workspaceIds = Array.from(new Set(memberships.map((item) => item.workspace_id)));
const result = [];
for (const workspaceId of workspaceIds) {
const workspace = await ctx.db
.query("workspaces")
.withIndex("by_workspace_id", (q) => q.eq("id", workspaceId))
.first();
if (!workspace) continue;
const memberCount = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_id", (q) => q.eq("workspace_id", workspaceId))
.collect();
result.push({
id: workspace.id,
name: workspace.name,
type: workspace.type,
iconUrl: workspace.icon_url ?? null,
memberCount: memberCount.length,
isDefault: memberships.some(
(item) => item.workspace_id === workspaceId && item.is_default,
),
});
}
return result;
},
});
export const createPage = mutation({
args: {
userId: v.string(),
id: v.string(),
workspaceId: v.string(),
parentId: v.union(v.string(), v.null()),
title: v.string(),
accessScope: v.union(
v.literal("private"),
v.literal("shared"),
v.literal("public"),
),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const ts = nowIso();
await ctx.db.insert("pages", {
id: args.id,
workspace_id: args.workspaceId,
parent_id: args.parentId,
title: args.title,
access_scope: args.accessScope,
block_ids: [],
child_page_ids: [],
asset_ids: [],
created_by: args.userId,
created_at: ts,
updated_at: ts,
deleted_at: null,
});
return {
id: args.id,
workspaceId: args.workspaceId,
parentId: args.parentId,
title: args.title,
accessScope: args.accessScope,
createdBy: args.userId,
createdAt: ts,
updatedAt: ts,
archivedAt: null,
sortOrder: null,
};
},
});
export const renamePage = mutation({
args: {
userId: v.string(),
id: v.string(),
title: v.string(),
},
handler: async (ctx, args) => {
const page = await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
if (!page) throw new Error("页面不存在");
await assertWorkspaceMember(ctx, args.userId, page.workspace_id);
const ts = nowIso();
await ctx.db.patch(page._id, {
title: args.title,
updated_at: ts,
});
return {
id: args.id,
title: args.title,
updatedAt: ts,
};
},
});
export const movePage = mutation({
args: {
userId: v.string(),
id: v.string(),
parentId: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
const page = await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
if (!page) throw new Error("页面不存在");
await assertWorkspaceMember(ctx, args.userId, page.workspace_id);
const ts = nowIso();
await ctx.db.patch(page._id, {
parent_id: args.parentId,
updated_at: ts,
});
return {
id: args.id,
parentId: args.parentId,
sortOrder: null,
updatedAt: ts,
};
},
});
export const archivePage = mutation({
args: {
userId: v.string(),
id: v.string(),
},
handler: async (ctx, args) => {
const page = await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
if (!page) throw new Error("页面不存在");
await assertWorkspaceMember(ctx, args.userId, page.workspace_id);
const ts = nowIso();
await ctx.db.patch(page._id, {
deleted_at: ts,
updated_at: ts,
});
return {
id: args.id,
archivedAt: ts,
updatedAt: ts,
};
},
});
export const listPagesByWorkspace = query({
args: {
userId: v.string(),
workspaceId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("pages")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
},
});
export const appendBlocks = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
pageId: v.string(),
blocks: v.array(
v.object({
id: v.string(),
parentBlockId: v.union(v.string(), v.null()),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
orderKey: v.string(),
}),
),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const ts = nowIso();
for (const block of args.blocks) {
await ctx.db.insert("blocks", {
id: block.id,
workspace_id: args.workspaceId,
page_id: args.pageId,
parent_block_id: block.parentBlockId,
type: block.type,
props: block.props,
content: block.content,
child_block_ids: [],
created_by: args.userId,
created_at: ts,
updated_at: ts,
});
}
return {
count: args.blocks.length,
pageId: args.pageId,
blockIds: args.blocks.map((item) => item.id),
};
},
});
export const insertBlockAfter = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
pageId: v.string(),
afterBlockId: v.string(),
block: v.object({
id: v.string(),
parentBlockId: v.union(v.string(), v.null()),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
orderKey: v.string(),
}),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const ts = nowIso();
await ctx.db.insert("blocks", {
id: args.block.id,
workspace_id: args.workspaceId,
page_id: args.pageId,
parent_block_id: args.block.parentBlockId,
type: args.block.type,
props: args.block.props,
content: args.block.content,
child_block_ids: [],
created_by: args.userId,
created_at: ts,
updated_at: ts,
});
return {
pageId: args.pageId,
afterBlockId: args.afterBlockId,
blockId: args.block.id,
};
},
});
export const replaceBlock = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
blockId: v.string(),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const block = await ctx.db
.query("blocks")
.withIndex("by_block_id", (q) => q.eq("id", args.blockId))
.first();
if (!block) throw new Error("块不存在");
const ts = nowIso();
await ctx.db.patch(block._id, {
type: args.type,
props: args.props,
content: args.content,
updated_at: ts,
});
return {
blockId: args.blockId,
updatedAt: ts,
};
},
});
export const deleteBlock = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
blockId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const block = await ctx.db
.query("blocks")
.withIndex("by_block_id", (q) => q.eq("id", args.blockId))
.first();
if (!block) throw new Error("块不存在");
await ctx.db.delete(block._id);
const ts = nowIso();
return {
blockId: args.blockId,
deletedAt: ts,
updatedAt: ts,
};
},
});
export const listBlocksByPage = query({
args: {
userId: v.string(),
workspaceId: v.string(),
pageId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("blocks")
.withIndex("by_page", (q) => q.eq("page_id", args.pageId))
.collect();
},
});
export const createAgentActionStarted = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
sessionId: v.string(),
actionType: v.string(),
actor: v.union(v.literal("human"), v.literal("agent"), v.literal("automation")),
input: v.any(),
startedAt: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
await ctx.db.insert("agent_actions", {
id: args.actionId,
workspace_id: args.workspaceId,
session_id: args.sessionId,
action_type: args.actionType,
status: "running",
actor: args.actor,
input: args.input,
output: null,
error: null,
started_at: args.startedAt,
finished_at: null,
});
return { ok: true, actionId: args.actionId };
},
});
export const markAgentActionSucceeded = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
output: v.any(),
finishedAt: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const row = await ctx.db
.query("agent_actions")
.withIndex("by_action_id", (q) => q.eq("id", args.actionId))
.first();
if (!row) throw new Error("action log not found");
await ctx.db.patch(row._id, {
status: "succeeded",
output: args.output,
finished_at: args.finishedAt,
});
return { ok: true };
},
});
export const markAgentActionFailed = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
actionId: v.string(),
error: v.string(),
finishedAt: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const row = await ctx.db
.query("agent_actions")
.withIndex("by_action_id", (q) => q.eq("id", args.actionId))
.first();
if (!row) throw new Error("action log not found");
await ctx.db.patch(row._id, {
status: "failed",
error: args.error,
finished_at: args.finishedAt,
});
return { ok: true };
},
});
export const listAgentActionsBySession = query({
args: {
userId: v.string(),
workspaceId: v.string(),
sessionId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("agent_actions")
.withIndex("by_workspace_session", (q) =>
q.eq("workspace_id", args.workspaceId).eq("session_id", args.sessionId),
)
.collect();
},
});
+150
View File
@@ -0,0 +1,150 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(
ctx: QueryCtx | MutationCtx,
userId: string,
workspaceId: string,
) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) =>
q.eq("workspace_id", workspaceId).eq("user_id", userId),
)
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
return membership;
}
export const getById = query({
args: {
userId: v.string(),
workspaceId: v.string(),
id: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
},
});
export const listByWorkspace = query({
args: {
userId: v.string(),
workspaceId: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
return await ctx.db
.query("pages")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
},
});
export const create = mutation({
args: {
userId: v.string(),
id: v.string(),
workspaceId: v.string(),
parentId: v.union(v.string(), v.null()),
title: v.string(),
accessScope: v.union(
v.literal("private"),
v.literal("shared"),
v.literal("public"),
),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const ts = nowIso();
await ctx.db.insert("pages", {
id: args.id,
workspace_id: args.workspaceId,
parent_id: args.parentId,
title: args.title,
access_scope: args.accessScope,
block_ids: [],
child_page_ids: [],
asset_ids: [],
created_by: args.userId,
created_at: ts,
updated_at: ts,
deleted_at: null,
});
return { ok: true, id: args.id };
},
});
export const rename = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
id: v.string(),
title: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const page = await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
if (!page) throw new Error("页面不存在");
await ctx.db.patch(page._id, {
title: args.title,
updated_at: nowIso(),
});
return { ok: true };
},
});
export const move = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
id: v.string(),
parentId: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const page = await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
if (!page) throw new Error("页面不存在");
await ctx.db.patch(page._id, {
parent_id: args.parentId,
updated_at: nowIso(),
});
return { ok: true };
},
});
export const archive = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
id: v.string(),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const page = await ctx.db
.query("pages")
.withIndex("by_page_id", (q) => q.eq("id", args.id))
.first();
if (!page) throw new Error("页面不存在");
const ts = nowIso();
await ctx.db.patch(page._id, {
deleted_at: ts,
updated_at: ts,
});
return { ok: true };
},
});
+61
View File
@@ -385,4 +385,65 @@ export default defineSchema({
.index("by_workspace_user", ["workspace_id", "user_id"])
.index("by_user_document", ["user_id", "document_id"])
.index("by_workspace_document_user", ["workspace_id", "document_id", "user_id"]),
// === 新领域模型表(Action Execution System===
// Pages 表 - 对应域模型 Page
pages: defineTable({
id: v.string(),
workspace_id: v.string(),
parent_id: v.union(v.string(), v.null()),
title: v.string(),
access_scope: v.union(v.literal("private"), v.literal("shared"), v.literal("public")),
block_ids: v.array(v.string()),
child_page_ids: v.array(v.string()),
asset_ids: v.array(v.string()),
created_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
deleted_at: v.union(v.string(), v.null()),
})
.index("by_page_id", ["id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_parent", ["workspace_id", "parent_id"])
.index("by_workspace_deleted", ["workspace_id", "deleted_at"]),
// Blocks 表 - 对应域模型 Block
blocks: defineTable({
id: v.string(),
workspace_id: v.string(),
page_id: v.string(),
parent_block_id: v.union(v.string(), v.null()),
type: v.string(),
props: v.optional(v.any()),
content: v.optional(v.any()),
child_block_ids: v.array(v.string()),
created_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_block_id", ["id"])
.index("by_workspace", ["workspace_id"])
.index("by_page", ["page_id"])
.index("by_page_parent", ["page_id", "parent_block_id"]),
// Agent Actions 表 - 对应域模型 AgentAction(持久化日志)
agent_actions: defineTable({
id: v.string(),
workspace_id: v.string(),
session_id: v.string(),
action_type: v.string(),
status: v.union(v.literal("planned"), v.literal("running"), v.literal("succeeded"), v.literal("failed"), v.literal("cancelled")),
actor: v.union(v.literal("human"), v.literal("agent"), v.literal("automation")),
input: v.any(),
output: v.optional(v.any()),
error: v.optional(v.string()),
started_at: v.string(),
finished_at: v.optional(v.string()),
})
.index("by_action_id", ["id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_session", ["workspace_id", "session_id"])
.index("by_session", ["session_id"])
.index("by_workspace_status", ["workspace_id", "status"]),
});
+221 -221
View File
@@ -14,102 +14,102 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string
}
return membership;
}
// Query: 获取单个表格
export const get = query({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) return null;
if (table.workspace_id) {
// 验证用户是否在工作区中(简化版:假设 userId 有效)
// TODO: 添加 workspace_members 验证
}
return {
id: table.id,
workspace_id: table.workspace_id,
document_id: table.document_id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
view_preferences: table.view_preferences,
snapshot: table.snapshot ?? null,
is_archived: table.is_archived,
last_synced_at: table.last_synced_at ?? null,
created_by: table.created_by,
updated_by: table.updated_by ?? null,
created_at: table.created_at,
updated_at: table.updated_at,
};
},
});
// Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook
export const getByGridKey = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回 Luckysheet 需要的格式
return {
title: table.title,
gridKey: table.grid_key,
lang: "zh",
};
},
});
// Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load
export const getByGridKeyFull = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回完整的表格数据,包括 schema 和 snapshot
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot ?? null,
};
},
});
// Query: 列出文档的所有表格
// Query: 获取单个表格
export const get = query({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) return null;
if (table.workspace_id) {
// 验证用户是否在工作区中(简化版:假设 userId 有效)
// TODO: 添加 workspace_members 验证
}
return {
id: table.id,
workspace_id: table.workspace_id,
document_id: table.document_id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
view_preferences: table.view_preferences,
snapshot: table.snapshot ?? null,
is_archived: table.is_archived,
last_synced_at: table.last_synced_at ?? null,
created_by: table.created_by,
updated_by: table.updated_by ?? null,
created_at: table.created_at,
updated_at: table.updated_at,
};
},
});
// Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook
export const getByGridKey = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回 Luckysheet 需要的格式
return {
title: table.title,
gridKey: table.grid_key,
lang: "zh",
};
},
});
// Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load
export const getByGridKeyFull = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回完整的表格数据,包括 schema 和 snapshot
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot ?? null,
};
},
});
// Query: 列出文档的所有表格
export const listByDocument = query({
args: { userId: v.string(), documentId: v.string() },
handler: async (ctx, args) => {
const tables = await ctx.db
.query("document_tables")
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
.collect();
return tables
.filter((t) => !t.is_archived)
.map((t) => ({
id: t.id,
document_id: t.document_id,
grid_key: t.grid_key,
title: t.title,
schema: t.schema,
view_preferences: t.view_preferences,
snapshot: t.snapshot ?? null,
is_archived: t.is_archived,
last_synced_at: t.last_synced_at ?? null,
created_at: t.created_at,
updated_at: t.updated_at,
const tables = await ctx.db
.query("document_tables")
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
.collect();
return tables
.filter((t) => !t.is_archived)
.map((t) => ({
id: t.id,
document_id: t.document_id,
grid_key: t.grid_key,
title: t.title,
schema: t.schema,
view_preferences: t.view_preferences,
snapshot: t.snapshot ?? null,
is_archived: t.is_archived,
last_synced_at: t.last_synced_at ?? null,
created_at: t.created_at,
updated_at: t.updated_at,
}));
},
});
@@ -150,22 +150,22 @@ export const listByWorkspaceForSearch = query({
}));
},
});
// Mutation: 创建表格
export const create = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
documentId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
snapshot: v.optional(v.any()),
},
handler: async (ctx, args) => {
const now = nowIso();
const tableId = generateId();
const gridKey = generateId(); // 用于 WebSocket 协同标识
// Mutation: 创建表格
export const create = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
documentId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
snapshot: v.optional(v.any()),
},
handler: async (ctx, args) => {
const now = nowIso();
const tableId = generateId();
const gridKey = generateId(); // 用于 WebSocket 协同标识
const table = {
id: tableId,
workspace_id: args.workspaceId,
@@ -185,95 +185,95 @@ export const create = mutation({
created_at: now,
updated_at: now,
};
await ctx.db.insert("document_tables", table);
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot,
};
},
});
// Mutation: 更新表格
export const update = mutation({
args: {
userId: v.string(),
tableId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
view_preferences: v.optional(v.any()),
snapshot: v.optional(v.any()),
rows: v.optional(v.any()),
},
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) {
throw new Error("Table not found");
}
const now = nowIso();
const updates: any = {
updated_at: now,
updated_by: args.userId,
last_synced_at: now,
};
if (args.title !== undefined) updates.title = args.title;
if (args.schema !== undefined) updates.schema = args.schema;
if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences;
if (args.snapshot !== undefined) updates.snapshot = args.snapshot;
await ctx.db.patch(table._id, updates);
// 如果提供了 rows,更新行数据
if (args.rows && Array.isArray(args.rows)) {
// 先删除旧行
const existingRows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of existingRows) {
await ctx.db.delete(row._id);
}
// 插入新行
for (const [index, rowData] of args.rows.entries()) {
await ctx.db.insert("document_table_rows", {
id: generateId(),
workspace_id: table.workspace_id,
document_id: table.document_id,
table_id: args.tableId,
row_index: index,
row_data: rowData,
row_hash: JSON.stringify(rowData),
is_deleted: false,
updated_by: args.userId,
created_at: now,
updated_at: now,
});
}
}
return {
id: table.id,
title: updates.title ?? table.title,
schema: updates.schema ?? table.schema,
view_preferences: updates.view_preferences ?? table.view_preferences,
snapshot: updates.snapshot ?? table.snapshot,
updated_at: now,
};
},
});
// Mutation: 删除表格(软删除)
await ctx.db.insert("document_tables", table);
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot,
};
},
});
// Mutation: 更新表格
export const update = mutation({
args: {
userId: v.string(),
tableId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
view_preferences: v.optional(v.any()),
snapshot: v.optional(v.any()),
rows: v.optional(v.any()),
},
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) {
throw new Error("Table not found");
}
const now = nowIso();
const updates: any = {
updated_at: now,
updated_by: args.userId,
last_synced_at: now,
};
if (args.title !== undefined) updates.title = args.title;
if (args.schema !== undefined) updates.schema = args.schema;
if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences;
if (args.snapshot !== undefined) updates.snapshot = args.snapshot;
await ctx.db.patch(table._id, updates);
// 如果提供了 rows,更新行数据
if (args.rows && Array.isArray(args.rows)) {
// 先删除旧行
const existingRows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of existingRows) {
await ctx.db.delete(row._id);
}
// 插入新行
for (const [index, rowData] of args.rows.entries()) {
await ctx.db.insert("document_table_rows", {
id: generateId(),
workspace_id: table.workspace_id,
document_id: table.document_id,
table_id: args.tableId,
row_index: index,
row_data: rowData,
row_hash: JSON.stringify(rowData),
is_deleted: false,
updated_by: args.userId,
created_at: now,
updated_at: now,
});
}
}
return {
id: table.id,
title: updates.title ?? table.title,
schema: updates.schema ?? table.schema,
view_preferences: updates.view_preferences ?? table.view_preferences,
snapshot: updates.snapshot ?? table.snapshot,
updated_at: now,
};
},
});
// Mutation: 删除表格(软删除)
export const remove = mutation({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
@@ -324,8 +324,8 @@ export const restore = mutation({
return { success: true };
},
});
// Mutation: 永久删除表格
// Mutation: 永久删除表格
export const purge = mutation({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
@@ -336,17 +336,17 @@ export const purge = mutation({
if (!table) {
throw new Error("Table not found");
}
// 删除关联的行
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of rows) {
await ctx.db.delete(row._id);
}
// 删除关联的行
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of rows) {
await ctx.db.delete(row._id);
}
// 删除表格
await ctx.db.delete(table._id);
@@ -392,19 +392,19 @@ export const emptyTrashByWorkspace = mutation({
return { ok: true, deleted: targets.length };
},
});
// Query: 获取表格行数据
// Query: 获取表格行数据
export const getRows = query({
args: { tableId: v.string() },
handler: async (ctx, args) => {
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
return rows
.filter((r) => !r.is_deleted)
.sort((a, b) => a.row_index - b.row_index)
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
return rows
.filter((r) => !r.is_deleted)
.sort((a, b) => a.row_index - b.row_index)
.map((r) => r.row_data);
},
});
+78 -78
View File
@@ -1,78 +1,78 @@
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\node_modules\.pnpm\next@16.0.3_@babel+core@7.2_a6e7fe7b2107bfd5c9e45031e322c5c3\node_modules\next\dist\compiled\next-server\app-page-turbo.runtime.dev.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__c5e4d87b._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
ReferenceError: window is not defined
at <unknown> (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js:421:9)
at Se (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js:419:354)
at BlockNoteEditor (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__c5e4d87b._.js:87:315) {
digest: '2629247923'
}
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\node_modules__pnpm_ecee5139._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error: Route "/documents/[id]" used `params.id`. `params` is a Promise and must be unwrapped with `await` or `React.use()` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at DocumentPage (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__d0423852._.js:120:150)
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\node_modules\.pnpm\next@16.0.3_@babel+core@7.2_a6e7fe7b2107bfd5c9e45031e322c5c3\node_modules\next\dist\compiled\next-server\app-page-turbo.runtime.dev.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__c5e4d87b._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
ReferenceError: window is not defined
at <unknown> (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js:421:9)
at Se (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\2536e_@blocknote_react_dist_blocknote-react_65a5c21f.js:419:354)
at BlockNoteEditor (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__c5e4d87b._.js:87:315) {
digest: '2629247923'
}
+60 -60
View File
@@ -1,60 +1,60 @@
> wolai-frontend@0.1.0 dev F:\SOFT\MNOTE\wolai-frontend
> next dev "--port" "3001"
▲ Next.js 16.0.3 (Turbopack)
- Local: http://localhost:3001
- Network: http://192.168.121.1:3001
- Environments: .env.local
✓ Starting...
✓ Ready in 729ms
GET /login 200 in 2.3s (compile: 2.1s, render: 265ms)
GET / 200 in 613ms (compile: 365ms, render: 248ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 3.4s (compile: 2.9s, render: 473ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 442ms (compile: 26ms, render: 415ms)
GET /documents/1f256e20-0ef4-4090-9bec-954fcd799a4c 200 in 245ms (compile: 25ms, render: 220ms)
POST /api/documents/create 200 in 424ms (compile: 311ms, render: 113ms)
GET /documents/880f0a7a-e894-465b-a64d-bea1afa3da48 200 in 62ms (compile: 27ms, render: 34ms)
GET /documents/1f256e20-0ef4-4090-9bec-954fcd799a4c 200 in 68ms (compile: 22ms, render: 47ms)
GET /documents/e8b15472-3665-425e-b945-0de6bc9a15fb 200 in 56ms (compile: 22ms, render: 35ms)
GET /documents/880f0a7a-e894-465b-a64d-bea1afa3da48 200 in 56ms (compile: 22ms, render: 34ms)
GET / 307 in 297ms (compile: 2ms, render: 295ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 222ms (compile: 23ms, render: 199ms)
GET / 307 in 102ms (compile: 3ms, render: 99ms)
GET /login 200 in 53ms (compile: 3ms, render: 49ms)
GET / 200 in 20ms (compile: 3ms, render: 17ms)
GET /login 200 in 35ms (compile: 4ms, render: 31ms)
GET /login 200 in 18ms (compile: 2ms, render: 15ms)
GET / 200 in 20ms (compile: 3ms, render: 17ms)
GET /login 200 in 34ms (compile: 3ms, render: 31ms)
GET /login 200 in 18ms (compile: 2ms, render: 16ms)
GET / 307 in 108ms (compile: 3ms, render: 105ms)
GET /login 200 in 57ms (compile: 5ms, render: 52ms)
GET / 307 in 193ms (compile: 3ms, render: 190ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 125ms (compile: 23ms, render: 102ms)
GET / 200 in 27ms (compile: 4ms, render: 23ms)
GET /login 200 in 34ms (compile: 3ms, render: 31ms)
GET /login 200 in 19ms (compile: 3ms, render: 16ms)
GET / 200 in 19ms (compile: 3ms, render: 16ms)
GET /login 200 in 39ms (compile: 3ms, render: 36ms)
GET /login 200 in 19ms (compile: 3ms, render: 16ms)
GET / 200 in 20ms (compile: 3ms, render: 18ms)
GET /login 200 in 40ms (compile: 9ms, render: 31ms)
GET /login 200 in 19ms (compile: 2ms, render: 17ms)
GET / 200 in 27ms (compile: 5ms, render: 22ms)
GET /login 200 in 41ms (compile: 3ms, render: 37ms)
GET /login 200 in 21ms (compile: 2ms, render: 18ms)
GET / 200 in 23ms (compile: 3ms, render: 20ms)
GET /login 200 in 40ms (compile: 3ms, render: 37ms)
GET /login 200 in 22ms (compile: 3ms, render: 19ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 307 in 181ms (compile: 29ms, render: 152ms)
GET /login 200 in 60ms (compile: 3ms, render: 57ms)
✓ Compiled in 90ms
GET /login 200 in 235ms (compile: 93ms, render: 142ms)
GET /login 200 in 42ms (compile: 3ms, render: 39ms)
GET / 200 in 175ms (compile: 93ms, render: 82ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 313ms (compile: 125ms, render: 188ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 67ms (compile: 4ms, render: 64ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 235ms (compile: 118ms, render: 117ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 611ms (compile: 24ms, render: 587ms)
> wolai-frontend@0.1.0 dev F:\SOFT\MNOTE\wolai-frontend
> next dev "--port" "3001"
▲ Next.js 16.0.3 (Turbopack)
- Local: http://localhost:3001
- Network: http://192.168.121.1:3001
- Environments: .env.local
✓ Starting...
✓ Ready in 729ms
GET /login 200 in 2.3s (compile: 2.1s, render: 265ms)
GET / 200 in 613ms (compile: 365ms, render: 248ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 3.4s (compile: 2.9s, render: 473ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 442ms (compile: 26ms, render: 415ms)
GET /documents/1f256e20-0ef4-4090-9bec-954fcd799a4c 200 in 245ms (compile: 25ms, render: 220ms)
POST /api/documents/create 200 in 424ms (compile: 311ms, render: 113ms)
GET /documents/880f0a7a-e894-465b-a64d-bea1afa3da48 200 in 62ms (compile: 27ms, render: 34ms)
GET /documents/1f256e20-0ef4-4090-9bec-954fcd799a4c 200 in 68ms (compile: 22ms, render: 47ms)
GET /documents/e8b15472-3665-425e-b945-0de6bc9a15fb 200 in 56ms (compile: 22ms, render: 35ms)
GET /documents/880f0a7a-e894-465b-a64d-bea1afa3da48 200 in 56ms (compile: 22ms, render: 34ms)
GET / 307 in 297ms (compile: 2ms, render: 295ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 222ms (compile: 23ms, render: 199ms)
GET / 307 in 102ms (compile: 3ms, render: 99ms)
GET /login 200 in 53ms (compile: 3ms, render: 49ms)
GET / 200 in 20ms (compile: 3ms, render: 17ms)
GET /login 200 in 35ms (compile: 4ms, render: 31ms)
GET /login 200 in 18ms (compile: 2ms, render: 15ms)
GET / 200 in 20ms (compile: 3ms, render: 17ms)
GET /login 200 in 34ms (compile: 3ms, render: 31ms)
GET /login 200 in 18ms (compile: 2ms, render: 16ms)
GET / 307 in 108ms (compile: 3ms, render: 105ms)
GET /login 200 in 57ms (compile: 5ms, render: 52ms)
GET / 307 in 193ms (compile: 3ms, render: 190ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 125ms (compile: 23ms, render: 102ms)
GET / 200 in 27ms (compile: 4ms, render: 23ms)
GET /login 200 in 34ms (compile: 3ms, render: 31ms)
GET /login 200 in 19ms (compile: 3ms, render: 16ms)
GET / 200 in 19ms (compile: 3ms, render: 16ms)
GET /login 200 in 39ms (compile: 3ms, render: 36ms)
GET /login 200 in 19ms (compile: 3ms, render: 16ms)
GET / 200 in 20ms (compile: 3ms, render: 18ms)
GET /login 200 in 40ms (compile: 9ms, render: 31ms)
GET /login 200 in 19ms (compile: 2ms, render: 17ms)
GET / 200 in 27ms (compile: 5ms, render: 22ms)
GET /login 200 in 41ms (compile: 3ms, render: 37ms)
GET /login 200 in 21ms (compile: 2ms, render: 18ms)
GET / 200 in 23ms (compile: 3ms, render: 20ms)
GET /login 200 in 40ms (compile: 3ms, render: 37ms)
GET /login 200 in 22ms (compile: 3ms, render: 19ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 307 in 181ms (compile: 29ms, render: 152ms)
GET /login 200 in 60ms (compile: 3ms, render: 57ms)
✓ Compiled in 90ms
GET /login 200 in 235ms (compile: 93ms, render: 142ms)
GET /login 200 in 42ms (compile: 3ms, render: 39ms)
GET / 200 in 175ms (compile: 93ms, render: 82ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 313ms (compile: 125ms, render: 188ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 67ms (compile: 4ms, render: 64ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 235ms (compile: 118ms, render: 117ms)
GET /documents/2b01d6b7-dc39-49b9-97bb-0be59956e6fb 200 in 611ms (compile: 24ms, render: 587ms)
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
/usr/bin/python3
+29 -29
View File
@@ -72,32 +72,32 @@ Using the user object as returned from supabase.auth.getSession() or from some s
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
<--- Last few GCs --->
[30704:000001C2AAECC000] 3154411 ms: Mark-Compact 31843.8 (32807.5) -> 31742.3 (32803.9) MB, pooled: 0 MB, 10826.74 / 19.88 ms (average mu = 0.281, current mu = 0.289) task; scavenge might not succeed
[30704:000001C2AAECC000] 3167016 ms: Mark-Compact 31812.8 (32808.6) -> 31722.8 (32794.1) MB, pooled: 8 MB, 11662.27 / 4.09 ms (average mu = 0.187, current mu = 0.075) task; scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
1: 00007FF6DC3D7F8F node::OnFatalError+1343
2: 00007FF6DD01B1E7 v8::Function::NewInstance+423
3: 00007FF6DCE1BA97 v8::base::AddressSpaceReservation::AddressSpaceReservation+322071
4: 00007FF6DCE1F7A4 v8::base::AddressSpaceReservation::AddressSpaceReservation+337700
5: 00007FF6DCE2E73C v8::internal::StrongRootAllocatorBase::deallocate_impl+16604
6: 00007FF6DCE2DF7B v8::internal::StrongRootAllocatorBase::deallocate_impl+14619
7: 00007FF6DE295D2D v8::base::UnsignedDivisionByConstant<unsigned __int64>+2791341
8: 00007FF6DCE19560 v8::base::AddressSpaceReservation::AddressSpaceReservation+312544
9: 00007FF6DCDC9F12 EVP_PKEY_asn1_set_get_priv_key+86258
10: 00007FF6DC32A48A node::GetNodeReport+98346
11: 00007FF6DC328C70 node::GetNodeReport+92176
12: 00007FF6DD082BCB uv_run+1867
13: 00007FF6DD08273F uv_run+703
14: 00007FF6DC4E4E3F node::DecodeWrite+367
15: 00007FF6DC3659B2 node::MultiIsolatePlatform::DisposeIsolate+240642
16: 00007FF6DC430F4C node::Start+1052
17: 00007FF6DD449A82 AES_cbc_encrypt+2546
18: 00007FF6DE29F434 v8::base::UnsignedDivisionByConstant<unsigned __int64>+2830004
19: 00007FFEDDDAE8D7 BaseThreadInitThunk+23
20: 00007FFEDFAAC53C RtlUserThreadStart+44
<--- Last few GCs --->
[30704:000001C2AAECC000] 3154411 ms: Mark-Compact 31843.8 (32807.5) -> 31742.3 (32803.9) MB, pooled: 0 MB, 10826.74 / 19.88 ms (average mu = 0.281, current mu = 0.289) task; scavenge might not succeed
[30704:000001C2AAECC000] 3167016 ms: Mark-Compact 31812.8 (32808.6) -> 31722.8 (32794.1) MB, pooled: 8 MB, 11662.27 / 4.09 ms (average mu = 0.187, current mu = 0.075) task; scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
1: 00007FF6DC3D7F8F node::OnFatalError+1343
2: 00007FF6DD01B1E7 v8::Function::NewInstance+423
3: 00007FF6DCE1BA97 v8::base::AddressSpaceReservation::AddressSpaceReservation+322071
4: 00007FF6DCE1F7A4 v8::base::AddressSpaceReservation::AddressSpaceReservation+337700
5: 00007FF6DCE2E73C v8::internal::StrongRootAllocatorBase::deallocate_impl+16604
6: 00007FF6DCE2DF7B v8::internal::StrongRootAllocatorBase::deallocate_impl+14619
7: 00007FF6DE295D2D v8::base::UnsignedDivisionByConstant<unsigned __int64>+2791341
8: 00007FF6DCE19560 v8::base::AddressSpaceReservation::AddressSpaceReservation+312544
9: 00007FF6DCDC9F12 EVP_PKEY_asn1_set_get_priv_key+86258
10: 00007FF6DC32A48A node::GetNodeReport+98346
11: 00007FF6DC328C70 node::GetNodeReport+92176
12: 00007FF6DD082BCB uv_run+1867
13: 00007FF6DD08273F uv_run+703
14: 00007FF6DC4E4E3F node::DecodeWrite+367
15: 00007FF6DC3659B2 node::MultiIsolatePlatform::DisposeIsolate+240642
16: 00007FF6DC430F4C node::Start+1052
17: 00007FF6DD449A82 AES_cbc_encrypt+2546
18: 00007FF6DE29F434 v8::base::UnsignedDivisionByConstant<unsigned __int64>+2830004
19: 00007FFEDDDAE8D7 BaseThreadInitThunk+23
20: 00007FFEDFAAC53C RtlUserThreadStart+44
+1 -1
View File
@@ -1 +1 @@
27684
27684
+1
View File
@@ -35,6 +35,7 @@
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/auth-helpers-react": "^0.5.0",
"@supabase/supabase-js": "^2.81.1",
"@svgdotjs/svg.js": "^3.2.5",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-virtual": "^3.11.0",
+8
View File
@@ -80,6 +80,9 @@ importers:
'@supabase/supabase-js':
specifier: ^2.81.1
version: 2.81.1
'@svgdotjs/svg.js':
specifier: ^3.2.5
version: 3.2.5
'@tanstack/react-query':
specifier: ^5.90.10
version: 5.90.10(react@19.2.0)
@@ -2096,6 +2099,9 @@ packages:
'@svgdotjs/svg.js@3.2.0':
resolution: {integrity: sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA==}
'@svgdotjs/svg.js@3.2.5':
resolution: {integrity: sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -7168,6 +7174,8 @@ snapshots:
'@svgdotjs/svg.js@3.2.0': {}
'@svgdotjs/svg.js@3.2.5': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
File diff suppressed because one or more lines are too long
+367 -367
View File
@@ -1,367 +1,367 @@
#!/usr/bin/env node
/**
* 自定义 Next dev server
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
* - Next App Router 的 Route Handler 无法处理 Upgrade,因此必须在 Node http server 层做透传。
*
* 用法(保持与 next dev 类似):
* - pnpm dev -p 3000
* - node scripts/dev-server.js -p 3000
*
* 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag);
if (idx === -1) return null;
const v = process.argv[idx + 1];
if (!v || v.startsWith("-")) return null;
return v;
}
function resolvePort() {
const fromArg = readArgValue("-p") || readArgValue("--port");
const raw = fromArg || process.env.PORT || "3000";
const n = Number(raw);
return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000;
}
function resolveHostname() {
return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0";
}
function isOnlyOfficePath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`);
} catch {
return false;
}
}
function isConvexPath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`);
} catch {
return false;
}
}
function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/";
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
const lines = [];
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
const headers = req.headers || {};
const headerValue = (name) => {
const v = headers[name];
if (!v) return "";
return Array.isArray(v) ? v[0] : String(v);
};
// 说明:ONLYOFFICE 在反向代理下会依赖 X-Forwarded-* 推导“对外地址”,用于拼出 ws/wss 与缓存资源 URL。
// 这里尽量沿用上游(frp/nginx)注入的 x-forwarded-proto/host;若缺失,则回退到本机 http。
const originLike = headerValue("origin") || headerValue("referer") || "";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const forwardedHostRaw =
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
const forwardedProto =
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
"http";
const forwardedPort = (() => {
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
if (fromHeader) return fromHeader;
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
for (const [k, v] of Object.entries(headers)) {
if (!v) continue;
const key = String(k);
if (key.toLowerCase() === "host") continue;
if (Array.isArray(v)) {
lines.push(`${key}: ${v.join(", ")}`);
} else {
lines.push(`${key}: ${String(v)}`);
}
}
// 说明:补齐/覆盖 forward 信息,避免 ONLYOFFICE 返回指向内部端口的绝对 URL。
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
lines.push(`Host: ${targetUrl.host}`);
lines.push("");
lines.push("");
return lines.join("\r\n");
}
function proxyOnlyOfficeUpgrade(req, socket, head) {
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/");
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch (e) {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[dev-server][onlyoffice-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function resolveConvexInternalUrl() {
// 说明:Convex 本地 dev server 默认 3210;对外访问(如 frp https)时,浏览器需要 wss
// 因此这里通过同源反代把 Upgrade 转发到本机 3210。
const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim();
try {
return new URL(raw.replace(/\/+$/, "") + "/");
} catch {
return new URL("http://127.0.0.1:3210/");
}
}
function proxyConvexUpgrade(req, socket, head) {
const target = resolveConvexInternalUrl();
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[dev-server][convex-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http");
const headers = { ...(req.headers || {}) };
headers.host = target.host;
// 说明:让上游能感知对外协议/域名(主要用于调试;Convex 本身通常不依赖这些头)。
const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || "");
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
const forwardedProto =
String(headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
(String(req.headers.origin || "").startsWith("https") ? "https" : "http");
const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim();
const forwardedPort = (() => {
if (forwardedPortRaw) return forwardedPortRaw;
const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = forwardedProto;
headers["x-forwarded-port"] = forwardedPort;
headers["x-forwarded-prefix"] = CONVEX_PREFIX;
const upstreamReq = mod.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: upstreamPath,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {});
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (err) => {
try {
console.log("[dev-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {}
try {
res.statusCode = 502;
res.end("Bad Gateway");
} catch {}
});
req.pipe(upstreamReq);
}
async function main() {
const port = resolvePort();
const hostname = resolveHostname();
const dev = true;
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
// 说明:Next dev 的 HMR 依赖 WebSocket/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-dev-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
res.setHeader("x-mnote-convex-ws-proxy", "1");
} catch {
// ignore
}
if (isConvexPath(req.url || "/")) {
proxyConvexHttp(req, res);
return;
}
const parsed = parseUrl(req.url || "/", true);
handle(req, res, parsed);
});
server.on("upgrade", (req, socket, head) => {
if (isConvexPath(req.url || "/")) {
try {
console.log(
"[dev-server][convex-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
try {
console.log(
"[dev-server][onlyoffice-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyOnlyOfficeUpgrade(req, socket, head);
return;
}
if (handleUpgrade) {
handleUpgrade(req, socket, head);
return;
}
try {
socket.destroy();
} catch {
// ignore
}
});
server.listen(port, hostname, () => {
console.log(
`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
});
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});
#!/usr/bin/env node
/**
* 自定义 Next dev server
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
* - Next App Router 的 Route Handler 无法处理 Upgrade,因此必须在 Node http server 层做透传。
*
* 用法(保持与 next dev 类似):
* - pnpm dev -p 3000
* - node scripts/dev-server.js -p 3000
*
* 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag);
if (idx === -1) return null;
const v = process.argv[idx + 1];
if (!v || v.startsWith("-")) return null;
return v;
}
function resolvePort() {
const fromArg = readArgValue("-p") || readArgValue("--port");
const raw = fromArg || process.env.PORT || "3000";
const n = Number(raw);
return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000;
}
function resolveHostname() {
return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0";
}
function isOnlyOfficePath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`);
} catch {
return false;
}
}
function isConvexPath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`);
} catch {
return false;
}
}
function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/";
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
const lines = [];
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
const headers = req.headers || {};
const headerValue = (name) => {
const v = headers[name];
if (!v) return "";
return Array.isArray(v) ? v[0] : String(v);
};
// 说明:ONLYOFFICE 在反向代理下会依赖 X-Forwarded-* 推导“对外地址”,用于拼出 ws/wss 与缓存资源 URL。
// 这里尽量沿用上游(frp/nginx)注入的 x-forwarded-proto/host;若缺失,则回退到本机 http。
const originLike = headerValue("origin") || headerValue("referer") || "";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const forwardedHostRaw =
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
const forwardedProto =
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
"http";
const forwardedPort = (() => {
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
if (fromHeader) return fromHeader;
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
for (const [k, v] of Object.entries(headers)) {
if (!v) continue;
const key = String(k);
if (key.toLowerCase() === "host") continue;
if (Array.isArray(v)) {
lines.push(`${key}: ${v.join(", ")}`);
} else {
lines.push(`${key}: ${String(v)}`);
}
}
// 说明:补齐/覆盖 forward 信息,避免 ONLYOFFICE 返回指向内部端口的绝对 URL。
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
// 说明:Host 必须指向 ONLYOFFICE_INTERNAL_URL,否则上游可能拒绝 Upgrade。
lines.push(`Host: ${targetUrl.host}`);
lines.push("");
lines.push("");
return lines.join("\r\n");
}
function proxyOnlyOfficeUpgrade(req, socket, head) {
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/");
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch (e) {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[dev-server][onlyoffice-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function resolveConvexInternalUrl() {
// 说明:Convex 本地 dev server 默认 3210;对外访问(如 frp https)时,浏览器需要 wss
// 因此这里通过同源反代把 Upgrade 转发到本机 3210。
const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim();
try {
return new URL(raw.replace(/\/+$/, "") + "/");
} catch {
return new URL("http://127.0.0.1:3210/");
}
}
function proxyConvexUpgrade(req, socket, head) {
const target = resolveConvexInternalUrl();
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[dev-server][convex-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http");
const headers = { ...(req.headers || {}) };
headers.host = target.host;
// 说明:让上游能感知对外协议/域名(主要用于调试;Convex 本身通常不依赖这些头)。
const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || "");
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
const forwardedProto =
String(headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
(String(req.headers.origin || "").startsWith("https") ? "https" : "http");
const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim();
const forwardedPort = (() => {
if (forwardedPortRaw) return forwardedPortRaw;
const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = forwardedProto;
headers["x-forwarded-port"] = forwardedPort;
headers["x-forwarded-prefix"] = CONVEX_PREFIX;
const upstreamReq = mod.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: upstreamPath,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {});
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (err) => {
try {
console.log("[dev-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {}
try {
res.statusCode = 502;
res.end("Bad Gateway");
} catch {}
});
req.pipe(upstreamReq);
}
async function main() {
const port = resolvePort();
const hostname = resolveHostname();
const dev = true;
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
// 说明:Next dev 的 HMR 依赖 WebSocket/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-dev-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
res.setHeader("x-mnote-convex-ws-proxy", "1");
} catch {
// ignore
}
if (isConvexPath(req.url || "/")) {
proxyConvexHttp(req, res);
return;
}
const parsed = parseUrl(req.url || "/", true);
handle(req, res, parsed);
});
server.on("upgrade", (req, socket, head) => {
if (isConvexPath(req.url || "/")) {
try {
console.log(
"[dev-server][convex-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
try {
console.log(
"[dev-server][onlyoffice-ws] upgrade",
req.url,
"host=",
req.headers.host,
"xfp=",
req.headers["x-forwarded-proto"],
"xfh=",
req.headers["x-forwarded-host"],
);
} catch {}
proxyOnlyOfficeUpgrade(req, socket, head);
return;
}
if (handleUpgrade) {
handleUpgrade(req, socket, head);
return;
}
try {
socket.destroy();
} catch {
// ignore
}
});
server.listen(port, hostname, () => {
console.log(
`[dev-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
});
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});
+47 -47
View File
@@ -14,43 +14,43 @@ function setConvexEnv(name, value) {
const privateKey = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1
gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
AT5PRKihrWbg63/rXaSCxw==
-----END PRIVATE KEY-----`;
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
// 使用临时目录(跨平台兼容)
const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp';
const keyFile = path.join(tmpDir, 'convex_jwt_key.txt');
const jwksFile = path.join(tmpDir, 'convex_jwks.txt');
// 写入临时文件
fs.writeFileSync(keyFile, privateKey);
fs.writeFileSync(jwksFile, jwks);
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
AT5PRKihrWbg63/rXaSCxw==
-----END PRIVATE KEY-----`;
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
// 使用临时目录(跨平台兼容)
const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp';
const keyFile = path.join(tmpDir, 'convex_jwt_key.txt');
const jwksFile = path.join(tmpDir, 'convex_jwks.txt');
// 写入临时文件
fs.writeFileSync(keyFile, privateKey);
fs.writeFileSync(jwksFile, jwks);
console.log('Setting JWT_PRIVATE_KEY from file...');
const keyContent = fs.readFileSync(keyFile, 'utf8');
try {
@@ -67,13 +67,13 @@ try {
} catch (e) {
console.error('✗ Failed to set JWKS:', e.message);
}
// 清理临时文件
try {
fs.unlinkSync(keyFile);
fs.unlinkSync(jwksFile);
} catch (e) {
// 忽略清理错误
}
console.log('Done! You can now test registration.');
// 清理临时文件
try {
fs.unlinkSync(keyFile);
fs.unlinkSync(jwksFile);
} catch (e) {
// 忽略清理错误
}
console.log('Done! You can now test registration.');
+13 -13
View File
@@ -1,13 +1,13 @@
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
const keys = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
process.stdout.write(
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`,
);
process.stdout.write("\n");
process.stdout.write(`JWKS=${jwks}`);
process.stdout.write("\n");
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
const keys = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
process.stdout.write(
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`,
);
process.stdout.write("\n");
process.stdout.write(`JWKS=${jwks}`);
process.stdout.write("\n");
+330 -330
View File
@@ -1,330 +1,330 @@
#!/usr/bin/env node
/**
* 自定义 Next 生产 server
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
* - 解决 Convex 在 HTTPSfrp/nginx)场景下浏览器不能连接 ws:// 的问题:通过同源 `/convex/*` 反代到本机 Convex。
*
* 用法:
* - pnpm build
* - pnpm start (默认会执行本脚本)
*
* 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081
* - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag);
if (idx === -1) return null;
const v = process.argv[idx + 1];
if (!v || v.startsWith("-")) return null;
return v;
}
function resolvePort() {
const fromArg = readArgValue("-p") || readArgValue("--port");
const raw = fromArg || process.env.PORT || "3000";
const n = Number(raw);
return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000;
}
function resolveHostname() {
return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0";
}
function isOnlyOfficePath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`);
} catch {
return false;
}
}
function isConvexPath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`);
} catch {
return false;
}
}
function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/";
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
const lines = [];
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
const headers = req.headers || {};
const headerValue = (name) => {
const v = headers[name];
if (!v) return "";
return Array.isArray(v) ? v[0] : String(v);
};
const originLike = headerValue("origin") || headerValue("referer") || "";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const forwardedHostRaw =
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
const forwardedProto =
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
"http";
const forwardedPort = (() => {
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
if (fromHeader) return fromHeader;
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
for (const [k, v] of Object.entries(headers)) {
if (!v) continue;
const key = String(k);
if (key.toLowerCase() === "host") continue;
if (Array.isArray(v)) {
lines.push(`${key}: ${v.join(", ")}`);
} else {
lines.push(`${key}: ${String(v)}`);
}
}
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
lines.push(`Host: ${targetUrl.host}`);
lines.push("");
lines.push("");
return lines.join("\r\n");
}
function proxyOnlyOfficeUpgrade(req, socket, head) {
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/");
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][onlyoffice-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function resolveConvexInternalUrl() {
const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim();
try {
return new URL(raw.replace(/\/+$/, "") + "/");
} catch {
return new URL("http://127.0.0.1:3210/");
}
}
function proxyConvexUpgrade(req, socket, head) {
const target = resolveConvexInternalUrl();
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][convex-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http");
const headers = { ...(req.headers || {}) };
headers.host = target.host;
const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || "");
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
const forwardedProto =
String(headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
(String(req.headers.origin || "").startsWith("https") ? "https" : "http");
const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim();
const forwardedPort = (() => {
if (forwardedPortRaw) return forwardedPortRaw;
const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = forwardedProto;
headers["x-forwarded-port"] = forwardedPort;
headers["x-forwarded-prefix"] = CONVEX_PREFIX;
const upstreamReq = mod.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: upstreamPath,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {});
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (err) => {
try {
console.log("[prod-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {}
try {
res.statusCode = 502;
res.end("Bad Gateway");
} catch {}
});
req.pipe(upstreamReq);
}
async function main() {
const port = resolvePort();
const hostname = resolveHostname();
const dev = false;
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-prod-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
res.setHeader("x-mnote-convex-ws-proxy", "1");
} catch {
// ignore
}
if (isConvexPath(req.url || "/")) {
proxyConvexHttp(req, res);
return;
}
const parsed = parseUrl(req.url || "/", true);
handle(req, res, parsed);
});
server.on("upgrade", (req, socket, head) => {
if (isConvexPath(req.url || "/")) {
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
proxyOnlyOfficeUpgrade(req, socket, head);
return;
}
if (handleUpgrade) {
handleUpgrade(req, socket, head);
return;
}
try {
socket.destroy();
} catch {
// ignore
}
});
server.listen(port, hostname, () => {
console.log(
`[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
});
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});
#!/usr/bin/env node
/**
* 自定义 Next 生产 server
* - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。
* - 解决 Convex 在 HTTPSfrp/nginx)场景下浏览器不能连接 ws:// 的问题:通过同源 `/convex/*` 反代到本机 Convex。
*
* 用法:
* - pnpm build
* - pnpm start (默认会执行本脚本)
*
* 依赖环境变量:
* - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081
* - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
function readArgValue(flag) {
const idx = process.argv.findIndex((x) => x === flag);
if (idx === -1) return null;
const v = process.argv[idx + 1];
if (!v || v.startsWith("-")) return null;
return v;
}
function resolvePort() {
const fromArg = readArgValue("-p") || readArgValue("--port");
const raw = fromArg || process.env.PORT || "3000";
const n = Number(raw);
return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000;
}
function resolveHostname() {
return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0";
}
function isOnlyOfficePath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`);
} catch {
return false;
}
}
function isConvexPath(urlString) {
try {
const u = new URL(urlString, "http://localhost");
return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`);
} catch {
return false;
}
}
function buildUpstreamRequestHead(req, targetUrl, prefix) {
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/";
const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || "";
const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || "");
const lines = [];
lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`);
const headers = req.headers || {};
const headerValue = (name) => {
const v = headers[name];
if (!v) return "";
return Array.isArray(v) ? v[0] : String(v);
};
const originLike = headerValue("origin") || headerValue("referer") || "";
const originUrl = (() => {
try {
if (!originLike) return null;
return new URL(originLike);
} catch {
return null;
}
})();
const forwardedHostRaw =
headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || "";
const forwardedProto =
(headerValue("x-forwarded-proto") || "").split(",")[0].trim() ||
(originUrl ? originUrl.protocol.replace(":", "") : "") ||
"http";
const forwardedPort = (() => {
const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim();
if (fromHeader) return fromHeader;
const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
for (const [k, v] of Object.entries(headers)) {
if (!v) continue;
const key = String(k);
if (key.toLowerCase() === "host") continue;
if (Array.isArray(v)) {
lines.push(`${key}: ${v.join(", ")}`);
} else {
lines.push(`${key}: ${String(v)}`);
}
}
lines.push(`x-forwarded-host: ${forwardedHost}`);
lines.push(`x-forwarded-proto: ${forwardedProto}`);
lines.push(`x-forwarded-port: ${forwardedPort}`);
lines.push(`x-forwarded-prefix: ${prefix}`);
lines.push(`Host: ${targetUrl.host}`);
lines.push("");
lines.push("");
return lines.join("\r\n");
}
function proxyOnlyOfficeUpgrade(req, socket, head) {
const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/");
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][onlyoffice-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function resolveConvexInternalUrl() {
const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim();
try {
return new URL(raw.replace(/\/+$/, "") + "/");
} catch {
return new URL("http://127.0.0.1:3210/");
}
}
function proxyConvexUpgrade(req, socket, head) {
const target = resolveConvexInternalUrl();
const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80);
const upstream = net.connect({ host: target.hostname, port }, () => {
try {
const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX);
upstream.write(reqHead);
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
} catch {
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
}
});
const onError = (err) => {
try {
const msg = err && err.message ? String(err.message) : String(err || "");
console.log("[prod-server][convex-ws] proxy error", msg);
} catch {}
try {
socket.destroy();
} catch {}
try {
upstream.destroy();
} catch {}
};
upstream.on("error", onError);
socket.on("error", onError);
}
function proxyConvexHttp(req, res) {
const target = resolveConvexInternalUrl();
const incoming = new URL(req.url || "/", "http://localhost");
const rawPath = incoming.pathname || "/";
const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/";
const upstreamPath = stripped + (incoming.search || "");
const isHttps = target.protocol === "https:";
const mod = isHttps ? require("https") : require("http");
const headers = { ...(req.headers || {}) };
headers.host = target.host;
const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || "");
const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost";
const forwardedProto =
String(headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
(String(req.headers.origin || "").startsWith("https") ? "https" : "http");
const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim();
const forwardedPort = (() => {
if (forwardedPortRaw) return forwardedPortRaw;
const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : "";
if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort;
return forwardedProto === "https" ? "443" : "80";
})();
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = forwardedProto;
headers["x-forwarded-port"] = forwardedPort;
headers["x-forwarded-prefix"] = CONVEX_PREFIX;
const upstreamReq = mod.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: upstreamPath,
headers,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {});
upstreamRes.pipe(res);
},
);
upstreamReq.on("error", (err) => {
try {
console.log("[prod-server][convex-http] proxy error", err && err.message ? err.message : String(err || ""));
} catch {}
try {
res.statusCode = 502;
res.end("Bad Gateway");
} catch {}
});
req.pipe(upstreamReq);
}
async function main() {
const port = resolvePort();
const hostname = resolveHostname();
const dev = false;
const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler();
await app.prepare();
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null;
const server = http.createServer((req, res) => {
try {
res.setHeader("x-mnote-prod-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
res.setHeader("x-mnote-convex-ws-proxy", "1");
} catch {
// ignore
}
if (isConvexPath(req.url || "/")) {
proxyConvexHttp(req, res);
return;
}
const parsed = parseUrl(req.url || "/", true);
handle(req, res, parsed);
});
server.on("upgrade", (req, socket, head) => {
if (isConvexPath(req.url || "/")) {
proxyConvexUpgrade(req, socket, head);
return;
}
if (isOnlyOfficePath(req.url || "/")) {
proxyOnlyOfficeUpgrade(req, socket, head);
return;
}
if (handleUpgrade) {
handleUpgrade(req, socket, head);
return;
}
try {
socket.destroy();
} catch {
// ignore
}
});
server.listen(port, hostname, () => {
console.log(
`[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`,
);
});
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});
+60 -60
View File
@@ -1,60 +1,60 @@
/**
* 注册测试账号脚本
*
* 用途:快速注册测试账号,方便开发和测试
* 运行:node scripts/register-test-user.js
*/
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
name: "测试用户",
};
async function registerTestUser() {
const baseUrl = "http://localhost:3000";
console.log("正在注册测试账号...");
console.log(`邮箱: ${TEST_CREDENTIALS.email}`);
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
}),
});
const result = await response.json();
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else if (response.status === 501) {
console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
} else {
console.error("✗ 注册失败:", result.error || result.message);
}
} catch (error) {
console.error("✗ 请求失败:", error.message);
console.log("\n请确保开发服务器正在运行 (pnpm dev)");
}
}
registerTestUser();
/**
* 注册测试账号脚本
*
* 用途:快速注册测试账号,方便开发和测试
* 运行:node scripts/register-test-user.js
*/
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
name: "测试用户",
};
async function registerTestUser() {
const baseUrl = "http://localhost:3000";
console.log("正在注册测试账号...");
console.log(`邮箱: ${TEST_CREDENTIALS.email}`);
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
}),
});
const result = await response.json();
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else if (response.status === 501) {
console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
} else {
console.error("✗ 注册失败:", result.error || result.message);
}
} catch (error) {
console.error("✗ 请求失败:", error.message);
console.log("\n请确保开发服务器正在运行 (pnpm dev)");
}
}
registerTestUser();
+26 -26
View File
@@ -12,32 +12,32 @@ function setConvexEnv(name, value) {
const privateKey = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1
gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
AT5PRKihrWbg63/rXaSCxw==
-----END PRIVATE KEY-----`;
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
AT5PRKihrWbg63/rXaSCxw==
-----END PRIVATE KEY-----`;
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
console.log('Setting JWT_PRIVATE_KEY...');
+2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar/sidebar";
import { GlobalAiAgentHost } from "@/components/ai-agent/GlobalAiAgentHost";
import { Breadcrumb } from "@/components/breadcrumb";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import type { DocumentRecord } from "@/lib/documents";
@@ -243,6 +244,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
<GlobalAiAgentHost />
</div>
</div>
);
+130 -130
View File
@@ -1,5 +1,5 @@
"use client";
"use client";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { useAuthActions } from "@convex-dev/auth/react";
import { useState, useCallback, useEffect } from "react";
@@ -7,22 +7,22 @@ import { useRouter } from "next/navigation";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getUserFacingErrorMessage } from "@/lib/auth/errors";
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
/**
* Convex Auth 登录/注册页面
*
* 支持功能:
* - 邮箱密码登录
* - 邮箱密码注册
*/
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
/**
* Convex Auth 登录/注册页面
*
* 支持功能:
* - 邮箱密码登录
* - 邮箱密码注册
*/
export default function AuthPage() {
const { isLoading, isAuthenticated } = useConvexAuth();
const { signIn } = useAuthActions();
@@ -39,7 +39,7 @@ export default function AuthPage() {
router.replace("/");
}
}, [currentUser, isAuthenticated, isLoading, router]);
const [flow, setFlow] = useState<AuthStep>("signIn");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -127,36 +127,36 @@ export default function AuthPage() {
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "操作失败,请重试") });
}
}, [router, signIn, username]);
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await performSignIn(email, password, flow);
}, [email, password, flow, performSignIn]);
// 检查是否启用了 Convex
const isConvex = isConvexEnabled();
if (!isConvex) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4"></h1>
<p className="text-gray-600"> Convex </p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await performSignIn(email, password, flow);
}, [email, password, flow, performSignIn]);
// 检查是否启用了 Convex
const isConvex = isConvexEnabled();
if (!isConvex) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4"></h1>
<p className="text-gray-600"> Convex </p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
if (isAuthenticated && currentUser && currentUser.name) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
@@ -244,29 +244,29 @@ export default function AuthPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow === "signIn" ? "登录账户" : "创建账户"}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
使 Convex Auth
</p>
</div>
{message && (
<div className={`rounded-md p-4 ${
message.type === "success" ? "bg-green-50 text-green-800" :
message.type === "error" ? "bg-red-50 text-red-800" :
"bg-blue-50 text-blue-800"
}`}>
<p className="text-sm">{message.text}</p>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow === "signIn" ? "登录账户" : "创建账户"}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
使 Convex Auth
</p>
</div>
{message && (
<div className={`rounded-md p-4 ${
message.type === "success" ? "bg-green-50 text-green-800" :
message.type === "error" ? "bg-red-50 text-red-800" :
"bg-blue-50 text-blue-800"
}`}>
<p className="text-sm">{message.text}</p>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only"></label>
<input
@@ -298,61 +298,61 @@ export default function AuthPage() {
</div>
)}
<div>
<label htmlFor="password" className="sr-only"></label>
<input
id="password"
name="password"
type="password"
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="密码(至少 8 位)"
/>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{flow === "signIn" ? "登录" : "注册"}
</button>
</div>
{flow === "signIn" && (
<div>
<button
type="button"
onClick={() => {
setEmail(TEST_CREDENTIALS.email);
setPassword(TEST_CREDENTIALS.password);
// 直接调用登录逻辑
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
}}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
</div>
)}
<div className="text-center">
<button
type="button"
onClick={() => {
setFlow(flow === "signIn" ? "signUp" : "signIn");
setMessage(null);
}}
className="text-blue-600 hover:text-blue-500 text-sm"
>
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
</button>
</div>
</form>
</div>
</div>
);
}
<label htmlFor="password" className="sr-only"></label>
<input
id="password"
name="password"
type="password"
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="密码(至少 8 位)"
/>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{flow === "signIn" ? "登录" : "注册"}
</button>
</div>
{flow === "signIn" && (
<div>
<button
type="button"
onClick={() => {
setEmail(TEST_CREDENTIALS.email);
setPassword(TEST_CREDENTIALS.password);
// 直接调用登录逻辑
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
}}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
</div>
)}
<div className="text-center">
<button
type="button"
onClick={() => {
setFlow(flow === "signIn" ? "signUp" : "signIn");
setMessage(null);
}}
className="text-blue-600 hover:text-blue-500 text-sm"
>
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry";
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
@@ -33,6 +34,8 @@ export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type AiProvider = "online" | "local" | "ollama" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
stream?: boolean;
@@ -48,7 +51,7 @@ type RequestPayload = {
// v1BlockNote 文档快照(前端可选传入,避免覆盖未落盘编辑)
documentBlocks?: unknown;
};
options?: { searxng?: boolean; ai?: { provider?: "online" | "local"; model?: string } };
options?: { searxng?: boolean; ai?: { provider?: AiProvider; model?: string; sessionId?: string } };
};
/** 按作用域分组的工具集 ID 映射 */
@@ -116,6 +119,38 @@ const toSseFrame = (event: string, data: unknown) => {
return `event: ${event}\ndata: ${json}\n\n`;
};
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const normalizeProvider = (raw: unknown): AiProvider => {
const s = String(raw ?? "").trim();
if (s === "local" || s === "online" || s === "ollama" || s === "codex") return s;
return "online";
};
const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => {
const s = String(text ?? "");
const m = s.match(/^\s*#(chat|test|dev)\b[\s:\-–—]*/i);
if (!m) return { mode: null, text: s };
const mode = String(m[1] ?? "").toLowerCase() as CodexMode;
const rest = s.slice(m[0].length);
return { mode, text: rest.trimStart() };
};
const extractCodexModeFromMessages = (messages: AgentMessage[]) => {
const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content ?? "";
const picked = stripCodexModePrefix(lastUser);
const mode: CodexMode = picked.mode ?? "chat";
const cleaned: AgentMessage[] = messages.map((m) => {
if (m.role !== "user") return m;
const r = stripCodexModePrefix(m.content);
return { ...m, content: r.text };
});
return { mode, cleanedMessages: cleaned };
};
export async function POST(request: Request) {
const payload = await safeGetJsonBody<RequestPayload>(request);
if (!payload) {
@@ -144,14 +179,228 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = payload.options?.ai?.provider === "local" ? "local" : "online";
const provider = normalizeProvider(payload.options?.ai?.provider);
const modelOverride = String(payload.options?.ai?.model ?? "").trim() || null;
const cfg =
provider === "local"
? await loadLocalAiConfig().catch(() => null)
: await loadOnlineAiConfig().catch(() => null);
// Codex:三种模式(默认 #chat
let codexMode: CodexMode = "chat";
let effectiveMessages: AgentMessage[] = payload.messages.slice(0, 50);
let codexWorkspaceRoot: string | null = null;
if (provider === "codex") {
const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50));
codexMode = mode;
effectiveMessages = cleanedMessages;
codexWorkspaceRoot = await findWorkspaceRoot(process.cwd());
// #chat / #dev:直接运行 codex exec(不走本 Agent 工具链)
if (codexMode !== "test") {
const stream = payload.stream !== false;
const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
const encoder = new TextEncoder();
let runKillOuter: (() => void) | null = null;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(toSseFrame(event, data)));
};
const requestId = makeRunId();
send("ready", { ok: true, requestId });
// 说明:同一对话内允许 #chat/#test/#dev 来回切换;为了保证后续随时可进入 #dev,
// web 侧创建的新 session 统一用 workspace-write(是否改文件由 prompt 约束)。
const sandbox: "read-only" | "workspace-write" = "workspace-write";
const sys =
codexMode === "dev"
? "你当前处于 #dev 模式:行为尽量与 Codex CLI 一致。你可以在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。"
: "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。";
const prompt = (() => {
// 新会话:把系统说明 + 对话历史一起喂给 Codex(保证一致性)
if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]);
// 续聊:只发送本次用户输入(带模式前缀),同时重复一遍系统约束以对齐行为
const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? "";
const userText = String(lastUser || "").trim();
if (!userText) return codexMessagesToPrompt([{ role: "system", content: sys }, ...effectiveMessages]);
return codexMessagesToPrompt([{ role: "system", content: sys }, { role: "user", content: userText }]);
})();
const toolStartAt = new Map<string, number>();
let sessionSent = false;
let assistantSent = false;
let runKill: (() => void) | null = null;
runKillOuter = () => {
try {
runKill?.();
} catch {
// ignore
}
};
const onAbort = () => {
try {
runKillOuter?.();
} catch {
// ignore
}
};
try {
request.signal?.addEventListener("abort", onAbort, { once: true });
} catch {
// ignore
}
(async () => {
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot!,
sandbox,
prompt,
model: null,
sessionId: sessionIdRaw || null,
onJsonLine: (line) => {
if (line.type === "thread.started") {
const sid = String((line as any).thread_id ?? "").trim();
if (sid && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: sid });
}
return;
}
if (line.type === "item.started" && (line as any).item?.type === "command_execution") {
const item = (line as any).item;
const id = String(item?.id ?? "").trim();
const cmd = String(item?.command ?? "");
if (!id) return;
toolStartAt.set(id, Date.now());
send("tool_call", { id, tool: "codex_command", args: { command: cmd } });
return;
}
if (line.type === "item.completed" && (line as any).item?.type === "command_execution") {
const item = (line as any).item;
const id = String(item?.id ?? "").trim();
if (!id) return;
const t0 = toolStartAt.get(id) ?? Date.now();
const ms = Math.max(0, Date.now() - t0);
const exitCode = Number(item?.exit_code ?? 0);
send("tool_result", {
id,
tool: "codex_command",
ok: exitCode === 0,
ms,
result: { exitCode, output: String(item?.aggregated_output ?? "") },
});
return;
}
if (line.type === "item.completed" && (line as any).item?.type === "agent_message") {
const item = (line as any).item;
const text = String(item?.text ?? "").trim();
if (text) {
assistantSent = true;
send("assistant_message", { text });
}
}
},
});
runKill = run.kill;
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error });
return;
}
if (result.threadId && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: result.threadId });
}
if (!assistantSent && result.text) {
assistantSent = true;
send("assistant_message", { text: result.text });
}
send("completion", { ok: true, text: result.text, steps: 1 });
})()
.catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
send("error", { ok: false, message: msg });
})
.finally(() => {
try {
request.signal?.removeEventListener("abort", onAbort);
} catch {
// ignore
}
controller.close();
});
},
cancel() {
// 前端 abort fetch 时会触发 cancel:尽量终止 codex 进程(类似按 ESC)
// 说明:kill 函数在 start 的闭包里赋值;这里不做任何强假设。
try {
runKillOuter?.();
} catch {
// ignore
}
},
});
if (!stream) {
const prompt = (() => {
if (!sessionIdRaw) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]);
const lastUser = [...effectiveMessages].reverse().find((m) => m.role === "user")?.content ?? "";
const userText = String(lastUser || "").trim();
if (!userText) return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, ...effectiveMessages]);
return codexMessagesToPrompt([{ role: "system", content: "请用简体中文回复。" }, { role: "user", content: userText }]);
})();
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot!,
sandbox: "workspace-write",
prompt,
model: null,
sessionId: sessionIdRaw || null,
});
const result = await run.done;
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 500 });
return NextResponse.json({ text: result.text, steps: 1, events: [], sessionId: result.threadId || sessionIdRaw || null });
}
return new Response(body, { headers: sseHeaders });
}
}
// 非 Codexonline/local/ollama 走 OpenAI 兼容网关
const cfg = await (async () => {
if (provider === "codex") {
// #test:工具链可能需要 cfg(例如 mindmap_expand_node),因此这里尽量给一个可用的兜底 cfg
return (
(await loadLocalAiConfig().catch(() => null)) ??
(await loadOnlineAiConfig().catch(() => null)) ?? {
baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL,
apiKey: "",
model: OLLAMA_QWEN3_30B,
}
);
}
if (provider === "ollama") {
return {
baseUrl: (process.env.OLLAMA_BASE_URL ?? "").trim() || OLLAMA_DEFAULT_BASE_URL,
apiKey: "",
model: modelOverride ?? OLLAMA_QWEN3_30B,
};
}
if (provider === "local") return await loadLocalAiConfig().catch(() => null);
return await loadOnlineAiConfig().catch(() => null);
})();
if (!cfg) {
return errorResponses.aiConfigError(provider);
// 仅 online/local 需要配置文件/环境变量
return errorResponses.aiConfigError(provider === "local" ? "local" : "online");
}
const registry = createToolRegistry({ tools: builtinTools, toolSets: builtinToolSets });
@@ -547,9 +796,53 @@ export async function POST(request: Request) {
const stream = payload.stream !== false;
if (!stream) {
const events: Array<{ type: string; data: unknown }> = [];
const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
let codexSessionIdInRequest: string | null = codexSessionIdRaw || null;
let codexSessionEmitted = false;
const chatForAgent =
provider === "codex"
? async (messages: Array<{ role: "system" | "user" | "assistant"; content: string }>) => {
const prompt = codexMessagesToPrompt([
{
role: "system",
content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。",
},
...messages,
]);
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot ?? process.cwd(),
sandbox: "workspace-write",
prompt,
model: null,
sessionId: codexSessionIdInRequest,
onJsonLine: (line) => {
if (line.type !== "thread.started") return;
const sid = String((line as any).thread_id ?? "").trim();
if (!sid) return;
if (!codexSessionIdInRequest) codexSessionIdInRequest = sid;
if (!codexSessionEmitted) {
codexSessionEmitted = true;
events.push({ type: "codex_session", data: { sessionId: sid } });
}
},
});
const result = await run.done;
if (!result.ok) throw new Error(result.error);
if (result.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = result.threadId;
if (result.threadId && !codexSessionEmitted) {
codexSessionEmitted = true;
events.push({ type: "codex_session", data: { sessionId: result.threadId } });
}
return { text: result.text, raw: null };
}
: undefined;
const result = await runAiAgent({
userMessages: payload.messages.slice(0, 50),
userMessages: effectiveMessages,
cfg: { ...cfg, model: modelOverride ?? cfg.model },
...(chatForAgent ? { chat: chatForAgent } : {}),
allowedToolIds,
runTool,
maxSteps,
@@ -562,6 +855,16 @@ export async function POST(request: Request) {
}
const encoder = new TextEncoder();
let activeCodexKill: (() => void) | null = null;
const stopActiveCodexRun = () => {
try {
activeCodexKill?.();
} catch {
// ignore
} finally {
activeCodexKill = null;
}
};
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
@@ -573,6 +876,9 @@ export async function POST(request: Request) {
send("ready", { ok: true, requestId });
let lastToolCall: { id: string; tool: string; args: Record<string, unknown> } | null = null;
const codexSessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
let codexSessionIdInRequest: string | null = codexSessionIdRaw || null;
let codexSessionEmitted = false;
const runToolStream = async (toolId: string, toolArgs: Record<string, unknown>) => {
if (isOnlyOfficeClientTool(toolId)) {
@@ -603,10 +909,60 @@ export async function POST(request: Request) {
}
}, 15_000);
const onAbort = () => {
stopActiveCodexRun();
};
try {
request.signal?.addEventListener("abort", onAbort, { once: true });
} catch {
// ignore
}
(async () => {
const result = await runAiAgent({
userMessages: payload.messages.slice(0, 50),
userMessages: effectiveMessages,
cfg: { ...cfg, model: modelOverride ?? cfg.model },
...(provider === "codex"
? {
chat: async (messages) => {
const prompt = codexMessagesToPrompt([
{
role: "system",
content: "补充约束:你当前处于 #test 模式。你必须严格遵守工具标签协议输出;不要执行任何命令;不要读写文件。",
},
...messages,
]);
const run = startCodexJsonRun({
cwd: codexWorkspaceRoot ?? process.cwd(),
sandbox: "workspace-write",
prompt,
model: null,
sessionId: codexSessionIdInRequest,
onJsonLine: (line) => {
if (line.type !== "thread.started") return;
const sid = String((line as any).thread_id ?? "").trim();
if (!sid) return;
if (!codexSessionIdInRequest) codexSessionIdInRequest = sid;
if (!codexSessionEmitted) {
codexSessionEmitted = true;
send("codex_session", { sessionId: sid });
}
},
});
activeCodexKill = run.kill;
const r = await run.done;
if (activeCodexKill === run.kill) activeCodexKill = null;
if (!r.ok) throw new Error(r.error);
if (r.threadId && !codexSessionIdInRequest) codexSessionIdInRequest = r.threadId;
if (r.threadId && !codexSessionEmitted) {
codexSessionEmitted = true;
send("codex_session", { sessionId: r.threadId });
}
return { text: r.text, raw: null };
},
}
: {}),
allowedToolIds,
runTool: runToolStream,
maxSteps,
@@ -648,9 +1004,18 @@ export async function POST(request: Request) {
})
.finally(() => {
clearInterval(ping);
try {
request.signal?.removeEventListener("abort", onAbort);
} catch {
// ignore
}
stopActiveCodexRun();
controller.close();
});
},
cancel() {
stopActiveCodexRun();
},
});
return new Response(body, { headers: sseHeaders });
+15 -15
View File
@@ -1,21 +1,21 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
interface SignInRequest {
email: string;
password: string;
name?: string;
flow: "signIn" | "signUp";
}
/**
* POST /api/auth/signin
*
* /
* - Convex Convex mutation
* - Supabase Supabase Auth
*/
interface SignInRequest {
email: string;
password: string;
name?: string;
flow: "signIn" | "signUp";
}
/**
* POST /api/auth/signin
*
* /
* - Convex Convex mutation
* - Supabase Supabase Auth
*/
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
@@ -32,11 +32,11 @@ export async function POST(request: Request) {
return await handleCreateRequest(request);
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequestConvex(request: Request) {
const { auth, client } = await getAuthedConvexClient();
@@ -117,78 +117,78 @@ async function handleCreateRequest(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: rawSiblingCount, error: countError } = await siblingQuery;
const siblingCount = rawSiblingCount ?? 0;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
@@ -200,28 +200,28 @@ async function handleCreateRequest(request: Request) {
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
*/
}
@@ -54,39 +54,39 @@ export async function GET(request: Request) {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
let query = supabase
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
const assetType = searchParams.get("assetType");
if (assetType) {
query = query.eq("asset_type", assetType);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
let query = supabase
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
const assetType = searchParams.get("assetType");
if (assetType) {
query = query.eq("asset_type", assetType);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
}
export async function POST(request: Request) {
@@ -171,46 +171,46 @@ export async function POST(request: Request) {
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const { data, error } = await supabase
.from("media_assets")
.insert({
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: data as MediaAsset });
}
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const { data, error } = await supabase
.from("media_assets")
.insert({
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: data as MediaAsset });
}
+43 -43
View File
@@ -9,9 +9,9 @@ import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
type Action = "copy" | "move" | "delete" | "rename" | "restore";
interface BatchPayload {
action: Action;
assetIds: string[];
@@ -19,7 +19,7 @@ interface BatchPayload {
targetSubPath?: string;
newName?: string;
}
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
@@ -225,31 +225,31 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: assets, error: fetchError } = await supabase
.from("media_assets")
.select("*")
.in("id", payload.assetIds);
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
try {
switch (payload.action) {
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: assets, error: fetchError } = await supabase
.from("media_assets")
.select("*")
.in("id", payload.assetIds);
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
try {
switch (payload.action) {
case "delete": {
// 可撤销删除:仅标记 deleted_at,真正清理(OCR/Storage/LightRAG)由后台宽限期任务处理
const { error } = await supabase
@@ -327,14 +327,14 @@ export async function POST(request: Request) {
}
case "copy":
case "move": {
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const { data: targetDoc, error: docErr } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", payload.targetDocumentId)
.single();
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const { data: targetDoc, error: docErr } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", payload.targetDocumentId)
.single();
if (docErr || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
@@ -411,12 +411,12 @@ export async function POST(request: Request) {
}
return NextResponse.json({ items: results });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
*/
}
+13 -13
View File
@@ -15,23 +15,23 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json()) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json()) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { error } = await supabase
.from("media_assets")
.update({ ocr_status: "processing" })
.eq("id", assetId)
.is("deleted_at", null)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
console.warn("触发后端 OCR 失败", err);
});
}
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true });
*/
}
@@ -113,44 +113,44 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
// 与 /api/media/batch 的 move/rename 规则对齐:放到 workspaceId/documentId 下
const mindmapId =
mindmapIdRaw && /^[a-zA-Z0-9_-]{1,128}$/.test(mindmapIdRaw) ? mindmapIdRaw : "";
const subdir = mindmapId ? `mindmaps/${mindmapId}` : "";
const path = `${workspaceId}/${documentId}${subdir ? `/${subdir}` : ""}/${Date.now()}-${uniqueId}${extension}`;
const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, {
contentType: file.type,
upsert: false,
});
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 });
}
// 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新
// 对于图片,使用高质量参数以获得更好的显示效果
const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(DOC_BUCKET).upload(path, buffer, {
contentType: file.type,
upsert: false,
});
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 });
}
// 为私有桶生成临时访问链接(7 天);前端可在需要时通过 /api/media/signed-url 刷新
// 对于图片,使用高质量参数以获得更好的显示效果
let signedUrl = "";
if (assetType === "image") {
const { data: signed } = await supabase.storage.from(DOC_BUCKET).createSignedUrl(path, 60 * 60 * 24 * 7, {
@@ -166,37 +166,37 @@ export async function POST(request: Request) {
}
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
const { data: asset, error } = await supabase
.from("media_assets")
.insert({
workspace_id: workspaceId,
document_id: documentId,
file_url: signedUrl,
thumbnail_url: signedUrl,
bucket: DOC_BUCKET,
storage_path: path,
asset_type: assetType,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// 返回 asset 和一个特殊的 asset:id 格式用于存储在思维导图中
return NextResponse.json({
asset: asset as MediaAsset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
const { data: asset, error } = await supabase
.from("media_assets")
.insert({
workspace_id: workspaceId,
document_id: documentId,
file_url: signedUrl,
thumbnail_url: signedUrl,
bucket: DOC_BUCKET,
storage_path: path,
asset_type: assetType,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// 返回 asset 和一个特殊的 asset:id 格式用于存储在思维导图中
return NextResponse.json({
asset: asset as MediaAsset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
*/
}
@@ -1,66 +0,0 @@
import { NextResponse } from "next/server";
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
type RequestPayload = {
ops: MindmapOp[];
actor?: { kind?: string; provider?: string; model?: string };
reason?: string;
};
export async function POST(
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { client } = await getAuthedConvexClient();
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
if (!ops.length) {
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
}
if (ops.length > 80) {
return NextResponse.json({ error: "ops 过多(最大 80" }, { status: 400 });
}
try {
const current = await client.query(api.mindmaps.get, { docId, mindmapId });
const baseData = current?.data ?? defaultMindmapData;
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
await client.mutation(api.mindmaps.put, {
docId,
mindmapId,
data: nextData,
});
return NextResponse.json({
ok: true,
applied,
errors,
data: nextData,
meta: {
documentId: docId,
mindmapId,
actor: payload?.actor ?? null,
reason: payload?.reason ?? null,
},
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
+21 -21
View File
@@ -268,22 +268,22 @@ export async function GET(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
try {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const docIds = dataset.documents.map((d) => d.id);
@@ -360,10 +360,10 @@ export async function GET(request: Request) {
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
*/
}
+101 -101
View File
@@ -3,115 +3,115 @@ import { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
// 定义请求体类型
interface CreateTableRequestBody {
documentId: string;
title: string;
schema: TableSchema;
snapshot?: DocumentTableSnapshot | null;
}
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
// 定义请求体类型
interface CreateTableRequestBody {
documentId: string;
title: string;
schema: TableSchema;
snapshot?: DocumentTableSnapshot | null;
}
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 获取 document 所在的 workspace_id
const document = await client.query(api.documents.getMeta, {
id: documentId,
});
if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// 创建表格
const result = await client.mutation(api.tables.create, {
userId: auth.userId,
workspaceId: document.workspace_id,
documentId,
title,
schema,
snapshot,
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// 创建表格
const result = await client.mutation(api.tables.create, {
userId: auth.userId,
workspaceId: document.workspace_id,
documentId,
title,
schema,
snapshot,
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 1. 获取 document 所在的 workspace_id
const { data: documentData, error: documentError } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", documentId)
.single();
if (documentError || !documentData?.workspace_id) {
console.error("Error fetching document or workspace:", documentError);
return NextResponse.json({ error: "Document not found or missing workspace_id" }, { status: 404 });
}
const workspaceId = documentData.workspace_id;
// 2. 插入新的 document_tables 记录
const { data: newTable, error: insertError } = await supabase
.from("document_tables")
.insert({
workspace_id: workspaceId,
document_id: documentId,
title: title,
schema: schema,
view_preferences: {},
is_archived: false,
created_by: user.id,
updated_by: user.id,
snapshot: snapshot ?? {},
})
.select()
.single();
if (insertError) {
console.error("Error inserting table:", insertError);
return NextResponse.json({ error: "Failed to create table in database" }, { status: 500 });
}
// 假设 document_tables 返回的结构与 DocumentTable 接口兼容
return NextResponse.json(newTable, { status: 201 });
} catch (error) {
console.error("API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 1. 获取 document 所在的 workspace_id
const { data: documentData, error: documentError } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", documentId)
.single();
if (documentError || !documentData?.workspace_id) {
console.error("Error fetching document or workspace:", documentError);
return NextResponse.json({ error: "Document not found or missing workspace_id" }, { status: 404 });
}
const workspaceId = documentData.workspace_id;
// 2. 插入新的 document_tables 记录
const { data: newTable, error: insertError } = await supabase
.from("document_tables")
.insert({
workspace_id: workspaceId,
document_id: documentId,
title: title,
schema: schema,
view_preferences: {},
is_archived: false,
created_by: user.id,
updated_by: user.id,
snapshot: snapshot ?? {},
})
.select()
.single();
if (insertError) {
console.error("Error inserting table:", insertError);
return NextResponse.json({ error: "Failed to create table in database" }, { status: 500 });
}
// 假设 document_tables 返回的结构与 DocumentTable 接口兼容
return NextResponse.json(newTable, { status: 201 });
} catch (error) {
console.error("API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
*/
}
+10 -6
View File
@@ -2,13 +2,17 @@ import { AiAgentPanel } from "@/components/ai-agent/AiAgentPanel";
export default function DevAiAgentPage() {
return (
<div className="mx-auto max-w-[1200px] p-4">
<div className="mb-3 text-lg font-semibold">AI Agent v1M0</div>
<div className="mb-4 text-sm text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">/api/ai-agent/run</code>SSE
<div className="min-h-screen bg-[#04070f] px-4 py-6 text-white">
<div className="mx-auto flex max-w-[1600px] flex-col gap-4">
<div className="px-1">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE · Global AI Lab</div>
<div className="mt-2 text-sm text-white/55">
<code className="rounded bg-white/10 px-1.5 py-0.5 text-white">/api/ai-agent/run</code> 使 SSE
</div>
</div>
<AiAgentPanel />
</div>
<AiAgentPanel />
</div>
);
}
+16 -16
View File
@@ -1,5 +1,5 @@
"use client";
"use client";
import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock";
import type { BlockNoteEditor } from "@blocknote/core";
import type { CustomBlockSchema } from "@/components/editor/schema";
@@ -14,17 +14,17 @@ const stubBlock = {
content: [],
children: [],
} as any;
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
"use client";
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
@@ -103,19 +103,19 @@ setupOnlyOfficeGlobalErrorCapture();
const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
const hashKey = (input: string) => {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
@@ -1063,11 +1063,11 @@ export default function OnlyOfficePage() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-slate-50">
<p className="text-base font-semibold text-red-600">ONLYOFFICE </p>
<p className="text-sm text-gray-600">{error}</p>
</div>
);
}
<p className="text-sm text-gray-600">{error}</p>
</div>
);
}
return (
<div className="relative h-screen w-screen bg-slate-50">
<div id="onlyoffice-frame" className="h-full w-full" />
@@ -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} />;
}
+19 -19
View File
@@ -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>
);
}
+12 -12
View File
@@ -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
+10 -10
View File
@@ -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", () => {
// ignorecodex --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
View File
@@ -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
View File
@@ -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));
+1 -1
View File
@@ -27,4 +27,4 @@ export const composeContentWithBlocks = (content: unknown, blocks: Json[]): Json
} as Json;
}
return { blocks } as Json;
};
};
+28 -28
View File
@@ -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>;
}
+34 -34
View File
@@ -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;
};
+187 -187
View File
@@ -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);
}
+95 -95
View File
@@ -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);
});
});
+9 -1
View File
@@ -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 });
},
}));
+17 -17
View File
@@ -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 }),
}));
+4 -4
View File
@@ -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;
+12 -12
View File
@@ -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";
+21 -21
View 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?: {
+455 -455
View File
@@ -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;
};
};
};
+1 -1
View File
@@ -43,4 +43,4 @@
"exclude": [
"node_modules"
]
}
}