0.5 缩减重构

This commit is contained in:
lix-2026
2026-04-13 19:21:42 +08:00
parent af92c4b149
commit 71fb1aee7e
2023 changed files with 21113 additions and 394493 deletions
@@ -2,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 ?? "";
}