0.3.3 外网下载修复
This commit is contained in:
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
MIN_API_TIMEOUT_MS,
|
||||
MAX_API_TIMEOUT_MS,
|
||||
DEFAULT_API_TIMEOUT_MS,
|
||||
MIN_COMPLETION_TOKENS,
|
||||
MAX_COMPLETION_TOKENS,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export type OpenAiCompatibleChatMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
@@ -34,18 +42,18 @@ export const openAiCompatibleChat = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ text: string; raw: unknown }> => {
|
||||
const timeoutMs = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
const timeoutMs = Math.max(MIN_API_TIMEOUT_MS, Math.min(MAX_API_TIMEOUT_MS, opts.timeoutMs ?? DEFAULT_API_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const maxCompletionTokens =
|
||||
typeof opts.maxCompletionTokens === "number" && Number.isFinite(opts.maxCompletionTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxCompletionTokens)))
|
||||
? Math.max(MIN_COMPLETION_TOKENS, Math.min(MAX_COMPLETION_TOKENS, Math.floor(opts.maxCompletionTokens)))
|
||||
: undefined;
|
||||
const maxTokens =
|
||||
typeof opts.maxTokens === "number" && Number.isFinite(opts.maxTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxTokens)))
|
||||
? Math.max(MIN_COMPLETION_TOKENS, Math.min(MAX_COMPLETION_TOKENS, Math.floor(opts.maxTokens)))
|
||||
: undefined;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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,
|
||||
details?: unknown,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const payload: 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildDocumentTree, type DocumentRecord } from "./documents";
|
||||
|
||||
type TreeLike = { id: string; children: TreeLike[] };
|
||||
|
||||
function collectIds(nodes: TreeLike[]): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: TreeLike[]) => {
|
||||
list.forEach((node) => {
|
||||
ids.push(node.id);
|
||||
if (node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("buildDocumentTree", () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy?.mockRestore();
|
||||
warnSpy = null;
|
||||
});
|
||||
|
||||
it("应当去重重复的记录 id(根节点)", () => {
|
||||
const base: DocumentRecord = {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
const tree = buildDocumentTree([base, { ...base, title: "B" }]);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].id).toBe("doc-1");
|
||||
expect(tree[0].title).toBe("B");
|
||||
});
|
||||
|
||||
it("应当去重重复的记录 id(子节点)", () => {
|
||||
const parent: DocumentRecord = {
|
||||
access_scope: "private",
|
||||
id: "doc-p",
|
||||
workspace_id: "ws-1",
|
||||
title: "P",
|
||||
parent_id: null,
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
};
|
||||
const child: DocumentRecord = {
|
||||
access_scope: "private",
|
||||
id: "doc-c",
|
||||
workspace_id: "ws-1",
|
||||
title: "C",
|
||||
parent_id: "doc-p",
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:01.000Z",
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
const tree = buildDocumentTree([parent, child, { ...child, title: "C2" }]);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].id).toBe("doc-c");
|
||||
expect(tree[0].children[0].title).toBe("C2");
|
||||
});
|
||||
|
||||
it("生成的树中不应出现重复 id", () => {
|
||||
const records: DocumentRecord[] = [
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "a",
|
||||
workspace_id: "ws-1",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "b",
|
||||
workspace_id: "ws-1",
|
||||
title: "B",
|
||||
parent_id: "a",
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:01.000Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "b",
|
||||
workspace_id: "ws-1",
|
||||
title: "B2",
|
||||
parent_id: "a",
|
||||
sort_order: null,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "2025-01-01T00:00:01.000Z",
|
||||
updated_at: "2025-01-01T00:00:02.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const tree = buildDocumentTree(records);
|
||||
const ids = collectIds(tree);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -16,13 +16,36 @@ export interface DocumentNode extends DocumentRecord {
|
||||
}
|
||||
|
||||
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
||||
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
|
||||
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
|
||||
const seen = new Set<string>();
|
||||
const duplicatedIds = new Set<string>();
|
||||
const uniqueRecords: DocumentRecord[] = [];
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const record = records[i];
|
||||
if (seen.has(record.id)) {
|
||||
duplicatedIds.add(record.id);
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
uniqueRecords.push(record);
|
||||
}
|
||||
uniqueRecords.reverse();
|
||||
|
||||
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, DocumentNode>();
|
||||
records.forEach((record) => {
|
||||
uniqueRecords.forEach((record) => {
|
||||
nodeMap.set(record.id, { ...record, children: [] });
|
||||
});
|
||||
|
||||
const roots: DocumentNode[] = [];
|
||||
records.forEach((record) => {
|
||||
uniqueRecords.forEach((record) => {
|
||||
const node = nodeMap.get(record.id);
|
||||
if (!node) return;
|
||||
if (record.parent_id && nodeMap.has(record.parent_id)) {
|
||||
|
||||
@@ -91,6 +91,31 @@ describe("buildVisibleRows", () => {
|
||||
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("输入树包含重复 docId 时应自动去重", () => {
|
||||
const a = {
|
||||
access_scope: "private" as const,
|
||||
id: "a",
|
||||
workspace_id: "w",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
nodes: [a, a],
|
||||
expanded: new Set(["a"]),
|
||||
assetsByDoc: {},
|
||||
});
|
||||
|
||||
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileTreeRowId", () => {
|
||||
@@ -102,4 +127,3 @@ describe("parseFileTreeRowId", () => {
|
||||
expect(parseFileTreeRowId("doc:")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,14 @@ export function buildVisibleRows({
|
||||
expandedAssetFolderIds?: Set<string>;
|
||||
}): FileTreeRow[] {
|
||||
const rows: FileTreeRow[] = [];
|
||||
const visitedDocIds = new Set<string>();
|
||||
|
||||
const walk = (node: DocumentNode, depth: number) => {
|
||||
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
|
||||
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
|
||||
if (visitedDocIds.has(node.id)) return;
|
||||
visitedDocIds.add(node.id);
|
||||
|
||||
const assets = assetsByDoc[node.id] ?? [];
|
||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
||||
const isExpanded = expanded.has(node.id);
|
||||
|
||||
@@ -62,9 +62,9 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
||||
// 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
|
||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
const fs = require("fs") as typeof import("fs");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
const path = require("path") as typeof import("path");
|
||||
|
||||
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`,
|
||||
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
let binary = "";
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b);
|
||||
});
|
||||
const b64 = btoa(binary);
|
||||
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* 将“仅本机可达”的 URL(例如 http://127.0.0.1:3210/api/storage/...)转换为浏览器可访问的地址。
|
||||
*
|
||||
* - 本地访问(页面本身是 localhost/127)时不改写
|
||||
* - 外网访问时,若目标是 localhost/127/host.docker.internal,则改为走 /api/onlyoffice/proxy 由 Next 服务端回源
|
||||
*/
|
||||
export const toBrowserAccessibleUrl = (rawUrl: string | null | undefined): string | null => {
|
||||
const input = String(rawUrl ?? "").trim();
|
||||
if (!input) return rawUrl ?? null;
|
||||
if (typeof window === "undefined") return input;
|
||||
|
||||
try {
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
const pageHost = window.location.hostname;
|
||||
if (isLocalHostname(pageHost)) return input;
|
||||
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
|
||||
const proxy = new URL("/api/onlyoffice/proxy", window.location.origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user