Compare commits

...

17 Commits

Author SHA1 Message Date
ab7b7fb189 fix: 质量修复 — ESLint 规则 TS6 兼容 + catch 注解 + 空函数体注释化 + 后端架构对齐 + 前端红线修复
- enforce-catch-type: 增加 TSUnknownKeyword 判断,消除28个 TS6 假阳性
- no-empty-function: 统一为注释方案,移除测试/生产分支和 eslint-disable 引导
- logger.ts: 空函数体改为注释说明,删除无用 eslint-disable 指令
- 补充15处 catch 子句 : unknown 类型注解
- 清理7个测试文件失效 eslint-disable 指令
- chat/send.ts: 提取 getModelWithProvider DAO,消除直接 Drizzle 操作
- projects/update.ts: 修复死代码+条件逻辑 bug
- providers/update.ts: 补充至少一个字段校验
- 前端: inline style → CSS className, ProviderFormModal whitespace 校验
- 开发文档: 更新 Zod 使用说明(AI SDK 框架级约束)
2026-06-01 23:11:42 +08:00
0d60120219 chore: merge dev-document into master
# Conflicts:
#	docs/development/frontend.md
2026-06-01 20:59:48 +08:00
459cc76edf chore: merge dev-eslint-rules into master 2026-06-01 20:47:55 +08:00
844562303c feat: 全栈 Logger 依赖注入 — DB/Route/AI 层传参 + 前端 Logger + 测试更新 + 归档 add-frontend-logger 2026-06-01 20:32:19 +08:00
df5b60eb53 feat: ESLint 自定义规则增强 — 空函数和 catch 模式的项目修复指引 2026-06-01 20:19:13 +08:00
b3f77e8ac6 docs: 细化 config.yaml subagent 使用指引 2026-06-01 16:51:29 +08:00
b225b0a0c7 docs: 开发文档面向AI精简重构,补充文档编撰规范 2026-06-01 16:43:17 +08:00
de51a817fb docs: 开发规范重构 — 新增后端红线、禁止事项汇总、前端能力优先级,精简 config.yaml 2026-06-01 16:12:21 +08:00
4c72754739 feat: 前端统一 Logger 模块 — 接口、双流 Sink、ESLint 规则、测试 2026-06-01 14:26:17 +08:00
d765f86b65 docs: 文档全面审查更新与规范集中化重构 2026-06-01 13:13:52 +08:00
60843f7dbf feat: Python 版 init-dev-branch 改写为 Node.js 脚本 2026-06-01 11:28:05 +08:00
897fad95eb refactor: 标题生成重构、UI样式优化、测试增强
- 将标题生成逻辑提取为独立函数,提前到Agent调用前非阻塞执行

- 修复模型/供应商不存在时的HTTP状态码 500→400

- ChatPanel: 分离模型选择useEffect、CSS类替代内联样式、按钮样式统一

- use-conversations: fetchConversations/fetchMessages改用handleResponse去重

- 聊天面板滚动优化(scroll-behavior: smooth, overflow-anchor: auto)

- 测试: mock支持onFinish回调,新增首次消息标题生成测试

- 移除未使用的SendMessageRequest接口
2026-06-01 10:49:38 +08:00
f34028368d fix: 欢迎页发送消息后侧边栏即时刷新会话列表
在 handleSend 中 createConversation 成功后立即调用 invalidateQueries,
同时传入 displayModelId 保持与侧边栏新建会话行为一致。
2026-06-01 08:34:50 +08:00
8463274c4b feat: 聊天页优化 — 欢迎页、标题自动生成、消息操作 2026-06-01 07:37:23 +08:00
f2e3d84fb1 feat: 聊天室模型选择器 + 会话更新 API + 消息部件重构
- 新增 PATCH /api/projects/:id/conversations/:cid 端点,支持更新 modelId 和 title
- 聊天面板新增模型选择下拉框,切换模型自动持久化
- 新建会话时传入默认文本模型 modelId
- 将 ToolCallCard 拆分为 ReasoningPart / TextPart / ToolPart 独立部件
- ToolPart 增加流式状态图标、折叠面板自动展开、错误详情展示
- ReasoningPart 增加思考中/思考完成状态指示
- 补充 PATCH 端点测试:更新成功、跨项目 403、不存在 404、无效 modelId 400
2026-05-31 21:56:50 +08:00
3e1f3b554d fix: 优化聊天室消息卡片与页面滚动布局
- 统一用户和AI消息卡片外观,移除头像和背景色区分

- AI消息名称为蓝色l阿福l,用户消息名称为默认色l用户l

- 修正全局布局高度链,实现内容区域滚动而非页面滚动

- 消息列表内部滚动,输入框固定在页面底部

- 侧边栏会话列表添加min-height:0确保滚动正常
2026-05-31 19:01:53 +08:00
6eeb4ced7b refactor: 聊天室 Agent 重构 — ToolLoopAgent + 论坛式布局
后端:
- 删除 agent-stream.ts,新建 alfred-agent.ts (ToolLoopAgent 工厂)
- 新建 get-current-time.ts 工具 (zod schema)
- 重构 send.ts: createAgentUIStreamResponse + onFinish 可靠持久化

前端:
- 删除 MessageBubble.tsx,新建 ToolCallCard.tsx (四态)
- 重构 ChatPanel.tsx: 论坛式 Card 布局 + PartRenderer 分派
- 移除 @ant-design/x 依赖,改用 antd 组件 + streamdown

依赖:
+ zod + streamdown
- @ant-design/x - @ant-design/x-markdown

测试: 306 pass, typecheck/lint 0 errors
2026-05-31 17:25:29 +08:00
102 changed files with 4728 additions and 1281 deletions

1
.gitignore vendored
View File

@@ -415,6 +415,7 @@ data/
backend/bin
backend/server
backend/desktop
!src/**/*
# Embedfs generated
embedfs/assets/

245
bin/init-dev-branch.js Normal file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* 开发分支工作区初始化脚本
*
* 用于创建基于远端分支或新建的开发分支工作区。
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { relative, resolve } from "node:path";
import { createInterface } from "node:readline";
function addWorktree(name, dir, base) {
const args = ["worktree", "add", "-b", name, dir];
if (base) args.push(base);
git(args);
}
function addWorktreeSafe(name, dir, base) {
assertCanCreate(name, dir);
addWorktree(name, dir, base);
console.log(`工作区已创建于 ${dir}`);
}
function ask(rl, prompt) {
return new Promise((resolve) => rl.question(prompt, resolve));
}
function assertCanCreate(name, dir) {
if (existsSync(dir)) {
throw new Error(`工作区已存在于 ${dir}`);
}
if (worktreeExists(dir)) {
throw new Error(`工作区 '${name}' 已存在`);
}
if (localBranchExists(name)) {
throw new Error(`本地分支 '${name}' 已存在`);
}
}
function fetchRemote() {
try {
git(["fetch", "--quiet"]);
} catch {
console.warn("警告: 无法获取远端信息,继续使用本地数据");
}
}
function getRootDir() {
try {
return resolve(git(["rev-parse", "--show-toplevel"]).trim());
} catch {
console.error("错误: 不在 git 仓库中");
process.exit(1);
}
}
function git(args, opts) {
return execFileSync("git", args, { encoding: "utf-8", stdio: "pipe", ...opts });
}
async function handleWithName(name, worktreesDir) {
const dir = resolve(worktreesDir, name);
try {
assertCanCreate(name, dir);
} catch (e) {
console.error(`错误: ${e.message}`);
process.exit(1);
}
const remotes = matchingRemoteBranches(name);
try {
if (remotes.length > 0) {
const base = await selectFromList(remotes, "找到远端分支:", true);
addWorktree(name, dir, base ?? undefined);
} else {
console.log("未找到远端分支,创建新分支");
addWorktree(name, dir);
}
console.log(`工作区已创建于 ${dir}`);
} catch (e) {
console.error(`错误: ${e.message}`);
process.exit(1);
}
}
async function handleWithoutName(worktreesDir) {
const remotes = listRemoteBranches();
if (remotes.length === 0) {
console.log("未找到远端分支");
process.exit(1);
}
const selected = await selectFromList(remotes, "远端分支列表:", true);
if (selected) {
const name = shortBranchName(selected);
const dir = resolve(worktreesDir, name);
try {
addWorktreeSafe(name, dir, selected);
} catch (e) {
console.error(`错误: ${e.message}`);
process.exit(1);
}
} else {
const name = await inputBranchName();
const dir = resolve(worktreesDir, name);
try {
addWorktreeSafe(name, dir);
} catch (e) {
console.error(`错误: ${e.message}`);
process.exit(1);
}
}
}
async function inputBranchName() {
const rl = createInterface({ input: process.stdin, output: process.stdout });
let cancelled = false;
rl.on("close", () => {
cancelled = true;
});
while (true) {
const name = (await ask(rl, "请输入新分支名称: ")).trim();
if (cancelled) {
rl.close();
process.exit(1);
}
if (name) {
rl.close();
return name;
}
console.log("错误: 分支名称不能为空");
}
}
function listRemoteBranches() {
try {
return git(["branch", "-r"])
.trim()
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.includes(" -> "));
} catch {
return [];
}
}
function localBranchExists(name) {
try {
git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
return true;
} catch {
return false;
}
}
function matchingRemoteBranches(name) {
return listRemoteBranches().filter((l) => l.endsWith(`/${name}`));
}
async function selectFromList(items, prompt, allowCreate) {
if (items.length === 0) return null;
console.log(prompt);
items.forEach((item, i) => console.log(` ${i + 1}\t${item}`));
if (allowCreate) console.log(` ${items.length + 1}\t创建新分支`);
console.log();
const max = allowCreate ? items.length + 1 : items.length;
const rl = createInterface({ input: process.stdin, output: process.stdout });
let cancelled = false;
rl.on("close", () => {
cancelled = true;
});
while (true) {
const raw = await ask(rl, `请选择 (1-${max}): `);
if (cancelled) {
rl.close();
process.exit(1);
}
const n = Number.parseInt(raw, 10);
if (Number.isNaN(n) || n < 1 || n > max) {
console.log(`错误: 请输入 1-${max} 之间的数字`);
continue;
}
if (n <= items.length) {
const sel = items[n - 1];
console.log(`已选择: ${sel}`);
rl.close();
return sel;
}
rl.close();
return null;
}
}
function shortBranchName(remoteRef) {
const idx = remoteRef.indexOf("/");
return idx === -1 ? remoteRef : remoteRef.slice(idx + 1);
}
function worktreeExists(worktreeDir) {
try {
const out = git(["worktree", "list"]);
const target = resolve(worktreeDir);
return out.split(/\r?\n/).some((line) => {
const fields = line.trim().split(/\s+/);
return fields.length > 0 && resolve(fields[0]) === target;
});
} catch {
return false;
}
}
process.on("SIGINT", () => process.exit(1));
async function main() {
const branchName = process.argv[2];
const rootDir = getRootDir();
const worktreesDir = resolve(rootDir, ".worktrees");
mkdirSync(worktreesDir, { recursive: true });
console.log("正在从远端获取最新分支信息...");
fetchRemote();
if (branchName) {
await handleWithName(branchName, worktreesDir);
} else {
await handleWithoutName(worktreesDir);
}
}
main().catch((e) => {
console.error(`错误: ${e.message}`);
process.exit(1);
});

419
bun.lock
View File

@@ -10,7 +10,6 @@
"@ai-sdk/openai-compatible": "^2.0.48",
"@ai-sdk/react": "^3.0.195",
"@ant-design/icons": "^6.2.3",
"@ant-design/x": "^2.7.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.14",
"ai": "^6.0.193",
@@ -25,6 +24,8 @@
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"recharts": "^3.8.1",
"streamdown": "^2.5.0",
"zod": "^4.4.3",
},
"devDependencies": {
"@commitlint/cli": "^21.0.1",
@@ -85,9 +86,7 @@
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
"@ant-design/x": ["@ant-design/x@2.7.0", "https://registry.npmmirror.com/@ant-design/x/-/x-2.7.0.tgz", { "dependencies": { "@ant-design/colors": "^8.0.0", "@ant-design/cssinjs": "^2.0.1", "@ant-design/cssinjs-utils": "^2.0.2", "@ant-design/fast-color": "^3.0.0", "@ant-design/icons": "^6.0.0", "@babel/runtime": "^7.25.6", "@rc-component/motion": "^1.1.6", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "lodash.throttle": "^4.1.1", "mermaid": "^11.12.1", "react-syntax-highlighter": "^16.1.0" }, "peerDependencies": { "antd": "^6.1.1", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-p5OtxQ9elbmeFRllGt1yj5wi6VHe41PIAmwrBU/OlaYydru5qIYsJzCS3DPRhkWkVdErU5oZwU74Z2oce2F5Uw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
@@ -131,11 +130,11 @@
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@commitlint/cli": ["@commitlint/cli@21.0.1", "https://registry.npmmirror.com/@commitlint/cli/-/cli-21.0.1.tgz", { "dependencies": { "@commitlint/format": "^21.0.1", "@commitlint/lint": "^21.0.1", "@commitlint/load": "^21.0.1", "@commitlint/read": "^21.0.1", "@commitlint/types": "^21.0.1", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" } }, "sha512-8vq10krmbJwBkvzXKhbs4o4JQEVscd3pqOlWuDUaDBwbeL694/P33UC29tZQFTAgPU9fVJ2+f2m3zw16yKWxHg=="],
@@ -281,9 +280,9 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.3", "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.3.tgz", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
"@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -295,7 +294,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.1.tgz", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
@@ -455,75 +454,79 @@
"@types/bun": ["@types/bun@1.3.14", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/d3": ["@types/d3@7.4.3", "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-axis": ["@types/d3-axis@3.0.6", "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
"@types/d3-brush": ["@types/d3-brush@3.0.6", "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
"@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
"@types/d3-chord": ["@types/d3-chord@3.0.6", "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
"@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-contour": ["@types/d3-contour@3.0.6", "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
"@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-format": ["@types/d3-format@3.0.4", "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
"@types/d3-random": ["@types/d3-random@3.0.3", "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/geojson": ["@types/geojson@7946.0.16", "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/jsdom": ["@types/jsdom@28.0.3", "https://registry.npmmirror.com/@types/jsdom/-/jsdom-28.0.3.tgz", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ=="],
@@ -531,9 +534,11 @@
"@types/json5": ["@types/json5@0.0.29", "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@25.6.2", "https://registry.npmmirror.com/@types/node/-/node-25.6.2.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/prismjs": ["@types/prismjs@1.26.6", "https://registry.npmmirror.com/@types/prismjs/-/prismjs-1.26.6.tgz", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.6.2", "https://registry.npmmirror.com/@types/node/-/node-25.6.2.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"@types/react": ["@types/react@19.2.15", "https://registry.npmmirror.com/@types/react/-/react-19.2.15.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
@@ -541,9 +546,9 @@
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -567,6 +572,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
@@ -605,7 +612,7 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.2.0.tgz", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
@@ -651,6 +658,8 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
@@ -675,11 +684,15 @@
"caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
"character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"cli-cursor": ["cli-cursor@5.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -691,9 +704,9 @@
"colorette": ["colorette@2.0.20", "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"compare-func": ["compare-func@2.0.0", "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
@@ -711,7 +724,7 @@
"cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cose-base": ["cose-base@1.0.3", "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
"cosmiconfig": ["cosmiconfig@9.0.1", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
@@ -723,63 +736,63 @@
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cytoscape": ["cytoscape@3.33.4", "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.4.tgz", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="],
"cytoscape": ["cytoscape@3.33.4", "", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="],
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
"d3": ["d3@7.9.0", "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
"d3-array": ["d3-array@3.2.4", "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-axis": ["d3-axis@3.0.0", "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
"d3-brush": ["d3-brush@3.0.0", "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
"d3-chord": ["d3-chord@3.0.1", "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
"d3-color": ["d3-color@3.1.0", "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-contour": ["d3-contour@4.0.2", "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
"d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
"d3-delaunay": ["d3-delaunay@6.0.4", "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-dsv": ["d3-dsv@3.0.1", "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
"d3-ease": ["d3-ease@3.0.1", "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-fetch": ["d3-fetch@3.0.1", "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
"d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
"d3-force": ["d3-force@3.0.0", "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-format": ["d3-format@3.1.2", "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-hierarchy": ["d3-hierarchy@3.1.2", "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@3.0.1", "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-random": ["d3-random@3.0.1", "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
"d3-sankey": ["d3-sankey@0.12.3", "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
"d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
"d3-scale": ["d3-scale@4.0.2", "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-selection": ["d3-selection@3.0.0", "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-shape": ["d3-shape@3.2.0", "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
@@ -789,11 +802,11 @@
"d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"dagre-d3-es": ["dagre-d3-es@7.0.14", "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"data-urls": ["data-urls@7.0.0", "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
@@ -815,7 +828,7 @@
"decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -823,17 +836,19 @@
"define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"delaunator": ["delaunator@5.1.0", "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
"delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
"dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dompurify": ["dompurify@3.4.7", "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.7.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
"dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
"dot-prop": ["dot-prop@5.3.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
@@ -913,12 +928,16 @@
"estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -933,8 +952,6 @@
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fault": ["fault@1.0.4", "https://registry.npmmirror.com/fault/-/fault-1.0.4.tgz", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -947,8 +964,6 @@
"for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"format": ["format@0.2.2", "https://registry.npmmirror.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -983,7 +998,7 @@
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"hachure-fill": ["hachure-fill@0.5.2", "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
"has-bigints": ["has-bigints@1.1.0", "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
@@ -997,9 +1012,21 @@
"hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hastscript": ["hastscript@9.0.1", "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"help-me": ["help-me@5.0.0", "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
@@ -1007,15 +1034,15 @@
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@10.7.3", "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
"highlightjs-vue": ["highlightjs-vue@1.0.0", "https://registry.npmmirror.com/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1023,19 +1050,21 @@
"import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"ini": ["ini@6.0.0", "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@@ -1057,7 +1086,7 @@
"is-date-object": ["is-date-object@1.1.0", "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-decimal": ["is-decimal@2.0.1", "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -1069,7 +1098,7 @@
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-map": ["is-map@2.0.3", "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
@@ -1133,13 +1162,13 @@
"json5": ["json5@1.0.2", "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"katex": ["katex@0.16.47", "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"khroma": ["khroma@2.1.0", "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
"layout-base": ["layout-base@1.0.2", "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -1175,27 +1204,113 @@
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"log-update": ["log-update@6.1.0", "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"lowlight": ["lowlight@1.20.0", "https://registry.npmmirror.com/lowlight/-/lowlight-1.20.0.tgz", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.3.6", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lz-string": ["lz-string@1.5.0", "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"meow": ["meow@13.2.0", "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
"mermaid": ["mermaid@11.15.0", "https://registry.npmmirror.com/mermaid/-/mermaid-11.15.0.tgz", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
"mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -1245,17 +1360,17 @@
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"parse5": ["parse5@8.0.1", "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"path-data-parser": ["path-data-parser@0.1.0", "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -1277,9 +1392,9 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"points-on-curve": ["points-on-curve@0.2.0", "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
"points-on-path": ["points-on-path@0.2.1", "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -1293,11 +1408,9 @@
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prismjs": ["prismjs@1.30.0", "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"process-warning": ["process-warning@5.0.0", "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
@@ -1315,8 +1428,6 @@
"react-router": ["react-router@7.15.1", "https://registry.npmmirror.com/react-router/-/react-router-7.15.1.tgz", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="],
"react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "https://registry.npmmirror.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="],
"real-require": ["real-require@0.2.0", "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
@@ -1327,10 +1438,24 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"refractor": ["refractor@5.0.0", "https://registry.npmmirror.com/refractor/-/refractor-5.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="],
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
@@ -1345,13 +1470,13 @@
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rolldown": ["rolldown@1.0.2", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.2.tgz", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="],
"roughjs": ["roughjs@4.6.6", "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"rw": ["rw@1.3.3", "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safe-array-concat": ["safe-array-concat@1.1.4", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
@@ -1361,7 +1486,7 @@
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
@@ -1405,7 +1530,7 @@
"source-map-support": ["source-map-support@0.5.21", "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
@@ -1413,6 +1538,8 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="],
"string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-convert": ["string-convert@0.2.1", "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
@@ -1425,12 +1552,18 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"stylis": ["stylis@4.4.0", "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -1441,6 +1574,8 @@
"synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"thread-stream": ["thread-stream@4.2.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"throttle-debounce": ["throttle-debounce@5.0.2", "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
@@ -1461,9 +1596,13 @@
"tr46": ["tr46@6.0.0", "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"ts-dedent": ["ts-dedent@2.2.0", "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"tsconfig-paths": ["tsconfig-paths@3.15.0", "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
@@ -1491,6 +1630,18 @@
"undici-types": ["undici-types@7.25.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.25.0.tgz", {}, "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unrs-resolver": ["unrs-resolver@1.11.1", "https://registry.npmmirror.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -1499,7 +1650,13 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"uuid": ["uuid@14.0.0", "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
@@ -1507,6 +1664,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
@@ -1549,6 +1708,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/core/json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -1603,13 +1764,13 @@
"cliui/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
"d3-dsv/commander": ["commander@7.2.0", "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -1621,6 +1782,8 @@
"eslint-plugin-import/minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"import-fresh/resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"is-bun-module/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
@@ -1629,7 +1792,11 @@
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"pretty-format/react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@@ -1709,16 +1876,18 @@
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],

View File

@@ -32,9 +32,9 @@ docs/
## 入口文档
| 入口 | 定位 |
| --------------------------------- | -------------------------------------- |
| --------------------------------- | -------------------------------------------------------- |
| [项目 README](../README.md) | 项目整体介绍、快速开始、文档引导 |
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
| [开发文档](development/README.md) | 开发入口、**全部开发规范**、常用命令、质量门禁、专题索引 |
| [用户文档](user/README.md) | 使用、配置、部署、排障入口 |
## 按任务阅读路径
@@ -55,13 +55,14 @@ docs/
## 文档归属矩阵
| 变更类型 | 默认更新位置 |
| --------------------------------------------------------- | ---------------------------------------- |
| -------------------------------------------------------- | ---------------------------------------- |
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
| 开发规范(全局规则、库优先级、类型规范、样式、测试等) | `docs/development/README.md` |
| 开发入口、常用命令、质量门禁、目录边界、OpenSpec 约定 | `docs/development/README.md` |
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
| 后端 API、配置加载、logger、helpers、类型规范、后端测试 | `docs/development/backend.md` |
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
| 快速开始、安装配置 | `docs/user/usage.md` |
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
@@ -73,9 +74,10 @@ docs/
开发文档解释"如何实现和维护"。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`
- **全部开发规范**(全局规则、后端规范、前端通用规范、样式规范、测试规范)统一维护在 `docs/development/README.md`
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`
- 后端 API、配置加载、logger、helpers、类型规范和后端测试规范更新到 `docs/development/backend.md`
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`
- 后端模块 API、工具函数索引、数据库 schema、AI 层实现、日志模块更新到 `docs/development/backend.md`
- 前端运行时代码结构、组件索引、页面组成、hooks/工具清单更新到 `docs/development/frontend.md`
- 构建、脚本和发布验证更新到 `docs/development/release.md`
- 不新增"杂项"开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`

View File

@@ -1,120 +1,261 @@
# 开发文档
# 开发规范
本文档是 alfred 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题
AI 工具必须严格遵守以下全部约束
适用场景:修改源码、测试、构建脚本、开发流程、架构边界或项目工程规则。
## 专题索引
## 专题文档
| 文档 | 内容 |
| ---------------------------------- | ---------------------------------------------------------------- |
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
| [backend.md](backend.md) | 后端库优先级、API 路由、共享工具、类型规范、配置契约、日志、测试 |
| [frontend.md](frontend.md) | React、Ant Design、TanStack Query、组件、样式和前端测试规范 |
| [release.md](release.md) | 开发服务、前后端集成、构建、脚本、环境变量 |
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
| ---------------------------------- | ---------------------------------- |
| [architecture.md](architecture.md) | 项目结构、启动流程、前后端边界 |
| [backend.md](backend.md) | 模块 API、数据访问函数、AI 层说明 |
| [frontend.md](frontend.md) | 组件索引、页面组成、hooks/工具清单 |
| [release.md](release.md) | 开发服务、构建、脚本、环境变量 |
---
## 一、全局规则
### 语言与环境
- 使用中文编写注释、文档和交流内容。
- 仅使用 bun 作为包管理器和 bunx 作为工具运行器;禁止 npm、pnpm、yarn、npx、pnpx。
- 无需考虑向前兼容。
### 依赖引入
**后端**优先级(上层已有方案则不得引入新依赖):
1. Bun 内置 APIBun.serve、bun:sqlite 等)
2. es-toolkit
3. 标准 Web APIfetch、Headers 等)
4. 已批准三方库pino、@sinclair/typebox、ajv、drizzle-orm、ai、@ai-sdk/\*
5. 自行实现(仅以上都不满足时)
**前端**:优先复用已有组件/hooks/依赖库;确需新增依赖时先说明原因。
**Zod**AI 工具层(`src/server/ai/tools/`)使用 Zod 定义 `tool()``inputSchema`,以满足 AI SDK 对 `ZodSchema` 的类型推断要求,属于框架级约束而非项目选型冲突。配置校验层使用 TypeBox + Ajv两层级各司其职不混用。
### 目录边界
| 目录 | 约束 |
| ------------------------ | -------------------------------------------- |
| `src/server/` | 后端,禁止 import src/web/ |
| `src/server/db/` | 数据库层schema、connection、migration、DAO |
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
| `src/server/helpers/` | 跨路由工具响应格式化、URL 拼接 |
| `src/server/middleware/` | 参数校验 + 错误处理中间件 |
| `src/web/` | 前端,禁止 import src/server/ 运行时实现 |
| `src/web/consoles/` | 控制台外壳Admin / Workbench |
| `src/shared/` | 前后端共享类型api.ts和常量app.ts |
| `scripts/` | 独立脚本,可 import 项目源码 |
| `drizzle/` | SQL migration 文件(开发期产出) |
| `tests/` | 测试目录,镜像 src/ 结构 |
### 类型与配置
- 共享类型唯一源头:`src/shared/api.ts`;应用常量唯一源头:`src/shared/app.ts`;版本号唯一源头:`package.json`
- 配置加载流程unknown → AuthoringConfig → NormalizedConfig → ValidatedConfig → ServerConfig。
- Ajv 严格拒绝模式:不类型转换、不注入默认值、不删除未知字段。
- 新增/修改配置字段必须同步更新 TypeBox schema、`config.schema.json`、测试和用户文档。
### 后端日志
- 运行时代码通过 Logger 接口输出,禁止 `console.*`(仅 `logger.ts` 实现类内部可用)。
- 敏感字段authorization、cookie、password 等)自动 redact。
### API 路由
- 每个端点一个文件:`src/server/routes/{资源}/{操作}.ts`
- 路由在 `server.ts``Bun.serve({ routes })` 中声明式注册。
- 新增路由:创建 handler → 在 `server.ts` 注册 → 在 `tests/server/` 添加测试。
### 前端数据层
- 统一使用 `fetch`,不引入 axios。
- 错误抛异常TanStack Query error 状态承接。
- 返回类型必须匹配后端 JSON 形状;包装对象(如 `{ project }`)须在 hook 内提取业务对象。
- 服务端状态用 TanStack Query组件内状态用 `useState`,不引入额外状态管理库。
### 禁止事项
- 禁止前端 import `src/server/`
- 禁止后端使用 `console.*`
- 禁止组件内联 `style` 属性
- 禁止 CSS 覆盖 `.ant-*` 内部类名
- 禁止 `!important`
- 禁止硬编码色值(使用 `var(--ant-*)` CSS 变量)
- 禁止路由 handler 直接操作 `bun:sqlite` / Drizzle ORM须通过 DAO 层)
- 禁止跳过或忽略测试
- 禁止在 `Modal onOk` 中直接执行异步提交(用 `Form onFinish`
---
## 二、后端红线
### 路由 handler
- 签名:`(req: Request, db: Database, mode: RuntimeMode, logger?: Logger): Promise<Response>`
- 业务错误:`jsonResponse(createApiError(msg, status), { mode, status })`;未知异常直接 throw`withErrorHandler` 兜底。
- body 解析后立即校验必填字段和类型,失败返回 400。
- ID 参数走 `validateIdParam`,分页参数走 `validatePagination`
- 路由注册:`withErrorHandler` 包裹 + 动态 `await import()` 加载 handler。
### 数据访问层
- handler 通过 `src/server/db/*.ts` 函数操作数据库,禁止直接使用 `bun:sqlite` 或 Drizzle。
- DAO 函数第一个参数接收原始 `Database`,内部 `wrap(db)` 转 Drizzle。
- 返回联合类型:成功 `{ resource: T }`,失败 `{ error: string; status: number }`
- 输入输出类型来自 `src/shared/api.ts`
- 列表查询使用 `paginateQuery()`,不重复实现分页。
- 列名 snake_caseTS 类型 camelCaseDrizzle schema 映射。
### AI 调用层
- Provider 实例:`buildProviderRegistry(db)`,每次从 DB 重建,不缓存。
- Agent 实例:`createAlfredAgent(model)` 工厂。
- SSE 响应:`createAgentUIStreamResponse``onFinish` 持久化完整 parts。
- 工具定义:`src/server/ai/tools/`
### 错误处理
- 业务异常用 `AppError(statusCode)``jsonResponse(createApiError(...))`
- handler 外层 `withErrorHandler` 兜底,不手动 try-catch 整个 handler。
- 生产模式错误响应不暴露内部细节。
---
## 三、前端红线
### 组件规范
- 优先 antd 默认能力 + props不额外改写视觉。
- `ConfigProvider(zhCN)` 配置中文 locale 和主题,不在 CSS 硬编码亮/暗分支。
- 应用级能力message、modal、notification通过 `AntApp` + `App.useApp()` 获取。
- 页面保持编排职责,组合 hooks + 展示组件;复杂页面按功能边界拆分。
### 能力优先级(上层满足则不用下层)
1. antd 组件/props
2. antd 布局组件Layout、Space、Flex
3. antd theme token + CSS 变量
4. TanStack Query + useState
5. 已有 hooks`use-*.ts`)和工具函数(`utils/`
6. CSS Modules就近放置
7. 引入新依赖(需说明原因)
### 样式红线
- 严禁内联 `style`、覆盖 `.ant-*``!important`、硬编码色值。
- 颜色使用 `var(--ant-*)` CSS 变量。
- 不引入 Tailwind/Sass/Less/CSS-in-JS 等额外样式方案。
- 全局 CSS 类名用 `app-*` 前缀,禁止泛名。样式增长后用 CSS Modules 就近维护。
### 表单与交互
- Modal + Form`Form onFinish` 处理提交,`Modal onOk` 只调 `form.submit()`
- 必填文本字段同时配 `required` + `whitespace`
- 操作确认用 `Popconfirm`,反馈用 antd message。
### 错误边界
- 生产入口必须启用 `ErrorBoundary``ReactQueryDevtools``DEV` 模式渲染。
---
## 四、测试规范
### 后端
- 路由测试通过真实 `startServer` 覆盖路由注册、HTTP method、fallback、header 和核心错误路径。
- SQLite 测试复用 `tests/helpers.ts`,不分散实现临时目录清理。
- DAO/路由测试优先用真实 migration 初始化。
- logger/bootstrap fallback 输出须捕获断言,正常测试不污染 stdout/stderr。
### 前端
- 目录 `tests/web/`,结构对应 `src/web/`
- 用 jsdom + `@testing-library/react` 测试用户行为,断言基于可见文本/role/按钮。
- 系统边界复用 `tests/web/test-utils.tsx`
- 数据页面覆盖:请求参数、成功可见结果、关键错误路径。
- ErrorBoundary/hooks/fetch helper 用单元测试覆盖异常,页面测试只保留用户路径。
---
## 常用命令
| 命令 | 说明 |
| -------------------------------- | -------------------------------------- |
| `bun install` | 安装依赖 |
| `bun run dev config.yaml` | 启动双进程开发环境 |
| `bun run dev:server config.yaml` | 仅启动后端 API server |
| `bun run dev:web` | 仅启动 Vite dev server |
| `bun run schema` | 生成 config.schema.json |
| `bun run schema:check` | 检查导出 schema 是否同步 |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run lint` | ESLint 和 Prettier 格式检查 |
| `bun run format` | Prettier 自动格式化 |
| `bun run format:check` | Prettier 格式检查 |
| `bun test` | 运行全部测试 |
| `bun run dev:server config.yaml` | 仅后端 API server |
| `bun run dev:web` | 仅 Vite dev server |
| `bun run check` | schema:check + typecheck + lint + test |
| `bun run build` | 构建生产可执行文件 |
| `bun run verify` | check + build 完整验证 |
| `bun run clean` | 清理构建缓存与临时文件 |
| `bun run version:patch` | 升迁 patch 版本x.y.Z |
| `bun run version:minor` | 升迁 minor 版本x.Y.0 |
| `bun run version:major` | 升迁 major 版本X.0.0 |
| `bun run build` | 构建生产可执行文件 |
| `bun run schema` | 生成 config.schema.json |
| `bun run schema:check` | 检查 schema 同步 |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run lint` | ESLint + Prettier 检查 |
| `bun run format` | Prettier 格式化 |
| `bun test` | 运行全部测试 |
| `bun run clean` | 清理构建缓存 |
| `bun run version:patch` | 升迁 patch 版本 |
| `bun run version:minor` | 升迁 minor 版本 |
| `bun run version:major` | 升迁 major 版本 |
| `bun run version:set` | 显式设置版本号 |
## 质量门禁
代码变更必须按影响范围执行验证。
| 变更类型 | 必跑命令 |
| -------------------------- | ------------------------------------------------------------- |
| ----------------------- | --------------------------------------------------------- |
| 常规代码变更 | `bun run check` |
| 构建、部署、前后端集成变更 | `bun run verify` |
| 构建、部署、集成变更 | `bun run verify` |
| 配置 schema 变化 | `bun run schema``bun run schema:check``bun run check` |
| SQLite 测试基础设施变化 | 相关单文件测试 + SQLite 聚焦 `--rerun-each` + `bun run check` |
| SQLite 测试基础设施变化 | 相关测试 + SQLite 聚焦 `--rerun-each` + `bun run check` |
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
正式提交或影响构建产物时优先运行 `bun run verify`如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因
正式提交优先运行 `bun run verify`无法执行时须在收尾说明中记录。
## 已知设计决策
本节记录项目中有意保留的设计决策,避免后续审查重复报错。
| 决策 | 原因 |
| -------------------------- | ----------------------------------------------------------------------------------------- |
| Provider.apiKey 返回给前端 | 个人项目apiKey 非严格密码,前端需要展示和编辑。如需保护,应改为返回脱敏值或仅后端存储。 |
## 全局工程规则
- 使用中文编写注释、文档和项目内交流内容。
- 仅使用 bun 作为包管理器,禁止使用 npm、pnpm、yarn。
- 运行工具使用 bunx禁止使用 npx、pnpx。
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
- 后端优先使用 Bun 内置 API其次是 es-toolkit、标准 Web API、主流三方库最后才自行实现。
- 前端优先使用 Ant Design 组件默认能力和组件 props 组合界面,具体组件、样式、数据流和测试细节见 [frontend.md](frontend.md)。
- 当前项目无需考虑向前兼容。
## 包管理、依赖与提交
- 仅使用 bun 安装依赖和运行项目脚本,锁文件为 bun.lock。
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
- Git 提交信息使用中文,格式为"类型: 简短描述"。
- 提交类型限定为 feat、fix、refactor、docs、style、test、chore。
- 多行提交描述时,标题和正文之间空一行。
## 目录边界
| 目录 | 约定 |
| ------------------- | -------------------------------------------------------------------- |
| `src/server/` | Bun 后端代码,不能 import src/web/HTML import 集成除外 |
| `src/server/db/` | SQLite 数据库模块,包含 schema、connection、migration 和 data access |
| `src/server/ai/` | AI Provider Registry 构建与连接测试 |
| `src/web/` | React 前端,不能 import src/server/ 运行时实现 |
| `src/shared/` | 前后端共享 TypeScript 类型 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `drizzle/` | Drizzle Kit 生成的 SQL migration 文件(开发期产出) |
| `tests/` | 测试目录,结构镜像 src/ |
| `docs/user/` | 用户使用、配置、部署和排障文档 |
| `docs/development/` | 架构、后端、前端、发布开发文档 |
| `openspec/` | OpenSpec 变更管理与规格文档 |
## 文档影响分析
每次代码变更都必须执行文档影响分析。
| 如果变更影响 | 更新 |
| ------------------------------------------ | ------------------------------------------ |
| 变更影响 | 更新 |
| -------------------------------------- | ------------------------------------------ |
| 用户可见行为、配置、部署、运行行为 | `docs/user/` 对应文档 |
| 开发流程、架构、测试、构建发布流程 | `docs/development/` 对应文档 |
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
| 文档同步规则或文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
| 项目定位、快速开始、核心能力、文档导航 | `README.md` |
| 文档同步规则或归属矩阵 | `docs/README.md``openspec/config.yaml` |
| 开发规范变化 | 本文档对应章节 |
如果无需更新文档,须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
## 事实来源
| 主题 | 事实来源 |
| -------------- | -------------------------------------------------- |
| 代码结构和实现 | `src/``scripts/``tests/` |
| 配置 schema | TypeBox fragments、config.schema.json、schema 测试 |
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
无需更新文档,须在收尾说明中说明原因。
## 更新触发条件
修改常用命令、质量门禁、全局工程规则、目录边界或开发文档索引时,必须更新本文档。
修改常用命令、质量门禁、开发规范(任何章节)、目录边界或开发文档索引时,必须更新本文档。
### 文档编撰规范
本节开发文档面向 AI 工具阅读,编撰时遵循以下原则:
**精简原则**
- 删除引导语、适用场景、过渡句等装饰性文字。AI 无需"应首先阅读"或"本文档说明…"类引导。
- 不重复项目结构树AI 可通过 glob 获取目录结构。
- 不重复已在 README.md 声明的规范细节专题文档只记录实现层面的函数签名、API 端点、模块职责等索引信息。
- 表格标题自明时不再加说明段落。
**信息完整性**
- 所有函数签名、API 端点(方法+路径+说明)、数据访问函数清单必须完整列举,不可用"等"省略。
- 页面行为描述须包含关键交互逻辑(如 Tab 切换、默认值、条件跳转、测试不阻止保存等),不可只写组件名。
- 配置文件列表必须完整,不可遗漏已有文件。
**结构规范**
- 每个专题文档末尾保留 `## 更新触发条件` 章节,明确列出哪些变更必须更新该文档。
- 用表格和编号列表替代散文段落,减少 token 消耗。
- 同一信息只在一处维护,避免多处重复导致不一致。

View File

@@ -1,116 +1,61 @@
# 架构与边界
本文档说明 alfred 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
适用场景修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
## 项目结构
```text
src/
server/
bootstrap.ts 统一启动引导loadServerConfig -> DB 初始化 -> startServer
config.ts CLI 参数解析与配置文件加载 facade
config/ 配置解析模块types、issues、variables、normalizer、schema
db/ SQLite 数据库模块
schema.ts Drizzle ORM schema 定义
connection.ts 数据库连接与 PRAGMA 设置
load-migrations.ts 从文件系统加载 migration SQL
migrate.ts migration 执行器(备份 + 事务应用)
projects.ts 项目数据访问函数
providers.ts 供应商数据访问函数
models.ts 模型数据访问函数
conversations.ts 会话数据访问函数
index.ts 数据库模块导出
ai/ AI 服务层
types.ts AI 配置类型定义
registry.ts AI Provider Registry 构建与连接测试
agent-stream.ts AI Agent 流式调用
dev.ts 开发模式启动入口
main.ts 生产模式启动入口
server.ts HTTP server 启动工厂Bun.serve routes 声明式路由)
static.ts 生产模式静态资源服务
helpers.ts 共享响应格式化工具
middleware.ts API 参数校验中间件
logger.ts 结构化日志(基于 pino + pino-roll
version.ts 运行时版本号读取
routes/ API 路由处理器
providers/ 供应商 CRUD 路由
models/ 模型 CRUD 路由
chat/ 聊天会话与消息路由
shared/
api.ts 前后端共享 TypeScript 类型定义
app.ts 应用全局常量name、title、subtitle、description
web/ React 前端(通过 Vite 构建)
index.html HTML 入口
main.tsx React 入口
app.tsx 根组件
routes.tsx 路由配置
styles.css 全局样式
pages/ 页面组件
models/ 模型管理页面
components/ UI 组件
hooks/ React Hooks
utils/ 前端工具函数
scripts/ 独立运行脚本
tests/ 测试文件(镜像 src 目录结构)
docs/ 项目文档
openspec/ OpenSpec 规格、变更与 fast-drive workflow schema
```
## 启动流程
```text
dev.ts / main.ts
-> parseRuntimeArgs(cli args)
-> 必须指定 config.yaml
-> parseRuntimeArgs(cli args) — 必须指定 config.yaml
-> bootstrap({ configPath, mode })
-> loadServerConfig(configPath)
-> createRuntimeLogger(config.logging)
-> 确保 dataDir 就绪mkdirSync
-> 加载 migrations生产嵌入 bytes开发磁盘 drizzle/ 目录
-> mkdirSync(dataDir)
-> 加载 migrations生产嵌入 bytes开发磁盘 drizzle/
-> createDatabase(dataDir)
-> runMigrations(db, migrations)pending migration 存在时先备份 DB
-> runMigrations(db, migrations)pending 时先备份 DB
-> startServer({ config, logger, db })
-> logger 记录启动成功
-> SIGINT/SIGTERM -> db.close() -> logger.flush() -> exit
```
## HTTP 请求流程
```text
Request
-> Bun.serve routes 声明式匹配
-> routes/*.ts handler
-> helpers.ts 响应格式化
-> Response
Request -> Bun.serve routes 声明式匹配 -> routes/*.ts handler -> helpers/ 响应格式化 -> Response
```
生产模式下,非 API 路径由 fetch fallback 处理:有文件扩展名返回静态资源或 404无扩展名返回 SPA index.html。
开发模式下Vite proxy 将 /api 请求转发到 Bun API server。
- 生产模式非 API 路径由 fetch fallback 处理,有扩展名返回静态资源或 404无扩展名返回 SPA index.html。
- 开发模式Vite proxy 将 /api 转发到 Bun。
## 前后端边界
- 前端只通过 HTTP 调用后端API 路径为 /api/\*。
- 共享类型在 src/shared/。
- 前端不得 import src/server/ 运行时实现。
- 后端不得依赖 src/web/ 运行时代码HTML import 集成除外。
- 前端只通过 HTTP /api/\* 调用后端
- 共享类型在 `src/shared/`
- 前端禁止 import `src/server/` 运行时实现;后端禁止依赖 `src/web/` 运行时代码HTML import 集成除外)
## 主要模块职责
## 主要模块
| 模块 | 职责 |
| ------------------------- | --------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server routes 注册 |
| `src/server/routes/` | API handler按端点拆分 |
| `src/server/db/` | SQLite 连接、schema、migrationdata access |
| `src/server/ai/` | AI Provider Registry 构建与 Agent 流式调用 |
| `src/server/config/` | 配置解析模块types、variables、schema |
| `src/web/` | React 前端 |
| ------------------------- | ------------------------------------------------ |
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server + routes 注册 |
| `src/server/routes/` | API handler资源端点拆分 |
| `src/server/db/` | SQLite 连接、schema、migrationdata access |
| `src/server/ai/` | AI Provider Registry + Agent 流式调用 |
| `src/server/config/` | 配置解析types、variables、normalizer、schema |
| `src/server/helpers/` | 响应格式化、URL 工具 |
| `src/server/middleware/` | 参数校验 + 错误处理 |
| `src/shared/api.ts` | 前后端共享 API 类型 |
| `src/shared/app.ts` | 应用全局常量 |
## 路由分组
| 资源 | 路径前缀 | 文件目录 |
| --------- | ----------------------------------------------- | ------------------- |
| meta | `/api/meta` | `routes/meta.ts` |
| providers | `/api/providers` | `routes/providers/` |
| models | `/api/models` | `routes/models/` |
| projects | `/api/projects` | `routes/projects/` |
| chat | `/api/projects/:id/conversations``:id/chat` | `routes/chat/` |
## 更新触发条件
修改项目结构、启动流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。

View File

@@ -1,104 +1,50 @@
# 后端开发
本文档说明 alfred 后端的 API、配置加载、日志、版本管理和后端测试开发约定
适用场景:修改 src/server/、src/shared/api.ts、后端测试、配置契约、API 响应或日志模块。
## 库使用优先级
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | ---------------------------------------------------- |
| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn、bun:sqlite |
| 2 | es-toolkit | 类型判断、深度比较、并发控制 |
| 3 | 标准 Web API | Headers、fetch、AbortController |
| 4 | 主流三方库 | pino、@sinclair/typebox、ajv、drizzle-orm |
| 5 | 自行实现 | 仅在以上都无法满足时 |
新增依赖前必须先检查上述每一层是否已有可用方案。
## API 路由开发
路由文件位于 src/server/routes/,每个端点一个文件。路由通过 server.ts 的 Bun.serve({ routes }) 声明式注册。
新增路由步骤:
1. 在 src/server/routes/ 下创建 <name>.ts
2. 实现 handler 函数并 export
3. 在 server.ts 的 routes 对象中注册路径和 method handler
4. 在 tests/server/ 中添加对应测试
开发规范见 [开发规范文档](README.md)
## 共享工具
helpers.ts 提供跨路由共用的响应工具:
`src/server/helpers/`:
- createApiError(error, status) — 构造 API 错误体
- createHeaders(mode, init) — 创建响应 Headers
- jsonResponse(body, options) — JSON 响应构造
- `response.ts``createApiError(error, status)``createHeaders(mode, init)``createMetaResponse(version)``formatDuration(ms)``jsonResponse(body, options)`
- `url.ts``parseIdFromUrl(url)`
middleware.ts 提供 API 参数校验函数:
`src/server/middleware/`:
- validateIdParam(idStr, mode) — 校验 ID 参数格式
- validatePagination(pageParam, pageSizeParam, mode) — 校验分页参数
- validateTimeRange(from, to, mode) — 校验时间范围参数
- `validate.ts``validateIdParam(idStr, mode)` — 校验 ID 格式(字母数字 + `_-``validatePagination(pageParam, pageSizeParam, mode)` — page≥1, pageSize≤200`validateTimeRange(from, to, mode)`
- `error-handler.ts``AppError` — 业务异常类(含 statusCode`withErrorHandler(fn, mode, logger?)` — 包裹 handler 捕获异常
## 数据库
项目使用 SQLite 作为存储后端,通过 bun:sqlite + Drizzle ORM 实现类型安全的数据访问
SQLite + bun:sqlite + Drizzle ORM。
### schema 定义
- `src/server/db/schema.ts`Drizzle 表结构,列名 snake_caseTS 类型 camelCase。
- `src/server/db/connection.ts``createDatabase(dataDir, logger)` 打开 `alfred.db`PRAGMAforeign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转为 Drizzle 实例。`paginateQuery()` 分页工具。
- Migration开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/backups/`,事务中执行,失败回滚。
`src/server/db/schema.ts` 使用 Drizzle ORM 定义表结构,列名使用 snake_caseTypeScript 类型使用 camelCaseDrizzle schema 负责映射。
### 数据访问函数
### 数据库连接
| 文件 | 函数 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `projects.ts` | createProject、getProject、listProjects、updateProject、deleteProject、archiveProject、restoreProject |
| `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider |
| `models.ts` | createModel、getModel、listModels、getModelsByProviderId、updateModel、deleteModel |
| `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages |
`src/server/db/connection.ts``createDatabase(dataDir, logger)` 打开 `<dataDir>/alfred.db`,设置 PRAGMAforeign_keys=ON、journal_mode=WAL、busy_timeout=5000
### migration 机制
- 开发期:使用 `drizzle-kit generate` 从 TS schema 生成 SQL migration 文件到 `drizzle/` 目录
- 生产期:构建时将 `drizzle/*.sql` 嵌入可执行文件,启动时自动应用 pending migrations
- 每次 migration 前自动备份现有 DB 到 `<dataDir>/backups/alfred-<timestamp>.db`
- migration 在事务中执行,失败则回滚并停止启动
### 数据访问
`src/server/db/projects.ts` 提供项目数据访问函数,`src/server/db/providers.ts` 提供供应商数据访问函数,`src/server/db/models.ts` 提供模型数据访问函数,`src/server/db/conversations.ts` 提供会话和消息数据访问函数。输入输出使用 `src/shared/api.ts` 的类型。函数内部使用 Drizzle query builder 包装 `bun:sqlite` Database。
输入输出类型来自 `src/shared/api.ts`
## AI 服务层
`src/server/ai/` 提供 AI Provider Registry 构建与连接测试能力
- `src/server/ai/types.ts``AIProviderConfig`name、type、baseUrl、apiKey`AIModelConfig`providerId、modelId、capabilities
- `src/server/ai/registry.ts`
- `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry每次调用重建不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。
- `testProviderConnection(config)` — 测试 Base URL 可达性 + `/models` 接口
- `testModelConnection(config)` — 测试模型连通性
- `countModels(db)` — 统计已配置模型数
- `src/server/ai/agents/alfred-agent.ts``createAlfredAgent(model)` — ToolLoopAgent + `stepCountIs(20)` + `getCurrentTime` 工具。
- `src/server/ai/tools/`AI 工具定义。
### 类型定义
`src/server/ai/types.ts` 定义 AI 配置类型:
- `AIProviderConfig` — 供应商配置name、type、baseUrl、apiKey
- `AIModelConfig` — 模型配置providerId、modelId、capabilities
- `AIRegistryConfig` — Registry 构建配置providers、models供后续 AI 调用层组合使用
### Registry 构建
`src/server/ai/registry.ts` 提供:
- `buildProviderRegistry(db)` — 从 DB 查询所有供应商,构建 Vercel AI SDK Provider Registry
- `testProviderConnection(config)` — 先测试 Base URL 可达性,再请求 `/models` 验证 API Key 和模型列表接口
每次 AI 调用时从 DB 查询 providers构建 registry 后通过 `registry.languageModel('providerId:modelId')` 获取模型实例。不使用缓存层。模型是否存在以及业务能力标签由调用方基于 models 表先行校验registry 只负责将 providerId/modelId 映射到 AI SDK 模型实例。
### Agent 流式调用
`src/server/ai/agent-stream.ts` 提供 `agentStream(options)` 函数,封装 Vercel AI SDK `streamText` 调用。接收数据库实例、消息数组和模型 DB ID从 DB 查询模型与供应商信息后构建 Provider Registry使用 `:` 作为 provider 和 modelId 的分隔符。默认使用 `stepCountIs(1)` 限制单步调用。返回 `StreamTextResult`,路由层通过 `result.toUIMessageStreamResponse()` 转为 SSE 响应。
### 供应商连通性测试
供应商连通性测试返回 `{ providerTestResponse: { ok, message } }`,前端根据 `ok` 展示成功或失败提示。
- `POST /api/providers/test` — 使用表单中尚未保存的供应商配置测试连接
- `POST /api/models/test` — 使用模型关联供应商配置和 modelId 测试模型连接
测试连接不会写入数据库也不会阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false`Base URL 可达但 `/models` 不支持、非标准或返回非鉴权错误时返回 `ok: true` 并在 `message` 中提示用户可检查 URL 或忽略提醒。
### 支持的供应商类型
### 供应商类型
| type | AI SDK factory |
| ------------------- | --------------------------------------------------- |
@@ -106,83 +52,38 @@ middleware.ts 提供 API 参数校验函数:
| `anthropic` | `createAnthropic({ apiKey, baseURL })` |
| `openai-compatible` | `createOpenAICompatible({ name, apiKey, baseURL })` |
## 聊天 API
### 连通性测试
聊天 API 按项目维度组织会话和消息:
- `POST /api/providers/test` — 用未保存配置测试,不写入 DB不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false``/models` 不支持返回 `ok: true` + 提示。
- `POST /api/models/test` — 用模型关联供应商 + modelId 测试。
## 聊天 API
| 方法 | 路径 | 说明 |
| ------ | ----------------------------------------------- | ------------------ |
| GET | `/api/projects/:id/conversations` | 列出项目下所有会话 |
| POST | `/api/projects/:id/conversations` | 创建新会话 |
| GET | `/api/projects/:id/conversations/:cid` | 获取会话详情 |
| DELETE | `/api/projects/:id/conversations/:cid` | 删除会话及其消息 |
| GET | `/api/projects/:id/conversations/:cid/messages` | 获取会话消息列表 |
| POST | `/api/projects/:id/chat` | 发送消息并流式回复 |
| PATCH | `/api/projects/:id/conversations/:cid` | 更新会话 |
| DELETE | `/api/projects/:id/conversations/:cid` | 删除会话消息 |
| GET | `/api/projects/:id/conversations/:cid/messages` | 获取消息列表 |
| POST | `/api/projects/:id/chat` | 发送消息SSE 回复 |
聊天路由处理器位于 `src/server/routes/chat/`,遵循统一的 handler 模式。`send.ts` 处理发送消息:验证会话归属保存用户消息到 DB调用 `agentStream` 获取流式响应,返回 SSE UI 消息流,流结束后后台保存 AI 回复到 DB
`send.ts`:验证会话归属保存用户消息`createAlfredAgent``createAgentUIStreamResponse``onFinish` 持久化 AI 回复。
## 类型规范
- 共享类型以 src/shared/api.ts 为唯一源头
- 应用常量以 src/shared/app.ts 为唯一源头
- 版本号以 package.json.version 为唯一源头
- 前端不得 import src/server/ 下的任何文件
- 严格联合类型优先于宽类型
## 配置契约
配置加载流程固定为unknown -> AuthoringConfig -> NormalizedConfig -> ValidatedConfig -> ServerConfig。
Ajv 保持严格拒绝模式allErrors: true、不启用类型强制转换、不注入默认值、不自动删除未知字段。
新增或修改配置字段时必须同步更新 TypeBox schema fragments、config.schema.json、测试和对应用户文档。
## 日志模块
后端运行时代码统一通过 Logger 接口输出日志,禁止直接使用 console.\*。
## 日志
| 实现 | 用途 |
| --------------------- | ------------------------ |
| --------------------- | -------------- |
| PinoLoggerWrapper | 生产运行时 |
| ConsoleFallbackLogger | 配置加载失败前的降级日志 |
| NoopLogger | 静默丢弃日志 |
| ConsoleFallbackLogger | 配置加载前降级 |
| NoopLogger | 静默丢弃 |
| MemoryLogger | 测试替身 |
敏感信息会自动 redact authorization、cookie、password 等字段。
## 版本管理
项目使用 package.json.version 作为版本号唯一来源
版本获取方式:
- 开发模式src/server/version.ts 运行时从 package.json 读取
- 生产模式scripts/build.ts 在构建时将版本号烘焙为字面量注入
版本升迁命令:
```bash
bun run version:patch # 升迁 patch 版本
bun run version:minor # 升迁 minor 版本
bun run version:major # 升迁 major 版本
bun run version:set # 显式设置版本号
```
## 后端测试
| 变更类型 | 测试重点 |
| ------------------ | --------------------------------- |
| API 路由 | tests/server/app.test.ts 集成行为 |
| 配置 schema | schema 导出、合法/非法配置 |
| helpers/middleware | 单元测试 |
后端测试约定:
- API 路由集成测试必须通过真实 `startServer` 覆盖路由注册、HTTP method、fallback、响应 header 和核心错误路径。
- SQLite 相关测试必须复用 `tests/helpers.ts` 中的测试数据库 helper不要在测试文件内分散实现临时目录清理或直接裸用 `rmSync(dir, { recursive: true })`
- DAO 和路由边界测试应优先使用真实 migration 初始化测试库,只有 migration 执行器单测可以使用最小 fake migration。
- logger/bootstrap 中预期的 fallback 输出必须在测试中捕获并断言,正常通过的测试不应污染 stdout/stderr。
唯一来源:`package.json`。开发模式从 package.json 运行时读取;生产模式构建时烘焙为字面量
## 更新触发条件
修改后端 API、共享类型、配置契约、日志模块、版本管理或后端测试规范时,必须更新本文档。
修改后端模块 API、共享工具、数据库 schema、AI 服务层或聊天 API 时,必须更新本文档。

View File

@@ -1,180 +1,131 @@
# 前端开发
本文档说明 alfred 前端的 React、Ant Design、TanStack Query、组件、样式和前端测试约定
开发规范见 [开发规范文档](README.md)
适用场景:修改 src/web/、前端共享类型使用方式、组件结构、样式规则或前端测试。
## 控制台架构
## 技术栈
两个控制台入口共享 ConsoleShell`src/web/components/ConsoleShell/`
| 层面 | 技术 | 用途 |
| ------ | ----------------------------------------------------- | ------------------------ |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Vite开发+ Bun compile生产 | 开发服务 HMR 与生产构建 |
| 语言 | TypeScript | 类型安全 |
| UI 库 | Ant Design (antd) + @ant-design/icons + @ant-design/x | UI 组件、图标与聊天 UI |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动刷新 |
| AI 层 | Vercel AI SDK (`@ai-sdk/react`) | 聊天状态管理与流式通信 |
| 路由 | React Router v7 (Declarative mode) | SPA 路由与页面导航 |
- **Admin**`src/web/consoles/admin/`):路由 `/`(总览)、`/projects``/models`
- **Workbench**`src/web/consoles/workbench/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
不引入额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 useState
ConsoleShell 包含:`ConfigProvider(zhCN)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和控制台标题
## 组件开发规范
`Sidebar``src/web/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
- 每个 React 组件一个 .tsx 文件,文件名使用 PascalCase
- 组件 props 定义为 interface XxxProps紧邻组件函数声明
- 类型从 src/shared/api.ts 导入,使用 import type
- 展示组件放在 components/,通过 props 接收数据,通过回调返回事件;页面专属展示组件可就近放在 pages/\*/components/
- 容器逻辑放在 hooks 中,组件只做数据消费;全局共享查询可提取为独立 hook如 use-meta
- 工具函数放在 utils/,保持纯函数无副作用
## 页面
页面组件保持编排职责,组合 hooks 和展示组件;当页面同时承担查询、筛选、分页、表格列、弹窗表单和 mutation 时,应按工具栏、表格、表单弹窗等功能边界拆分。拆分以降低职责复杂度为目标,避免为了拆分而拆分。
## Ant Design 使用规范
- 优先使用 antd 组件默认状态和官方交互模式;没有明确产品定制需求时,不额外改写组件视觉。
- 优先通过组件 props 配置行为和外观,例如 `collapsible``theme``scroll``locale``status``variant``color`
- 全局使用 `ConfigProvider` 配置 antd 中文 locale`antd/locale/zh_CN` 导入 `zhCN`
- 需要 message、modal、notification 等 antd 应用级能力时,在 `ConfigProvider` 内包裹 `App`(代码中可别名为 `AntApp`),组件内通过 `App.useApp()` 获取。
- 状态页、异常页、空结果优先使用 `Result``Empty``Alert``Spin` 等 antd 组件。
- 信息展示优先使用 `Typography``Card``Descriptions``Table` 等 antd 组件,避免用原生标签加自定义 CSS 复刻。
- 搜索输入优先使用 `Input.Search`,保持回车搜索、按钮搜索和清空行为一致。
- 表格在窄屏有挤压风险时必须提供明确的 `scroll` 或响应式列策略。
## 样式开发规范
前端基于 Ant Design 构建 UI。样式管理目标是让 antd 继续承担主视觉系统,项目 CSS 只补足页面外壳、局部布局和自有组件视觉,不另起一套与 antd 竞争的样式体系。
样式开发优先级:
1. antd 组件默认能力,例如 `Button``Card``Table``Form``Result``Empty`
2. antd 组件 props例如 `size``type``variant``color``status``layout``scroll``gutter`
3. antd 布局组件,例如 `Layout``Flex``Space``Row``Col`,避免为普通排列关系新增 CSS。
4. `ConfigProvider` theme token 和 antd 组件 token处理主题级或组件级统一调整。
5. antd CSS 变量(`--ant-*`),用于项目自有 CSS 中引用颜色、间距、字体、圆角和阴影等设计值。
6. 全局 CSS仅承载应用外壳、全局基础样式和少量明确复用的工具类。
7. CSS Modules用于页面专属布局或项目自有组件视觉仅在局部样式增长到需要就近维护时使用。
8. 自行开发视觉组件,仅在 antd 组件和组合方式无法表达明确产品需求时使用。
红线:
- 严禁在组件中使用 `style` 属性内联调整样式。
- 严禁通过 CSS 覆盖 antd 组件内部类名,例如 `.ant-*`
- 严禁使用 `!important`
- 颜色统一使用 antd Design Token / CSS 变量,不使用硬编码色值。
- 默认不引入 Tailwind、UnoCSS、Sass、Less、CSS-in-JS 或额外 PostCSS 插件;确需引入时必须先说明现有 antd + CSS Modules 无法满足的具体问题、影响范围和迁移成本。
默认状态原则:如果 antd 组件默认样式已经满足当前需求,不为其增加额外 CSS 类;不要通过外层 CSS 修改 Sider、Menu、Table、Modal 等组件内部结构样式。
全局 CSS 归属:
- 当前入口保留 `src/web/styles.css`;当文件继续增长时,优先拆分为 `src/web/styles/global.css``src/web/styles/app-shell.css``src/web/styles/utilities.css` 等按职责命名的文件,再由入口样式文件集中导入。
- `global.css` 仅放 `html``body``:root`、字体渲染、全局背景等应用级基础样式。
- `app-shell.css` 仅放应用外壳样式,例如 `app-layout``app-header``app-content`、Header 内容分布和主内容间距。
- `utilities.css` 只放至少两处复用、语义稳定、不会与 antd props 重叠的工具类;只有一处使用时优先改为 antd 布局组件或局部 CSS Modules。
- 全局类名必须带有明确前缀,应用外壳使用 `app-*`,工具类使用 `u-*`;禁止新增 `.container``.title``.content` 等容易跨页面冲突的泛名类。
CSS Modules 归属:
- 页面专属样式与页面就近放置,例如 `src/web/pages/projects/projects.module.css`
- 自有组件样式与组件就近放置,例如 `src/web/components/FooCard/foo-card.module.css`
- CSS Modules 中类名使用职责语义,例如 `.root``.toolbar``.summaryCard``.emptyState`;通过导入对象绑定到组件,避免字符串拼写散落。
- 同一类样式只服务当前页面或组件;一旦被多处复用,应先判断能否用 antd 组件或 props 表达,再考虑提取共享组件,而不是直接提升为全局 CSS。
- 首次实际使用 `*.module.css` 时,同步补全 TypeScript 声明和必要测试,确保类型检查与构建链路稳定。
antd 定制边界:
- 优先使用官方 props、theme token 和组件 token不要因为视觉微调直接写 CSS。
- antd v6 组件暴露 `classNames` 语义插槽时,可以把项目自有类绑定到官方稳定插槽;仍然不得选择 `.ant-*` 内部 DOM 类名。
- 避免使用 antd `styles` 语义插槽写内联样式;如果必须使用,应先评估是否可以通过 token、CSS 变量或 CSS Modules 表达。
- 弹窗、下拉、表格、菜单等复杂组件不依赖内部 DOM 结构做布局修补;发现必须修补时,优先调整组件组合或交互设计。
token 和 CSS 变量规则:
- 颜色使用 `var(--ant-color-*)`,例如文本、边框、背景和状态色。
- 间距、字号、圆角和阴影优先使用 `var(--ant-padding-*)``var(--ant-margin-*)``var(--ant-font-size-*)``var(--ant-border-radius*)``var(--ant-box-shadow*)` 等 antd 变量。
- 项目自定义 CSS 变量只能定义在 `:root` 或清晰的主题容器上,并且必须基于 antd token 派生;不要创建与 antd 平行的颜色、间距、字号体系。
- 主题切换统一通过 `ConfigProvider` theme algorithm 和 token 控制,不在 CSS 中硬编码亮色或暗色分支。
响应式规则:
- 页面必须在桌面和移动端正常加载和可读。
- 优先使用 antd `Flex``Grid``Table scroll`、响应式列配置处理布局收缩。
- 媒体查询只处理页面或自有组件的布局断点;不要用媒体查询覆盖 antd 内部结构。
- 移动端适配优先保证内容可访问、操作可点击和横向溢出可控,不追求与桌面完全一致的排版。
新增样式前检查:
1. 这个需求是否可以由 antd 组件或 props 完成?
2. 这个样式是否属于主题级统一调整,应该放到 `ConfigProvider` theme token
3. 这个样式是否只服务页面外壳,应该留在全局 CSS
4. 这个样式是否只服务单个页面或自有组件,应该使用 CSS Modules
5. 这个样式是否在覆盖 antd 内部结构?如果是,应重新设计组件组合。
6. 这个样式是否引入了硬编码色值、`style``.ant-*``!important`?如果是,不应合入。
## 表单与交互规范
- Modal + Form 提交使用 `Form onFinish` 处理业务提交,`Modal onOk` 只触发 `form.submit()`
- 不在 `Modal onOk` 中直接执行异步 `validateFields` 和提交逻辑,也不通过 lint disable 绕过该问题。
- 文本必填字段同时配置 `required: true``whitespace: true`,保持前端校验与后端 trim 后校验一致。
- 提交中状态传给 antd 组件的 loading/confirmLoading 等 props避免自行实现重复状态样式。
- 操作确认优先使用 `Popconfirm`,成功/失败反馈优先使用 antd message。
## 运行时外壳规范
前端提供两个入口外壳,共享通用 Console Shell 组件:
- **Admin管理台**`src/web/consoles/admin/AdminConsoleLayout.tsx`,菜单配置在 `menu.tsx`,路由 `/``/projects``/models`
- **Workbench工作台**`src/web/consoles/workbench/WorkbenchProjectGate.tsx``WorkbenchConsoleLayout.tsx`,菜单配置和路由构造在 `routes.ts`,路由 `/workbench/:projectId`。默认菜单为"聊天室",使用 `ChatPage` 作为主页面。
通用 Console Shell`src/web/components/ConsoleShell/ConsoleShell.tsx`)包含 Layout、Header、Sider、Content、主题切换、版本展示和侧边栏折叠状态由 Admin 和 Workbench 复用。Header 显示品牌名、版本号和控制台标题Admin 显示"管理台"Workbench 显示"工作台 · 项目名")。
Sidebar`src/web/components/Sidebar/index.tsx`)是纯展示/导航组件,通过 `menuItems` props 接收菜单配置由调用方决定菜单内容和路径。Admin 传入静态路径 `/``/projects``/models`Workbench 通过 route builder`buildWorkbenchPath`)将相对菜单路径拼成 `/workbench/:projectId` 的子路径。
Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectGate` 中从 URL path param 读取 `projectId`,通过 `useProject(projectId)` 加载项目,仅 active 项目渲染工作台布局,不存在或 archived 项目显示"项目不存在或不可访问"。
模型管理页面(`src/web/pages/models/index.tsx`)属于 Admin 路由 `/models`,通过 antd `Tabs` 在同页组织供应商和模型两个视图。页面使用 `ModelsToolbar``ProviderTable``ProviderFormModal``ModelTable``ModelFormModal` 拆分筛选、表格和表单职责;模型表单和模型表格必须使用 `GET /api/providers/options` 获取最小供应商选项,不能复用供应商标签页当前分页或搜索结果作为全量选项。
供应商表单必须支持未保存配置的连通性测试,新建供应商时 type 默认 `openai-compatible`baseURL 不设默认值。连通性测试返回 `ok: false` 时应展示失败反馈,不得使用成功提示样式;`/models` 不支持或响应格式不兼容属于可忽略提醒,不得阻止保存。
| 页面 | 路径 | 入口 |
| -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 总览 | `/` | `pages/dashboard/index.tsx` |
| 项目管理 | `/projects` | `pages/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
| 模型管理 | `/models` | `pages/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
| 聊天室 | `/workbench/:id` | `consoles/workbench/pages/ChatPage.tsx` |
| 404 | `*` | `pages/404/index.tsx` |
### 聊天页面
Workbench 聊天页面位于 `src/web/consoles/workbench/pages/ChatPage.tsx`,组合 `ChatSidebar` `ChatPanel` 两个子组件
`ChatPage` = `ChatSidebar` + `ChatPanel`
- **ChatSidebar**使用 TanStack Query 管理会话列表,提供创建删除会话操作。
- **ChatPanel**使用 Vercel AI SDK `useChat` hook来自 `@ai-sdk/react`)管理聊天状态,通过 `DefaultChatTransport`来自 `ai` 包)与后端 SSE 端点通信。使用 `@ant-design/x``Bubble.List``Sender` 组件渲染消息列表和输入框。`useChat` 返回的 `UIMessage` 使用 `parts` 数组存储内容,不包含 `content` 属性;需从 `parts` 中提取 `type: "text"` 的文本内容用于 Bubble 展示
- **MessageBubble**简单的纯文本消息气泡组件MVP)。
- **use-conversations hook**:位于 `src/web/hooks/use-conversations.ts`,封装会话 CRUD 的 fetch 调用。
- **ChatSidebar**TanStack Query 管理会话列表,创建/删除操作。
- **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。 `part.type` 分派渲染TextPartstreamdown Markdown、ReasoningPart、ToolPart四态。支持编辑重发、重新生成、复制
- **ChatInputArea**`Input.TextArea` + `Button` + `Select`(模型切换)。
- 生产入口必须启用 `ErrorBoundary`,运行时渲染异常使用 antd `Result status="500"` 或等价组件展示。
- `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。
- 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。
## Hooks
## TanStack Query 规范
| Hook | 说明 |
| ----------------------- | ----------------------------------------- |
| `use-meta.ts` | `/api/meta`30s 轮询5s staleTime |
| `use-projects.ts` | 项目 CRUD + archive/restore |
| `use-providers.ts` | 供应商 CRUD + test connection |
| `use-models.ts` | 模型 CRUD + test connection |
| `use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks |
| `use-theme-preference` | 主题偏好 localStorage 持久化 |
| `use-sidebar-collapsed` | 侧边栏折叠 localStorage 持久化 |
- Query key 使用 structured array使用 as const 保持字面量类型
- 全局面板级查询可持续刷新,详情级查询必须按状态条件启用
- 多处页面使用同一后端资源时,应提取共享 hook避免重复定义 fetch 函数。
## 工具函数
## fetch 封装
统一使用 fetch不引入 axios。错误抛异常由 TanStack Query 的 error 状态承接。
前后端共享的请求和响应类型定义在 src/shared/api.ts。前端 fetch 函数的返回类型必须匹配后端真实 JSON 形状;如果后端返回包装对象,例如 `{ project: Project }`,应声明对应响应类型并在 hook 内提取业务对象。
## 前端测试
- 测试目录为 tests/web/,结构对应 src/web/
- 单元测试重点覆盖 utils/ 和 hooks 中的纯逻辑
- 组件测试使用 jsdom 和 @testing-library/react
- 测试用户行为而非实现细节
- 只 mock 系统边界,使用真实的 QueryClientProvider 包裹组件
- 组件测试环境由 tests/setup.ts 和 bunfig.toml preload 提供
- 断言优先基于用户可见文本、role、按钮和交互结果不依赖 `.ant-*` 内部类名。
- 对 antd 组件只断言本项目传入的可观察行为或配置结果,避免把 antd 内部 DOM 结构当作稳定契约。
- fetch mock、路由、QueryClientProvider 等系统边界优先复用 tests/web/test-utils.tsx避免在每个测试文件重复安装 `window.fetch`
- 项目页这类数据驱动页面至少覆盖请求 URL/query、method/body、成功后的用户可见结果以及关键错误路径或失败后状态。
- ErrorBoundary、hooks 纯逻辑和 fetch request helper 应使用单元测试覆盖异常回退,页面测试只保留真实用户路径。
| 文件 | 导出 |
| --------------- | --------------------------------------------------------------------------------------------- |
| `utils/api.ts` | `handleResponse(response, extract)``handleVoidResponse(response)` |
| `utils/time.ts` | `formatCountdown``formatDurationUnit``formatRelativeTime``isOlderThan``subtractHours` |
## 更新触发条件
修改前端技术栈、组件边界、数据流、样式规则、测试环境前端验证方式时,必须更新本文档。
修改前端技术栈、组件边界、数据流、样式规则、测试环境前端验证方式、运行时代码结构、页面组成、组件索引或 hooks/工具清单时,必须更新本文档。
## 日志模块
### Logger 接口
`src/web/utils/logger.ts` 提供与后端镜像的 Logger 抽象:
```typescript
export interface Logger {
child(bindings: Record<string, unknown>): Logger;
debug(message: string, data?: unknown): void;
error(message: string, data?: unknown): void;
info(message: string, data?: unknown): void;
setLevel(level: LogLevel): void;
warn(message: string, data?: unknown): void;
}
```
### 实现
| 实现 | 工厂函数 | 用途 |
| ----------------------- | --------------------------------------- | ------------------------------------------------------- |
| `DefaultLogger` + Sinks | `useLogger()` / `createDefaultLogger()` | 组件内使用ConsoleSink + AntdMessageSink 双流 |
| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数ErrorBoundary、工具函数仅 ConsoleSink |
| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 |
| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 |
### 使用方式
**组件内(推荐):**
```typescript
import { useLogger } from "../hooks/use-logger";
function MyComponent() {
const logger = useLogger();
logger.info("数据加载完成", { count: 42 });
logger.warn("即将超时");
logger.error("操作失败", { error: new Error("...") });
}
```
**非组件纯函数:**
```typescript
import { createConsoleLogger } from "../utils/logger";
const logger = createConsoleLogger();
logger.debug("调试信息");
```
**作用域绑定:**
```typescript
const pageLogger = logger.child({ page: "projects" });
pageLogger.info("页面加载"); // [Alfred:INFO] 页面加载 [page=projects]
```
### notification 红线
- `AntdMessageSink` 仅对 **warn**`message.warning`)和 **error**`message.error`)触发用户可见通知。
- `debug``info` 级别绝不对用户弹出 notification仅在开发者控制台输出。
- 错误详情通过 `data` 参数传入(如 `logger.error("提交失败", { error })``data` 不经序列化透传,保留 Error 堆栈展开能力。
### 生产环境行为
生产环境(`import.meta.env["PROD"]`)自动将 ConsoleSink 最小级别设为 `warn`,屏蔽 debug/info 输出。`useLogger()``createConsoleLogger()` 自动处理此逻辑,调用方无需关心环境判断。
### ErrorBoundary 特殊说明
`ErrorBoundary` 是 class 组件,无法使用 `useLogger()` hook。它以 `createConsoleLogger()` 直接创建独立的 ConsoleLogger 实例,仅输出到控制台不触发用户通知。
### 测试
- 单元测试使用 `createMemoryLogger()` 断言日志记录,使用 `createNoopLogger()` 静默无关日志。
- `createDefaultLogger(sinks, isProduction)` 接受 `isProduction` 参数,测试中可显式控制级别过滤行为,不依赖 `import.meta.env`

View File

@@ -1,97 +1,60 @@
# 构建与发布
本文档说明开发服务、前后端集成、生产构建、脚本维护和环境变量。
## 开发运行
适用场景:修改 scripts/、构建流程、静态资源集成或环境变量。
- `bun run dev config.yaml` — 双进程Bun API server --watch + Vite dev server HMR
- `bun run dev:server config.yaml` — 仅后端
- `bun run dev:web` — 仅前端
## 开发期运行
```bash
bun run dev config.yaml
```
scripts/dev.ts 同时启动两个进程:
| 进程 | 用途 |
| --------------- | --------------------------------------- |
| Bun API server | 后端 API 服务,--watch 监听变更自动重启 |
| Vite dev server | 前端 SPA、HMR 热更新 |
也可以单独启动:
```bash
bun run dev:server config.yaml # 仅启动后端 API server
bun run dev:web # 仅启动 Vite dev server
```
开发模式 Vite proxy 将 /api/\* 转发到 Bun。
## 前后端集成
开发模式下Vite 通过 proxy 将 /api/\* 转发到 Bun
生产模式Vite 构建为静态资源 → `import with { type: "file" }` 嵌入 Bun 可执行文件 → 非 API 路径 fetch fallback 处理
生产模式下,前端通过 Vite 构建为静态资源,通过 import with { type: "file" } 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理
路由优先级Bun routes 具体路径 > 通配符。/api/meta 优先于 /api/\*。
路由优先级Bun routes 具体路径 > 通配符。`/api/meta` 优先于 `/api/*`
## 构建
```bash
bun run build
```
`bun run build` 流程:
构建流程:
1. `Vite build``dist/web/`
2. `Code generation``.build/static-assets.ts` + `.build/migrations-data.ts` + `.build/server-entry.ts`
3. `Bun compile``dist/alfred`
4. `Cleanup` → 清理 `.build/`
```text
1. Vite build -> dist/web/
2. Code generation -> .build/static-assets.ts + .build/migrations-data.ts + .build/server-entry.ts
3. Bun compile -> dist/alfred
4. Cleanup -> 清理 .build/ 临时目录
```
构建参数:`BUN_TARGET` / `BUILD_TARGET` — 交叉编译目标平台。
构建参数:
| 环境变量 | 说明 |
| ------------------------- | ---------------- |
| BUN_TARGET / BUILD_TARGET | 交叉编译目标平台 |
## 脚本说明
## 脚本
| 脚本 | 文件 | 说明 |
| --------------------- | --------------------------------- | ------------------------------ |
| bun run dev | scripts/dev.ts | 双进程开发服务 |
| bun run dev:server | src/server/dev.ts | 仅启动后端 API server |
| bun run dev:web | Vite CLI | 仅启动 Vite dev server |
| bun run build | scripts/build.ts | Vite -> codegen -> Bun compile |
| bun run schema | scripts/generate-config-schema.ts | 生成配置 JSON Schema |
| bun run schema:check | scripts/generate-config-schema.ts | 检查配置 JSON Schema 同步 |
| bun run clean | scripts/clean.ts | 清理构建缓存与临时文件 |
| bun run version:patch | scripts/bump-version.ts | 升迁 patch 版本 |
| bun run version:minor | scripts/bump-version.ts | 升迁 minor 版本 |
| bun run version:major | scripts/bump-version.ts | 升迁 major 版本 |
| bun run version:set | scripts/bump-version.ts | 显式设置版本号 |
| ----------------------------- | --------------------------------- | ------------------------ |
| dev | scripts/dev.ts | 双进程开发服务 |
| dev:server | src/server/dev.ts | 仅后端 |
| dev:web | Vite CLI | 仅前端 |
| build | scripts/build.ts | Vite codegen compile |
| schema | scripts/generate-config-schema.ts | 生成 JSON Schema |
| schema:check | (同上) | 检查 Schema 同步 |
| clean | scripts/clean.ts | 清理构建缓存 |
| version:patch/minor/major/set | scripts/bump-version.ts | 版本升迁 |
## 项目配置文件
内部辅助:`scripts/bump-version-logic.ts`(版本逻辑)、`scripts/generate-migrations-data.ts`(构建时嵌入 SQL
## 配置文件
| 文件 | 用途 |
| -------------------- | --------------------------- |
| package.json | 项目信息、脚本、依赖声明 |
| -------------------- | -------------------- |
| package.json | 项目信息、脚本、依赖 |
| tsconfig.json | TypeScript 配置 |
| eslint.config.js | ESLint 规则 |
| commitlint.config.js | commitlint 提交信息格式校验 |
| .prettierrc.json | Prettier 格式化规则 |
| commitlint.config.js | 提交信息格式校验 |
| .prettierrc.json | Prettier 规则 |
| .lintstagedrc.json | lint-staged 配置 |
| config.example.yaml | 配置文件示例 |
| config.schema.json | 配置文件 JSON Schema |
| config.example.yaml | 配置示例 |
| config.schema.json | 配置 JSON Schema |
| vite.config.ts | Vite 构建配置 |
| bunfig.toml | Bun 配置 |
## 验证期望
| 变更类型 | 验证方式 |
| ---------------- | -------------------- |
| 构建脚本 | bun run verify |
| 静态资源集成 | bun run build |
| 配置 schema 同步 | bun run schema:check |
| 发布前完整验证 | bun run verify |
| drizzle.config.ts | Drizzle ORM 配置 |
## 更新触发条件

View File

@@ -15,7 +15,6 @@ bun run build
| ------------------------------ | ------------------- |
| http://127.0.0.1:3000/ | 返回前端 SPA |
| http://127.0.0.1:3000/api/meta | 返回应用元信息 JSON |
| http://127.0.0.1:3000/health | 返回健康检查 |
## 构建流程
@@ -23,8 +22,9 @@ scripts/build.ts 执行三步流水线:
```text
1. Vite build -> dist/web/(前端静态资源,含 code splitting
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts含版本号字面量注入
2. Code generation -> .build/static-assets.ts + .build/migrations-data.ts + .build/server-entry.ts含版本号字面量注入
3. Bun compile -> dist/alfred单可执行文件
4. Cleanup -> 清理 .build/ 临时目录
```
- Vite 构建前端资源到 dist/web/,自动 code splittingvendor-react、vendor-antd、vendor-chart

View File

@@ -38,8 +38,6 @@ bun run dev config.yaml
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
| 用户管理 | `/users` | 页面建设中 |
| 系统设置 | `/settings` | 页面建设中 |
平台提供两个入口:
@@ -61,4 +59,12 @@ bun run dev config.yaml
在 Workbench 工作台中,默认进入聊天室页面。左侧为会话列表,可新建和删除会话;右侧为聊天面板,输入消息后 AI 将流式回复。
未选择会话时,聊天面板显示欢迎页面,用户可直接输入消息发送,系统会自动创建新会话。会话标题在首次对话后自动生成。
消息支持以下操作(仅限最后一条消息):
- **复制**:所有消息均支持复制文本内容
- **编辑**:最后一条用户消息可编辑,确认后重新发送
- **重新生成**:最后一条 AI 消息可重新生成回复
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。

View File

@@ -0,0 +1,60 @@
export const enforceCatchType = {
meta: {
type: "problem",
docs: {
description:
"强制 catch 子句使用 e: unknown并用 instanceof Error 提取错误信息;空的 catch 块应添加注释说明原因",
},
messages: {
missingTypeAnnotation:
"catch 子句缺少类型注解。请使用 catch (e: unknown),然后用 e instanceof Error ? e.message : String(e) 提取错误信息。",
nonUnknownType:
"catch 的类型注解应为 unknown切勿使用 any。请改为 catch (e: unknown),然后用 e instanceof Error ? e.message : String(e) 提取错误信息。",
emptyCatchNoComment:
"空的 catch 块应添加注释说明为什么忽略此异常。如果确有理由静默吞掉错误,请在该 catch body 内添加注释。",
},
schema: [],
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
function isUnknownType(typeAnnotation) {
if (!typeAnnotation) return false;
const type = typeAnnotation.typeAnnotation;
if (type?.type === "TSTypeReference") {
return type.typeName?.name === "unknown";
}
if (type?.type === "TSUnknownKeyword") {
return true;
}
return false;
}
function hasCommentsInBody(body) {
if (!body) return false;
return sourceCode.getCommentsInside(body).length > 0;
}
function check(node) {
const { param, body } = node;
if (param) {
const typeAnnotation = param.typeAnnotation;
if (!typeAnnotation) {
context.report({ node: param, messageId: "missingTypeAnnotation" });
} else if (!isUnknownType(typeAnnotation)) {
context.report({ node: typeAnnotation, messageId: "nonUnknownType" });
}
}
if (body && body.type === "BlockStatement" && body.body.length === 0 && !hasCommentsInBody(body)) {
context.report({ node: body, messageId: "emptyCatchNoComment" });
}
}
return {
CatchClause: check,
};
},
};

View File

@@ -0,0 +1,59 @@
export const noEmptyFunction = {
meta: {
type: "problem",
docs: {
description: "禁止空函数体。修复方式在函数体内添加注释说明为何为空实现如接口契约、测试桩、noop。",
},
messages: {
unexpected: "空函数体禁止使用。请在 {} 内添加注释说明原因,例如:/* 实现 Logger 接口契约,有意静默丢弃 */。",
},
schema: [],
},
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
const allowedFunctionTypes = new Set(["ArrowFunctionExpression", "FunctionDeclaration", "FunctionExpression"]);
function isEmptyBody(body) {
return (
body.type === "BlockStatement" && body.body.length === 0 && sourceCode.getCommentsInside(body).length === 0
);
}
function hasDecorator(node) {
return Array.isArray(node.decorators) && node.decorators.length > 0;
}
function isPrivateOrProtectedConstructor(node) {
if (node.parent?.type !== "MethodDefinition") return false;
if (node.parent.kind !== "constructor") return false;
const accessibility = node.parent.accessibility;
return accessibility === "private" || accessibility === "protected";
}
function isOverrideMethod(node) {
if (node.parent?.type !== "MethodDefinition") return false;
return node.parent.override === true;
}
function check(node) {
if (!allowedFunctionTypes.has(node.type)) return;
if (!isEmptyBody(node.body)) return;
if (hasDecorator(node)) return;
if (isPrivateOrProtectedConstructor(node)) return;
if (isOverrideMethod(node)) return;
context.report({
node,
messageId: "unexpected",
});
}
return {
ArrowFunctionExpression: check,
FunctionDeclaration: check,
FunctionExpression: check,
};
},
};

View File

@@ -6,9 +6,15 @@ import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { enforceCatchType } from "./eslint-rules/enforce-catch-type.js";
import { noEmptyFunction } from "./eslint-rules/no-empty-function.js";
const noDirectConsoleMessage =
"后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。";
const noDirectConsoleFrontendMessage =
"前端代码禁止直接使用 console.*;请使用 useLogger() hook组件内或 createConsoleLogger()(非组件纯函数)。";
export default tseslint.config(
{
ignores: [
@@ -21,8 +27,11 @@ export default tseslint.config(
".claude/**",
".codex/**",
".agents/**",
".worktrees/**",
"bin/**",
"bun.lock",
"data/**",
"eslint-rules/**",
],
},
js.configs.recommended,
@@ -47,14 +56,38 @@ export default tseslint.config(
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/only-throw-error": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"import/no-unresolved": ["error", { ignore: ["^bun:"] }],
"no-restricted-syntax": [
"error",
{
message:
"禁止 throw 字面量。项目约定只允许 throw new Error(...) 或 throw new AppError(msg, statusCode)。Re-throw 已捕获的 Error 实例时使用 throw e。",
selector: "ThrowStatement > Literal",
},
],
"no-undef": "off",
},
},
{
files: ["src/**/*.{ts,tsx}"],
plugins: {
local: {
rules: {
"enforce-catch-type": enforceCatchType,
"no-empty-function": noEmptyFunction,
},
},
},
rules: {
"local/enforce-catch-type": "warn",
"local/no-empty-function": "error",
},
},
{
files: ["eslint.config.js"],
rules: {
@@ -77,6 +110,7 @@ export default tseslint.config(
},
{
files: ["src/web/**/*.{ts,tsx}"],
ignores: ["src/web/**/logger.ts"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
@@ -103,6 +137,13 @@ export default tseslint.config(
],
},
],
"no-restricted-syntax": [
"error",
{
message: noDirectConsoleFrontendMessage,
selector: "MemberExpression[object.name='console']",
},
],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},

View File

@@ -1,32 +1,34 @@
schema: fast-drive
context: |
## 项目概览
- 本项目为 Bun 全栈应用Alfred·阿福Bun 是唯一包管理器和运行时,严禁使用 npm、pnpm、yarn、npx、pnpx
- docs/user/ 记录用户使用方法docs/development/ 记录开发技术细节
- 使用中文(注释、文档、交流),面向中文开发者
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
- **其次阅读 docs/development/README.md** 获取开发规范、常用命令、质量门禁和全局规则
- 文档文件名优先使用单个英文单词usage.md、config.md、deploy.md、troubleshoot.md目录上下文足以消歧时不在文件名重复限定词
- 每次代码变更必须执行文档影响分析:
- 用户可见行为、配置、部署、运行行为变更 → 更新 docs/user/ 对应文档
- 开发流程、架构、测试、构建发布流程变更 → 更新 docs/development/ 对应文档
- 项目定位、快速开始、核心能力列表、文档导航变更 → 更新 README.md
- 文档同步规则或文档归属矩阵变更 → 更新 docs/README.md 和本文件
- 无需更新文档时必须在收尾说明中说明原因
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- src/web目录下是基于Bun HTML import、React、Ant Design实现的前端代码
- 前端最高规约:优先使用 antd 组件默认能力和组件 props 组合界面,具体组件、样式、数据流和测试细节遵循 docs/development/frontend.md
- 前端样式管理antd 组件/props/token 优先AppShell 使用最小全局 CSS页面和自有组件样式增长后使用就近 CSS Modules默认不引入 Tailwind、UnoCSS、Sass、Less、CSS-in-JS 等额外样式体系
- 前端样式红线:禁止组件内联 style、覆盖 antd 内部类名、使用 !important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务节省上下文空间加速任务执行
- 优先使用提问工具对用户进行提问
- 本项目为 Bun 全栈应用Alfred·阿福docs/user/ 记录用户使用方法docs/development/ 记录开发技术细节
- 本项目无需考虑向前兼容性
## 文档入口(按顺序阅读)
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
- **其次阅读 docs/development/README.md** 获取完整开发规范、常用命令和质量门禁
## 全局红线
- 前端禁止导入 src/server/ 的后端运行时实现
- 后端运行时代码禁止直接使用 console.*,通过 Logger 实例输出
- 新增逻辑必须编写完善的测试,不允许跳过任何测试
- 每次代码变更必须执行文档影响分析(详见 docs/README.md
- 新增代码优先复用已有组件、工具、依赖库,不轻易引入新依赖
## Git 规范
- 提交信息中文,格式"类型: 简短描述"类型feat/fix/refactor/docs/style/test/chore
- 禁止创建 git 操作 task
## 工作方式
- 积极使用 subagent 并行独立子任务,节省上下文空间;能并行的步骤明确并行
- subagent 仅用于只读收集和分析禁止用于文件修改、代码生成、git 操作或依赖安装
- 单个文件或目录只分配给一个 subagent不重复分配subagent 输出文件路径、行号和问题摘要,不输出大段源码
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
- 优先使用提问工具对用户确认
rules:
design:
- fast-drive的design.md章节标题和正文使用中文仅OpenSpec术语、文件名、schema字段名、命令和代码符号保留英文

View File

@@ -58,7 +58,6 @@
"@ai-sdk/openai-compatible": "^2.0.48",
"@ai-sdk/react": "^3.0.195",
"@ant-design/icons": "^6.2.3",
"@ant-design/x": "^2.7.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.14",
"ai": "^6.0.193",
@@ -72,6 +71,8 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"streamdown": "^2.5.0",
"zod": "^4.4.3"
}
}

View File

@@ -1,71 +0,0 @@
import type { ModelMessage } from "ai";
import type Database from "bun:sqlite";
import { stepCountIs, streamText } from "ai";
import { eq } from "drizzle-orm";
import { wrap } from "../db/connection";
import { models, providers } from "../db/schema";
import { buildProviderRegistry } from "./registry";
const SYSTEM_PROMPT = "你是 Alfred 的 AI 助手。你可以帮助用户回答问题、分析数据和完成各种任务。请用中文回复。";
export interface AgentStreamOptions {
db: Database;
messages: IncomingMessage[];
modelDbId: string;
}
export interface IncomingMessage {
content?: string;
id?: string;
parts?: Array<{ text?: string; type: string }>;
role?: string;
}
export function agentStream(options: AgentStreamOptions) {
const db = wrap(options.db);
const modelRow = db.select().from(models).where(eq(models.id, options.modelDbId)).get();
if (!modelRow) throw new Error(`模型不存在: ${options.modelDbId}`);
const providerRow = db.select().from(providers).where(eq(providers.id, modelRow.providerId)).get();
if (!providerRow) throw new Error(`供应商不存在: ${modelRow.providerId}`);
const registry = buildProviderRegistry(options.db);
const model = registry.languageModel(`${providerRow.id}:${modelRow.modelId}`);
return streamText({
messages: toCoreMessages(options.messages),
model,
stopWhen: stepCountIs(1),
system: SYSTEM_PROMPT,
});
}
export function extractTextContent(msg: IncomingMessage): string {
return (
msg.content ??
(Array.isArray(msg.parts)
? msg.parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text!)
.join("")
: "")
);
}
function toCoreMessages(messages: IncomingMessage[]): ModelMessage[] {
return messages.map((msg) => {
const content =
msg.content ??
(Array.isArray(msg.parts)
? msg.parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text!)
.join("")
: "");
return { content, role: msg.role as ModelMessage["role"] } as ModelMessage;
});
}

View File

@@ -0,0 +1,22 @@
import { type LanguageModel, stepCountIs, ToolLoopAgent } from "ai";
import type { Logger } from "../../logger";
import { createGetCurrentTime } from "../tools/get-current-time";
const SYSTEM_PROMPT = `你是 Alfred一个 AI 助手。
## 输出规范
- 使用中文回复
- 代码块用 Markdown 围栏语法,标注语言
- 给出结论时简洁直接,不要长篇铺垫
- 不确定的事明确说"不确定"`;
export function createAlfredAgent(model: LanguageModel, logger?: Logger) {
return new ToolLoopAgent({
instructions: SYSTEM_PROMPT,
model,
stopWhen: stepCountIs(20),
tools: { getCurrentTime: createGetCurrentTime(logger) },
});
}

View File

@@ -5,6 +5,7 @@ import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createProviderRegistry, generateText } from "ai";
import type { Logger } from "../logger";
import type { AIProviderConfig } from "./types";
export function buildProviderRegistry(db: Database) {
@@ -25,6 +26,7 @@ export function buildProviderRegistry(db: Database) {
export async function testModelConnection(
config: AIProviderConfig & { modelId: string },
logger: Logger,
): Promise<{ message: string; ok: boolean }> {
try {
const provider = createProvider(config);
@@ -36,12 +38,16 @@ export async function testModelConnection(
return { message: "模型连接成功", ok: true };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger.warn({ error: msg, modelId: config.modelId, providerType: config.type }, "模型连接测试失败");
return { message: `模型连接失败:${msg}`, ok: false };
}
}
export async function testProviderConnection(config: AIProviderConfig): Promise<{ message: string; ok: boolean }> {
const baseUrlResult = await probeBaseUrl(config.baseUrl);
export async function testProviderConnection(
config: AIProviderConfig,
logger: Logger,
): Promise<{ message: string; ok: boolean }> {
const baseUrlResult = await probeBaseUrl(config.baseUrl, logger);
if (!baseUrlResult.ok) return baseUrlResult;
const modelsUrl = buildModelsUrl(config.baseUrl);
@@ -82,6 +88,7 @@ export async function testProviderConnection(config: AIProviderConfig): Promise<
};
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger.warn({ error: msg, providerType: config.type }, "供应商 /models 请求异常");
return { message: `Base URL 可连接,但 /models 请求异常:${msg};可检查 URL 或忽略此提示。`, ok: true };
}
}
@@ -154,7 +161,7 @@ function getProviders(db: Database): Array<{
}>;
}
async function probeBaseUrl(baseUrl: string): Promise<{ message: string; ok: boolean }> {
async function probeBaseUrl(baseUrl: string, logger: Logger): Promise<{ message: string; ok: boolean }> {
try {
await fetch(baseUrl, {
method: "HEAD",
@@ -163,6 +170,7 @@ async function probeBaseUrl(baseUrl: string): Promise<{ message: string; ok: boo
return { message: "Base URL 可连接", ok: true };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger.warn({ baseUrl, error: msg }, "Base URL 不可达");
return { message: `Base URL 不可达:${msg}`, ok: false };
}
}

View File

@@ -0,0 +1,42 @@
import { tool } from "ai";
import { z } from "zod";
import type { Logger } from "../../logger";
export function createGetCurrentTime(logger?: Logger) {
return tool({
description: "获取当前日期和时间。可选指定时区,默认返回本地时间。",
execute: ({ timezone }) => Promise.resolve(formatCurrentTime(timezone, logger)),
inputSchema: z.object({
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
}),
});
}
export function formatCurrentTime(timezone?: string, logger?: Logger) {
const now = new Date();
const iso = now.toISOString();
const timestamp = now.getTime();
let local: string;
if (timezone) {
try {
local = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "full",
timeStyle: "long",
timeZone: timezone,
}).format(now);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger?.warn({ error: msg, timezone }, "无效时区,使用默认格式");
local = now.toString();
}
} else {
local = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "full",
timeStyle: "long",
}).format(now);
}
return { iso, local, timestamp };
}

View File

@@ -47,7 +47,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
try {
logger = await buildLogger(config.logging, options.mode, options.version);
} catch (logInitError) {
} catch (logInitError: unknown) {
createFallback().fatal(
`日志初始化失败: ${logInitError instanceof Error ? logInitError.message : String(logInitError)}`,
);
@@ -83,7 +83,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
staticAssets: options.staticAssets,
version: options.version,
});
} catch (error) {
} catch (error: unknown) {
if (logger) {
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
logger.flush();

View File

@@ -192,7 +192,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
if (bytes <= 0) {
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
}
} catch (error) {
} catch (error: unknown) {
issues.push(
issue(
"invalid-value",

View File

@@ -2,7 +2,8 @@ import type Database from "bun:sqlite";
import { desc, eq } from "drizzle-orm";
import type { Conversation, Message } from "../../shared/api";
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { conversations, messages, models } from "./schema";
@@ -10,6 +11,7 @@ import { conversations, messages, models } from "./schema";
export function createConversation(
raw: Database,
projectId: string,
logger: Logger,
defaultModelId?: string,
): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw);
@@ -50,6 +52,7 @@ export function createMessage(
parts?: string;
role: "assistant" | "system" | "user";
},
_logger: Logger,
): Message {
const db = wrap(raw);
const id = crypto.randomUUID();
@@ -78,6 +81,7 @@ export function createMessages(
parts?: string;
role: "assistant" | "system" | "user";
}>,
_logger: Logger,
): Message[] {
const db = wrap(raw);
const now = new Date().toISOString();
@@ -102,7 +106,11 @@ export function createMessages(
return results;
}
export function deleteConversation(raw: Database, id: string): { error: string; status: number } | { success: true } {
export function deleteConversation(
raw: Database,
id: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
if (!existing) return { error: "会话不存在", status: 404 };
@@ -150,6 +158,34 @@ export function listMessages(
});
}
export function updateConversation(
raw: Database,
id: string,
data: UpdateConversationRequest,
_logger: Logger,
): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
if (!existing) return { error: "会话不存在", status: 404 };
const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() };
if (data.modelId !== undefined) {
const model = db.select().from(models).where(eq(models.id, data.modelId)).get();
if (!model) return { error: "模型不存在", status: 400 };
updates.modelId = data.modelId;
}
if (data.title !== undefined) {
updates.title = data.title;
}
db.update(conversations).set(updates).where(eq(conversations.id, id)).run();
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
return { conversation: toConversation(row!) };
}
export function updateConversationTimestamp(raw: Database, id: string): void {
const db = wrap(raw);
db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run();

View File

@@ -11,4 +11,13 @@ export {
} from "./conversations";
export { loadMigrationsFromDir, type MigrationRecord } from "./load-migrations";
export { runMigrations } from "./migrate";
export {
createModel,
deleteModel,
getModel,
getModelsByProviderId,
getModelWithProvider,
listModels,
updateModel,
} from "./models";
export { conversations, messages, projects, schemaMigrations } from "./schema";

View File

@@ -35,9 +35,15 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD
db.transaction(() => {
for (const migration of pending) {
try {
logger.info({ id: migration.id }, "执行 migration");
db.exec(migration.sql);
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
logger.error({ error: msg, id: migration.id }, "migration 执行失败");
throw e;
}
}
})();

View File

@@ -3,6 +3,7 @@ import type Database from "bun:sqlite";
import { desc, eq, like, or, sql } from "drizzle-orm";
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { models, providers } from "./schema";
@@ -10,6 +11,7 @@ import { models, providers } from "./schema";
export function createModel(
raw: Database,
request: CreateModelRequest,
logger: Logger,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
@@ -49,6 +51,7 @@ export function createModel(
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败");
throw e;
}
@@ -56,7 +59,11 @@ export function createModel(
return { model: toModel(row!) };
}
export function deleteModel(raw: Database, id: string): { error: string; status: number } | { success: true } {
export function deleteModel(
raw: Database,
id: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
if (!existing) return { error: "模型不存在", status: 404 };
@@ -83,6 +90,38 @@ export function getModelsByProviderId(raw: Database, providerId: string): number
return Number(result?.count ?? 0);
}
export function getModelWithProvider(
raw: Database,
modelId: string,
):
| { error: string; status: number }
| {
model: { modelId: string; name: string; providerId: string };
provider: { apiKey: string; baseUrl: string; id: string; type: string };
} {
const db = wrap(raw);
const row = db.select().from(models).where(eq(models.id, modelId)).get();
if (!row) return { error: "模型不存在", status: 404 };
const providerRow = db.select().from(providers).where(eq(providers.id, row.providerId)).get();
if (!providerRow) return { error: "供应商不存在", status: 404 };
return {
model: {
modelId: row.modelId,
name: row.name,
providerId: row.providerId,
},
provider: {
apiKey: providerRow.apiKey,
baseUrl: providerRow.baseUrl,
id: providerRow.id,
type: providerRow.type,
},
};
}
export function listModels(
raw: Database,
options: { keyword?: string; page: number; pageSize: number; providerId?: string },
@@ -111,6 +150,7 @@ export function updateModel(
raw: Database,
id: string,
request: UpdateModelRequest,
logger: Logger,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
@@ -164,6 +204,7 @@ export function updateModel(
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "models" }, "数据库操作失败");
throw e;
}

View File

@@ -3,11 +3,16 @@ import type Database from "bun:sqlite";
import { desc, eq, like, or } from "drizzle-orm";
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { projects } from "./schema";
export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
export function archiveProject(
raw: Database,
id: string,
_logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
@@ -23,6 +28,7 @@ export function archiveProject(raw: Database, id: string): { error: string; stat
export function createProject(
raw: Database,
request: CreateProjectRequest,
logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const name = request.name.trim();
@@ -50,6 +56,7 @@ export function createProject(
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "projects" }, "数据库操作失败");
throw e;
}
@@ -57,7 +64,11 @@ export function createProject(
return { project: toProject(row!) };
}
export function deleteProject(raw: Database, id: string): { error: string; status: number } | { success: true } {
export function deleteProject(
raw: Database,
id: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
@@ -99,7 +110,11 @@ export function listProjects(
});
}
export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
export function restoreProject(
raw: Database,
id: string,
_logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
if (!existing) return { error: "项目不存在", status: 404 };
@@ -116,6 +131,7 @@ export function updateProject(
raw: Database,
id: string,
request: UpdateProjectRequest,
logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
@@ -150,6 +166,7 @@ export function updateProject(
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "projects" }, "数据库操作失败");
throw e;
}

View File

@@ -3,6 +3,7 @@ import type Database from "bun:sqlite";
import { desc, eq, like } from "drizzle-orm";
import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { providers } from "./schema";
@@ -10,6 +11,7 @@ import { providers } from "./schema";
export function createProvider(
raw: Database,
request: CreateProviderRequest,
logger: Logger,
): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const name = request.name.trim();
@@ -41,6 +43,7 @@ export function createProvider(
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败");
throw e;
}
@@ -48,7 +51,11 @@ export function createProvider(
return { provider: toProvider(row!) };
}
export function deleteProvider(raw: Database, id: string): { error: string; status: number } | { success: true } {
export function deleteProvider(
raw: Database,
id: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
if (!existing) return { error: "供应商不存在", status: 404 };
@@ -100,6 +107,7 @@ export function updateProvider(
raw: Database,
id: string,
request: UpdateProviderRequest,
logger: Logger,
): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
@@ -142,6 +150,7 @@ export function updateProvider(
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "providers" }, "数据库操作失败");
throw e;
}

View File

@@ -1,12 +1,18 @@
import type Database from "bun:sqlite";
import type { CreateConversationRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createConversation } from "../../db/conversations";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleCreateConversation(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleCreateConversation(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const projectId = url.pathname.split("/")[3];
@@ -16,14 +22,16 @@ export async function handleCreateConversation(req: Request, db: Database, mode:
let body: CreateConversationRequest = {};
try {
body = (await req.json()) as CreateConversationRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
// empty body is ok, defaults will be used
}
const result = createConversation(db, validated.id, body.modelId);
const result = createConversation(db, validated.id, logger, body.modelId);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ conversationId: result.conversation.id, projectId: validated.id }, "会话创建成功");
return jsonResponse({ conversation: result.conversation }, { mode, status: 201 });
}

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteConversation, getConversation } from "../../db/conversations";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteConversation(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleDeleteConversation(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const parts = new URL(req.url).pathname.split("/");
const projectId = parts[3];
const conversationId = parts[5];
@@ -26,10 +27,11 @@ export function handleDeleteConversation(req: Request, db: Database, mode: Runti
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
}
const result = deleteConversation(db, validatedConv.id);
const result = deleteConversation(db, validatedConv.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ conversationId: validatedConv.id }, "会话删除成功");
return jsonResponse({ success: true }, { mode });
}

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getConversation } from "../../db/conversations";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetConversation(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleGetConversation(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const parts = new URL(req.url).pathname.split("/");
const projectId = parts[3];
const conversationId = parts[5];

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listConversations } from "../../db/conversations";
import { jsonResponse } from "../../helpers";
import { validateIdParam, validatePagination } from "../../middleware";
export function handleListConversations(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleListConversations(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const projectId = url.pathname.split("/")[3];

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getConversation, listMessages } from "../../db/conversations";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam, validatePagination } from "../../middleware";
export function handleListMessages(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleListMessages(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const parts = new URL(req.url).pathname.split("/");
const projectId = parts[3];
const conversationId = parts[5];

View File

@@ -1,24 +1,34 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { IncomingMessage } from "../../ai/agent-stream";
import { createAgentUIStreamResponse, generateText, type UIMessage } from "ai";
import { agentStream, extractTextContent } from "../../ai/agent-stream";
import { createMessage, getConversation, updateConversationTimestamp } from "../../db/conversations";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createAlfredAgent } from "../../ai/agents/alfred-agent";
import { buildProviderRegistry } from "../../ai/registry";
import {
createMessage,
getConversation,
updateConversation,
updateConversationTimestamp,
} from "../../db/conversations";
import { getModelWithProvider } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleSendChat(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleSendChat(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Promise<Response> {
const url = new URL(req.url);
const projectId = url.pathname.split("/")[3];
const validated = validateIdParam(projectId ?? "", mode);
if (validated instanceof Response) return validated;
let body: { conversationId?: string; messages?: IncomingMessage[] };
let body: { conversationId?: string; messages?: UIMessage[] };
try {
body = (await req.json()) as typeof body;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -46,46 +56,109 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
}
for (const msg of body.messages ?? []) {
createMessage(db, {
content: extractTextContent(msg),
conversationId: conversation.id,
role: (msg.role ?? "user") as "assistant" | "system" | "user",
});
}
updateConversationTimestamp(db, conversation.id);
try {
const result = agentStream({
const lastMsg = body.messages[body.messages.length - 1];
if (lastMsg?.role === "user") {
const content =
lastMsg.parts
?.filter((p) => p.type === "text")
.map((p) => p.text)
.join("") ?? "";
createMessage(
db,
messages: body.messages,
modelDbId: conversation.modelId,
});
const stream = result.toUIMessageStreamResponse();
const saveReply = async () => {
try {
const fullContent = await result.text;
if (fullContent) {
createMessage(db, {
content: fullContent,
{
content,
conversationId: conversation.id,
role: "assistant",
});
parts: JSON.stringify(lastMsg.parts ?? []),
role: "user",
},
logger,
);
}
updateConversationTimestamp(db, conversation.id);
}
} catch {
// stream ended without content, nothing to persist
}
};
void saveReply();
let model;
try {
const result = getModelWithProvider(db, conversation.modelId);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return stream;
const registry = buildProviderRegistry(db);
model = registry.languageModel(`${result.provider.id}:${result.model.modelId}`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
}
try {
const firstUserMsg = body.messages.find((m) => m.role === "user");
const firstUserText =
firstUserMsg?.parts
?.filter((p) => p.type === "text")
.map((p) => p.text)
.join("") ?? "";
if (conversation.title === "新会话" && firstUserText) {
generateConversationTitle(firstUserText, model, db, conversation.id, logger);
}
const agent = createAlfredAgent(model);
return await createAgentUIStreamResponse({
agent,
onFinish: ({ responseMessage }) => {
const text = responseMessage.parts
.filter((p): p is { text: string; type: "text" } => p.type === "text")
.map((p) => p.text)
.join("");
createMessage(
db,
{
content: text,
conversationId: conversation.id,
parts: JSON.stringify(responseMessage.parts),
role: "assistant",
},
logger,
);
updateConversationTimestamp(db, conversation.id);
},
uiMessages: body.messages,
});
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse(createApiError(`AI 调用失败:${msg}`, 500), { mode, status: 500 });
}
}
function generateConversationTitle(
firstUserText: string,
model: ReturnType<ReturnType<typeof buildProviderRegistry>["languageModel"]>,
db: Database,
conversationId: string,
logger: Logger,
): void {
if (firstUserText.length <= 5) {
updateConversation(db, conversationId, { title: firstUserText }, logger);
return;
}
void generateText({
model,
prompt: `请根据以下对话开头生成一个简短标题不超过10个字${firstUserText}`,
system: "你是一个标题生成助手,只返回标题文本,不要解释。",
})
.then((result) => {
const title = result.text.trim().slice(0, 10);
updateConversation(db, conversationId, { title: title || firstUserText.slice(0, 10) }, logger);
})
.catch((titleError: unknown) => {
const titleMsg = titleError instanceof Error ? titleError.message : String(titleError);
logger.error({ conversationId, error: titleMsg }, "标题生成失败");
try {
updateConversation(db, conversationId, { title: firstUserText.slice(0, 10) }, logger);
} catch {
logger.error({ conversationId }, "标题兜底更新失败");
}
});
}

View File

@@ -0,0 +1,55 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateConversationRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getConversation, updateConversation } from "../../db/conversations";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateConversation(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectId = parts[3];
const conversationId = parts[5];
const validatedProject = validateIdParam(projectId ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedConv = validateIdParam(conversationId ?? "", mode);
if (validatedConv instanceof Response) return validatedConv;
const existing = getConversation(db, validatedConv.id);
if ("error" in existing) {
return jsonResponse(createApiError(existing.error, existing.status), { mode, status: existing.status });
}
if (existing.conversation.projectId !== validatedProject.id) {
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
}
let body: UpdateConversationRequest;
try {
body = (await req.json()) as UpdateConversationRequest;
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (body.modelId === undefined && body.title === undefined) {
return jsonResponse(createApiError("至少需要传 modelId 或 title", 400), { mode, status: 400 });
}
const result = updateConversation(db, validatedConv.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ conversationId: result.conversation.id }, "会话更新成功");
return jsonResponse({ conversation: result.conversation }, { mode });
}

View File

@@ -1,7 +1,8 @@
import type { RuntimeMode } from "../../shared/api";
import type { Logger } from "../logger";
import { createMetaResponse, jsonResponse } from "../helpers";
export function handleMeta(mode: RuntimeMode, version: string): Response {
export function handleMeta(mode: RuntimeMode, version: string, _logger: Logger): Response {
return jsonResponse(createMetaResponse(version), { mode });
}

View File

@@ -1,16 +1,23 @@
import type Database from "bun:sqlite";
import type { CreateModelRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { MODEL_CAPABILITIES } from "../../../shared/api";
import { createModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleCreateModel(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleCreateModel(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
let body: CreateModelRequest;
try {
body = (await req.json()) as CreateModelRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -44,11 +51,15 @@ export async function handleCreateModel(req: Request, db: Database, mode: Runtim
const tokenError = validateOptionalPositiveInteger("maxOutputTokens", body.maxOutputTokens);
if (tokenError) return jsonResponse(createApiError(tokenError, 400), { mode, status: 400 });
const result = createModel(db, body);
const result = createModel(db, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info(
{ modelId: result.model.id, name: result.model.name, providerId: result.model.providerId },
"模型创建成功",
);
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -1,22 +1,24 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteModel(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleDeleteModel(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = deleteModel(db, validated.id);
const result = deleteModel(db, validated.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ modelId: validated.id }, "模型删除成功");
return new Response(null, { status: 204 });
}

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetModel(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleGetModel(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listModels } from "../../db/models";
import { jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
export function handleListModels(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleListModels(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");

View File

@@ -1,16 +1,23 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, TestModelRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { testModelConnection } from "../../ai/registry";
import { getProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleTestModelConfig(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleTestModelConfig(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
let body: TestModelRequest;
try {
body = (await req.json()) as TestModelRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -30,13 +37,23 @@ export async function handleTestModelConfig(req: Request, db: Database, mode: Ru
});
}
const testResult = await testModelConnection({
const testResult = await testModelConnection(
{
apiKey: providerResult.provider.apiKey,
baseUrl: providerResult.provider.baseUrl,
modelId: body.modelId,
name: providerResult.provider.name,
type: providerResult.provider.type,
});
},
logger,
);
if (!testResult.ok) {
logger.warn(
{ message: testResult.message, modelId: body.modelId, providerId: body.providerId },
"模型连接测试失败",
);
}
return jsonResponse({ modelTestResponse: testResult }, { mode });
}

View File

@@ -1,13 +1,19 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateModelRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { MODEL_CAPABILITIES } from "../../../shared/api";
import { updateModel } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateModel(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleUpdateModel(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
@@ -17,7 +23,8 @@ export async function handleUpdateModel(req: Request, db: Database, mode: Runtim
let body: UpdateModelRequest;
try {
body = (await req.json()) as UpdateModelRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -40,11 +47,12 @@ export async function handleUpdateModel(req: Request, db: Database, mode: Runtim
const tokenError = validateOptionalPositiveInteger("maxOutputTokens", body.maxOutputTokens);
if (tokenError) return jsonResponse(createApiError(tokenError, 400), { mode, status: 400 });
const result = updateModel(db, validated.id, body);
const result = updateModel(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ modelId: result.model.id }, "模型更新成功");
return jsonResponse(result, { mode });
}

View File

@@ -1,22 +1,24 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { archiveProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleArchiveProject(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = archiveProject(db, validated.id);
const result = archiveProject(db, validated.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ projectId: validated.id }, "项目归档成功");
return jsonResponse(result, { mode });
}

View File

@@ -1,15 +1,22 @@
import type Database from "bun:sqlite";
import type { CreateProjectRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleCreateProject(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleCreateProject(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
let body: CreateProjectRequest;
try {
body = (await req.json()) as CreateProjectRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -17,10 +24,11 @@ export async function handleCreateProject(req: Request, db: Database, mode: Runt
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
const result = createProject(db, body);
const result = createProject(db, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ name: result.project.name, projectId: result.project.id }, "项目创建成功");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -1,22 +1,24 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteProject } from "../../db/projects";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleDeleteProject(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const idStr = parseIdFromUrl(url);
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = deleteProject(db, validated.id);
const result = deleteProject(db, validated.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ projectId: validated.id }, "项目删除成功");
return new Response(null, { status: 204 });
}

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getProject } from "../../db/projects";
import { jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetProject(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleGetProject(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listProjects } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleListProjects(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");

View File

@@ -1,22 +1,24 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { restoreProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleRestoreProject(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
const validated = validateIdParam(idStr ?? "", mode);
if (validated instanceof Response) return validated;
const result = restoreProject(db, validated.id);
const result = restoreProject(db, validated.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ projectId: validated.id }, "项目恢复成功");
return jsonResponse(result, { mode });
}

View File

@@ -1,12 +1,18 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateProjectRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { updateProject } from "../../db/projects";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateProject(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleUpdateProject(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
@@ -16,18 +22,22 @@ export async function handleUpdateProject(req: Request, db: Database, mode: Runt
let body: UpdateProjectRequest;
try {
body = (await req.json()) as UpdateProjectRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.name && !body.description && body.name !== "" && body.description !== "") {
const hasName = body.name !== undefined && body.name.trim() !== "";
const hasDescription = body.description !== undefined && body.description.trim() !== "";
if (!hasName && !hasDescription) {
return jsonResponse(createApiError("At least one of name or description is required", 400), { mode, status: 400 });
}
const result = updateProject(db, validated.id, body);
const result = updateProject(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ projectId: result.project.id }, "项目更新成功");
return jsonResponse(result, { mode });
}

View File

@@ -1,15 +1,22 @@
import type Database from "bun:sqlite";
import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleCreateProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleCreateProvider(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
let body: CreateProviderRequest;
try {
body = (await req.json()) as CreateProviderRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -32,10 +39,14 @@ export async function handleCreateProvider(req: Request, db: Database, mode: Run
});
}
const result = createProvider(db, body);
const result = createProvider(db, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info(
{ name: result.provider.name, providerId: result.provider.id, type: result.provider.type },
"供应商创建成功",
);
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -1,13 +1,14 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getModelsByProviderId } from "../../db/models";
import { deleteProvider } from "../../db/providers";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const idStr = parseIdFromUrl(url);
@@ -19,10 +20,11 @@ export function handleDeleteProvider(req: Request, db: Database, mode: RuntimeMo
return jsonResponse(createApiError("该供应商下存在模型,无法删除", 409), { mode, status: 409 });
}
const result = deleteProvider(db, validated.id);
const result = deleteProvider(db, validated.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ providerId: validated.id }, "供应商删除成功");
return new Response(null, { status: 204 });
}

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetProvider(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleGetProvider(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];

View File

@@ -1,12 +1,13 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listProviders } from "../../db/providers";
import { jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode): Response {
export function handleListProviders(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");

View File

@@ -1,10 +1,11 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listProviderOptions } from "../../db/providers";
import { jsonResponse } from "../../helpers";
export function handleListProviderOptions(db: Database, mode: RuntimeMode): Response {
export function handleListProviderOptions(db: Database, mode: RuntimeMode, _logger: Logger): Response {
return jsonResponse({ items: listProviderOptions(db) }, { mode });
}

View File

@@ -1,29 +1,46 @@
import type Database from "bun:sqlite";
import type { CreateProviderRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { testProviderConnection } from "../../ai/registry";
import { createApiError, jsonResponse } from "../../helpers";
export async function handleTestProviderConfig(req: Request, _db: Database, mode: RuntimeMode): Promise<Response> {
const validated = await readProviderConfig(req, mode);
export async function handleTestProviderConfig(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const validated = await readProviderConfig(req, mode, logger);
if (validated instanceof Response) return validated;
const testResult = await testProviderConnection({
const testResult = await testProviderConnection(
{
apiKey: validated.apiKey,
baseUrl: validated.baseUrl,
name: validated.name,
type: validated.type,
});
},
logger,
);
if (!testResult.ok) {
logger.warn({ message: testResult.message, name: validated.name, type: validated.type }, "供应商连接测试失败");
}
return jsonResponse({ providerTestResponse: testResult }, { mode });
}
async function readProviderConfig(req: Request, mode: RuntimeMode): Promise<CreateProviderRequest | Response> {
async function readProviderConfig(
req: Request,
mode: RuntimeMode,
logger: Logger,
): Promise<CreateProviderRequest | Response> {
let body: CreateProviderRequest;
try {
body = (await req.json()) as CreateProviderRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}

View File

@@ -1,12 +1,18 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateProviderRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { updateProvider } from "../../db/providers";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateProvider(req: Request, db: Database, mode: RuntimeMode): Promise<Response> {
export async function handleUpdateProvider(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const idStr = url.pathname.split("/")[3];
@@ -16,7 +22,8 @@ export async function handleUpdateProvider(req: Request, db: Database, mode: Run
let body: UpdateProviderRequest;
try {
body = (await req.json()) as UpdateProviderRequest;
} catch {
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
@@ -27,10 +34,18 @@ export async function handleUpdateProvider(req: Request, db: Database, mode: Run
});
}
const result = updateProvider(db, validated.id, body);
if (body.name === undefined && body.baseUrl === undefined && body.apiKey === undefined && body.type === undefined) {
return jsonResponse(createApiError("至少需要提供 name, baseUrl, apiKey 或 type 中的一个字段", 400), {
mode,
status: 400,
});
}
const result = updateProvider(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ providerId: result.provider.id }, "供应商更新成功");
return jsonResponse(result, { mode });
}

View File

@@ -42,7 +42,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async () => {
const resolvedVersion = await resolveVersion();
return handleMeta(mode, resolvedVersion);
return handleMeta(mode, resolvedVersion, logger);
},
mode,
logger,
@@ -52,7 +52,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleListModels } = await import("./routes/models/list");
return handleListModels(req, db, mode);
return handleListModels(req, db, mode, logger);
},
mode,
logger,
@@ -60,7 +60,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleCreateModel } = await import("./routes/models/create");
return handleCreateModel(req, db, mode);
return handleCreateModel(req, db, mode, logger);
},
mode,
logger,
@@ -70,7 +70,7 @@ export function startServer(options: StartServerOptions) {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteModel } = await import("./routes/models/delete");
return handleDeleteModel(req, db, mode);
return handleDeleteModel(req, db, mode, logger);
},
mode,
logger,
@@ -78,7 +78,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleGetModel } = await import("./routes/models/get");
return handleGetModel(req, db, mode);
return handleGetModel(req, db, mode, logger);
},
mode,
logger,
@@ -86,7 +86,7 @@ export function startServer(options: StartServerOptions) {
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateModel } = await import("./routes/models/update");
return handleUpdateModel(req, db, mode);
return handleUpdateModel(req, db, mode, logger);
},
mode,
logger,
@@ -96,7 +96,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleTestModelConfig } = await import("./routes/models/test");
return handleTestModelConfig(req, db, mode);
return handleTestModelConfig(req, db, mode, logger);
},
mode,
logger,
@@ -106,7 +106,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleListProjects } = await import("./routes/projects/list");
return handleListProjects(req, db, mode);
return handleListProjects(req, db, mode, logger);
},
mode,
logger,
@@ -114,7 +114,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleCreateProject } = await import("./routes/projects/create");
return handleCreateProject(req, db, mode);
return handleCreateProject(req, db, mode, logger);
},
mode,
logger,
@@ -124,7 +124,7 @@ export function startServer(options: StartServerOptions) {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteProject } = await import("./routes/projects/delete");
return handleDeleteProject(req, db, mode);
return handleDeleteProject(req, db, mode, logger);
},
mode,
logger,
@@ -132,7 +132,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleGetProject } = await import("./routes/projects/get");
return handleGetProject(req, db, mode);
return handleGetProject(req, db, mode, logger);
},
mode,
logger,
@@ -140,7 +140,7 @@ export function startServer(options: StartServerOptions) {
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateProject } = await import("./routes/projects/update");
return handleUpdateProject(req, db, mode);
return handleUpdateProject(req, db, mode, logger);
},
mode,
logger,
@@ -150,7 +150,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleArchiveProject } = await import("./routes/projects/archive");
return handleArchiveProject(req, db, mode);
return handleArchiveProject(req, db, mode, logger);
},
mode,
logger,
@@ -160,7 +160,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleSendChat } = await import("./routes/chat/send");
return handleSendChat(req, db, mode);
return handleSendChat(req, db, mode, logger);
},
mode,
logger,
@@ -170,7 +170,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleListConversations } = await import("./routes/chat/list");
return handleListConversations(req, db, mode);
return handleListConversations(req, db, mode, logger);
},
mode,
logger,
@@ -178,7 +178,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleCreateConversation } = await import("./routes/chat/create");
return handleCreateConversation(req, db, mode);
return handleCreateConversation(req, db, mode, logger);
},
mode,
logger,
@@ -188,7 +188,7 @@ export function startServer(options: StartServerOptions) {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteConversation } = await import("./routes/chat/delete");
return handleDeleteConversation(req, db, mode);
return handleDeleteConversation(req, db, mode, logger);
},
mode,
logger,
@@ -196,7 +196,15 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleGetConversation } = await import("./routes/chat/get");
return handleGetConversation(req, db, mode);
return handleGetConversation(req, db, mode, logger);
},
mode,
logger,
),
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateConversation } = await import("./routes/chat/update");
return handleUpdateConversation(req, db, mode, logger);
},
mode,
logger,
@@ -206,7 +214,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleListMessages } = await import("./routes/chat/messages");
return handleListMessages(req, db, mode);
return handleListMessages(req, db, mode, logger);
},
mode,
logger,
@@ -216,7 +224,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleRestoreProject } = await import("./routes/projects/restore");
return handleRestoreProject(req, db, mode);
return handleRestoreProject(req, db, mode, logger);
},
mode,
logger,
@@ -226,7 +234,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleListProviders } = await import("./routes/providers/list");
return handleListProviders(req, db, mode);
return handleListProviders(req, db, mode, logger);
},
mode,
logger,
@@ -234,7 +242,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleCreateProvider } = await import("./routes/providers/create");
return handleCreateProvider(req, db, mode);
return handleCreateProvider(req, db, mode, logger);
},
mode,
logger,
@@ -244,7 +252,7 @@ export function startServer(options: StartServerOptions) {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteProvider } = await import("./routes/providers/delete");
return handleDeleteProvider(req, db, mode);
return handleDeleteProvider(req, db, mode, logger);
},
mode,
logger,
@@ -252,7 +260,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async (req) => {
const { handleGetProvider } = await import("./routes/providers/get");
return handleGetProvider(req, db, mode);
return handleGetProvider(req, db, mode, logger);
},
mode,
logger,
@@ -260,7 +268,7 @@ export function startServer(options: StartServerOptions) {
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateProvider } = await import("./routes/providers/update");
return handleUpdateProvider(req, db, mode);
return handleUpdateProvider(req, db, mode, logger);
},
mode,
logger,
@@ -270,7 +278,7 @@ export function startServer(options: StartServerOptions) {
GET: withErrorHandler(
async () => {
const { handleListProviderOptions } = await import("./routes/providers/options");
return handleListProviderOptions(db, mode);
return handleListProviderOptions(db, mode, logger);
},
mode,
logger,
@@ -280,7 +288,7 @@ export function startServer(options: StartServerOptions) {
POST: withErrorHandler(
async (req) => {
const { handleTestProviderConfig } = await import("./routes/providers/test");
return handleTestProviderConfig(req, db, mode);
return handleTestProviderConfig(req, db, mode, logger);
},
mode,
logger,

View File

@@ -42,11 +42,6 @@ export interface CreateProjectRequest {
name: string;
}
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface CreateProviderRequest {
apiKey: string;
baseUrl: string;
@@ -54,6 +49,11 @@ export interface CreateProviderRequest {
type: ProviderType;
}
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface Message {
content: string;
conversationId: string;
@@ -99,9 +99,9 @@ export type ModelCapability =
| "video-generation"
| "video-recognition";
export interface SendMessageRequest {
conversationId: string;
messages: Array<{ content: string; role: "assistant" | "system" | "user" }>;
export interface UpdateConversationRequest {
modelId?: string;
title?: string;
}
export const MODEL_CAPABILITIES: readonly ModelCapability[] = [

View File

@@ -3,6 +3,8 @@ import type { ErrorInfo, ReactNode } from "react";
import { Button, Result } from "antd";
import { Component } from "react";
import { createConsoleLogger } from "../utils/logger";
interface Props {
children: ReactNode;
}
@@ -19,7 +21,7 @@ export class ErrorBoundary extends Component<Props, State> {
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("渲染错误:", error, info.componentStack);
createConsoleLogger().error("渲染错误", { componentStack: info.componentStack, error });
}
override render() {

View File

@@ -0,0 +1,68 @@
import { Button, Flex, Input, Select } from "antd";
interface ChatInputAreaProps {
displayModelId: null | string;
input: string;
isLoading: boolean;
modelOptions: Array<{ label: string; value: string }>;
onInputChange: (value: string) => void;
onModelChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
}
export function ChatInputArea({
displayModelId,
input,
isLoading,
modelOptions,
onInputChange,
onModelChange,
onSend,
onStop,
}: ChatInputAreaProps) {
return (
<div className="chat-input-area">
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
className="chat-textarea"
disabled={isLoading}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void onSend();
}
}}
placeholder="输入消息..."
value={input}
/>
<Flex align="center" gap={8} justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={onModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center" gap={8}>
{isLoading ? (
<Button
danger
onClick={() => {
onStop?.();
}}
>
</Button>
) : (
<Button onClick={() => void onSend()} type="primary">
</Button>
)}
</Flex>
</Flex>
</div>
);
}

View File

@@ -1,13 +1,22 @@
import type { BubbleItemType } from "@ant-design/x";
import { useChat } from "@ai-sdk/react";
import { Bubble, Sender } from "@ant-design/x";
import { DefaultChatTransport } from "ai";
import { App, Empty, Spin } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { CopyOutlined, EditOutlined, RedoOutlined, RobotOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Flex, Input, Spin, Typography } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchMessages } from "../../../../hooks/use-conversations";
import { MessageBubble } from "./MessageBubble";
import {
createConversation,
fetchConversation,
fetchMessages,
updateConversation,
} from "../../../../hooks/use-conversations";
import { useLogger } from "../../../../hooks/use-logger";
import { useModelList } from "../../../../hooks/use-models";
import { ChatInputArea } from "./ChatInputArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
@@ -15,24 +24,33 @@ interface ChatPanelProps {
projectId: string;
}
export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
const { message } = App.useApp();
const logger = useLogger().child({ component: "ChatPanel", page: "workbench" });
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [editingMessageId, setEditingMessageId] = useState<null | string>(null);
const [editText, setEditText] = useState("");
const [loadingHistory, setLoadingHistory] = useState(false);
const [selectedModelId, setSelectedModelId] = useState<null | string>(null);
const fetchRef = useRef(fetchMessages);
const scrollRef = useRef<HTMLDivElement>(null);
const skipHistoryLoadRef = useRef<null | string>(null);
const conversationIdRef = useRef(conversationId);
useEffect(() => {
conversationIdRef.current = conversationId;
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const { messages, sendMessage, setMessages, status } = useChat({
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({
onError: (err) => {
logger.error("聊天发送失败", { error: err.message });
void message.error(`发送失败:${err.message}`);
},
transport: new DefaultChatTransport({
api: `/api/projects/${projectId}/chat`,
}),
transport: new DefaultChatTransport({ api: `/api/projects/${projectId}/chat` }),
});
const isLoading = status === "submitted" || status === "streaming";
@@ -43,26 +61,36 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
return;
}
if (skipHistoryLoadRef.current === conversationId) {
skipHistoryLoadRef.current = null;
return;
}
let cancelled = false;
const load = async () => {
setLoadingHistory(true);
setInput("");
setMessages([]);
try {
const data = await fetchRef.current(projectId, conversationId);
const msgPromise = fetchRef.current(projectId, conversationId);
const data = await msgPromise;
if (cancelled) return;
const history = data.items
.filter((m: { role: string }) => m.role === "user" || m.role === "assistant")
.reverse()
.map((m: { content: string; id: string; role: string }) => ({
.map((m: { content: string; id: string; parts: null | string; role: string }) => ({
id: m.id,
parts: [{ text: m.content, type: "text" as const }],
parts: m.parts ? (JSON.parse(m.parts) as UIMessage["parts"]) : [{ text: m.content, type: "text" as const }],
role: m.role as "assistant" | "user",
}));
setMessages(history);
} catch (err: unknown) {
if (!cancelled) {
const msg = err instanceof Error ? err.message : String(err);
logger.error("加载历史失败", { conversationId, error: msg, projectId });
void message.error(`加载历史失败:${msg}`);
}
} finally {
@@ -75,30 +103,229 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
return () => {
cancelled = true;
};
}, [conversationId, projectId, setMessages, message]);
}, [conversationId, projectId, setMessages, message, logger]);
const bubbleItems: BubbleItemType[] = messages.map((msg) => ({
content: msg.parts
.filter((p): p is { text: string; type: "text" } => p.type === "text")
.map((p) => p.text)
.join(""),
key: msg.id,
role: msg.role === "user" ? "user" : "ai",
}));
useEffect(() => {
if (!conversationId) return;
const firstTextId = textModels[0]?.id;
if (!firstTextId) return;
const onSubmit = useCallback(
(nextInput: string) => {
if (!nextInput.trim()) return;
setInput("");
void sendMessage({ text: nextInput }, { body: { conversationId: conversationIdRef.current } });
void fetchConversation(projectId, conversationId)
.then((conv) => {
if (textModels.every((m) => m.id !== conv.modelId)) {
setSelectedModelId(firstTextId);
void updateConversation(projectId, conversationId, { modelId: firstTextId });
} else {
setSelectedModelId(conv.modelId);
}
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("获取会话模型信息失败", { conversationId, error: msg, projectId });
});
}, [conversationId, textModels, projectId, logger]);
useEffect(() => {
scrollRef.current?.scrollTo({ behavior: "smooth", top: scrollRef.current.scrollHeight });
}, [messages]);
useEffect(() => {
if (status === "ready" && conversationId) {
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
}
}, [status, conversationId, projectId, queryClient]);
const displayModelId = conversationId ? selectedModelId : (textModels[0]?.id ?? null);
const handleModelChange = useCallback(
(value: string) => {
setSelectedModelId(value);
if (conversationId) {
void updateConversation(projectId, conversationId, { modelId: value }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("更新会话模型失败", { conversationId, error: msg, projectId });
});
}
},
[sendMessage],
[projectId, conversationId, logger],
);
const handleSend = useCallback(async () => {
if (!input.trim()) return;
const text = input;
setInput("");
if (!conversationId) {
try {
const conv = await createConversation(projectId, displayModelId ?? undefined);
skipHistoryLoadRef.current = conv.id;
void queryClient.invalidateQueries({ queryKey: ["conversations", projectId] });
void sendMessage({ text }, { body: { conversationId: conv.id } });
onConversationCreated(conv.id);
} catch (err: unknown) {
setInput(text);
const msg = err instanceof Error ? err.message : String(err);
logger.error("创建会话失败", { error: msg, projectId });
void message.error(`创建会话失败:${msg}`);
}
return;
}
void sendMessage({ text }, { body: { conversationId } }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error("发送消息失败", { conversationId, error: msg, projectId });
});
}, [
input,
sendMessage,
conversationId,
projectId,
onConversationCreated,
message,
queryClient,
displayModelId,
logger,
]);
const extractText = useCallback((msg: UIMessage) => {
return msg.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string; type: "text" }).text)
.join("");
}, []);
const handleCopy = useCallback(
(msg: UIMessage) => {
const text = extractText(msg);
void navigator.clipboard
.writeText(text)
.then(() => {
void message.success("已复制");
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("复制失败", { error: msg });
});
},
[extractText, message, logger],
);
const handleEditStart = useCallback(
(msg: UIMessage) => {
setEditingMessageId(msg.id);
setEditText(extractText(msg));
},
[extractText],
);
const handleEditConfirm = useCallback(() => {
if (!editText.trim() || !conversationId) return;
setEditingMessageId(null);
const idx = messages.findIndex((m) => m.id === editingMessageId);
if (idx === -1) return;
setMessages(messages.slice(0, idx));
void sendMessage({ text: editText }, { body: { conversationId } }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error("重新发送消息失败", { conversationId, error: msg, projectId });
});
}, [editText, conversationId, messages, editingMessageId, setMessages, sendMessage, logger, projectId]);
const handleEditCancel = useCallback(() => {
setEditingMessageId(null);
setEditText("");
}, []);
const handleRegenerate = useCallback(() => {
if (!conversationId) return;
void regenerate({ body: { conversationId } }).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.error("重新生成失败", { conversationId, error: msg, projectId });
});
}, [regenerate, conversationId, logger, projectId]);
const getCardExtra = useCallback(
(msg: UIMessage, idx: number) => {
const isLast = idx === messages.length - 1;
const lastUserIdx = messages.findLastIndex((m) => m.role === "user");
const isLastUser = lastUserIdx >= 0 && idx === lastUserIdx;
const isLastAssistant = isLast && msg.role === "assistant";
const isEditing = editingMessageId === msg.id;
if (isLoading) return null;
const buttons: React.ReactNode[] = [];
buttons.push(
<Button
className="btn-dimmed"
icon={<CopyOutlined />}
key="copy"
onClick={() => handleCopy(msg)}
size="small"
type="text"
/>,
);
if (isLastUser && !isEditing) {
buttons.push(
<Button
className="btn-dimmed"
icon={<EditOutlined />}
key="edit"
onClick={() => handleEditStart(msg)}
size="small"
type="text"
/>,
);
}
if (isLastAssistant) {
buttons.push(
<Button
className="btn-dimmed"
icon={<RedoOutlined />}
key="regenerate"
onClick={handleRegenerate}
size="small"
type="text"
/>,
);
}
return <Flex gap={4}>{buttons}</Flex>;
},
[messages, isLoading, editingMessageId, handleCopy, handleEditStart, handleRegenerate],
);
if (!conversationId) {
return (
<div className="app-chat-panel app-chat-panel-empty">
<Empty description="选择或创建一个会话开始聊天" />
<div className="app-chat-panel">
<div className="chat-welcome-area">
<Flex align="center" gap={12} vertical>
<RobotOutlined className="welcome-icon" />
<Typography.Title className="welcome-title" level={3}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</Flex>
</div>
<ChatInputArea
displayModelId={displayModelId}
input={input}
isLoading={isLoading}
modelOptions={modelOptions}
onInputChange={setInput}
onModelChange={handleModelChange}
onSend={() => {
void handleSend();
}}
onStop={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
/>
</div>
);
}
@@ -110,23 +337,81 @@ export function ChatPanel({ conversationId, projectId }: ChatPanelProps) {
<Spin />
</div>
) : (
<Bubble.List
items={bubbleItems}
role={{
ai: {
contentRender: (content: string) => <MessageBubble content={content} />,
placement: "start",
},
user: {
placement: "end",
},
}}
style={{ flex: 1, overflow: "auto", padding: "16px" }}
<div className="chat-scroll-area" ref={scrollRef}>
<Flex gap={8} vertical>
{messages.map((msg, idx) => (
<Card
classNames={{ extra: "card-extra-actions" }}
extra={getCardExtra(msg, idx)}
key={msg.id}
size="small"
title={msg.role === "user" ? "用户" : <span className="msg-title-ai"></span>}
>
<div className="message-body">
{editingMessageId === msg.id ? (
<Flex gap={8} vertical>
<Input.TextArea
autoSize={{ maxRows: 6, minRows: 1 }}
onChange={(e) => setEditText(e.target.value)}
value={editText}
/>
<Flex gap={8} justify="flex-end">
<Button onClick={handleEditCancel} size="small">
</Button>
<Button onClick={handleEditConfirm} size="small" type="primary">
</Button>
</Flex>
</Flex>
) : (
msg.parts.map((part: Record<string, unknown>, i: number) => (
<PartRenderer key={i} part={part} role={msg.role} />
))
)}
<div className="app-chat-panel-sender">
<Sender loading={isLoading} onChange={setInput} onSubmit={onSubmit} placeholder="输入消息..." value={input} />
</div>
</Card>
))}
{isLoading && (
<Flex className="chat-loading-indicator" justify="center">
<Spin size="small" />
</Flex>
)}
</Flex>
</div>
)}
<ChatInputArea
displayModelId={displayModelId}
input={input}
isLoading={isLoading}
modelOptions={modelOptions}
onInputChange={setInput}
onModelChange={handleModelChange}
onSend={() => {
void handleSend();
}}
onStop={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
/>
</div>
);
}
function PartRenderer({ part, role }: { part: Record<string, unknown>; role: string }) {
const partType = typeof part["type"] === "string" ? part["type"] : "";
if (partType === "text") {
return <TextPart part={part} role={role} />;
}
if (partType.startsWith("tool-")) {
return <ToolPart part={part} />;
}
if (partType === "reasoning") {
return <ReasoningPart part={part} />;
}
return null;
}

View File

@@ -5,6 +5,7 @@ import { App, Button, Flex, Popconfirm, Spin, Typography } from "antd";
import type { Conversation } from "../../../../../shared/api";
import { createConversation, deleteConversation, fetchConversations } from "../../../../hooks/use-conversations";
import { useModelList } from "../../../../hooks/use-models";
interface ChatSidebarProps {
activeId: null | string;
@@ -23,8 +24,12 @@ export function ChatSidebar({ activeId, onSelect, projectId }: ChatSidebarProps)
queryKey: CONVERSATIONS_KEY,
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
const defaultModelId = textModels[0]?.id;
const createMutation = useMutation({
mutationFn: () => createConversation(projectId),
mutationFn: () => createConversation(projectId, defaultModelId),
onError: (err: Error) => {
void message.error(`创建会话失败:${err.message}`);
},

View File

@@ -1,7 +0,0 @@
interface MessageBubbleProps {
content: string;
}
export function MessageBubble({ content }: MessageBubbleProps) {
return <div className="app-chat-message-bubble">{content}</div>;
}

View File

@@ -0,0 +1,34 @@
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import type { PartProps } from "./types";
export function ReasoningPart({ part }: PartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
const state = typeof part["state"] === "string" ? part["state"] : "";
const isStreaming = state === "streaming";
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="secondary">{text}</Typography.Text>,
key: "reasoning",
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
<Typography.Text type="secondary"></Typography.Text>
</Flex>
) : (
<Flex align="center" component="span" gap={4}>
<CheckCircleFilled className="icon-success" />
<Typography.Text type="secondary"></Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}

View File

@@ -0,0 +1,22 @@
import { Typography } from "antd";
import { Streamdown } from "streamdown";
import type { PartProps } from "./types";
interface TextPartProps extends PartProps {
role: string;
}
export function TextPart({ part, role }: TextPartProps) {
const text = typeof part["text"] === "string" ? part["text"] : "";
return (
<div className="part-body">
{role === "user" ? (
<Typography.Paragraph className="message-body-text">{text}</Typography.Paragraph>
) : (
<Streamdown parseIncompleteMarkdown>{text}</Streamdown>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons";
import { Collapse, Flex, Typography } from "antd";
import type { PartProps } from "./types";
interface ToolPartData {
errorText?: string;
input?: unknown;
output?: unknown;
toolCallId?: string;
toolName?: string;
type?: string;
}
function getToolState(part: ToolPartData) {
if ("errorText" in part && part.errorText) return "output-error" as const;
if ("output" in part) return "output-available" as const;
if ("input" in part) return "input-available" as const;
return "input-streaming" as const;
}
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
export function ToolPart({ part }: PartProps) {
const toolPart = part as unknown as ToolPartData;
const state = getToolState(toolPart);
const toolName = toolPart.toolName ?? (toolPart.type ?? "unknown").replace(/^tool-/, "");
const isStreaming = state === "input-streaming" || state === "input-available";
if (state === "output-error") {
return (
<Collapse
ghost
items={[
{
children: <Typography.Text type="danger">{toolPart.errorText}</Typography.Text>,
key: toolPart.toolCallId ?? toolName,
label: (
<Flex align="center" component="span" gap={4}>
<CloseCircleFilled className="icon-error" />
<Typography.Text type="danger">{toolName} </Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}
return (
<Collapse
defaultActiveKey={isStreaming ? [toolPart.toolCallId ?? toolName] : undefined}
ghost
items={[
{
children: (
<Flex gap={4} vertical>
{toolPart.input != null && (
<>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.input)}</pre>
</>
)}
{"output" in toolPart && toolPart.output != null && (
<>
<Typography.Text type="secondary"></Typography.Text>
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.output)}</pre>
</>
)}
{!toolPart.input && !("output" in toolPart) && (
<Typography.Text type="secondary">...</Typography.Text>
)}
</Flex>
),
key: toolPart.toolCallId ?? toolName,
label: isStreaming ? (
<Flex align="center" component="span" gap={4}>
<LoadingOutlined className="icon-primary" />
<Typography.Text type="secondary">
{state === "input-streaming" ? "生成参数" : `调用 ${toolName}`}
</Typography.Text>
</Flex>
) : (
<Flex align="center" component="span" gap={4}>
<CheckCircleFilled className="icon-success" />
<Typography.Text type="secondary">{toolName}</Typography.Text>
</Flex>
),
},
]}
size="small"
/>
);
}

View File

@@ -0,0 +1,3 @@
export interface PartProps {
part: Record<string, unknown>;
}

View File

@@ -3,9 +3,13 @@ import type {
ConversationListResponse,
ConversationResponse,
MessageListResponse,
UpdateConversationRequest,
} from "../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const logger = createConsoleLogger();
export async function createConversation(projectId: string, modelId?: string): Promise<Conversation> {
const response = await fetch(`/api/projects/${projectId}/conversations`, {
@@ -22,24 +26,47 @@ export async function deleteConversation(projectId: string, conversationId: stri
}
export async function fetchConversation(projectId: string, conversationId: string): Promise<Conversation> {
try {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`);
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
} catch (err: unknown) {
logger.error("获取会话失败", {
conversationId,
error: err instanceof Error ? err.message : String(err),
projectId,
});
throw err;
}
}
export async function fetchConversations(projectId: string): Promise<ConversationListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations?pageSize=100`);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ConversationListResponse>;
return handleResponse(response, (data) => data as ConversationListResponse);
}
export async function fetchMessages(projectId: string, conversationId: string): Promise<MessageListResponse> {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}/messages?pageSize=200`);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<MessageListResponse>;
return handleResponse(response, (data) => data as MessageListResponse);
}
export async function updateConversation(
projectId: string,
conversationId: string,
data: UpdateConversationRequest,
): Promise<Conversation> {
try {
const response = await fetch(`/api/projects/${projectId}/conversations/${conversationId}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response, (data) => (data as ConversationResponse).conversation);
} catch (err: unknown) {
logger.error("更新会话失败", {
conversationId,
error: err instanceof Error ? err.message : String(err),
projectId,
});
throw err;
}
}

View File

@@ -0,0 +1,14 @@
import { App } from "antd";
import { useMemo } from "react";
import type { Logger } from "../utils/logger";
import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logger";
export function useLogger(): Logger {
const { message } = App.useApp();
return useMemo(() => {
const isProduction = !!import.meta.env["PROD"];
return createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction);
}, [message]);
}

View File

@@ -12,8 +12,10 @@ import type {
} from "../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const MODELS_KEY = ["models"] as const;
const logger = createConsoleLogger();
export async function createModel(data: CreateModelRequest): Promise<Model> {
const response = await fetch("/api/models", {
@@ -82,7 +84,8 @@ export function useCreateModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createModel,
onSuccess: () => {
onSuccess: (data) => {
logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
@@ -92,7 +95,8 @@ export function useDeleteModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteModel,
onSuccess: () => {
onSuccess: (_data, variables) => {
logger.info("模型删除成功", { modelId: variables });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
@@ -123,7 +127,8 @@ export function useUpdateModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data),
onSuccess: () => {
onSuccess: (data) => {
logger.info("模型更新成功", { modelId: data.modelId, providerId: data.providerId });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});

View File

@@ -10,8 +10,10 @@ import type {
} from "../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const PROJECTS_KEY = ["projects"] as const;
const logger = createConsoleLogger();
export async function archiveProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
@@ -76,7 +78,8 @@ export function useArchiveProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: archiveProject,
onSuccess: () => {
onSuccess: (data) => {
logger.info("项目归档成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
@@ -86,7 +89,8 @@ export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProject,
onSuccess: () => {
onSuccess: (data) => {
logger.info("项目创建成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
@@ -96,7 +100,8 @@ export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteProject,
onSuccess: () => {
onSuccess: (_data, variables) => {
logger.info("项目删除成功", { projectId: variables });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
@@ -121,7 +126,8 @@ export function useRestoreProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: restoreProject,
onSuccess: () => {
onSuccess: (data) => {
logger.info("项目恢复成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});
@@ -131,7 +137,8 @@ export function useUpdateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateProjectRequest; id: string }) => updateProject(args.id, args.data),
onSuccess: () => {
onSuccess: (data) => {
logger.info("项目更新成功", { name: data.name, projectId: data.id });
void queryClient.invalidateQueries({ queryKey: PROJECTS_KEY });
},
});

View File

@@ -12,9 +12,11 @@ import type {
} from "../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const PROVIDERS_KEY = ["providers"] as const;
const MODELS_KEY = ["models"] as const;
const logger = createConsoleLogger();
export async function createProvider(data: CreateProviderRequest): Promise<Provider> {
const response = await fetch("/api/providers", {
@@ -90,7 +92,8 @@ export function useCreateProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProvider,
onSuccess: () => {
onSuccess: (data) => {
logger.info("供应商创建成功", { name: data.name, providerId: data.id });
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
@@ -100,7 +103,8 @@ export function useDeleteProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteProvider,
onSuccess: () => {
onSuccess: (_data, variables) => {
logger.info("供应商删除成功", { providerId: variables });
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
@@ -139,7 +143,8 @@ export function useUpdateProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateProviderRequest; id: string }) => updateProvider(args.id, args.data),
onSuccess: () => {
onSuccess: (data) => {
logger.info("供应商更新成功", { name: data.name, providerId: data.id });
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});

View File

@@ -1,4 +1,4 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
@@ -6,8 +6,11 @@ import { BrowserRouter } from "react-router";
import { App } from "./app";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { createConsoleLogger } from "./utils/logger";
import "./styles.css";
const logger = createConsoleLogger();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -16,6 +19,19 @@ const queryClient = new QueryClient({
staleTime: 5000,
},
},
mutationCache: new MutationCache({
onError: (error: Error, _variables, _context, mutation) => {
logger.error("mutation failed", {
error: error.message,
mutationKey: mutation.options.mutationKey,
});
},
}),
queryCache: new QueryCache({
onError: (error: Error, query) => {
logger.error("query failed", { error: error.message, queryKey: query.queryKey });
},
}),
});
const rootElement = document.getElementById("root");
@@ -36,3 +52,18 @@ createRoot(rootElement).render(
</ErrorBoundary>
</StrictMode>,
);
window.onerror = (message, source, lineno, colno, error) => {
logger.error("未处理的异常", {
colno,
error: error instanceof Error ? error.message : String(error),
lineno,
message,
source,
});
};
window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => {
const msg = event.reason instanceof Error ? event.reason.message : String(event.reason);
logger.error("unhandled rejection", { reason: msg });
});

View File

@@ -109,7 +109,7 @@ export function ModelFormModal({
message.success("模型已创建");
}
onOpenChange(false);
} catch (err) {
} catch (err: unknown) {
if (err instanceof Error) {
message.error(err.message);
}
@@ -136,7 +136,7 @@ export function ModelFormModal({
} else {
message.error(result.message);
}
} catch (err) {
} catch (err: unknown) {
message.error((err as Error).message);
} finally {
setTesting(false);

View File

@@ -69,7 +69,7 @@ export function ModelTable({
try {
await onDelete(id);
message.success("模型已删除");
} catch (err) {
} catch (err: unknown) {
message.error((err as Error).message);
}
};

View File

@@ -83,7 +83,7 @@ export function ProviderFormModal({
message.success("供应商已创建");
}
onOpenChange(false);
} catch (err) {
} catch (err: unknown) {
if (err instanceof Error) {
message.error(err.message);
}
@@ -105,7 +105,7 @@ export function ProviderFormModal({
} else {
message.error(result.message);
}
} catch (err) {
} catch (err: unknown) {
if (err instanceof Error) {
message.error(err.message);
}
@@ -135,10 +135,18 @@ export function ProviderFormModal({
<Form.Item label="供应商类型" name="type" rules={[{ message: "请选择供应商类型", required: true }]}>
<Select options={TYPE_OPTIONS} placeholder="请选择供应商类型" />
</Form.Item>
<Form.Item label="Base URL" name="baseUrl" rules={[{ message: "请输入 Base URL", required: true }]}>
<Form.Item
label="Base URL"
name="baseUrl"
rules={[{ message: "请输入 Base URL", required: true, whitespace: true }]}
>
<Input placeholder="https://api.openai.com/v1" />
</Form.Item>
<Form.Item label="API Key" name="apiKey" rules={[{ message: "请输入 API Key", required: true }]}>
<Form.Item
label="API Key"
name="apiKey"
rules={[{ message: "请输入 API Key", required: true, whitespace: true }]}
>
<Input.Password placeholder="请输入 API Key" />
</Form.Item>
<Form.Item>

View File

@@ -39,7 +39,7 @@ export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, p
try {
await onDelete(id);
message.success("供应商已删除");
} catch (err) {
} catch (err: unknown) {
message.error((err as Error).message);
}
};

View File

@@ -111,7 +111,7 @@ export function ModelsPage() {
};
return (
<Space orientation="vertical" size="large" style={{ flex: 1 }}>
<Space className="app-page-flex" orientation="vertical" size="large">
<ModelsToolbar
activeTab={activeTab}
key={activeTab}

View File

@@ -53,7 +53,7 @@ export function ProjectFormModal({
message.success("项目已创建");
}
onOpenChange(false);
} catch (err) {
} catch (err: unknown) {
if (err instanceof Error) {
message.error(err.message);
}

View File

@@ -69,7 +69,7 @@ export function ProjectTable({
try {
await onArchive(id);
message.success("项目已归档");
} catch (err) {
} catch (err: unknown) {
message.error((err as Error).message);
}
};
@@ -78,7 +78,7 @@ export function ProjectTable({
try {
await onRestore(id);
message.success("项目已恢复");
} catch (err) {
} catch (err: unknown) {
message.error((err as Error).message);
}
};
@@ -87,7 +87,7 @@ export function ProjectTable({
try {
await onDelete(id);
message.success("项目已永久删除");
} catch (err) {
} catch (err: unknown) {
message.error((err as Error).message);
}
};

View File

@@ -35,7 +35,7 @@ export function ProjectsPage() {
const isRowActionPending = archiveMutation.isPending || restoreMutation.isPending || deleteMutation.isPending;
return (
<Space orientation="vertical" size="large" style={{ flex: 1 }}>
<Space className="app-page-flex" orientation="vertical" size="large">
<ProjectToolbar
activeTab={tabValue}
keyword={keyword}

View File

@@ -4,7 +4,8 @@ body {
}
.app-layout {
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.app-header {
@@ -50,11 +51,13 @@ body {
}
.app-content {
overflow: auto;
padding: var(--ant-padding-xl) var(--ant-padding-xl);
}
.app-chat-page {
height: 100%;
overflow: hidden;
}
.app-console-title {
@@ -92,6 +95,7 @@ body {
.app-chat-sidebar-list {
flex: 1;
min-height: 0;
overflow: auto;
}
@@ -135,12 +139,16 @@ body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.app-chat-panel-empty {
.chat-welcome-area {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: var(--ant-padding-xl);
}
.app-chat-panel-loading {
@@ -150,13 +158,99 @@ body {
flex: 1;
}
.app-chat-panel-sender {
padding: var(--ant-padding-sm) var(--ant-padding);
.chat-input-area {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding-left: var(--ant-padding-sm);
padding-top: var(--ant-padding-sm);
border-top: 1px solid var(--ant-color-border-secondary);
}
.app-chat-message-bubble {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
.message-body {
margin-top: 8px;
}
.message-body pre {
background: var(--ant-color-bg-layout);
padding: 8px 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
}
.chat-scroll-area {
flex: 1;
min-height: 0;
overflow: auto;
overflow-anchor: auto;
padding-left: var(--ant-padding-sm);
scroll-behavior: smooth;
}
.chat-loading-indicator {
padding: 8px;
}
.chat-textarea {
flex: 1;
}
.message-body-text {
margin-bottom: 0;
}
.step-divider {
margin: 4px 0;
}
.tool-result-pre {
font-size: 12px;
margin: 0;
}
.msg-title-ai {
color: var(--ant-color-primary);
}
.part-body {
padding: 0 var(--ant-padding-sm);
}
.icon-primary {
color: var(--ant-color-primary);
}
.icon-success {
color: var(--ant-color-success);
}
.icon-error {
color: var(--ant-color-error);
}
.welcome-icon {
color: var(--ant-color-primary);
font-size: 48px;
}
.welcome-title {
margin: 0;
}
.chat-model-select {
width: 180px;
}
.card-extra-actions .btn-dimmed {
color: var(--ant-color-text-quaternary);
}
.card-extra-actions .btn-dimmed:hover {
color: var(--ant-color-text-secondary);
}
.app-page-flex {
flex: 1;
}

View File

@@ -1,15 +1,49 @@
import { createConsoleLogger } from "./logger";
const logger = createConsoleLogger();
export async function handleResponse<T>(response: Response, extract: (data: unknown) => T): Promise<T> {
const start = performance.now();
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
const errorBody = body?.error ?? `HTTP ${response.status}`;
logger.warn("API request failed", {
duration: Math.round(performance.now() - start),
errorBody,
status: response.status,
url: response.url,
});
throw new Error(errorBody);
}
const data: unknown = await response.json();
if (import.meta.env["DEV"]) {
logger.debug("API request", {
duration: Math.round(performance.now() - start),
status: response.status,
url: response.url,
});
}
return extract(data);
}
export async function handleVoidResponse(response: Response): Promise<void> {
const start = performance.now();
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
const errorBody = body?.error ?? `HTTP ${response.status}`;
logger.warn("API request failed", {
duration: Math.round(performance.now() - start),
errorBody,
status: response.status,
url: response.url,
});
throw new Error(errorBody);
}
if (import.meta.env["DEV"]) {
logger.debug("API request", {
duration: Math.round(performance.now() - start),
status: response.status,
url: response.url,
});
}
}

176
src/web/utils/logger.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { MessageInstance } from "antd/es/message/interface";
export type LogLevel = "debug" | "error" | "info" | "warn";
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, error: 3, info: 1, warn: 2 };
export interface Logger {
child(bindings: Record<string, unknown>): Logger;
debug(message: string, data?: unknown): void;
error(message: string, data?: unknown): void;
info(message: string, data?: unknown): void;
setLevel(level: LogLevel): void;
warn(message: string, data?: unknown): void;
}
export interface Sink {
write(level: LogLevel, message: string, data: unknown, bindings: Record<string, unknown>): void;
}
class AntdMessageSink implements Sink {
constructor(private messageApi: MessageInstance) {
// 仅存储依赖,无需初始化操作
}
write(level: LogLevel, message: string, _data: unknown, _bindings: Record<string, unknown>): void {
if (level === "warn") this.messageApi.warning(message);
else if (level === "error") this.messageApi.error(message);
}
}
class BaseLogger implements Logger {
private minLevel: LogLevel;
constructor(
minLevel: LogLevel,
protected sinks: Sink[],
protected bindings: Record<string, unknown>,
) {
this.minLevel = minLevel;
}
child(bindings: Record<string, unknown>): Logger {
return new BaseLogger(this.minLevel, this.sinks, { ...this.bindings, ...bindings });
}
debug(message: string, data?: unknown): void {
this.log("debug", message, data);
}
error(message: string, data?: unknown): void {
this.log("error", message, data);
}
info(message: string, data?: unknown): void {
this.log("info", message, data);
}
setLevel(level: LogLevel): void {
this.minLevel = level;
}
warn(message: string, data?: unknown): void {
this.log("warn", message, data);
}
private log(level: LogLevel, message: string, data?: unknown): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) return;
for (const sink of this.sinks) {
sink.write(level, message, data, this.bindings);
}
}
}
class ConsoleSink implements Sink {
constructor(private isProduction: boolean) {
// 仅存储配置,无需初始化操作
}
write(level: LogLevel, message: string, data: unknown, bindings: Record<string, unknown>): void {
if (this.isProduction && LEVEL_ORDER[level] < LEVEL_ORDER.warn) return;
const prefix = `[Alfred:${level.toUpperCase()}]`;
const bindingStr = formatBindings(bindings);
const fullMessage = `${prefix} ${message}${bindingStr}`;
if (level === "error") console.error(fullMessage, data);
else if (level === "warn") console.warn(fullMessage, data);
else console.log(fullMessage, data);
}
}
class NoopLogger implements Logger {
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
error(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
info(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
setLevel(_level: LogLevel): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
warn(_message: string, _data?: unknown): void {
/* NoopLogger 实现 Logger 接口契约,有意静默丢弃所有日志 */
}
}
export class MemoryLogger implements Logger {
entries: Array<{ data?: unknown; level: LogLevel; message: string }> = [];
child(bindings: Record<string, unknown>): Logger {
void bindings;
return this;
}
debug(message: string, data?: unknown): void {
this.capture("debug", message, data);
}
error(message: string, data?: unknown): void {
this.capture("error", message, data);
}
info(message: string, data?: unknown): void {
this.capture("info", message, data);
}
setLevel(_level: LogLevel): void {
// MemoryLogger.setLevel 为接口兼容,无需实际过滤
}
warn(message: string, data?: unknown): void {
this.capture("warn", message, data);
}
private capture(level: LogLevel, message: string, data?: unknown): void {
this.entries.push({ data, level, message });
}
}
export function createConsoleLogger(): Logger {
const isProduction = !!import.meta.env["PROD"];
const minLevel: LogLevel = isProduction ? "warn" : "debug";
return new BaseLogger(minLevel, [new ConsoleSink(isProduction)], {});
}
export function createDefaultLogger(sinks: Sink[], isProduction: boolean): Logger {
const minLevel: LogLevel = isProduction ? "warn" : "debug";
return new BaseLogger(minLevel, sinks, {});
}
export function createMemoryLogger(): MemoryLogger {
return new MemoryLogger();
}
export { AntdMessageSink, ConsoleSink };
export function createNoopLogger(): Logger {
return new NoopLogger();
}
function formatBindings(bindings: Record<string, unknown>): string {
const entries = Object.entries(bindings);
if (entries.length === 0) return "";
return " " + entries.map(([k, v]) => `[${k}=${String(v)}]`).join("");
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, mock, test } from "bun:test";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
void mock.module("ai", () => ({
@@ -36,12 +37,15 @@ describe("AI registry", () => {
test("testProviderConnection reports unreachable Base URL", async () => {
const { testProviderConnection } = await import("../../../src/server/ai/registry");
const result = await testProviderConnection({
const result = await testProviderConnection(
{
apiKey: "bad-key",
baseUrl: "http://127.0.0.1:1",
name: "Bad",
type: "openai-compatible",
});
},
createNoopLogger(),
);
expect(result.ok).toBe(false);
expect(result.message).toContain("Base URL 不可达");
@@ -51,12 +55,15 @@ describe("AI registry", () => {
await withProviderServer(new Response(null, { status: 401 }), async (baseUrl) => {
const { testProviderConnection } = await import("../../../src/server/ai/registry");
const result = await testProviderConnection({
const result = await testProviderConnection(
{
apiKey: "bad-key",
baseUrl,
name: "Bad",
type: "openai-compatible",
});
},
createNoopLogger(),
);
expect(result.ok).toBe(false);
expect(result.message).toContain("API Key 无效");
@@ -68,12 +75,15 @@ describe("AI registry", () => {
await withProviderServer(Response.json({ data: [{ id: "gpt-4o" }] }), async (baseUrl) => {
const { testProviderConnection } = await import("../../../src/server/ai/registry");
const result = await testProviderConnection({
const result = await testProviderConnection(
{
apiKey: "sk-test",
baseUrl,
name: "Test",
type: "openai",
});
},
createNoopLogger(),
);
expect(result.ok).toBe(true);
expect(result.message).toContain("/models 返回 1 个模型");
@@ -84,12 +94,15 @@ describe("AI registry", () => {
await withProviderServer(new Response(null, { status: 404 }), async (baseUrl) => {
const { testProviderConnection } = await import("../../../src/server/ai/registry");
const result = await testProviderConnection({
const result = await testProviderConnection(
{
apiKey: "sk-test",
baseUrl,
name: "Test",
type: "openai",
});
},
createNoopLogger(),
);
expect(result.ok).toBe(true);
expect(result.message).toContain("可能不支持 /models");
@@ -134,13 +147,16 @@ describe("AI registry", () => {
test("testModelConnection 成功返回 ok:true", async () => {
const { testModelConnection } = await import("../../../src/server/ai/registry");
const result = await testModelConnection({
const result = await testModelConnection(
{
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
modelId: "gpt-4o",
name: "Test",
type: "openai",
});
},
createNoopLogger(),
);
expect(result.ok).toBe(true);
expect(result.message).toContain("模型连接成功");

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import { formatCurrentTime } from "../../../../src/server/ai/tools/get-current-time";
describe("getCurrentTime 工具", () => {
test("formatCurrentTime 返回 ISO/local/timestamp", () => {
const result = formatCurrentTime();
expect(result).toBeDefined();
expect(typeof result.iso).toBe("string");
expect(typeof result.local).toBe("string");
expect(typeof result.timestamp).toBe("number");
});
test("formatCurrentTime 指定 timezone", () => {
const result = formatCurrentTime("Asia/Shanghai");
expect(result).toBeDefined();
expect(typeof result.local).toBe("string");
});
test("formatCurrentTime 无效 timezone 优雅降级", () => {
const result = formatCurrentTime("Invalid/Zone");
expect(result).toBeDefined();
expect(typeof result.local).toBe("string");
});
});

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/require-await */
import { describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";

View File

@@ -11,10 +11,15 @@ import {
updateModel,
} from "../../../src/server/db/models";
import { createProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
function seedProvider(db: Database, name = "TestProvider"): string {
const result = createProvider(db, { apiKey: "sk-test", baseUrl: "https://api.test.com/v1", name, type: "openai" });
const result = createProvider(
db,
{ apiKey: "sk-test", baseUrl: "https://api.test.com/v1", name, type: "openai" },
createNoopLogger(),
);
return (result as { provider: { id: string } }).provider.id;
}
@@ -32,12 +37,16 @@ describe("模型数据访问层", () => {
test("创建模型", () => {
withDb((db) => {
const providerId = seedProvider(db);
const result = createModel(db, {
const result = createModel(
db,
{
capabilities: ["text", "reasoning"],
modelId: "gpt-4o",
name: "GPT-4o",
providerId,
});
},
createNoopLogger(),
);
expect("error" in result).toBe(false);
const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } })
.model;
@@ -50,12 +59,16 @@ describe("模型数据访问层", () => {
test("供应商不存在时创建失败", () => {
withDb((db) => {
const result = createModel(db, {
const result = createModel(
db,
{
capabilities: ["text"],
modelId: "test",
name: "Test",
providerId: "nonexistent",
});
},
createNoopLogger(),
);
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(400);
});
@@ -64,8 +77,12 @@ describe("模型数据访问层", () => {
test("同一供应商下模型 ID 唯一", () => {
withDb((db) => {
const providerId = seedProvider(db);
createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId });
const result = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId });
createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId }, createNoopLogger());
const result = createModel(
db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId },
createNoopLogger(),
);
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("已存在");
});
@@ -75,8 +92,16 @@ describe("模型数据访问层", () => {
withDb((db) => {
const p1 = seedProvider(db, "P1");
const p2 = seedProvider(db, "P2");
const r1 = createModel(db, { capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 });
const r2 = createModel(db, { capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 });
const r1 = createModel(
db,
{ capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 },
createNoopLogger(),
);
const r2 = createModel(
db,
{ capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 },
createNoopLogger(),
);
expect("error" in r1).toBe(false);
expect("error" in r2).toBe(false);
});
@@ -85,7 +110,11 @@ describe("模型数据访问层", () => {
test("能力标签为空时创建失败", () => {
withDb((db) => {
const providerId = seedProvider(db);
const result = createModel(db, { capabilities: [], modelId: "test", name: "Test", providerId });
const result = createModel(
db,
{ capabilities: [], modelId: "test", name: "Test", providerId },
createNoopLogger(),
);
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("能力标签");
});
@@ -95,9 +124,9 @@ describe("模型数据访问层", () => {
withDb((db) => {
const p1 = seedProvider(db, "P1");
const p2 = seedProvider(db, "P2");
createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 });
createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 });
createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 });
createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
const all = listModels(db, { page: 1, pageSize: 20 });
expect(all.total).toBe(3);
@@ -113,7 +142,11 @@ describe("模型数据访问层", () => {
test("获取模型详情", () => {
withDb((db) => {
const providerId = seedProvider(db);
const created = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId });
const created = createModel(
db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId },
createNoopLogger(),
);
const id = (created as { model: { id: string } }).model.id;
const result = getModel(db, id);
@@ -133,10 +166,14 @@ describe("模型数据访问层", () => {
test("更新模型", () => {
withDb((db) => {
const providerId = seedProvider(db);
const created = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId });
const created = createModel(
db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId },
createNoopLogger(),
);
const id = (created as { model: { id: string } }).model.id;
const result = updateModel(db, id, { capabilities: ["text", "reasoning"], name: "新名" });
const result = updateModel(db, id, { capabilities: ["text", "reasoning"], name: "新名" }, createNoopLogger());
expect("error" in result).toBe(false);
const updated = (result as { model: { capabilities: string[]; name: string } }).model;
expect(updated.name).toBe("新名");
@@ -147,10 +184,14 @@ describe("模型数据访问层", () => {
test("删除模型", () => {
withDb((db) => {
const providerId = seedProvider(db);
const created = createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId });
const created = createModel(
db,
{ capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId },
createNoopLogger(),
);
const id = (created as { model: { id: string } }).model.id;
const result = deleteModel(db, id);
const result = deleteModel(db, id, createNoopLogger());
expect("error" in result).toBe(false);
const after = getModel(db, id);
@@ -162,9 +203,9 @@ describe("模型数据访问层", () => {
withDb((db) => {
const p1 = seedProvider(db, "P1");
const p2 = seedProvider(db, "P2");
createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 });
createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 });
createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 });
createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
expect(getModelsByProviderId(db, p1)).toBe(2);
expect(getModelsByProviderId(db, p2)).toBe(1);
@@ -174,14 +215,18 @@ describe("模型数据访问层", () => {
test("可选字段 contextLength 和 maxOutputTokens", () => {
withDb((db) => {
const providerId = seedProvider(db);
const result = createModel(db, {
const result = createModel(
db,
{
capabilities: ["text"],
contextLength: 128000,
maxOutputTokens: 4096,
modelId: "gpt-4o",
name: "GPT-4o",
providerId,
});
},
createNoopLogger(),
);
expect("error" in result).toBe(false);
const model = (result as { model: { contextLength: null | number; maxOutputTokens: null | number } }).model;
expect(model.contextLength).toBe(128000);

View File

@@ -11,6 +11,7 @@ import {
restoreProject,
updateProject,
} from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
function withProjectsDb(callback: (db: Database) => void): void {
@@ -26,7 +27,7 @@ function withProjectsDb(callback: (db: Database) => void): void {
describe("项目数据访问层", () => {
test("创建项目", () => {
withProjectsDb((db) => {
const result = createProject(db, { description: "测试描述", name: "测试项目" });
const result = createProject(db, { description: "测试描述", name: "测试项目" }, createNoopLogger());
expect("error" in result).toBe(false);
expect((result as { project: unknown }).project).toBeDefined();
@@ -43,8 +44,8 @@ describe("项目数据访问层", () => {
test("项目名称全局唯一(含归档项目)", () => {
withProjectsDb((db) => {
createProject(db, { name: "唯一名称" });
const result2 = createProject(db, { name: "唯一名称" });
createProject(db, { name: "唯一名称" }, createNoopLogger());
const result2 = createProject(db, { name: "唯一名称" }, createNoopLogger());
expect("error" in result2).toBe(true);
expect((result2 as unknown as { error: string }).error).toContain("已存在");
});
@@ -52,7 +53,7 @@ describe("项目数据访问层", () => {
test("trim 后名称为空时创建失败", () => {
withProjectsDb((db) => {
const result = createProject(db, { name: " " });
const result = createProject(db, { name: " " }, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
});
@@ -60,9 +61,9 @@ describe("项目数据访问层", () => {
test("列表查询(分页和关键字)", () => {
withProjectsDb((db) => {
createProject(db, { description: "descA", name: "项目A" });
createProject(db, { description: "descB", name: "项目B" });
createProject(db, { name: "其他" });
createProject(db, { description: "descA", name: "项目A" }, createNoopLogger());
createProject(db, { description: "descB", name: "项目B" }, createNoopLogger());
createProject(db, { name: "其他" }, createNoopLogger());
const result1 = listProjects(db, { page: 1, pageSize: 20 });
expect(result1.total).toBe(3);
@@ -79,7 +80,7 @@ describe("项目数据访问层", () => {
test("获取项目详情", () => {
withProjectsDb((db) => {
const created = createProject(db, { description: "详情", name: "详情项目" });
const created = createProject(db, { description: "详情", name: "详情项目" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = getProject(db, id);
@@ -99,10 +100,10 @@ describe("项目数据访问层", () => {
test("更新项目名称和描述", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "原名" });
const created = createProject(db, { name: "原名" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { description: "新描述", name: "新名" });
const result = updateProject(db, id, { description: "新描述", name: "新名" }, createNoopLogger());
expect("error" in result).toBe(false);
const updated = result as { project: { description: string; name: string } };
@@ -113,11 +114,11 @@ describe("项目数据访问层", () => {
test("更新已归档项目失败", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "待归档" });
const created = createProject(db, { name: "待归档" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
archiveProject(db, id, createNoopLogger());
const result = updateProject(db, id, { name: "新名称" });
const result = updateProject(db, id, { name: "新名称" }, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
@@ -125,10 +126,10 @@ describe("项目数据访问层", () => {
test("归档项目", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "待归档" });
const created = createProject(db, { name: "待归档" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = archiveProject(db, id);
const result = archiveProject(db, id, createNoopLogger());
expect("error" in result).toBe(false);
const archived = (result as { project: { archivedAt: null | string; status: string } }).project;
@@ -146,10 +147,10 @@ describe("项目数据访问层", () => {
test("对已归档项目重复归档失败", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "测试" });
const created = createProject(db, { name: "测试" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = archiveProject(db, id);
archiveProject(db, id, createNoopLogger());
const result = archiveProject(db, id, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
@@ -157,11 +158,11 @@ describe("项目数据访问层", () => {
test("恢复已归档项目", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "恢复测试" });
const created = createProject(db, { name: "恢复测试" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
archiveProject(db, id, createNoopLogger());
const result = restoreProject(db, id);
const result = restoreProject(db, id, createNoopLogger());
expect("error" in result).toBe(false);
const restored = (result as { project: { archivedAt: null | string; status: string } }).project;
@@ -172,9 +173,9 @@ describe("项目数据访问层", () => {
test("恢复 active 项目失败", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "活跃项目" });
const created = createProject(db, { name: "活跃项目" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = restoreProject(db, id);
const result = restoreProject(db, id, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
@@ -182,11 +183,11 @@ describe("项目数据访问层", () => {
test("永久删除已归档项目", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "删除测试" });
const created = createProject(db, { name: "删除测试" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
archiveProject(db, id, createNoopLogger());
const result = deleteProject(db, id);
const result = deleteProject(db, id, createNoopLogger());
expect("error" in result).toBe(false);
const after = getProject(db, id);
@@ -196,10 +197,10 @@ describe("项目数据访问层", () => {
test("删除 active 项目失败", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "活跃项目" });
const created = createProject(db, { name: "活跃项目" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = deleteProject(db, id);
const result = deleteProject(db, id, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(409);
});
@@ -207,7 +208,7 @@ describe("项目数据访问层", () => {
test("创建项目名称超过 10 个字符失败", () => {
withProjectsDb((db) => {
const result = createProject(db, { name: "这是一个非常非常长的名字" });
const result = createProject(db, { name: "这是一个非常非常长的名字" }, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
});
@@ -215,7 +216,7 @@ describe("项目数据访问层", () => {
test("创建项目名称刚好 10 个字符成功", () => {
withProjectsDb((db) => {
const result = createProject(db, { name: "一二三四五六七八九十" });
const result = createProject(db, { name: "一二三四五六七八九十" }, createNoopLogger());
expect("error" in result).toBe(false);
const project = (result as { project: { name: string } }).project;
expect(project.name).toBe("一二三四五六七八九十");
@@ -224,10 +225,10 @@ describe("项目数据访问层", () => {
test("更新项目名称超过 10 个字符失败", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "短名" });
const created = createProject(db, { name: "短名" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: "这是一个非常非常长的名字" });
const result = updateProject(db, id, { name: "这是一个非常非常长的名字" }, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
});
@@ -235,10 +236,10 @@ describe("项目数据访问层", () => {
test("更新项目名称 trim 后为空失败", () => {
withProjectsDb((db) => {
const created = createProject(db, { name: "原名" });
const created = createProject(db, { name: "原名" }, createNoopLogger());
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { name: " " });
const result = updateProject(db, id, { name: " " }, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
});

View File

@@ -10,6 +10,7 @@ import {
listProviders,
updateProvider,
} from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
function withDb(callback: (db: Database) => void): void {
@@ -35,12 +36,16 @@ describe("供应商数据访问层", () => {
test("创建供应商", () => {
withDb((db) => {
const result = createProvider(db, {
const result = createProvider(
db,
{
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
name: "OpenAI",
type: "openai",
});
},
createNoopLogger(),
);
expect("error" in result).toBe(false);
const provider = (result as { provider: { apiKey: string; baseUrl: string; name: string; type: string } })
.provider;
@@ -53,8 +58,16 @@ describe("供应商数据访问层", () => {
test("供应商名称唯一", () => {
withDb((db) => {
createProvider(db, { apiKey: "sk-1", baseUrl: "https://a.com", name: "唯一", type: "openai" });
const result = createProvider(db, { apiKey: "sk-2", baseUrl: "https://b.com", name: "唯一", type: "openai" });
createProvider(
db,
{ apiKey: "sk-1", baseUrl: "https://a.com", name: "唯一", type: "openai" },
createNoopLogger(),
);
const result = createProvider(
db,
{ apiKey: "sk-2", baseUrl: "https://b.com", name: "唯一", type: "openai" },
createNoopLogger(),
);
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("已存在");
});
@@ -62,7 +75,11 @@ describe("供应商数据访问层", () => {
test("名称为空时创建失败", () => {
withDb((db) => {
const result = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: " ", type: "openai" });
const result = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: " ", type: "openai" },
createNoopLogger(),
);
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("不能为空");
});
@@ -70,9 +87,21 @@ describe("供应商数据访问层", () => {
test("列表查询(分页和关键字)", () => {
withDb((db) => {
createProvider(db, { apiKey: "sk-1", baseUrl: "https://a.com", name: "Alpha", type: "openai" });
createProvider(db, { apiKey: "sk-2", baseUrl: "https://b.com", name: "Beta", type: "anthropic" });
createProvider(db, { apiKey: "sk-3", baseUrl: "https://c.com", name: "Gamma", type: "openai-compatible" });
createProvider(
db,
{ apiKey: "sk-1", baseUrl: "https://a.com", name: "Alpha", type: "openai" },
createNoopLogger(),
);
createProvider(
db,
{ apiKey: "sk-2", baseUrl: "https://b.com", name: "Beta", type: "anthropic" },
createNoopLogger(),
);
createProvider(
db,
{ apiKey: "sk-3", baseUrl: "https://c.com", name: "Gamma", type: "openai-compatible" },
createNoopLogger(),
);
const result1 = listProviders(db, { page: 1, pageSize: 20 });
expect(result1.total).toBe(3);
@@ -88,7 +117,11 @@ describe("供应商数据访问层", () => {
test("获取供应商详情", () => {
withDb((db) => {
const created = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "详情", type: "openai" });
const created = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "详情", type: "openai" },
createNoopLogger(),
);
const id = (created as { provider: { id: string } }).provider.id;
const result = getProvider(db, id);
@@ -107,10 +140,14 @@ describe("供应商数据访问层", () => {
test("更新供应商", () => {
withDb((db) => {
const created = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "原名", type: "openai" });
const created = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "原名", type: "openai" },
createNoopLogger(),
);
const id = (created as { provider: { id: string } }).provider.id;
const result = updateProvider(db, id, { name: "新名" });
const result = updateProvider(db, id, { name: "新名" }, createNoopLogger());
expect("error" in result).toBe(false);
expect((result as { provider: { name: string } }).provider.name).toBe("新名");
});
@@ -118,11 +155,19 @@ describe("供应商数据访问层", () => {
test("更新供应商名称重复失败", () => {
withDb((db) => {
createProvider(db, { apiKey: "sk-1", baseUrl: "https://a.com", name: "已存在", type: "openai" });
const created = createProvider(db, { apiKey: "sk-2", baseUrl: "https://b.com", name: "原名", type: "openai" });
createProvider(
db,
{ apiKey: "sk-1", baseUrl: "https://a.com", name: "已存在", type: "openai" },
createNoopLogger(),
);
const created = createProvider(
db,
{ apiKey: "sk-2", baseUrl: "https://b.com", name: "原名", type: "openai" },
createNoopLogger(),
);
const id = (created as { provider: { id: string } }).provider.id;
const result = updateProvider(db, id, { name: "已存在" });
const result = updateProvider(db, id, { name: "已存在" }, createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { error: string }).error).toContain("已存在");
});
@@ -130,10 +175,14 @@ describe("供应商数据访问层", () => {
test("删除供应商", () => {
withDb((db) => {
const created = createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "删除测试", type: "openai" });
const created = createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "删除测试", type: "openai" },
createNoopLogger(),
);
const id = (created as { provider: { id: string } }).provider.id;
const result = deleteProvider(db, id);
const result = deleteProvider(db, id, createNoopLogger());
expect("error" in result).toBe(false);
const after = getProvider(db, id);
@@ -143,7 +192,7 @@ describe("供应商数据访问层", () => {
test("删除不存在的供应商返回 404", () => {
withDb((db) => {
const result = deleteProvider(db, "nonexistent");
const result = deleteProvider(db, "nonexistent", createNoopLogger());
expect("error" in result).toBe(true);
expect((result as unknown as { status: number }).status).toBe(404);
});
@@ -151,20 +200,28 @@ describe("供应商数据访问层", () => {
test("默认类型为 openai-compatible", () => {
withDb((db) => {
createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "默认类型", type: "openai-compatible" });
const result = createProvider(db, {
createProvider(
db,
{ apiKey: "sk", baseUrl: "https://a.com", name: "默认类型", type: "openai-compatible" },
createNoopLogger(),
);
const result = createProvider(
db,
{
apiKey: "sk2",
baseUrl: "https://b.com",
name: "显式默认",
type: "openai-compatible",
});
},
createNoopLogger(),
);
expect((result as { provider: { type: string } }).provider.type).toBe("openai-compatible");
});
});
test("供应商 options 返回最小字段", () => {
withDb((db) => {
createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "选项", type: "openai" });
createProvider(db, { apiKey: "sk", baseUrl: "https://a.com", name: "选项", type: "openai" }, createNoopLogger());
const options = listProviderOptions(db);
expect(options.length).toBe(1);

View File

@@ -7,81 +7,116 @@ import type { Conversation, Message, RuntimeMode } from "../../../src/shared/api
import { createModel } from "../../../src/server/db/models";
import { createProject } from "../../../src/server/db/projects";
import { createProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
void mock.module("ai", () => ({
createAgentUIStreamResponse: (opts: {
agent: unknown;
messages: unknown[];
onFinish:
| ((event: { finishReason?: string; responseMessage: { parts?: Array<{ text: string; type: string }> } }) => void)
| undefined;
}) => {
if (opts.onFinish) {
opts.onFinish({
responseMessage: {
parts: [{ text: "test reply from AI", type: "text" }],
},
});
}
return Promise.resolve(
new Response(
'data: {"type":"start-step"}\n\ndata: {"type":"text-start","id":"txt-1"}\n\ndata: {"type":"text-delta","id":"txt-1","delta":"test reply from AI"}\n\ndata: {"type":"text-end","id":"txt-1"}\n\ndata: {"type":"finish-step"}\n\ndata: {"type":"finish"}\n\n',
{
headers: { "Content-Type": "text/event-stream" },
},
),
);
},
createProviderRegistry: () => ({
languageModel: () => ({}),
}),
generateText: () => Promise.resolve({ text: "mock", usage: {} }),
generateText: () => Promise.resolve({ text: "AI总结标题", usage: {} }),
stepCountIs: () => () => true,
streamText: () => ({
text: Promise.resolve("test reply from AI"),
toUIMessageStreamResponse: () =>
new Response('data: {"type":"text","text":"test reply from AI"}\n\n', {
headers: { "Content-Type": "text/event-stream" },
}),
}),
tool: () => ({ execute: async () => await Promise.resolve({}) }),
ToolLoopAgent: function M() {
// no-op: createAgentUIStreamResponse handles streaming
},
}));
async function createConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateConversation: h } = await import("../../../src/server/routes/chat/create");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function deleteConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteConversation: h } = await import("../../../src/server/routes/chat/delete");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function getConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetConversation: h } = await import("../../../src/server/routes/chat/get");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function listConversationsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListConversations: h } = await import("../../../src/server/routes/chat/list");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function listMessagesViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListMessages: h } = await import("../../../src/server/routes/chat/messages");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
function seedModel(db: Database, providerId: string, modelName = "GPT-4o"): string {
const result = createModel(db, {
async function patchConversationViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateConversation: h } = await import("../../../src/server/routes/chat/update");
return h(req, db, MODE, LOG);
}
function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string {
const result = createModel(
db,
{
capabilities: ["text"],
modelId: "gpt-4o",
modelId,
name: modelName,
providerId,
});
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.model.id;
}
function seedProject(db: Database, name = "测试项目"): string {
const result = createProject(db, { description: "测试", name });
const result = createProject(db, { description: "测试", name }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project.id;
}
function seedProvider(db: Database, name = "测试供应商"): string {
const result = createProvider(db, {
const result = createProvider(
db,
{
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name,
type: "openai",
});
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.provider.id;
}
async function sendChatViaHandler(req: Request, db: Database): Promise<Response> {
const { handleSendChat: h } = await import("../../../src/server/routes/chat/send");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
describe("聊天 API 路由", () => {
@@ -346,6 +381,167 @@ describe("聊天 API 路由", () => {
});
});
describe("PATCH /api/projects/:id/conversations/:cid", () => {
test("更新会话 modelId 成功", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-model");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
const modelId1 = seedModel(db, providerId, "GPT-4o", "gpt-4o");
const modelId2 = seedModel(db, providerId, "GPT-4o-mini", "gpt-4o-mini");
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({ modelId: modelId1 }),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
body: JSON.stringify({ modelId: modelId2 }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { conversation: Conversation };
expect(body.conversation.modelId).toBe(modelId2);
handle.close();
} finally {
handle.cleanup();
}
});
test("更新会话 title 成功", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-title");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
body: JSON.stringify({ title: "新标题" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(200);
const body = (await res.json()) as { conversation: Conversation };
expect(body.conversation.title).toBe("新标题");
handle.close();
} finally {
handle.cleanup();
}
});
test("跨项目更新会话返回 403", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-403");
try {
const db = handle.db;
const projectA = seedProject(db, "项目A");
const projectB = seedProject(db, "项目B");
const providerId = seedProvider(db);
seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectA}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectB}/conversations/${created.id}`, {
body: JSON.stringify({ title: "探测" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(403);
handle.close();
} finally {
handle.cleanup();
}
});
test("不存在的会话返回 404", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-404");
try {
const db = handle.db;
const projectId = seedProject(db);
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/nonexistent`, {
body: JSON.stringify({ title: "探测" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(404);
handle.close();
} finally {
handle.cleanup();
}
});
test("无效 modelId 返回 400", async () => {
const handle = createMigratedMemoryTestDatabase("chat-patch-bad-model");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
const res = await patchConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`, {
body: JSON.stringify({ modelId: "invalid-model-id" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
}),
db,
);
expect(res.status).toBe(400);
handle.close();
} finally {
handle.cleanup();
}
});
});
describe("GET /api/projects/:id/conversations/:cid/messages", () => {
test("跨项目获取消息返回 403", async () => {
const handle = createMigratedMemoryTestDatabase("chat-msg-403");
@@ -420,8 +616,56 @@ describe("聊天 API 路由", () => {
db,
);
const msgBody = (await msgRes.json()) as { items: Message[] };
expect(msgBody.items.length).toBeGreaterThanOrEqual(1);
expect(msgBody.items.length).toBeGreaterThanOrEqual(2);
expect(msgBody.items.some((m) => m.role === "user")).toBe(true);
expect(msgBody.items.some((m) => m.role === "assistant")).toBe(true);
handle.close();
} finally {
handle.cleanup();
}
});
test("首次发送消息时触发标题生成", async () => {
const handle = createMigratedMemoryTestDatabase("chat-send-title");
try {
const db = handle.db;
const projectId = seedProject(db);
const providerId = seedProvider(db);
const modelId = seedModel(db, providerId);
const createRes = await createConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations`, {
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
const created = ((await createRes.json()) as { conversation: Conversation }).conversation;
expect(created.title).toBe("新会话");
await sendChatViaHandler(
new Request(`http://localhost/api/projects/${projectId}/chat`, {
body: JSON.stringify({
conversationId: created.id,
messages: [{ parts: [{ text: "请帮我分析一下这个项目的性能瓶颈", type: "text" }], role: "user" }],
modelDbId: modelId,
}),
headers: { "Content-Type": "application/json" },
method: "POST",
}),
db,
);
await new Promise((resolve) => setTimeout(resolve, 50));
const getRes = await getConversationViaHandler(
new Request(`http://localhost/api/projects/${projectId}/conversations/${created.id}`),
db,
);
const body = (await getRes.json()) as { conversation: Conversation };
expect(body.conversation.title).not.toBe("新会话");
expect(body.conversation.title).toBe("AI总结标题");
handle.close();
} finally {
handle.cleanup();

View File

@@ -4,40 +4,46 @@ import { describe, expect, mock, test } from "bun:test";
import type { Model, RuntimeMode } from "../../../src/shared/api";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function createModelViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateModel: h } = await import("../../../src/server/routes/models/create");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
function createTestModel(db: Database, pName: string, providerId?: string): Model {
const pid = providerId ?? seedProvider(db);
const result = createModel(db, {
const result = createModel(
db,
{
capabilities: ["text"],
modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
name: pName,
providerId: pid,
});
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.model;
}
async function deleteModelViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteModel: h } = await import("../../../src/server/routes/models/delete");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function getModelViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetModel: h } = await import("../../../src/server/routes/models/get");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function listModelsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListModels: h } = await import("../../../src/server/routes/models/list");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
import { createModel } from "../../../src/server/db/models";
@@ -51,24 +57,28 @@ void mock.module("ai", () => ({
}));
function seedProvider(db: Database, name?: string): string {
const result = createProvider(db, {
const result = createProvider(
db,
{
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: name ?? "TestProvider",
type: "openai",
});
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.provider.id;
}
async function testModelViaHandler(req: Request, db: Database): Promise<Response> {
const { handleTestModelConfig: h } = await import("../../../src/server/routes/models/test");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function updateModelViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateModel: h } = await import("../../../src/server/routes/models/update");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {

View File

@@ -4,50 +4,52 @@ import { describe, expect, test } from "bun:test";
import type { Project, RuntimeMode } from "../../../src/shared/api";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function archiveProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleArchiveProject: h } = await import("../../../src/server/routes/projects/archive");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
// Inline imports for actual route handler tests (each handler is in separate file)
async function createProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateProject: h } = await import("../../../src/server/routes/projects/create");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
function createTestProject(db: Database, name = "测试项目"): Project {
const result = createProject(db, { name });
const result = createProject(db, { name }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project;
}
async function deleteProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteProject: h } = await import("../../../src/server/routes/projects/delete");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function getProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetProject: h } = await import("../../../src/server/routes/projects/get");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function listProjectsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListProjects: h } = await import("../../../src/server/routes/projects/list");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function restoreProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleRestoreProject: h } = await import("../../../src/server/routes/projects/restore");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function updateProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateProject: h } = await import("../../../src/server/routes/projects/update");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
// Need db/projects for setup
@@ -135,7 +137,7 @@ describe("项目 API 路由", () => {
test("POST /api/projects/:id/restore 恢复项目", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "恢复路由");
archiveProject(db, project.id);
archiveProject(db, project.id, LOG);
const req = new Request(`http://localhost/api/projects/${project.id}/restore`, { method: "POST" });
const res = await restoreProjectViaHandler(req, db);
@@ -148,7 +150,7 @@ describe("项目 API 路由", () => {
test("DELETE /api/projects/:id 永久删除已归档项目", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db, "删除路由");
archiveProject(db, project.id);
archiveProject(db, project.id, LOG);
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);

View File

@@ -6,9 +6,11 @@ import type { Provider, ProviderOption, RuntimeMode } from "../../../src/shared/
import { createModel } from "../../../src/server/db/models";
import { createProvider } from "../../../src/server/db/providers";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
void mock.module("ai", () => ({
createProviderRegistry: () => ({
@@ -18,48 +20,52 @@ void mock.module("ai", () => ({
async function createProviderViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateProvider: h } = await import("../../../src/server/routes/providers/create");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
function createTestProvider(db: Database, name = "测试供应商", baseUrl = "https://api.test.com/v1"): Provider {
const result = createProvider(db, {
const result = createProvider(
db,
{
apiKey: "sk-test",
baseUrl,
name,
type: "openai",
});
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.provider;
}
async function deleteProviderViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteProvider: h } = await import("../../../src/server/routes/providers/delete");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function getProviderViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetProvider: h } = await import("../../../src/server/routes/providers/get");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function listProviderOptionsViaHandler(_req: Request, db: Database): Promise<Response> {
const { handleListProviderOptions: h } = await import("../../../src/server/routes/providers/options");
return h(db, MODE);
return h(db, MODE, LOG);
}
async function listProvidersViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListProviders: h } = await import("../../../src/server/routes/providers/list");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function testProviderConfigViaHandler(req: Request, db: Database): Promise<Response> {
const { handleTestProviderConfig: h } = await import("../../../src/server/routes/providers/test");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function updateProviderViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateProvider: h } = await import("../../../src/server/routes/providers/update");
return h(req, db, MODE);
return h(req, db, MODE, LOG);
}
async function withProviderServer(
@@ -182,12 +188,16 @@ describe("供应商 API 路由", () => {
test("DELETE /api/providers/:id 存在关联模型时返回 409", async () => {
await withRouteDb(async (db) => {
const provider = createTestProvider(db, "有关联模型");
const modelResult = createModel(db, {
const modelResult = createModel(
db,
{
capabilities: ["text"],
modelId: "gpt-4o",
name: "GPT-4o",
providerId: provider.id,
});
},
LOG,
);
if ("error" in modelResult) throw new Error(modelResult.error);
const req = new Request(`http://localhost/api/providers/${provider.id}`, { method: "DELETE" });

View File

@@ -3,8 +3,6 @@
* 后端测试无需 DOM 环境,前端测试依赖 jsdom 及 antd polyfill
*/
/* eslint-disable @typescript-eslint/no-empty-function */
// 仅当前端测试需要时初始化 jsdom所有测试共享 preload后端测试也在此环境中运行
import { JSDOM } from "jsdom";

View File

@@ -0,0 +1,167 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import type { Conversation, Model } from "../../../src/shared/api";
import { ChatPanel } from "../../../src/web/consoles/workbench/components/chat/ChatPanel";
import { installFetchMock, jsonResponse, renderWithProviders } from "../test-utils";
const PROJECT_ID = "proj-1";
const TEXT_MODEL: Model = {
capabilities: ["text"],
contextLength: null,
createdAt: "2024-01-01T00:00:00.000Z",
id: "model-1",
maxOutputTokens: null,
modelId: "gpt-4o",
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const CONVERSATION: Conversation = {
createdAt: "2024-01-01T00:00:00.000Z",
id: "conv-1",
modelId: "model-1",
projectId: PROJECT_ID,
title: "新会话",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const noop = () => {
return undefined;
};
void mock.module("@ai-sdk/react", () => ({
useChat: () => ({
messages: [],
regenerate: noop,
sendMessage: noop,
setMessages: (msgs: unknown) => msgs,
status: "ready",
stop: noop,
}),
}));
void mock.module("ai", () => ({
DefaultChatTransport: function () {
return undefined;
},
}));
function getSendButton() {
return screen.getByRole("button", { name: /发.*送/ });
}
function setupFetchMock() {
return installFetchMock((call) => {
if (call.url.includes("/models")) {
return jsonResponse({ items: [TEXT_MODEL], total: 1 });
}
if (call.url.endsWith("/conversations") && call.method === "POST") {
return jsonResponse({ conversation: { ...CONVERSATION, id: "conv-new" } }, { status: 201 });
}
if (/\/conversations\/conv-1$/.exec(call.url)) {
return jsonResponse({ conversation: CONVERSATION });
}
if (call.url.includes("/messages")) {
return jsonResponse({ items: [], total: 0 });
}
if (/\/conversations$/.exec(call.url) && call.method === "GET") {
return jsonResponse({ items: [], total: 0 });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
}
describe("ChatPanel", () => {
describe("欢迎页", () => {
test("无会话时显示欢迎页和输入框", () => {
setupFetchMock();
renderWithProviders(
createElement(ChatPanel, {
conversationId: null,
onConversationCreated: noop,
projectId: PROJECT_ID,
}),
);
expect(screen.getByText("你好,我是阿福")).toBeTruthy();
expect(screen.getByText("有什么我可以帮助你的吗?")).toBeTruthy();
expect(screen.getByPlaceholderText("输入消息...")).toBeTruthy();
expect(getSendButton()).toBeTruthy();
});
});
describe("自动创建会话", () => {
test("输入并发送后自动创建会话并通知父组件", async () => {
const calls = setupFetchMock();
const onCreated = mock<(id: string) => void>(() => undefined);
renderWithProviders(
createElement(ChatPanel, {
conversationId: null,
onConversationCreated: onCreated,
projectId: PROJECT_ID,
}),
);
const input = screen.getByPlaceholderText("输入消息...");
fireEvent.change(input, { target: { value: "你好" } });
fireEvent.click(getSendButton());
await waitFor(() => {
expect(onCreated).toHaveBeenCalledWith("conv-new");
});
const createCall = calls.find((c) => c.url.endsWith("/conversations") && c.method === "POST");
expect(createCall).toBeTruthy();
});
test("创建会话失败时恢复输入文本", async () => {
installFetchMock((call) => {
if (call.url.includes("/models")) {
return jsonResponse({ items: [TEXT_MODEL], total: 1 });
}
if (call.url.endsWith("/conversations") && call.method === "POST") {
return jsonResponse({ error: "服务器错误" }, { status: 500 });
}
return jsonResponse({ error: "not found" }, { status: 404 });
});
renderWithProviders(
createElement(ChatPanel, {
conversationId: null,
onConversationCreated: noop,
projectId: PROJECT_ID,
}),
);
const input = screen.getByPlaceholderText("输入消息...");
fireEvent.change(input, { target: { value: "测试输入" } });
fireEvent.click(getSendButton());
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe("测试输入");
});
});
});
describe("聊天面板", () => {
test("选中会话时显示消息列表区域", () => {
setupFetchMock();
renderWithProviders(
createElement(ChatPanel, {
conversationId: "conv-1",
onConversationCreated: noop,
projectId: PROJECT_ID,
}),
);
expect(screen.queryByText("你好,我是阿福")).toBeNull();
expect(screen.getByPlaceholderText("输入消息...")).toBeTruthy();
});
});
});

View File

@@ -40,7 +40,7 @@ describe("ErrorBoundary", () => {
expect(screen.getByText("渲染错误")).not.toBeNull();
expect(screen.getByText("页面渲染出现异常,请刷新重试")).not.toBeNull();
expect(screen.getByRole("button", { name: "刷新页面" })).not.toBeNull();
expect(errors.some((line) => line.includes("渲染错误:"))).toBe(true);
expect(errors.some((line) => line.includes("[Alfred:ERROR] 渲染错误"))).toBe(true);
});
test("点击刷新页面按钮不会破坏错误兜底界面", () => {

View File

@@ -0,0 +1,102 @@
import { MutationCache, QueryCache, QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement, useRef } from "react";
import { useCreateProject } from "../../../src/web/hooks/use-projects";
import { installFetchMock, jsonResponse } from "../test-utils";
describe("QueryClient MutationCache onError", () => {
test("mutation 错误触发 MutationCache onError 回调", async () => {
installFetchMock(() => jsonResponse({ error: "项目名称已存在" }, { status: 409 }));
const errors: string[] = [];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
mutationCache: new MutationCache({
onError: (error: Error) => {
errors.push(error.message);
},
}),
});
function TestComponent({ onResult }: { onResult: (mutate: () => void) => void }) {
const { mutate } = useCreateProject();
const called = useRef(false);
if (!called.current) {
called.current = true;
onResult(() => {
mutate(
{ name: "test" },
{
onError: () => {},
},
);
});
}
return null;
}
render(
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(TestComponent, { onResult: (fn) => fn() }),
),
);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(errors.length).toBe(1);
expect(errors[0]).toBe("项目名称已存在");
});
});
describe("QueryClient QueryCache onError", () => {
test("query 错误触发 QueryCache onError 回调", async () => {
installFetchMock(() => new Response("broken", { status: 500 }));
const errors: string[] = [];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
queryCache: new QueryCache({
onError: (error: Error) => {
errors.push(error.message);
},
}),
});
function TestComponent({ onResult }: { onResult: (trigger: () => void) => void }) {
const called = useRef(false);
useQuery({
queryFn: () => Promise.reject(new Error("test query error")),
queryKey: ["test-query-error"],
});
if (!called.current) {
called.current = true;
onResult(() => {
// no-op trigger
});
}
return null;
}
render(
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(TestComponent, { onResult: (fn) => fn() }),
),
);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(errors.length).toBe(1);
expect(errors[0]).toBe("test query error");
});
});

View File

@@ -0,0 +1,398 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement, useRef } from "react";
import {
useCreateModel,
useDeleteModel,
useTestModelConnection,
useUpdateModel,
} from "../../../src/web/hooks/use-models";
import {
useArchiveProject,
useCreateProject,
useDeleteProject,
useRestoreProject,
useUpdateProject,
} from "../../../src/web/hooks/use-projects";
import {
useCreateProvider,
useDeleteProvider,
useTestProviderConfig,
useUpdateProvider,
} from "../../../src/web/hooks/use-providers";
import { installFetchMock, jsonResponse } from "../test-utils";
const MODEL = {
autoAdapt: true,
capabilities: ["text"] as string[],
createdAt: "2024-01-01T00:00:00.000Z",
customApiKey: null,
customBaseUrl: null,
description: "测试模型",
id: "m1",
modelId: "gpt-4",
name: "测试模型",
providerId: "prov-1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const PROJECT = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "测试",
id: "p1",
name: "测试项目",
status: "active" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
};
const PROVIDER = {
createdAt: "2024-01-01T00:00:00.000Z",
id: "prov-1",
name: "测试供应商",
type: "openai" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
};
function getLogMessages(spy: ReturnType<typeof mock>) {
return spy.mock.calls.map((c) => c[0] as string).filter((s) => s.includes("[Alfred:INFO]"));
}
function makeQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
}
function setupModelFetches(result: unknown) {
installFetchMock((call) => {
if (call.method === "DELETE") return new Response(null, { status: 204 });
if (call.url.includes("test")) return jsonResponse({ modelTestResponse: { message: "ok", ok: true } });
return jsonResponse({ model: result }, { status: 201 });
});
}
function setupProjectFetches(result: unknown) {
installFetchMock((call) => {
if (call.method === "DELETE") return new Response(null, { status: 204 });
if (call.url.includes("archive")) return jsonResponse({ project: result });
if (call.url.includes("restore")) return jsonResponse({ project: result });
return jsonResponse({ project: result }, { status: 201 });
});
}
function setupProviderFetches(result: unknown) {
installFetchMock((call) => {
if (call.method === "DELETE") return new Response(null, { status: 204 });
if (call.url.includes("test")) return jsonResponse({ providerTestResponse: { message: "ok", ok: true } });
return jsonResponse({ provider: result }, { status: 201 });
});
}
function spyConsoleLog() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spy = mock((..._args: any[]) => {});
const orig = console.log;
console.log = spy;
return { orig, restore: () => (console.log = orig), spy };
}
describe("useProjects onSuccess 日志", () => {
const qc = makeQueryClient();
test("create onSuccess", async () => {
setupProjectFetches(PROJECT);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useCreateProject();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ name: "x" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/项目创建成功/);
restore();
});
test("update onSuccess", async () => {
setupProjectFetches(PROJECT);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useUpdateProject();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ data: { name: "y" }, id: "p1" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/项目更新成功/);
restore();
});
test("delete onSuccess", async () => {
setupProjectFetches(PROJECT);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useDeleteProject();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate("p1"));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/项目删除成功/);
restore();
});
test("archive onSuccess", async () => {
setupProjectFetches(PROJECT);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useArchiveProject();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate("p1"));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/项目归档成功/);
restore();
});
test("restore onSuccess", async () => {
setupProjectFetches(PROJECT);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useRestoreProject();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate("p1"));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/项目恢复成功/);
restore();
});
});
describe("useModels onSuccess 日志", () => {
const qc = makeQueryClient();
test("create onSuccess", async () => {
setupModelFetches(MODEL);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useCreateModel();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ capabilities: ["text"], modelId: "gpt-4", name: "x", providerId: "p1" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/模型创建成功/);
restore();
});
test("update onSuccess", async () => {
setupModelFetches(MODEL);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useUpdateModel();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ data: { name: "y" }, id: "m1" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/模型更新成功/);
restore();
});
test("delete onSuccess", async () => {
setupModelFetches(MODEL);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useDeleteModel();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate("m1"));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/模型删除成功/);
restore();
});
test("test onSuccess", async () => {
setupModelFetches(MODEL);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useTestModelConnection();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ modelId: "gpt-4", providerId: "p1" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
restore();
// useTestModelConnection has no onSuccess logger
const infoCalls = spy.mock.calls.filter((c) => typeof c[0] === "string" && c[0].includes("[Alfred:INFO]"));
expect(infoCalls.length).toBe(0);
});
});
describe("useProviders onSuccess 日志", () => {
const qc = makeQueryClient();
test("create onSuccess", async () => {
setupProviderFetches(PROVIDER);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useCreateProvider();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ apiKey: "k", baseUrl: "http://x", name: "x", type: "openai" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/供应商创建成功/);
restore();
});
test("update onSuccess", async () => {
setupProviderFetches(PROVIDER);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useUpdateProvider();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ data: { name: "y" }, id: "prov-1" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/供应商更新成功/);
restore();
});
test("delete onSuccess", async () => {
setupProviderFetches(PROVIDER);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useDeleteProvider();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate("prov-1"));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
const msgs = getLogMessages(spy);
expect(msgs).toHaveLength(1);
expect(msgs[0]).toMatch(/供应商删除成功/);
restore();
});
test("test onSuccess", async () => {
setupProviderFetches(PROVIDER);
const { restore, spy } = spyConsoleLog();
function T({ onResult }: { onResult: (fn: () => void) => void }) {
const { mutate } = useTestProviderConfig();
const c = useRef(false);
if (!c.current) {
c.current = true;
onResult(() => mutate({ apiKey: "k", baseUrl: "http://x", name: "x", type: "openai" }));
}
return null;
}
render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() })));
await new Promise((r) => setTimeout(r, 200));
restore();
// useTestProviderConfig has no onSuccess logger
const infoMsgs = spy.mock.calls.filter((c) => typeof c[0] === "string" && String(c[0]).includes("[Alfred:INFO]"));
expect(infoMsgs.length).toBe(0);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import type { Logger } from "../../../src/web/utils/logger";
import { useLogger } from "../../../src/web/hooks/use-logger";
import { renderWithProviders } from "../test-utils";
function HookTester({ onMount }: { onMount: (logger: Logger) => void }) {
const logger = useLogger();
onMount(logger);
return null;
}
describe("useLogger", () => {
test("返回 Logger 实例含所有方法", () => {
let logger: Logger | undefined;
const onMount = (l: Logger) => {
logger = l;
};
renderWithProviders(createElement(HookTester, { onMount }));
expect(logger).toBeDefined();
expect(typeof logger!.debug).toBe("function");
expect(typeof logger!.info).toBe("function");
expect(typeof logger!.warn).toBe("function");
expect(typeof logger!.error).toBe("function");
expect(typeof logger!.child).toBe("function");
expect(typeof logger!.setLevel).toBe("function");
});
test("调用 logger.warn 时静默不抛异常", () => {
const warnSpy = mock(() => {});
const origWarn = console.warn;
console.warn = warnSpy;
let logger: Logger | undefined;
renderWithProviders(
createElement(HookTester, {
onMount: (l: Logger) => {
logger = l;
},
}),
);
expect(() => logger!.warn("测试警告")).not.toThrow();
console.warn = origWarn;
expect(warnSpy).toHaveBeenCalled();
});
test("调用 logger.error 时静默不抛异常", () => {
const errorSpy = mock(() => {});
const origError = console.error;
console.error = errorSpy;
let logger: Logger | undefined;
renderWithProviders(
createElement(HookTester, {
onMount: (l: Logger) => {
logger = l;
},
}),
);
expect(() => logger!.error("测试错误")).not.toThrow();
console.error = origError;
expect(errorSpy).toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More