0.5 缩减重构
This commit is contained in:
@@ -2,9 +2,15 @@ import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@
|
||||
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
|
||||
|
||||
export type AgentChatFn = (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
cfg: OpenAiCompatibleChatOptions,
|
||||
) => Promise<{ text: string; raw: unknown }>;
|
||||
|
||||
export type RunAiAgentArgs = {
|
||||
userMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
cfg: OpenAiCompatibleChatOptions;
|
||||
chat?: AgentChatFn;
|
||||
allowedToolIds: Set<string>;
|
||||
runTool: (toolId: string, toolArgs: Record<string, unknown>) => Promise<unknown>;
|
||||
maxSteps?: number;
|
||||
@@ -246,6 +252,7 @@ export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text
|
||||
const maxSteps = Math.max(1, Math.min(24, Math.floor(args.maxSteps ?? DEFAULT_MAX_STEPS)));
|
||||
const allowedToolIds = args.allowedToolIds;
|
||||
const allowedToolNames = new Set<string>(Array.from(allowedToolIds));
|
||||
const chat = args.chat ?? openAiCompatibleChat;
|
||||
|
||||
const messages: OpenAiCompatibleChatMessage[] = [
|
||||
{ role: "system", content: buildSystemPrompt(allowedToolIds, args.systemContextText) },
|
||||
@@ -288,7 +295,7 @@ export const runAiAgent = async (args: RunAiAgentArgs): Promise<{ ok: true; text
|
||||
};
|
||||
|
||||
for (; steps < maxSteps; steps += 1) {
|
||||
const { text } = await openAiCompatibleChat(messages, args.cfg);
|
||||
const { text } = await chat(messages, args.cfg);
|
||||
const parsed = parseToolTagCalls(text, allowedToolNames);
|
||||
|
||||
if (parsed.calls.length === 0) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { spawn } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type CodexSandbox = "read-only" | "workspace-write";
|
||||
|
||||
export type CodexExecJsonLine = {
|
||||
type: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const getCodexBin = () => (process.platform === "win32" ? "codex.cmd" : "codex");
|
||||
|
||||
const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
|
||||
|
||||
const safeKill = (child: ReturnType<typeof spawn> | null | undefined) => {
|
||||
if (!child) return;
|
||||
try {
|
||||
if (child.exitCode !== null) return;
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 startDir 向上查找 workspace 根目录。
|
||||
* - 优先以 `AGENTS.md` 作为锚点(本仓库协作约定)
|
||||
* - 若找不到,则退化为 `startDir`
|
||||
*/
|
||||
export const findWorkspaceRoot = async (startDir: string): Promise<string> => {
|
||||
let dir = path.resolve(startDir || process.cwd());
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const agents = path.join(dir, "AGENTS.md");
|
||||
try {
|
||||
const st = await fs.stat(agents);
|
||||
if (st.isFile()) return dir;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return path.resolve(startDir || process.cwd());
|
||||
};
|
||||
|
||||
const buildCodexPromptFromMessages = (
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
|
||||
): string => {
|
||||
const lines: string[] = [];
|
||||
for (const m of messages) {
|
||||
const role = m.role === "system" ? "系统" : m.role === "user" ? "用户" : "助手";
|
||||
lines.push(`【${role}】`);
|
||||
lines.push(String(m.content ?? "").trimEnd());
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
};
|
||||
|
||||
export const codexExecToLastMessage = async ({
|
||||
cwd,
|
||||
sandbox,
|
||||
prompt,
|
||||
model,
|
||||
}: {
|
||||
cwd: string;
|
||||
sandbox: CodexSandbox;
|
||||
prompt: string;
|
||||
model?: string | null;
|
||||
}): Promise<{ text: string; rawLines: CodexExecJsonLine[] }> => {
|
||||
const bin = getCodexBin();
|
||||
// 用 stdin 传入 prompt,避免 Windows 命令行长度限制
|
||||
const args = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
|
||||
if (isNonEmptyString(model)) args.push("-m", model.trim());
|
||||
args.push("-");
|
||||
|
||||
const rawLines: CodexExecJsonLine[] = [];
|
||||
let lastAgentText = "";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(bin, args, {
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin?.setDefaultEncoding("utf8");
|
||||
child.stdin?.write(String(prompt ?? ""));
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) break;
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const j = JSON.parse(line) as CodexExecJsonLine;
|
||||
rawLines.push(j);
|
||||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||||
lastAgentText = String(j.item.text ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore:非 JSON 行
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", () => {
|
||||
// ignore:codex --json 主要走 stdout
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (buf.trim()) {
|
||||
try {
|
||||
const j = JSON.parse(buf.trim()) as CodexExecJsonLine;
|
||||
rawLines.push(j);
|
||||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||||
lastAgentText = String(j.item.text ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (code && code !== 0) {
|
||||
reject(new Error(`Codex 执行失败:exit=${code}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return { text: lastAgentText.trim() ? lastAgentText.trim() : "(无输出)", rawLines };
|
||||
};
|
||||
|
||||
export const codexMessagesToPrompt = buildCodexPromptFromMessages;
|
||||
|
||||
export const startCodexJsonRun = ({
|
||||
cwd,
|
||||
sandbox,
|
||||
prompt,
|
||||
model,
|
||||
sessionId,
|
||||
onJsonLine,
|
||||
}: {
|
||||
cwd: string;
|
||||
sandbox: CodexSandbox;
|
||||
prompt: string;
|
||||
model?: string | null;
|
||||
sessionId?: string | null;
|
||||
onJsonLine?: (line: CodexExecJsonLine) => void;
|
||||
}) => {
|
||||
const bin = getCodexBin();
|
||||
|
||||
const args = (() => {
|
||||
// 新会话:可指定 -C / -s
|
||||
if (!isNonEmptyString(sessionId)) {
|
||||
const a = ["exec", "--json", "-C", cwd, "-s", sandbox, "--color", "never"];
|
||||
if (isNonEmptyString(model)) a.push("-m", model.trim());
|
||||
a.push("-"); // stdin prompt
|
||||
return a;
|
||||
}
|
||||
|
||||
// 续聊:用 sessionId 恢复;resume 子命令不支持 -C/-s(沿用会话配置)
|
||||
const a = ["exec", "resume", sessionId.trim(), "--json"];
|
||||
if (isNonEmptyString(model)) a.push("-m", model.trim());
|
||||
a.push("-"); // stdin prompt
|
||||
return a;
|
||||
})();
|
||||
|
||||
const child = spawn(bin, args, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
try {
|
||||
child.stdin?.setDefaultEncoding("utf8");
|
||||
child.stdin?.write(String(prompt ?? ""));
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let threadId = "";
|
||||
let lastAgentText = "";
|
||||
|
||||
let buf = "";
|
||||
const parseLine = (line: string) => {
|
||||
const s = String(line ?? "").trim();
|
||||
if (!s) return;
|
||||
try {
|
||||
const j = JSON.parse(s) as CodexExecJsonLine;
|
||||
onJsonLine?.(j);
|
||||
if (j.type === "thread.started" && isNonEmptyString(j.thread_id)) {
|
||||
threadId = j.thread_id.trim();
|
||||
}
|
||||
if (j.type === "item.completed" && j.item && j.item.type === "agent_message") {
|
||||
lastAgentText = String(j.item.text ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
buf += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) break;
|
||||
const line = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + 1);
|
||||
parseLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on("data", () => {
|
||||
// ignore:--json 主要走 stdout
|
||||
});
|
||||
|
||||
const done = new Promise<{ ok: true; threadId: string; text: string } | { ok: false; error: string; threadId: string; text: string }>((resolve) => {
|
||||
child.on("close", (code) => {
|
||||
if (buf.trim()) parseLine(buf);
|
||||
const text = lastAgentText.trim() ? lastAgentText.trim() : "(无输出)";
|
||||
if (code && code !== 0) {
|
||||
resolve({ ok: false, error: `Codex 执行失败:exit=${code}`, threadId, text });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, threadId, text });
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
resolve({ ok: false, error: e instanceof Error ? e.message : String(e), threadId, text: lastAgentText.trim() || "(无输出)" });
|
||||
});
|
||||
});
|
||||
|
||||
return { child, done, kill: () => safeKill(child) };
|
||||
};
|
||||
+152
-152
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* API 工具函数
|
||||
* 提供统一的错误处理和响应解析
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 API 错误响应格式
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 API 错误响应
|
||||
* @param message 错误消息
|
||||
* @param status HTTP 状态码
|
||||
* @param details 额外的错误详情
|
||||
* @returns NextResponse
|
||||
*/
|
||||
/**
|
||||
* API 工具函数
|
||||
* 提供统一的错误处理和响应解析
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 API 错误响应格式
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 API 错误响应
|
||||
* @param message 错误消息
|
||||
* @param status HTTP 状态码
|
||||
* @param details 额外的错误详情
|
||||
* @returns NextResponse
|
||||
*/
|
||||
export function apiErrorResponse(
|
||||
message: string,
|
||||
status = 500,
|
||||
@@ -43,119 +43,119 @@ export function apiErrorResponse(
|
||||
typeof details === "undefined" ? { error: message } : { error: message, details };
|
||||
return NextResponse.json(payload, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用错误响应的快捷方法
|
||||
*/
|
||||
export const errorResponses = {
|
||||
/** 400 - 请求参数错误 */
|
||||
badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),
|
||||
|
||||
/** 401 - 未登录 */
|
||||
unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),
|
||||
|
||||
/** 403 - 无权限 */
|
||||
forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),
|
||||
|
||||
/** 404 - 资源不存在 */
|
||||
notFound: (message: string = "资源不存在") => apiErrorResponse(message, 404),
|
||||
|
||||
/** 500 - 服务器错误 */
|
||||
internalError: (message: string = "服务器错误") => apiErrorResponse(message, 500),
|
||||
|
||||
/** AI 配置错误 */
|
||||
aiConfigError: (provider: "online" | "local") =>
|
||||
apiErrorResponse(
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)",
|
||||
500,
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 处理 fetch 响应并解析 JSON
|
||||
* 如果响应不成功,抛出 ApiError
|
||||
* @param response Fetch Response 对象
|
||||
* @param defaultErrorMessage 默认错误消息
|
||||
* @returns 解析后的 JSON 数据
|
||||
*/
|
||||
export async function handleApiResponse<T>(
|
||||
response: Response,
|
||||
defaultErrorMessage: string = "请求失败",
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = defaultErrorMessage;
|
||||
let details: unknown;
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === "object") {
|
||||
if ("error" in payload && typeof payload.error === "string") {
|
||||
message = payload.error;
|
||||
}
|
||||
if ("details" in payload) {
|
||||
details = payload.details;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,使用默认消息
|
||||
}
|
||||
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地解析 JSON,失败时返回 null
|
||||
* @param raw JSON 字符串
|
||||
* @returns 解析后的对象或 null
|
||||
*/
|
||||
export function safeParseJson<T = unknown>(raw: string | null | undefined): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求体是否包含必需字段
|
||||
* @param body 请求体对象
|
||||
* @param requiredFields 必需字段列表
|
||||
* @returns 如果验证失败,返回错误响应;否则返回 null
|
||||
*/
|
||||
export function validateRequestBody<T extends Record<string, unknown>>(
|
||||
body: T | null,
|
||||
requiredFields: (keyof T)[],
|
||||
): NextResponse<ApiErrorResponse> | null {
|
||||
if (!body) {
|
||||
return apiErrorResponse("请求体为空", 400);
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in body) || body[field] === null || body[field] === undefined) {
|
||||
return apiErrorResponse(`缺少必需字段: ${String(field)}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中安全地获取 JSON
|
||||
* @param request Next.js Request 对象
|
||||
* @returns 解析后的 JSON 或 null
|
||||
*/
|
||||
export async function safeGetJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用错误响应的快捷方法
|
||||
*/
|
||||
export const errorResponses = {
|
||||
/** 400 - 请求参数错误 */
|
||||
badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),
|
||||
|
||||
/** 401 - 未登录 */
|
||||
unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),
|
||||
|
||||
/** 403 - 无权限 */
|
||||
forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),
|
||||
|
||||
/** 404 - 资源不存在 */
|
||||
notFound: (message: string = "资源不存在") => apiErrorResponse(message, 404),
|
||||
|
||||
/** 500 - 服务器错误 */
|
||||
internalError: (message: string = "服务器错误") => apiErrorResponse(message, 500),
|
||||
|
||||
/** AI 配置错误 */
|
||||
aiConfigError: (provider: "online" | "local") =>
|
||||
apiErrorResponse(
|
||||
provider === "local"
|
||||
? "未找到本地 AI 配置(LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md)"
|
||||
: "未找到在线 AI 配置(ai.md 或 ONLINE_AI_* 环境变量)",
|
||||
500,
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 处理 fetch 响应并解析 JSON
|
||||
* 如果响应不成功,抛出 ApiError
|
||||
* @param response Fetch Response 对象
|
||||
* @param defaultErrorMessage 默认错误消息
|
||||
* @returns 解析后的 JSON 数据
|
||||
*/
|
||||
export async function handleApiResponse<T>(
|
||||
response: Response,
|
||||
defaultErrorMessage: string = "请求失败",
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = defaultErrorMessage;
|
||||
let details: unknown;
|
||||
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload && typeof payload === "object") {
|
||||
if ("error" in payload && typeof payload.error === "string") {
|
||||
message = payload.error;
|
||||
}
|
||||
if ("details" in payload) {
|
||||
details = payload.details;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,使用默认消息
|
||||
}
|
||||
|
||||
throw new ApiError(message, response.status, details);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地解析 JSON,失败时返回 null
|
||||
* @param raw JSON 字符串
|
||||
* @returns 解析后的对象或 null
|
||||
*/
|
||||
export function safeParseJson<T = unknown>(raw: string | null | undefined): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求体是否包含必需字段
|
||||
* @param body 请求体对象
|
||||
* @param requiredFields 必需字段列表
|
||||
* @returns 如果验证失败,返回错误响应;否则返回 null
|
||||
*/
|
||||
export function validateRequestBody<T extends Record<string, unknown>>(
|
||||
body: T | null,
|
||||
requiredFields: (keyof T)[],
|
||||
): NextResponse<ApiErrorResponse> | null {
|
||||
if (!body) {
|
||||
return apiErrorResponse("请求体为空", 400);
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in body) || body[field] === null || body[field] === undefined) {
|
||||
return apiErrorResponse(`缺少必需字段: ${String(field)}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中安全地获取 JSON
|
||||
* @param request Next.js Request 对象
|
||||
* @returns 解析后的 JSON 或 null
|
||||
*/
|
||||
export async function safeGetJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return (await request.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+127
-127
@@ -1,127 +1,127 @@
|
||||
/**
|
||||
* 全局常量定义
|
||||
* 集中管理项目中的魔法数字和硬编码值
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// AI Agent 相关常量
|
||||
// ============================================
|
||||
|
||||
/** AI Agent 默认最大步数 */
|
||||
export const DEFAULT_AGENT_MAX_STEPS = 10;
|
||||
|
||||
/** AI Agent 最大步数限制 */
|
||||
export const MAX_AGENT_STEPS = 24;
|
||||
|
||||
/** AI Agent 最小步数 */
|
||||
export const MIN_AGENT_STEPS = 1;
|
||||
|
||||
/** 客户端工具默认超时时间(毫秒) */
|
||||
export const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// ============================================
|
||||
// 网络请求相关常量
|
||||
// ============================================
|
||||
|
||||
/** OpenAI 兼容 API 最小超时(毫秒) */
|
||||
export const MIN_API_TIMEOUT_MS = 500;
|
||||
|
||||
/** OpenAI 兼容 API 默认超时(毫秒) */
|
||||
export const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** OpenAI 兼容 API 最大超时(毫秒) */
|
||||
export const MAX_API_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OpenAI 最小输出 token 数 */
|
||||
export const MIN_COMPLETION_TOKENS = 64;
|
||||
|
||||
/** OpenAI 最大输出 token 数 */
|
||||
export const MAX_COMPLETION_TOKENS = 16_000;
|
||||
|
||||
// ============================================
|
||||
// 编辑器相关常量
|
||||
// ============================================
|
||||
|
||||
/** 内容加载延迟时间(毫秒) */
|
||||
export const CONTENT_LOADING_DELAY_MS = 200;
|
||||
|
||||
/** 编辑器块标题最小级别 */
|
||||
export const MIN_BLOCK_LEVEL = 1;
|
||||
|
||||
/** 编辑器块标题最大级别 */
|
||||
export const MAX_BLOCK_LEVEL = 5;
|
||||
|
||||
/** 零延迟 setTimeout(用于将任务推入事件循环) */
|
||||
export const ZERO_DELAY_MS = 0;
|
||||
|
||||
// ============================================
|
||||
// 思维导图相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图最大附件数量 */
|
||||
export const MAX_MINDMAP_ATTACHMENTS = 12;
|
||||
|
||||
/** 思维导图最大选中节点数 */
|
||||
export const MAX_SELECTED_NODES = 6;
|
||||
|
||||
// ============================================
|
||||
// RAG 搜索相关常量
|
||||
// ============================================
|
||||
|
||||
/** RAG 默认搜索结果数量 */
|
||||
export const DEFAULT_RAG_TOP_K = 12;
|
||||
|
||||
/** RAG 默认 chunk 结果数量 */
|
||||
export const DEFAULT_RAG_CHUNK_TOP_K = 12;
|
||||
|
||||
/** 文档搜索默认结果数量 */
|
||||
export const DEFAULT_DOCS_SEARCH_LIMIT = 12;
|
||||
|
||||
/** 文档读取默认最大字符数 */
|
||||
export const DEFAULT_DOCS_READ_MAX_CHARS = 2500;
|
||||
|
||||
/** 文档获取默认最大块数 */
|
||||
export const DEFAULT_DOC_GET_MAX_BLOCKS = 80;
|
||||
|
||||
/** 文档查找默认最大结果数 */
|
||||
export const DEFAULT_DOC_FIND_MAX_RESULTS = 8;
|
||||
|
||||
// ============================================
|
||||
// 资产/附件相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图从资产转换最大项目数 */
|
||||
export const DEFAULT_ASSET_TO_MINDMAP_MAX_ITEMS = 120;
|
||||
|
||||
/** 搜索默认结果数量 */
|
||||
export const DEFAULT_SEARCH_COUNT = 6;
|
||||
|
||||
// ============================================
|
||||
// UI 相关常量
|
||||
// ============================================
|
||||
|
||||
/** 表格嵌入预览最小高度(像素) */
|
||||
export const MIN_EMBED_HEIGHT = 120;
|
||||
|
||||
/** 表格嵌入预览最大高度(像素) */
|
||||
export const MAX_EMBED_HEIGHT = 600;
|
||||
|
||||
/** 表格嵌入预览默认高度(像素) */
|
||||
export const DEFAULT_EMBED_HEIGHT = 300;
|
||||
|
||||
/** 上下文菜单距离窗口边缘的最小内边距(像素) */
|
||||
export const CONTEXT_MENU_PADDING = 12;
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 将数值限制在指定范围内
|
||||
* @param n 输入值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 限制后的值
|
||||
*/
|
||||
export const clamp = (n: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, n));
|
||||
/**
|
||||
* 全局常量定义
|
||||
* 集中管理项目中的魔法数字和硬编码值
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// AI Agent 相关常量
|
||||
// ============================================
|
||||
|
||||
/** AI Agent 默认最大步数 */
|
||||
export const DEFAULT_AGENT_MAX_STEPS = 10;
|
||||
|
||||
/** AI Agent 最大步数限制 */
|
||||
export const MAX_AGENT_STEPS = 24;
|
||||
|
||||
/** AI Agent 最小步数 */
|
||||
export const MIN_AGENT_STEPS = 1;
|
||||
|
||||
/** 客户端工具默认超时时间(毫秒) */
|
||||
export const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;
|
||||
|
||||
// ============================================
|
||||
// 网络请求相关常量
|
||||
// ============================================
|
||||
|
||||
/** OpenAI 兼容 API 最小超时(毫秒) */
|
||||
export const MIN_API_TIMEOUT_MS = 500;
|
||||
|
||||
/** OpenAI 兼容 API 默认超时(毫秒) */
|
||||
export const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** OpenAI 兼容 API 最大超时(毫秒) */
|
||||
export const MAX_API_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OpenAI 最小输出 token 数 */
|
||||
export const MIN_COMPLETION_TOKENS = 64;
|
||||
|
||||
/** OpenAI 最大输出 token 数 */
|
||||
export const MAX_COMPLETION_TOKENS = 16_000;
|
||||
|
||||
// ============================================
|
||||
// 编辑器相关常量
|
||||
// ============================================
|
||||
|
||||
/** 内容加载延迟时间(毫秒) */
|
||||
export const CONTENT_LOADING_DELAY_MS = 200;
|
||||
|
||||
/** 编辑器块标题最小级别 */
|
||||
export const MIN_BLOCK_LEVEL = 1;
|
||||
|
||||
/** 编辑器块标题最大级别 */
|
||||
export const MAX_BLOCK_LEVEL = 5;
|
||||
|
||||
/** 零延迟 setTimeout(用于将任务推入事件循环) */
|
||||
export const ZERO_DELAY_MS = 0;
|
||||
|
||||
// ============================================
|
||||
// 思维导图相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图最大附件数量 */
|
||||
export const MAX_MINDMAP_ATTACHMENTS = 12;
|
||||
|
||||
/** 思维导图最大选中节点数 */
|
||||
export const MAX_SELECTED_NODES = 6;
|
||||
|
||||
// ============================================
|
||||
// RAG 搜索相关常量
|
||||
// ============================================
|
||||
|
||||
/** RAG 默认搜索结果数量 */
|
||||
export const DEFAULT_RAG_TOP_K = 12;
|
||||
|
||||
/** RAG 默认 chunk 结果数量 */
|
||||
export const DEFAULT_RAG_CHUNK_TOP_K = 12;
|
||||
|
||||
/** 文档搜索默认结果数量 */
|
||||
export const DEFAULT_DOCS_SEARCH_LIMIT = 12;
|
||||
|
||||
/** 文档读取默认最大字符数 */
|
||||
export const DEFAULT_DOCS_READ_MAX_CHARS = 2500;
|
||||
|
||||
/** 文档获取默认最大块数 */
|
||||
export const DEFAULT_DOC_GET_MAX_BLOCKS = 80;
|
||||
|
||||
/** 文档查找默认最大结果数 */
|
||||
export const DEFAULT_DOC_FIND_MAX_RESULTS = 8;
|
||||
|
||||
// ============================================
|
||||
// 资产/附件相关常量
|
||||
// ============================================
|
||||
|
||||
/** 思维导图从资产转换最大项目数 */
|
||||
export const DEFAULT_ASSET_TO_MINDMAP_MAX_ITEMS = 120;
|
||||
|
||||
/** 搜索默认结果数量 */
|
||||
export const DEFAULT_SEARCH_COUNT = 6;
|
||||
|
||||
// ============================================
|
||||
// UI 相关常量
|
||||
// ============================================
|
||||
|
||||
/** 表格嵌入预览最小高度(像素) */
|
||||
export const MIN_EMBED_HEIGHT = 120;
|
||||
|
||||
/** 表格嵌入预览最大高度(像素) */
|
||||
export const MAX_EMBED_HEIGHT = 600;
|
||||
|
||||
/** 表格嵌入预览默认高度(像素) */
|
||||
export const DEFAULT_EMBED_HEIGHT = 300;
|
||||
|
||||
/** 上下文菜单距离窗口边缘的最小内边距(像素) */
|
||||
export const CONTEXT_MENU_PADDING = 12;
|
||||
|
||||
// ============================================
|
||||
// 工具函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 将数值限制在指定范围内
|
||||
* @param n 输入值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 限制后的值
|
||||
*/
|
||||
export const clamp = (n: number, min: number, max: number): number =>
|
||||
Math.max(min, Math.min(max, n));
|
||||
|
||||
@@ -27,4 +27,4 @@ export const composeContentWithBlocks = (content: unknown, blocks: Json[]): Json
|
||||
} as Json;
|
||||
}
|
||||
return { blocks } as Json;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -50,17 +50,17 @@ export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSn
|
||||
* @param documentId 当前文档的 ID
|
||||
* @param title 表格的初始标题
|
||||
* @returns 新创建的 DocumentTable
|
||||
*/
|
||||
export async function createOnlineTable(
|
||||
documentId: string,
|
||||
title: string = "未命名表格"
|
||||
): Promise<DocumentTable> {
|
||||
// 假设 Next.js API 路由 /api/tables/create 负责与 Supabase 交互
|
||||
const response = await fetch("/api/tables/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
*/
|
||||
export async function createOnlineTable(
|
||||
documentId: string,
|
||||
title: string = "未命名表格"
|
||||
): Promise<DocumentTable> {
|
||||
// 假设 Next.js API 路由 /api/tables/create 负责与 Supabase 交互
|
||||
const response = await fetch("/api/tables/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
title,
|
||||
@@ -68,28 +68,28 @@ export async function createOnlineTable(
|
||||
snapshot: createDefaultTableSnapshot(DEFAULT_TABLE_SCHEMA),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create online table.");
|
||||
}
|
||||
|
||||
const newTable: DocumentTable = await response.json();
|
||||
return newTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格元数据 (用于紧凑模式渲染)
|
||||
* 实际实现中,这可能需要一个更复杂的获取逻辑,例如同时获取前 N 行数据
|
||||
*/
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create online table.");
|
||||
}
|
||||
|
||||
const newTable: DocumentTable = await response.json();
|
||||
return newTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格元数据 (用于紧凑模式渲染)
|
||||
* 实际实现中,这可能需要一个更复杂的获取逻辑,例如同时获取前 N 行数据
|
||||
*/
|
||||
export async function getDocumentTable(tableId: string): Promise<DocumentTable> {
|
||||
const response = await fetch(`/api/tables/${tableId}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch document table.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch document table.");
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocumentTable>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
type RequestCookies = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
type RequestCookies = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
const decodeValue = (value?: string) => {
|
||||
if (!value) return value;
|
||||
let v = value;
|
||||
@@ -19,25 +19,25 @@ const decodeValue = (value?: string) => {
|
||||
}
|
||||
return v.startsWith("base64-") ? Buffer.from(v.slice(7), "base64").toString("utf8") : v;
|
||||
};
|
||||
|
||||
const encodeValue = (value: string) => {
|
||||
if (!value) return value;
|
||||
// Next.js 会自动 base64 编码,我们只需处理已有 base64 前缀的情况
|
||||
if (value.startsWith("base64-")) {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const wrapCookies = (store: RequestCookies) => {
|
||||
return {
|
||||
get: (name: string) => {
|
||||
const cookie = store.get(name);
|
||||
if (!cookie) return cookie;
|
||||
return { ...cookie, value: decodeValue(cookie.value) };
|
||||
},
|
||||
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
|
||||
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
|
||||
|
||||
const encodeValue = (value: string) => {
|
||||
if (!value) return value;
|
||||
// Next.js 会自动 base64 编码,我们只需处理已有 base64 前缀的情况
|
||||
if (value.startsWith("base64-")) {
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const wrapCookies = (store: RequestCookies) => {
|
||||
return {
|
||||
get: (name: string) => {
|
||||
const cookie = store.get(name);
|
||||
if (!cookie) return cookie;
|
||||
return { ...cookie, value: decodeValue(cookie.value) };
|
||||
},
|
||||
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
|
||||
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
|
||||
set: (...args: Parameters<RequestCookies["set"]>) => {
|
||||
const [name, value, options] = args as unknown as [
|
||||
unknown,
|
||||
@@ -50,12 +50,12 @@ const wrapCookies = (store: RequestCookies) => {
|
||||
}
|
||||
(store as any).set(...(args as any));
|
||||
},
|
||||
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
|
||||
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDecodedCookies = async () => {
|
||||
const store = await cookies();
|
||||
return wrapCookies(store) as RequestCookies;
|
||||
};
|
||||
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
|
||||
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDecodedCookies = async () => {
|
||||
const store = await cookies();
|
||||
return wrapCookies(store) as RequestCookies;
|
||||
};
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
/**
|
||||
* 类型守卫和类型断言工具
|
||||
* 用于替代 `any` 类型,提供更安全的类型检查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查值是否为普通对象(非 null、非数组)
|
||||
*/
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为字符串
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数字(有限)
|
||||
*/
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数组
|
||||
*/
|
||||
export function isArray<T = unknown>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (itemGuard) {
|
||||
return value.every(itemGuard);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含指定的属性
|
||||
*/
|
||||
export function hasProperty<K extends string>(
|
||||
obj: unknown,
|
||||
key: K,
|
||||
): obj is Record<K, unknown> {
|
||||
return isPlainObject(obj) && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含多个指定的属性
|
||||
*/
|
||||
export function hasProperties<K extends string>(
|
||||
obj: unknown,
|
||||
keys: K[],
|
||||
): obj is Record<K, unknown> {
|
||||
if (!isPlainObject(obj)) return false;
|
||||
return keys.every(key => key in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取字符串属性
|
||||
*/
|
||||
export function getStringProperty(obj: unknown, key: string, defaultValue: string = ""): string {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isString(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数字属性
|
||||
*/
|
||||
export function getNumberProperty(obj: unknown, key: string, defaultValue: number = 0): number {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isFiniteNumber(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取布尔属性
|
||||
*/
|
||||
export function getBooleanProperty(obj: unknown, key: string, defaultValue: boolean = false): boolean {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return typeof value === "boolean" ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数组属性
|
||||
*/
|
||||
export function getArrayProperty<T = unknown>(
|
||||
obj: unknown,
|
||||
key: string,
|
||||
defaultValue: T[] = [],
|
||||
): T[] {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行对象(包含 id 属性)
|
||||
*/
|
||||
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
|
||||
return isPlainObject(value) && ("id" in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行数组
|
||||
*/
|
||||
export function isDatabaseRowArray(value: unknown): value is Array<{ id: string | number; [key: string]: unknown }> {
|
||||
return isArray(value) && value.every(isDatabaseRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型断言:确保值不为 null/undefined
|
||||
*/
|
||||
export function assertNotNullOrUndefined<T>(value: T | null | undefined, message?: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message ?? "值不能为 null 或 undefined");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 unknown 转换为 Record<string, unknown>,如果类型不匹配则返回空对象
|
||||
*/
|
||||
export function toRecord(value: unknown): Record<string, unknown> {
|
||||
return isPlainObject(value) ? value : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地访问嵌套对象属性
|
||||
* @example getNestedValue(obj, 'a.b.c') === obj?.a?.b?.c
|
||||
*/
|
||||
export function getNestedValue<T = unknown>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T,
|
||||
): T | undefined {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!isPlainObject(current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current as T ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查响应是否为错误响应
|
||||
*/
|
||||
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
|
||||
return isPlainObject(value) && isString(value.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 unknown 类型中提取工具参数
|
||||
* 用于 AI agent 工具调用时的类型安全
|
||||
*/
|
||||
export function getToolArgs(args: unknown): Record<string, unknown> {
|
||||
if (isPlainObject(args)) {
|
||||
return args;
|
||||
}
|
||||
// 如果是数组或其他类型,返回空对象
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息
|
||||
*/
|
||||
export function isAgentMessage(value: unknown): value is { role: "user" | "assistant"; content: string } {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
(value.role === "user" || value.role === "assistant") &&
|
||||
isString(value.content)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息数组
|
||||
*/
|
||||
export function isAgentMessageArray(value: unknown): value is Array<{ role: "user" | "assistant"; content: string }> {
|
||||
return isArray(value) && value.every(isAgentMessage);
|
||||
}
|
||||
/**
|
||||
* 类型守卫和类型断言工具
|
||||
* 用于替代 `any` 类型,提供更安全的类型检查
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查值是否为普通对象(非 null、非数组)
|
||||
*/
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为字符串
|
||||
*/
|
||||
export function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数字(有限)
|
||||
*/
|
||||
export function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查值是否为数组
|
||||
*/
|
||||
export function isArray<T = unknown>(value: unknown, itemGuard?: (item: unknown) => item is T): value is T[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (itemGuard) {
|
||||
return value.every(itemGuard);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含指定的属性
|
||||
*/
|
||||
export function hasProperty<K extends string>(
|
||||
obj: unknown,
|
||||
key: K,
|
||||
): obj is Record<K, unknown> {
|
||||
return isPlainObject(obj) && key in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否包含多个指定的属性
|
||||
*/
|
||||
export function hasProperties<K extends string>(
|
||||
obj: unknown,
|
||||
keys: K[],
|
||||
): obj is Record<K, unknown> {
|
||||
if (!isPlainObject(obj)) return false;
|
||||
return keys.every(key => key in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取字符串属性
|
||||
*/
|
||||
export function getStringProperty(obj: unknown, key: string, defaultValue: string = ""): string {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isString(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数字属性
|
||||
*/
|
||||
export function getNumberProperty(obj: unknown, key: string, defaultValue: number = 0): number {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return isFiniteNumber(value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取布尔属性
|
||||
*/
|
||||
export function getBooleanProperty(obj: unknown, key: string, defaultValue: boolean = false): boolean {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return typeof value === "boolean" ? value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象中安全地提取数组属性
|
||||
*/
|
||||
export function getArrayProperty<T = unknown>(
|
||||
obj: unknown,
|
||||
key: string,
|
||||
defaultValue: T[] = [],
|
||||
): T[] {
|
||||
if (!isPlainObject(obj)) return defaultValue;
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行对象(包含 id 属性)
|
||||
*/
|
||||
export function isDatabaseRow(value: unknown): value is { id: string | number; [key: string]: unknown } {
|
||||
return isPlainObject(value) && ("id" in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 Supabase 行数组
|
||||
*/
|
||||
export function isDatabaseRowArray(value: unknown): value is Array<{ id: string | number; [key: string]: unknown }> {
|
||||
return isArray(value) && value.every(isDatabaseRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型断言:确保值不为 null/undefined
|
||||
*/
|
||||
export function assertNotNullOrUndefined<T>(value: T | null | undefined, message?: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message ?? "值不能为 null 或 undefined");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 unknown 转换为 Record<string, unknown>,如果类型不匹配则返回空对象
|
||||
*/
|
||||
export function toRecord(value: unknown): Record<string, unknown> {
|
||||
return isPlainObject(value) ? value : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地访问嵌套对象属性
|
||||
* @example getNestedValue(obj, 'a.b.c') === obj?.a?.b?.c
|
||||
*/
|
||||
export function getNestedValue<T = unknown>(
|
||||
obj: unknown,
|
||||
path: string,
|
||||
defaultValue?: T,
|
||||
): T | undefined {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!isPlainObject(current)) {
|
||||
return defaultValue;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current as T ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查响应是否为错误响应
|
||||
*/
|
||||
export function isErrorResponse(value: unknown): value is { error: string; details?: unknown } {
|
||||
return isPlainObject(value) && isString(value.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 unknown 类型中提取工具参数
|
||||
* 用于 AI agent 工具调用时的类型安全
|
||||
*/
|
||||
export function getToolArgs(args: unknown): Record<string, unknown> {
|
||||
if (isPlainObject(args)) {
|
||||
return args;
|
||||
}
|
||||
// 如果是数组或其他类型,返回空对象
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息
|
||||
*/
|
||||
export function isAgentMessage(value: unknown): value is { role: "user" | "assistant"; content: string } {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
(value.role === "user" || value.role === "assistant") &&
|
||||
isString(value.content)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为 AI Agent 消息数组
|
||||
*/
|
||||
export function isAgentMessageArray(value: unknown): value is Array<{ role: "user" | "assistant"; content: string }> {
|
||||
return isArray(value) && value.every(isAgentMessage);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
type TypedClient = {
|
||||
from: (table: string) => any;
|
||||
};
|
||||
|
||||
|
||||
interface WorkspaceMembershipRow {
|
||||
workspace_id: string;
|
||||
is_default: boolean;
|
||||
@@ -20,76 +20,76 @@ interface WorkspaceMembershipRow {
|
||||
}>
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
iconUrl: string | null;
|
||||
memberCount: number;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export async function ensureDefaultWorkspace(client: TypedClient, userId: string, fallbackName: string): Promise<void> {
|
||||
const { data: memberships, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("user_id", userId)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取工作空间成员信息失败:${error.message}`);
|
||||
}
|
||||
|
||||
if (memberships && memberships.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceName = fallbackName.trim() ? `${fallbackName.trim()} 的空间` : "我的空间";
|
||||
|
||||
const { data: workspace, error: workspaceError } = await client
|
||||
.from("workspaces")
|
||||
.insert({
|
||||
name: workspaceName,
|
||||
type: "personal",
|
||||
created_by: userId,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (workspaceError || !workspace) {
|
||||
throw new Error(`创建默认工作空间失败:${workspaceError?.message ?? "未知错误"}`);
|
||||
}
|
||||
|
||||
const { error: memberError } = await client.from("workspace_members").insert({
|
||||
workspace_id: workspace.id,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
is_default: true,
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
throw new Error(`创建工作空间成员失败:${memberError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSummaries(
|
||||
client: TypedClient,
|
||||
userId: string,
|
||||
): Promise<{ workspaces: WorkspaceSummary[]; activeWorkspaceId: string }> {
|
||||
const { data: memberRows, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default,workspaces(id,name,type,icon_url)")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
throw new Error(`拉取工作空间列表失败:${error.message}`);
|
||||
}
|
||||
|
||||
|
||||
export interface WorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "personal" | "team";
|
||||
iconUrl: string | null;
|
||||
memberCount: number;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export async function ensureDefaultWorkspace(client: TypedClient, userId: string, fallbackName: string): Promise<void> {
|
||||
const { data: memberships, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("user_id", userId)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取工作空间成员信息失败:${error.message}`);
|
||||
}
|
||||
|
||||
if (memberships && memberships.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceName = fallbackName.trim() ? `${fallbackName.trim()} 的空间` : "我的空间";
|
||||
|
||||
const { data: workspace, error: workspaceError } = await client
|
||||
.from("workspaces")
|
||||
.insert({
|
||||
name: workspaceName,
|
||||
type: "personal",
|
||||
created_by: userId,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (workspaceError || !workspace) {
|
||||
throw new Error(`创建默认工作空间失败:${workspaceError?.message ?? "未知错误"}`);
|
||||
}
|
||||
|
||||
const { error: memberError } = await client.from("workspace_members").insert({
|
||||
workspace_id: workspace.id,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
is_default: true,
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
throw new Error(`创建工作空间成员失败:${memberError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceSummaries(
|
||||
client: TypedClient,
|
||||
userId: string,
|
||||
): Promise<{ workspaces: WorkspaceSummary[]; activeWorkspaceId: string }> {
|
||||
const { data: memberRows, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default,workspaces(id,name,type,icon_url)")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
throw new Error(`拉取工作空间列表失败:${error.message}`);
|
||||
}
|
||||
|
||||
const rows: WorkspaceMembershipRow[] = (memberRows ?? []) as any;
|
||||
const workspaceIds = rows.map((row) => row.workspace_id);
|
||||
|
||||
|
||||
const memberCountMap: Record<string, number> = {};
|
||||
if (workspaceIds.length > 0) {
|
||||
const { data: memberCounts, error: countError } = await client
|
||||
@@ -107,7 +107,7 @@ export async function fetchWorkspaceSummaries(
|
||||
memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const summaries: WorkspaceSummary[] = rows
|
||||
.map((row) => {
|
||||
const workspace = Array.isArray(row.workspaces) ? row.workspaces[0] : row.workspaces;
|
||||
@@ -124,28 +124,28 @@ export async function fetchWorkspaceSummaries(
|
||||
} satisfies WorkspaceSummary;
|
||||
})
|
||||
.filter(Boolean) as WorkspaceSummary[];
|
||||
|
||||
const defaultWorkspace = summaries.find((workspace) => workspace.isDefault);
|
||||
const activeWorkspaceId = defaultWorkspace?.id ?? summaries[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
workspaces: summaries,
|
||||
activeWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveActiveWorkspaceId(client: TypedClient, userId: string): Promise<string> {
|
||||
const { data, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default")
|
||||
.eq("user_id", userId)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取当前工作空间失败:${error.message}`);
|
||||
}
|
||||
|
||||
return data?.[0]?.workspace_id ?? "";
|
||||
}
|
||||
|
||||
const defaultWorkspace = summaries.find((workspace) => workspace.isDefault);
|
||||
const activeWorkspaceId = defaultWorkspace?.id ?? summaries[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
workspaces: summaries,
|
||||
activeWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveActiveWorkspaceId(client: TypedClient, userId: string): Promise<string> {
|
||||
const { data, error } = await client
|
||||
.from("workspace_members")
|
||||
.select("workspace_id,is_default")
|
||||
.eq("user_id", userId)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`获取当前工作空间失败:${error.message}`);
|
||||
}
|
||||
|
||||
return data?.[0]?.workspace_id ?? "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user