Compare commits

...

54 Commits

Author SHA1 Message Date
12edf0b545 feat: 实现阶段二实体体系——AI预处理真实化+实体CRUD+审核归一化
- 新增 entities 数据表(含迁移)、Entity 类型、DAO 层完整 CRUD
- AI 预处理管道接入真实模型(generateText),输出结构化 JSON(摘要+规范化内容+候选实体)
- 模板接口重构为 {systemPrompt, buildUserPrompt, parseOutput},general/meeting 模板真实化
- 新增 5 个实体路由端点 + 实体管理前端页面(列表/详情/编辑弹窗)
- 审核面板增强:展示 AI 预处理结构化结果+候选实体归一化面板(合并/新建/选择/放弃)
- 素材通过时根据用户确认的候选实体写入 entities 表
- 工作台菜单新增"实体"入口
- 新增 entities DAO 测试(16)、processor 测试(11)、路由测试(8),服务端 367 测试全部通过
- TypeScript 0 错误
2026-06-08 18:49:30 +08:00
034496e946 style: 移除设置页表单最后项无底边距的冗余样式 2026-06-08 14:41:24 +08:00
d02abce58d fix: 修复测试套件质量审查问题——act环境、正则匹配、mock排序、超时设置 2026-06-08 14:13:45 +08:00
74266dc5cc style: 收集箱布局深化——卡片流、OS滚动、操作区fill按钮 2026-06-08 11:36:02 +08:00
b4e05a4a16 feat: 收集箱右侧区域布局优化——上下两段式,操作栏固定底部始终显示 2026-06-08 10:51:17 +08:00
2713897bdb feat: 更新 OpenSpec code-drive 工作流模板与 schema,同步 lint-staged 与 oxfmt 忽略规则 2026-06-08 10:06:24 +08:00
a1e2897364 chore: 更新依赖版本 2026-06-08 10:00:01 +08:00
90fdb44b20 feat: 素材处理管线——自动处理、审核流程、6状态机 2026-06-07 22:50:05 +08:00
a389888eb4 feat: 聊天室对话渲染增强 - 思考内容Markdown渲染 + 工具调用参数卡片化 2026-06-07 18:27:41 +08:00
1d82f4f961 chore: 移除 superpowers 插件,恢复 openspec 工作流 2026-06-07 13:39:58 +08:00
074ea0bb1a feat: 设置页新增模型卡片,支持为7种能力配置默认模型 2026-06-07 09:51:04 +08:00
43b14a94a3 chore: 移除 openspec,迁移项目规范到 AGENTS.md,接入 superpowers 插件 2026-06-06 23:35:02 +08:00
1f0c7608f4 feat: 合并设置页表单化与紧凑模式 2026-06-06 23:29:24 +08:00
6c4d9affae fix(settings): Radio.Group 按钮模式,help 说明,移除多余底边距 2026-06-06 23:28:59 +08:00
c0384f9a07 fix(settings): Radio.Group 添加 optionType="button" 启用按钮渲染 2026-06-06 23:12:57 +08:00
e3a9a6b47f docs: 同步设置页表单化与紧凑模式说明 2026-06-06 22:57:14 +08:00
dd2835bb94 feat(settings): 设置页改造为 Form + Radio.Group + Switch,紧凑模式开关 2026-06-06 22:55:33 +08:00
09845e0515 feat(shell): ConsoleShell 传递 compact 给 buildThemeConfig 2026-06-06 22:48:38 +08:00
e0466f9b99 feat(hooks): useThemePreference 扩展 compact 状态与同步 2026-06-06 22:46:56 +08:00
eccc3f62d2 refactor(theme): buildThemeConfig 改为对象参数,algorithm 支持紧凑模式组合 2026-06-06 22:42:18 +08:00
91ae52320b feat(settings): 扩展 SettingsData 加 compact 字段,后端解析与校验 2026-06-06 22:37:22 +08:00
f4318c7643 fix: 同步 MaterialCard 测试类名到 app-sidebar-list-item 2026-06-06 22:02:49 +08:00
b469662760 chore: jsdom 迁移至 happy-dom 2026-06-06 16:59:11 +08:00
5c0f02f1f8 refactor: waitFor→findBy 替换 + renderWithBasicProviders + jsdom 条件化回退 2026-06-06 10:15:02 +08:00
121c6f764f chore: 迁移 lint/format 工具链 ESLint+Prettier → oxlint+oxfmt 2026-06-06 00:57:55 +08:00
3f88e33bd1 feat: 全局设置系统 — settings 表、CRUD 路由、主题偏好持久化 2026-06-05 23:10:32 +08:00
e2eba6dc1f style: 调整 sidebar 边框 — sider 添加右边框,移除菜单冗余右边框 2026-06-05 18:27:20 +08:00
72a71818e7 fix: 修复 list-params 空字符串关键字处理和 useConfirmAction 测试类型错误 2026-06-05 17:31:53 +08:00
3b77041100 chore: merge dev-theme into master 2026-06-05 16:59:26 +08:00
98712cf047 fix: 消除 code-block-body 背景色与 shiki 主题背景色视觉割裂 2026-06-05 16:44:13 +08:00
85abc2a515 feat: antd 主题改造 — 启用 cssVar、纯黑白 colorPrimary、统一 sidebar/滚动条/按钮样式 2026-06-05 16:01:54 +08:00
db40d04dc5 refactor(db): 统一数据库 schema — 软删除、命名规范、约束标准化
- 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at
- models.model_id 重命名为 external_id,消除语义混淆
- conversations.model_id 改为可空(模型为建议而非绑定)
- messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联
- 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除)
- 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试
- 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role)
- DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护
- 路由/前端/测试全量适配 externalId 重命名及类型变更
2026-06-05 01:02:23 +08:00
e25b2537fd fix: 消除并发测试中的 tool 导出竞争和 SQLite 目录碰撞 2026-06-04 18:50:58 +08:00
6f547560d1 refactor: 统一管理页面布局 — FilterToolbar + usePageSearchParams + parseListParams 2026-06-04 17:25:36 +08:00
61b479e2be feat: 拆分模型/供应商为独立路由页面,侧边栏支持 SubMenu 分组 2026-06-04 11:11:32 +08:00
f67cfa84ef feat: 用自定义侧边栏替换聊天室 Conversations 组件,提取公共 SidebarGroup 和 date-group 2026-06-04 00:46:57 +08:00
dc7d9e83b8 feat: 收集箱侧边栏UI美化 — 自定义滚动条、隐藏空分组、优化列表项间距 2026-06-03 22:31:49 +08:00
525278870f style: 收集箱侧边栏对齐聊天室布局模式,按钮筛选栏区域独立padding,列表贴边 2026-06-03 21:46:44 +08:00
eb93de52d8 fix: 修正 markdown-to-jsx 导入方式 + 新增 formatDateLabel 日期工具函数
- TextPart: default import → named import
- MaterialCard: 使用 formatDateLabel 显示今天/昨天/日期
- 清理旧测试文件,新增 ResourceTable 测试
2026-06-03 21:08:00 +08:00
83cc28fe1b chore: 更新 skills-lock.json computed hashes 2026-06-03 18:59:39 +08:00
ad10134c20 chore: merge dev-chat into master
# Conflicts:
#	src/web/styles.css
2026-06-03 17:56:02 +08:00
ea9bc41e4c refactor(prompts): 移除 apply-review 中所有文档回写步骤,适配 fast-drive 单向工作流 2026-06-03 17:43:07 +08:00
a896091d27 feat: 增强 Markdown 代码块高亮和表格样式 2026-06-03 17:23:43 +08:00
1a7fd58553 feat(inbox): 侧边栏状态筛选与日期分组 — Segmented 图标筛选 + Skeleton 加载态 + 五级日期分组可折叠 + 卡片显示关联日期 2026-06-03 17:22:14 +08:00
abe30ead6a refactor(inbox): 侧边栏素材列表改为轻量 Flex 布局 — Card→Flex, 新增状态 Tag, hover 切换删除按钮, 左侧竖线选中态 2026-06-03 16:21:56 +08:00
714da2d633 feat: 聊天侧边栏新对话按钮统一为 antd Button 样式 2026-06-03 15:17:05 +08:00
21b557c255 feat(inbox): 素材持久化 CRUD — 数据库表 + API + 前端接入
- 新增 materials 表(id/projectId/description/associatedDate/status/createdAt/updatedAt)
- 新增 4 个后端 API 路由(list/create/get/delete)+ 13 个测试
- 新增 use-materials hooks(TanStack Query)
- 收集箱页面重构为三层架构(InboxPage + MaterialSidebar + MaterialDetailPanel)
- MaterialCard: Popconfirm 删除确认 + 粗粒度时间格式
- MaterialContent: 展示状态标签 + createdAt
- 更新开发文档 backend.md / frontend.md
2026-06-03 14:53:23 +08:00
02a202290f refactor: 替换 Markdown 渲染组件为 markdown-to-jsx 2026-06-03 13:13:04 +08:00
5b09a16bc3 refactor(web): React 最佳实践优化 — memo/callback + 目录边界 + 路由增强
- useLogger: useMemo + JSON.stringify 替代 useState 派生
- useIsDark: effectiveTheme 替代 token 色值比较
- useCurrentProject: layouts/ 提升到 shared/hooks/
- ConsoleShell: locale useMemo 缓存
- ConsoleOutlet: 添加 Suspense 边界
- routes: 添加 layout 级 errorElement
- Table 组件: operationColumn useMemo + useCallback
- ChatPanel: footer 合并为 useCallback, props 传入模型数据
- ChatPage: textModels/conversations useMemo 缓存
2026-06-03 11:32:28 +08:00
297293cb61 chore: 移除 .pi 和 .claude 目录及其 gitignore 规则 2026-06-03 09:46:36 +08:00
2cdbe474ce feat(workbench): 新增收集箱页面 — 素材列表/详情分栏布局 + 新增/选中/删除 mock 交互 2026-06-03 08:36:38 +08:00
83349bf01b chore: 移除 context.md 进度跟踪文件 2026-06-02 23:36:19 +08:00
b1dec691e9 refactor(web): 前端目录重构 — consoles/pages → layouts/features + shared
- consoles/admin/ → layouts/admin-layout/
- consoles/workbench/ → layouts/workbench-layout/ + features/chat/
- pages/ → features/ (dashboard, models, projects, not-found)
- components/ → shared/components/
- hooks/ → shared/hooks/
- utils/ → shared/utils/
- 更新所有 import 路径 (src/web/ + tests/web/)
- 更新开发文档 (README.md, frontend.md, architecture.md)
2026-06-02 23:17:28 +08:00
1f05f259d0 fix(chat): 修复暗黑模式下 Markdown 和滚动条样式 — 响应式 useIsDark hook + 动态主题切换 2026-06-02 22:44:46 +08:00
258 changed files with 16084 additions and 4839 deletions

7
.gitignore vendored
View File

@@ -399,13 +399,7 @@ env/
cython_debug/
# Custom
.claude/*
!.claude/settings.json
.opencode
.codex
.pi/*
!.pi/mcp.json
!.pi/extensions
openspec/changes/archive
temp
.agents
@@ -416,6 +410,7 @@ backend/bin
backend/server
backend/desktop
!src/**/*
docs/superpowers
# Embedfs generated
embedfs/assets/

View File

@@ -1,4 +1,5 @@
{
"*.{ts,tsx}": ["eslint --fix"],
"*.{md,json,yaml,yml}": ["prettier --write"]
"*.{ts,tsx,js,jsx}": ["oxlint --fix", "oxfmt"],
"*.{md,json,yaml,yml}": ["oxfmt"],
"!openspec/**": []
}

18
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"tabWidth": 2,
"useTabs": false,
"sortPackageJson": false,
"sortImports": {
"partitionByNewline": true,
"newlinesBetween": false
},
"ignorePatterns": ["openspec/**", "bun.lock", "bin/**", "eslint-rules/**", "skills-lock.json"]
}

274
.oxlintrc.json Normal file
View File

@@ -0,0 +1,274 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"jsPlugins": ["./eslint-rules/local-rules.js"],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true
},
"env": {
"builtin": true,
"es2018": true
},
"ignorePatterns": ["openspec/**", "bun.lock", "bin/**", "eslint-rules/**", "skills-lock.json"],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"import/namespace": "error",
"import/default": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"typescript/await-thenable": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-array-delete": "error",
"typescript/no-base-to-string": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "error",
"typescript/no-for-in-array": "error",
"typescript/no-implied-eval": "error",
"typescript/no-misused-new": "error",
"typescript/no-misused-promises": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-redundant-type-constituents": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-enum-comparison": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/only-throw-error": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/prefer-promise-reject-errors": "error",
"typescript/require-await": "error",
"typescript/restrict-plus-operands": "error",
"typescript/restrict-template-expressions": "error",
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/array-type": [
"error",
{
"default": "array-simple"
}
],
"typescript/ban-tslint-comment": "error",
"typescript/class-literal-property-style": "error",
"typescript/consistent-generic-constructors": "error",
"typescript/consistent-indexed-object-style": "error",
"typescript/consistent-type-assertions": [
"error",
{
"assertionStyle": "as"
}
],
"typescript/consistent-type-definitions": "error",
"typescript/dot-notation": "error",
"typescript/no-confusing-non-null-assertion": "error",
"typescript/no-inferrable-types": "error",
"typescript/non-nullable-type-assertion-style": "error",
"typescript/prefer-find": "error",
"typescript/prefer-for-of": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-nullish-coalescing": "error",
"typescript/prefer-regexp-exec": "error",
"typescript/prefer-string-starts-ends-with": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports"
}
]
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": ["src/**/*.{ts,tsx}"],
"rules": {
"local-rules/enforce-catch-type": "warn",
"local-rules/no-empty-function": "error"
}
},
{
"files": ["src/server/db/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"importNames": ["sqliteTable"],
"message": "请从 ./helpers.ts 导入 sqliteTable并在列定义中展开 baseColumns。参见 src/server/db/helpers.ts。",
"name": "drizzle-orm/sqlite-core"
}
]
}
]
}
},
{
"files": ["src/server/db/helpers.ts"],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["src/server/**/*.ts"],
"rules": {
"no-console": "error"
}
},
{
"files": ["src/server/logger.ts"],
"rules": {
"no-console": "off"
}
},
{
"files": ["src/web/**/*.{ts,tsx}"],
"rules": {
"no-console": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"../server/*",
"../server/**",
"../**/server/*",
"../**/server/**",
"../../server/*",
"../../server/**",
"src/server/*",
"src/server/**"
],
"message": "前端不得导入 src/server 后端运行时实现;请改用 src/shared 类型或 HTTP API。"
}
]
}
],
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"react/only-export-components": [
"warn",
{
"allowConstantExport": true
}
]
},
"plugins": ["react"]
},
{
"files": ["src/web/**/logger.ts"],
"rules": {
"no-console": "off"
}
}
]
}

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
"permission": {
"*": "allow",
"write": "allow",
"edit": "allow",
"bash": {
"*": "allow",
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",
"pnpx *": "deny"
},
"external_directory": {
"*": "ask",
"/tmp/*": "allow"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

View File

@@ -1,13 +0,0 @@
node_modules/
dist/
.build/
*.bun-build
openspec/
bun.lock
.opencode/
.claude/
.codex/
.agents/
skills-lock.json
data/
probe-config.schema.json

View File

@@ -1,11 +0,0 @@
{
"printWidth": 120,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"tabWidth": 2,
"useTabs": false
}

View File

@@ -6,5 +6,11 @@
"files.eol": "\n",
"files.encoding": "utf8",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true
"files.trimTrailingWhitespace": true,
"[javascript][typescript][javascriptreact][typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc"
},
"eslint.enable": false
}

View File

@@ -1 +1,32 @@
严格遵守openspec/config.yaml中context声明的项目规范
## 项目概览
- 本项目为 Bun 全栈应用Alfred·阿福Bun 是唯一包管理器和运行时,严禁使用 npm、pnpm、yarn、npx、pnpx
- 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 负责最终结论:去重、交叉验证、合并同根因问题
- 优先使用提问工具对用户确认

View File

@@ -1 +0,0 @@
严格遵守openspec/config.yaml中context声明的项目规范

1200
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
[test]
preload = ["./tests/setup.ts"]
preload = ["./tests/happydom.ts", "./tests/setup.ts"]
exclude = ["./tests/e2e/**"]

View File

@@ -1,129 +0,0 @@
# add-model-management 当前进度
## 变更信息
- **Change**: `openspec/changes/add-model-management/`
- **Workflow**: fast-drive (design.md + tasks.md)
- **状态**: apply-review 修复进行中,有一个阻塞问题
## 已完成工作
### 核心功能54/54 tasks 全部标记 [x]
所有代码已实现并通过验证:
- DB Schema: providers + models 表(含 FK、唯一索引
- 数据访问层: `src/server/db/providers.ts`, `src/server/db/models.ts`
- 后端路由: 15 个路由providers 8 + models 7注册在 `src/server/server.ts`
- AI 服务层: `src/server/ai/registry.ts`(含 `testProviderConnection` + `buildProviderRegistry`, `src/server/ai/types.ts`
- 共享类型: `src/shared/api.ts`(含 `MODEL_CAPABILITIES` 常量)
- 前端 Hooks: `src/web/hooks/use-providers.ts`, `src/web/hooks/use-models.ts`
- 前端页面: `src/web/pages/models/`6 个组件 + index
- 前端路由+菜单: `src/web/routes.tsx`, `src/web/consoles/admin/menu.tsx`
- 测试: 10 个测试文件66 个测试用例)
- 文档: backend.md, frontend.md, architecture.md, README.md 已更新
### apply-review 修复(进行中)
已完成的修复:
-**registry.ts 补充 `buildProviderRegistry`**: 新增从 DB 查询启用供应商构建 AI SDK Provider Registry 的函数
-**Q1 统一错误响应**: `providers/get.ts``models/get.ts` 改用 `createApiError()`
-**Q2 提取共享常量**: `MODEL_CAPABILITIES``shared/api.ts` 导出,`models/create.ts``models/update.ts` 不再重复定义
-**Q3 清理重复测试**: `tests/web/hooks/use-models.test.ts` 移除了残留的 provider 相关测试
-**文档修正**: `docs/development/backend.md``buildProviderRegistry` 签名已更新为 `(db)` 而非 `(config)`
-**design.md + tasks.md 更新**: tasks 6.1 和 design 执行计划第 12 项已反映 registry 完整范围
## 阻塞问题
### `bun test` 无法解析 `createProviderRegistry`
**现象**: 运行 `bun test tests/server/ai/registry.test.ts` 时报错:
```
SyntaxError: Export named 'createProviderRegistry' not found in module '...\node_modules\ai\dist\index.mjs'
```
**已确认**:
- `createProviderRegistry``node_modules/ai/dist/index.mjs` 中存在
- `bun -e "import { createProviderRegistry } from 'ai'; ..."` 正常工作
- `bun run typecheck` 通过(类型存在)
- 问题仅出现在 `bun test` 环境
- 此前 registry.ts 只导入 `generateText` 时测试正常;添加 `createProviderRegistry` 后全部 registry 测试失败
**可能原因**: Bun 1.3.14 的 `bun test` ESM 模块解析缓存问题,或 `mock.module("ai", ...)` 与静态导入 `createProviderRegistry` 的交互问题
**建议尝试方向**:
1. 清除 Bun 缓存: `rm -rf node_modules/.cache`
2. 升级 Bun 版本
3. 改用动态导入 `const { createProviderRegistry } = await import("ai")``buildProviderRegistry` 函数内部
4.`buildProviderRegistry` 的测试改为不 mock `ai` 模块(因为 `createProviderRegistry` 不需要 mock只有 `generateText` 需要)
5. 将 registry 拆为两个文件:`connection-test.ts`mock generateText`registry.ts`(不 mock
## 质量状态
- `bun run typecheck`: ✅ 0 errors
- `bun run lint`: ✅ 0 errors修复后
- 模型管理相关测试: ❌ 62/66 pass4 个 registry 测试因上述问题失败,其他 62 个全部通过)
- 已有 projects.test.tsx 有一个预存超时问题(与本次变更无关)
## 文件清单
### 新增文件untracked
```
drizzle/0001_wooden_rocket_raccoon.sql
drizzle/meta/0001_snapshot.json
src/server/ai/registry.ts
src/server/ai/types.ts
src/server/db/models.ts
src/server/db/providers.ts
src/server/routes/models/{create,delete,disable,enable,get,list,update}.ts
src/server/routes/providers/{create,delete,disable,enable,get,list,test,update}.ts
src/web/hooks/use-models.ts
src/web/hooks/use-providers.ts
src/web/pages/models/index.tsx
src/web/pages/models/components/{ModelFormModal,ModelTable,ModelToolbar,ProviderFormModal,ProviderTable,ProviderToolbar}.tsx
tests/server/ai/registry.test.ts
tests/server/db/models.test.ts
tests/server/db/providers.test.ts
tests/server/routes/models.test.ts
tests/server/routes/providers.test.ts
tests/web/components/ModelTable.test.tsx
tests/web/components/ProviderTable.test.tsx
tests/web/hooks/use-models.test.ts
tests/web/hooks/use-providers.test.ts
tests/web/routes/models.test.tsx
```
### 修改文件modified
```
bun.lock
docs/development/README.md
docs/development/architecture.md
docs/development/backend.md
docs/development/frontend.md
drizzle/meta/_journal.json
package.json
src/server/db/schema.ts
src/server/server.ts
src/shared/api.ts
src/web/consoles/admin/menu.tsx
src/web/routes.tsx
```
### OpenSpec 变更文档
```
openspec/changes/add-model-management/design.md
openspec/changes/add-model-management/tasks.md
```
## 后续步骤
1. 解决 `bun test``createProviderRegistry` 的兼容问题
2. 确保所有 66+ 测试通过
3. 归档变更(`/opsx-archive`

View File

@@ -5,14 +5,15 @@
## 目录索引
```text
docs/
README.md
development/
docs/
README.md
architecture.md
backend.md
frontend.md
release.md
development/
README.md
architecture.md
backend.md
crud.md
frontend.md
release.md
user/
README.md
usage.md
@@ -39,18 +40,18 @@ docs/
## 按任务阅读路径
| 任务 | 必读文档 |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
| 任务 | 必读文档 |
| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
| 修改后端 API、配置加载、日志 | [开发文档](development/README.md)、[后端开发](development/backend.md) |
| 修改前端 CRUD 管理页面 | [开发文档](development/README.md)、[前端开发](development/frontend.md)、[CRUD 模式](development/crud.md) |
| 修改构建、脚本、发布 | [构建与发布](development/release.md)、[部署文档](user/deploy.md) |
| 修改配置 schema | [配置文件](user/config.md)、[后端开发](development/backend.md) |
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 首次安装或配置 | [快速开始](user/usage.md)、[配置文件](user/config.md) |
| 排查运行或构建问题 | [故障排查](user/troubleshoot.md) |
## 文档归属矩阵
@@ -63,6 +64,7 @@ docs/
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
| 后端模块 API、工具函数索引、数据库 schema、AI 层实现 | `docs/development/backend.md` |
| 前端运行时代码结构、组件索引、页面组成、hooks/工具清单 | `docs/development/frontend.md` |
| 管理页面 CRUD 模式筛选工具条、URL 同步、分页排序约定) | `docs/development/crud.md` |
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
| 快速开始、安装配置 | `docs/user/usage.md` |
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |

View File

@@ -37,20 +37,127 @@ AI 工具必须严格遵守以下全部约束。
### 目录边界
| 目录 | 约束 |
| ------------------------ | -------------------------------------------- |
| `src/server/` | 后端,禁止 import src/web/ |
| `src/server/db/` | 数据库层schema、connection、migration、DAO |
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
| `src/server/config/` | 配置子系统types、variables、issues、schema |
| `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/server/` | 后端,禁止 import src/web/ |
| `src/server/db/` | 数据库层schema、connection、migration、DAO |
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
| `src/server/config/` | 配置子系统types、variables、issues、schema |
| `src/server/helpers/` | 跨路由工具响应格式化、URL 拼接 |
| `src/server/middleware/` | 参数校验 + 错误处理中间件 |
| `src/web/` | 前端,禁止 import src/server/ 运行时实现 |
| `src/web/layouts/` | 布局组件AdminLayout / WorkbenchLayout |
| `src/web/features/` | 功能模块dashboard / projects / models / chat 等) |
| `src/web/shared/` | 前端共享代码components / hooks / utils / types |
| `src/shared/` | 前后端共享类型api.ts和常量app.ts |
| `scripts/` | 独立脚本,可 import 项目源码 |
| `drizzle/` | SQL migration 文件(开发期产出) |
| `tests/` | 测试目录,镜像 src/ 结构 |
### 前端目录使用规范
#### 目录职责定义
```
src/web/
├── main.tsx ← 入口Provider 装配、全局初始化)
├── app.tsx ← App 组件(路由挂载)
├── routes.tsx ← 全局路由表(集中声明)
├── styles.css ← 全局样式
├── menu.tsx ← 菜单配置类型定义
├── layouts/ ← 布局组件
│ ├── admin-layout/ ← Admin 布局 + 侧边栏 + 菜单配置
│ └── workbench-layout/ ← Workbench 布局 + 项目上下文 + 入口守卫
├── features/ ← 功能模块(核心目录)
│ ├── dashboard/ ← 仪表盘页面 + 私有组件/hooks
│ ├── projects/ ← 项目管理页面 + 私有组件/hooks
│ ├── models/ ← 模型管理页面 + 私有组件/hooks
│ ├── chat/ ← 聊天页面 + 私有组件/hooks
│ └── <新增功能>/ ← 新功能直接新增目录
├── shared/ ← 跨 feature 共享代码
│ ├── components/ ← 通用 UI 组件ErrorBoundary, Sidebar, ConsoleShell 等)
│ ├── hooks/ ← 通用 hooksuse-logger, use-theme-preference, use-sidebar-collapsed 等)
│ ├── utils/ ← 通用工具函数api, logger, time
│ └── types.ts ← 前端内部共享类型
└── css.d.ts ← CSS 模块类型声明
```
#### 依赖规则
```text
layouts/ → shared/ ✅ 布局可使用共享代码
features/* → shared/ ✅ 功能模块可使用共享代码
features/* → layouts/ ❌ 功能模块不依赖布局(路由表负责组合)
features/A → features/B ❌ 功能模块间禁止直接依赖
shared/ → features/* ❌ 共享代码不依赖功能模块
```
#### feature 内部组织
每个 feature 目录自治,不强制统一内部结构,按需组织以下子目录:
```text
features/<name>/
├── index.tsx ← 页面组件(必须,路由入口)
├── components/ ← 私有 UI 组件
├── hooks/ ← 私有 hooks如 use-conversations 仅 chat 使用)
├── utils/ ← 私有工具函数
└── types.ts ← 私有类型定义
```
- 只有 `index.tsx`(页面组件)是必需的,其他按实际复杂度按需创建。
- feature 内部文件只在本 feature 内导入。被外部使用的必须提升到 `shared/`
#### 组件归属判定规则
| 判定维度 | 放在 feature/ | 放在 shared/ |
| -------- | -------------------------------------------------- | -------------------------------------------- |
| 使用范围 | 仅一个功能模块使用 | 两个及以上功能模块使用 |
| 业务耦合 | 包含特定业务逻辑(如项目 CRUD 表单、聊天消息渲染) | 纯展示或通用交互(如 ErrorBoundary、侧边栏 |
| 数据依赖 | 依赖特定 API 或业务数据 | 无业务数据依赖或通过 props 注入 |
| 可替换性 | 替换需理解业务上下文 | 可直接复用于任何页面 |
#### 组件升降级流程
**升级feature → shared** 当一个 feature 内的组件/hook/tool 同时满足以下条件时,应提升到 `shared/`
1. 至少被 2 个不同的 feature 或 layout 使用
2. 已消除对原 feature 业务逻辑的直接依赖(数据通过 props/callback 注入)
3. 有清晰的 props 接口定义
升级步骤:
1. 将文件从 `features/<name>/` 移动到 `shared/` 对应子目录
2. 更新所有 import 路径
3. 如原 feature 有对应的测试文件,一并迁移(`tests/web/shared/`
4. 运行 `bun run check` 确认无遗漏
**降级shared → feature** 当一个 shared 组件/hook/tool 仅被一个 feature 使用时,应降级到该 feature 内部:
1. 确认仅一个消费方(全局搜索 import
2. 移动到消费方 feature 的对应子目录
3. 更新 import 路径
4. 迁移对应测试文件
5. 运行 `bun run check` 确认无遗漏
#### 新增功能开发检查清单
新增功能模块时按以下顺序操作:
1.`features/` 下创建以功能名命名的目录kebab-case
2. 创建 `index.tsx` 作为页面组件入口
3.`routes.tsx` 中注册路由,选择对应 layout 包裹
4. 如需布局内菜单项,更新对应 layout 的菜单配置
5. 组件/hooks/utils 先写在 feature 内部
6. 当确认需要跨 feature 复用时,按升级流程提升到 `shared/`
7. 测试文件创建在 `tests/web/features/<name>/`
#### 禁止事项
- 禁止在 feature 目录外直接创建页面组件(`pages/` 目录不再使用)
- 禁止 feature 间通过 `../features/other-feature/` 直接导入
- 禁止在 shared/ 中放置仅单个 feature 使用的代码
- 禁止跳过升降级流程直接在 shared/ 中新建"预判通用"的代码(先写 feature确认复用后再提升
### 类型与配置
@@ -110,6 +217,9 @@ AI 工具必须严格遵守以下全部约束。
- 输入输出类型来自 `src/shared/api.ts`
- 列表查询使用 `paginateQuery()`,不重复实现分页。
- 列名 snake_caseTS 类型 camelCaseDrizzle schema 映射。
- 软删除:所有业务表使用 `deleted_at` 列,通过 `notDeleted(table)``paginateQuery({ softDelete })` 过滤。`deleted_at` 不暴露到 API 层。
- 唯一性:无数据库级 UNIQUE 约束DAO 层应用校验(同字段 + `deleted_at IS NULL`)。
- 表定义:通过 `helpers.ts``baseColumns` 展开 id/created_at/updated_at/deleted_at禁止直接 `sqliteTable()`oxlint 强制)。
### AI 调用层
@@ -141,10 +251,17 @@ AI 工具必须严格遵守以下全部约束。
2. antd 布局组件Layout、Space、Flex
3. antd theme token + CSS 变量
4. TanStack Query + useState
5. 已有 hooks`use-*.ts`)和工具函数(`utils/`
6. CSS Modules就近放置
5. 已有 hooks`shared/hooks/use-*.ts`)和工具函数(`shared/utils/`
6. CSS Modules就近放置在 feature 内部
7. 引入新依赖(需说明原因)
### 前端代码组织
- 新增页面在 `features/` 下创建功能目录,不使用 `pages/`
- 新增组件/hook/tool 默认放在所属 feature 内部;跨 feature 复用时提升到 `shared/`
- 布局组件放 `layouts/`,布局与页面通过 `routes.tsx` 组合,不互相导入。
- 详细规则见上方「前端目录使用规范」章节。
### 样式红线
- 严禁内联 `style`、覆盖 `.ant-*``!important`、硬编码色值。
@@ -176,7 +293,7 @@ AI 工具必须严格遵守以下全部约束。
### 前端
- 目录 `tests/web/`,结构对应 `src/web/`
-jsdom + `@testing-library/react` 测试用户行为,断言基于可见文本/role/按钮。
-happy-dom + `@testing-library/react` 测试用户行为,断言基于可见文本/role/按钮。
- 系统边界复用 `tests/web/test-utils.tsx`
- 数据页面覆盖:请求参数、成功可见结果、关键错误路径。
- ErrorBoundary/hooks/fetch helper 用单元测试覆盖异常,页面测试只保留用户路径。
@@ -196,8 +313,8 @@ AI 工具必须严格遵守以下全部约束。
| `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 lint` | oxlint 检查(--deny-warnings |
| `bun run format` | oxfmt 格式化 |
| `bun test` | 运行全部测试 |
| `bun run clean` | 清理构建缓存 |
| `bun run version:patch` | 升迁 patch 版本 |

View File

@@ -31,32 +31,45 @@ Request -> Bun.serve routes 声明式匹配 -> routes/*.ts handler -> helpers/
- 共享类型在 `src/shared/`
- 前端禁止 import `src/server/` 运行时实现;后端禁止依赖 `src/web/` 运行时代码HTML import 集成除外)。
## 配置定位
| 配置类型 | 来源 | 内容 | 可变性 |
| -------- | ------------------------------------------------------------------------ | ------------------------------ | ---------- |
| 启动配置 | CLI 传入的 `config.yaml` | 监听地址、端口、日志、数据目录 | 进程内只读 |
| 业务设置 | 管理台 `/settings` 页面 → `GET/PUT /api/settings` → SQLite `settings` 表 | 主题偏好等 UI/业务开关 | 运行时可变 |
启动配置由 `src/server/config.ts``loadServerConfig()` 在启动时加载并校验,运行时不可更改。业务设置通过独立 API 持久化,与启动配置在存储和生命周期上完全解耦。
## 主要模块
| 模块 | 职责 |
| ------------------------- | -------------------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化、shutdown 编排 |
| `src/server/config.ts` | 配置加载入口YAML 解析、规范化、契约校验、运行时校验 |
| `src/server/config/` | 配置子系统types、variables、issues、normalizer、schema |
| `src/server/logger.ts` | Logger 接口 + Pino 实现 + 测试替身 |
| `src/server/server.ts` | Bun HTTP server + routes 注册 |
| `src/server/routes/` | API handler按资源端点拆分 |
| `src/server/db/` | SQLite 连接、schema、migration、data access |
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
| `src/server/helpers/` | 响应格式化、URL 工具 |
| `src/server/middleware/` | 参数校验 + 错误处理 |
| `src/shared/api.ts` | 前后端共享 API 类型 |
| `src/shared/app.ts` | 应用全局常量 |
| 模块 | 职责 |
| ------------------------- | --------------------------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化、shutdown 编排 |
| `src/server/config.ts` | 配置加载入口YAML 解析、规范化、契约校验、运行时校验 |
| `src/server/config/` | 配置子系统types、variables、issues、normalizer、schema |
| `src/server/logger.ts` | Logger 接口 + Pino 实现 + 测试替身 |
| `src/server/server.ts` | Bun HTTP server + routes 注册 |
| `src/server/routes/` | API handler按资源端点拆分 |
| `src/server/db/` | SQLite 连接、schema、migration、data access |
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
| `src/server/helpers/` | 响应格式化、URL 工具 |
| `src/server/middleware/` | 参数校验 + 错误处理 |
| `src/web/layouts/` | 前端布局组件AdminLayout / WorkbenchLayout |
| `src/web/features/` | 前端功能模块dashboard / projects / models / chat / settings |
| `src/web/shared/` | 前端共享代码components / hooks / utils |
| `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/` |
| 资源 | 路径前缀 | 文件目录 |
| --------- | ----------------------------------------------- | -------------------- |
| 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/` |
| settings | `/api/settings` | `routes/settings.ts` |
## 更新触发条件

View File

@@ -8,6 +8,8 @@
- `response.ts``createApiError(error, status)``createHeaders(mode, init)``createMetaResponse(version)``formatDuration(ms)``jsonResponse(body, options)`
- `url.ts``parseIdFromUrl(url)`
- `list-params.ts``parseListParams(url, mode, options?)` — 统一校验分页/排序参数,替代 validatePagination
- `pagination.ts``paginateQuery()` — Drizzle 分页查询封装
`src/server/middleware/`:
@@ -18,9 +20,14 @@
SQLite + bun:sqlite + Drizzle ORM。
- `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 表结构,列名 snake_caseTS 类型 camelCase。所有业务表通过 `helpers.ts``baseColumns` 获取 id/created_at/updated_at/deleted_at。
- `src/server/db/helpers.ts``baseColumns` 常量id、createdAt、updatedAt、deletedAt+ Drizzle 构建器再导出。`src/server/db/` 内禁止直接从 `drizzle-orm/sqlite-core` 导入 `sqliteTable`ESLint 强制)
- `src/server/db/connection.ts``createDatabase(dataDir, logger)` 打开 `alfred.db`PRAGMAforeign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转 Drizzle 实例(`DrizzleDB` 类型)。工具函数:`timestamp()``notDeleted(table)``softDeleteRecord(db, table, id)``paginateQuery()`(支持 `softDelete` 参数自动过滤已删除行)
- Migration开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/backups/`,事务中执行(迁移期间临时关闭外键检查),失败回滚。
### 软删除
所有业务表projects、providers、models、conversations、materials、messages使用 `deleted_at` 列实现软删除,不暴露给 API 层。DAO 查询通过 `notDeleted(table)``paginateQuery({ softDelete })` 自动过滤已删除行。唯一性校验在应用层完成(同名 + `deleted_at IS NULL`),无数据库级 UNIQUE 约束。级联软删除:删除项目 → 级联软删除会话(→ 消息)+ 素材;删除会话 → 级联软删除消息;删除供应商 → 需无未删除模型。
### 数据访问函数
@@ -30,12 +37,13 @@ SQLite + bun:sqlite + Drizzle ORM。
| `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider |
| `models.ts` | createModel、getModel、listModels、getModelWithProvider、getModelsByProviderId、updateModel、deleteModel |
| `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages |
| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial、approveMaterial、discardMaterial、retryMaterial |
输入输出类型来自 `src/shared/api.ts`
## AI 服务层
- `src/server/ai/types.ts``AIProviderConfig`name、type、baseUrl、apiKey`AIModelConfig`providerId、modelId、capabilities
- `src/server/ai/types.ts``AIProviderConfig`name、type、baseUrl、apiKey`AIModelConfig`providerId、modelId、capabilitiesAI 层 `modelId` 对应 DB 层 `Model.externalId`
- `src/server/ai/registry.ts`
- `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry每次调用重建不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。
- `testProviderConnection(config, logger)` — 测试 Base URL 可达性 + `/models` 接口
@@ -54,7 +62,35 @@ SQLite + bun:sqlite + Drizzle ORM。
### 连通性测试
- `POST /api/providers/test` — 用未保存配置测试,不写入 DB不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false``/models` 不支持返回 `ok: true` + 提示。
- `POST /api/models/test` — 用模型关联供应商 + modelId 测试。
- `POST /api/models/test` — 用模型关联供应商 + externalId 测试。
## 素材 API
| 方法 | 路径 | 说明 |
| ------ | ------------------------------------------ | ------------------------------- |
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页+状态筛选) |
| POST | `/api/projects/:id/materials` | 创建素材 |
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(软删除) |
| POST | `/api/projects/:id/materials/:mid/approve` | 审核通过(需 review 状态) |
| POST | `/api/projects/:id/materials/:mid/discard` | 审核放弃(需 review 状态) |
| POST | `/api/projects/:id/materials/:mid/retry` | 重试处理(需 failed 状态) |
素材状态流转:`pending → processing → review → approved/discarded`,失败分支 `processing → failed`,用户重试 `failed → pending`。共 6 种状态:`pending``processing``review``approved``discarded``failed`
素材类型:`general`(通用)和 `meeting`(会议),创建时可选,默认 `general`
校验description 必填非空associatedDate 必填 YYYY-MM-DD项目须存在且 active 且未删除,素材归属校验不匹配返回 403。processing 状态禁止删除409。approve/discard 仅限 review 状态409retry 仅限 failed 状态409
## 素材处理引擎
`src/server/processing/`
- `processor.ts``MaterialProcessor` 类 — 后台定时扫描 pending 素材,按 FIFO 顺序处理(每 5 秒扫描一次),每次处理最多重试 3 次,成功后 status=review 并设置 processedContent全部失败后 status=failed。启动时自动恢复所有 processing 状态的素材为 pending`recoverStuckMaterials`)。
- `templates/`:处理模板目录 — `general.ts`(通用模板)和 `meeting.ts`(会议模板),当前为占位实现(原样回显 description`index.ts` 导出 `ProcessingTemplate` 类型和 `getTemplate(type)` 映射函数。
- `index.ts``startMaterialProcessor(db, logger)` — 工厂函数,创建并启动处理器实例。
处理器在 `bootstrap.ts` 中启动shutdown 时先停止处理器再关闭数据库。
## 聊天 API
@@ -85,4 +121,4 @@ SQLite + bun:sqlite + Drizzle ORM。
## 更新触发条件
修改后端模块 API、共享工具、数据库 schema、AI 服务层聊天 API 时,必须更新本文档。
修改后端模块 API、共享工具、数据库 schema、AI 服务层聊天 API 或列表查询参数解析时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。

167
docs/development/crud.md Normal file
View File

@@ -0,0 +1,167 @@
# CRUD 管理页面模式
管理类页面(项目管理、模型管理、供应商管理)遵循统一的 CRUD 模式。本文档描述该模式的后端和前端约定。
## 背景
三个管理页面原本各自实现了工具条、筛选、分页、搜索等逻辑,代码重复且交互不一致。统一后所有管理页面使用相同的基础设施。
## 页面结构
每个管理页面的组件结构:
```text
<Space className="app-page-flex" orientation="vertical" size="large">
<FilterToolbar /> ← 筛选 + 搜索 + 操作按钮
<EntityTable /> ← 数据表格(分页 + 排序 + 行操作)
<EntityFormModal /> ← 创建/编辑弹窗
</Space>
```
## 前端基础设施
### FilterToolbar
`src/web/shared/components/FilterToolbar.tsx` — 统一筛选工具条。
```tsx
interface FilterConfig {
key: string;
label: string;
placeholder: string;
options: Array<{ label: string; value: string }>;
value?: string; // 受控值
onChange: (value: string | undefined) => void;
}
interface SearchConfig {
placeholder: string;
keyword?: string; // 受控关键词(来自 URL
onSearch: (value: string) => void; // 点击搜索按钮
onReset: () => void; // 点击重置按钮
}
interface FilterToolbarProps {
filters?: FilterConfig[]; // 左侧筛选下拉框
search?: SearchConfig; // 搜索输入框 + 重置按钮
actions?: ReactNode; // 右侧操作区(如"新建"按钮)
}
```
**布局**:左侧 = 筛选 Select + 搜索框SearchOutlined 按钮) + 重置按钮UndoOutlined右侧 = actions 插槽。
**交互约定**
- 筛选下拉框:选中即过滤,调用 `onChange`(实时过滤)
- 搜索框:输入文本,点击搜索按钮或 Enter 触发 `onSearch`(点击搜索)
- 重置按钮:调用 `onReset` 清除全部筛选条件
### usePageSearchParams
`src/web/shared/hooks/usePageSearchParams.ts` — 将页面筛选/分页/排序状态同步到 URL 查询参数。
```tsx
const { params, setParams, resetAll } = usePageSearchParams({
defaults: { page: "1", pageSize: "20" },
});
```
- `params``Record<string, string>`,读取当前 URL 参数(含默认值)
- `setParams(patch)`:批量更新多个参数(推荐),内部使用函数式更新器 + `replace: true`
- `setParam(key, value)`:更新单个参数(**注意**:连续多次 `setParam` 会导致闭包快照覆盖,多参数更新必须使用 `setParams`
- `resetAll()`:清空所有 URL 参数
- 默认值(`defaults`)不出现在 URL 中
**核心约束**react-router 的 `setSearchParams` 函数式更新器使用 `useCallback` 闭包捕获的 `searchParams` 快照,连续调用时第二次的 `prev` 还是第一次更新前的旧值。多参数同时更新必须使用单次 `setParams({...})` 批量操作。
### useConfirmAction
`src/web/shared/hooks/useConfirmAction.ts` — 包装异步操作,提供成功/失败 toast 通知。
```tsx
const { confirmAction } = useConfirmAction();
confirmAction(() => deleteMutation.mutateAsync(id), "删除成功");
```
## 后端基础设施
### parseListParams
`src/server/helpers/list-params.ts` — 统一解析列表请求参数。
```ts
export interface ParsedListParams {
keyword?: string;
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: SortOrder;
}
export function parseListParams(
url: URL,
mode: RuntimeMode,
options?: { allowedSortBy?: string[] },
): ParsedListParams | Response;
```
- 校验 page正整数、pageSize正整数最大 200
- 校验 sortBy白名单由调用方传入 `allowedSortBy`
- 校验 sortOrder仅 "asc" / "desc"
- 校验失败返回 400 Response调用方应直接返回
- 替代了旧的 `validatePagination` 中间件
### 数据访问层约定
每个实体的 DB 函数(`listProjects` / `listModels` / `listProviders`
- 通过 `paginateQuery` 工具执行分页查询
- 接受 `sortBy` / `sortOrder` 参数,各实体自带白名单(`buildOrderBy`
- 默认排序:`desc(createdAt)`
- 实体专用筛选参数在 DB 层处理(如 `status``type``capabilities`
### 路由层约定
每个实体的列表路由:
```ts
// src/server/routes/{entity}/list.ts
const parsed = parseListParams(url, mode, { allowedSortBy: [...] });
if (parsed instanceof Response) return parsed;
// 解析实体专用筛选参数(如 status、providerId、capabilities、type
// 调用 DB 函数
```
## URL 参数约定
| 参数 | 说明 | 默认值 |
| -------------- | -------------- | ------ |
| `page` | 当前页码 | 1 |
| `pageSize` | 每页条数 | 20 |
| `keyword` | 搜索关键词 | - |
| `sortBy` | 排序列 | - |
| `sortOrder` | 排序方向 | - |
| `status` | 项目状态筛选 | - |
| `type` | 供应商类型筛选 | - |
| `providerId` | 模型供应商筛选 | - |
| `capabilities` | 模型能力筛选 | - |
默认值不出现在 URL 中。
## 排序约定
- 排序列后端白名单校验,拒绝无效列名
- 所有列排序通过后端 `ORDER BY` 实现,不使用前端排序
- Table 组件的 `column.sorter` 设为 `true` 启用排序指示器
- Table 的 `onChange` 回调统一处理分页 + 排序变更
## 交互约定
| 操作 | 触发时机 | 行为 |
| -------- | -------------------- | ----------------------------- |
| 筛选下拉 | 选中选项 / 清除 | 重置到第 1 页,更新 URL |
| 搜索 | 点击搜索按钮 / Enter | 重置到第 1 页,更新 URL |
| 重置 | 点击重置按钮 | 清除所有筛选条件,回到第 1 页 |
| 分页 | 点击分页器 | 更新 URLpage+pageSize |
| 排序 | 点击列头排序 | 重置到第 1 页,更新 URL |
| 删除 | Popconfirm 确认后 | toast 通知,刷新列表 |

View File

@@ -2,63 +2,103 @@
开发规范见 [开发规范文档](README.md)。
## 控制台架构
## 布局架构
两个控制台入口共享 ConsoleShell`src/web/components/ConsoleShell/`
两个布局入口共享 ConsoleShell`src/web/shared/components/ConsoleShell/`
- **Admin**`src/web/consoles/admin/`):路由 `/`(总览)、`/projects``/models`
- **Workbench**`src/web/consoles/workbench/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
- **AdminLayout**`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects``/models``/models/providers`
- **WorkbenchLayout**`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和控制台标题。
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content),主题配置由 `shared/theme/theme-config.ts``buildThemeConfig({ compact, effectiveTheme })` 集中构建(含 `algorithm` 数组组合 `compactAlgorithm``cssVar``borderRadius``controlHeight``components.Layout` 配色);侧边栏折叠。主题与紧凑模式切换在设置页(`/settings`。Header 显示品牌名、版本号和布局标题。
`Sidebar``src/web/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
`Sidebar``src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
## 功能模块
| 功能模块 | 路径 | 说明 |
| -------- | --------------------- | --------------------------- |
| 仪表盘 | `features/dashboard/` | 总览页面 |
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索、排序 |
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
| 设置 | `features/settings/` | 平台业务设置,卡片式布局 |
## 页面
| 页面 | 路径 | 入口 |
| -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 总览 | `/` | `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` |
| 页面 | 路径 | 入口 |
| -------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 总览 | `/` | `features/dashboard/index.tsx` |
| 项目管理 | `/projects` | `features/projects/index.tsx` — FilterToolbar状态 Select + 搜索 + 新建/归档恢复删除) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除、列表排序、URL 同步筛选参数。 |
| 模型管理 | `/models` `/models/providers` | 独立路由页面:`ModelListPage.tsx`FilterToolbar + ModelTable + `ProviderListPage.tsx`FilterToolbar + ProviderTable。模型支持供应商/能力筛选和列表排序,供应商支持类型筛选和列表排序。模型表单使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test` |
| 设置 | `/settings` | `features/settings/index.tsx` — 卡片式布局分区管理平台业务设置。"主题"卡片使用 antd Form 水平布局包含主题模式Radio.Group 按钮风:系统/明亮/黑暗和紧凑模式Switch 开关),使用 `useSettings` hook 通过 `GET/PUT /api/settings` 实时保存,`message` toast 反馈。 |
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层selectedId + modalOpen+ MaterialSidebar列表容器+ MaterialDetailPanel透明内容区+OS滚动+操作区卡片)+ MaterialContent纵向卡片流基本信息Card+ AddMaterialModal。操作区始终渲染按钮 fill 样式 + disabled 控制。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
| 404 | `*` | `features/not-found/index.tsx` |
### 聊天页面
`ChatPage` = `Conversations`@ant-design/x+ `ChatPanel`
`ChatPage` = `ConversationSidebar`(自定义组件+ `ChatPanel`
- **Conversations**:会话侧边栏TanStack Query 管理会话列表,支持创建/选中/删除menu dropdown)。
- **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。按 `part.type` 分派渲染TextPartXMarkdown、ReasoningPart、ToolPart四态)。支持编辑重发、重新生成、复制。
- **ConversationSidebar**:会话侧边栏数据加载层useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除Popconfirm)。
- **ChatPanel**`useChat`@ai-sdk/react+ `DefaultChatTransport`ai 包)与后端 SSE 通信。按 `part.type` 分派渲染TextPartmarkdown-to-jsx 含自定义 overridesCodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式、ReasoningPartmarkdown-to-jsx 渲染流式优化、ToolPart四态入参/出参分层卡片展示,通过 HighlightBlock 提供 Shiki 语法高亮和复制按钮)。支持编辑重发、重新生成、复制。
- **Sender**@ant-design/x输入框 + 发送/停止按钮 + 模型 Selectfooter slot
## Hooks
## 共享代码
| 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 持久化 |
### 共享组件
## 工具函数
| 组件 | 路径 | 说明 |
| ------------- | ------------------------------------- | ------------------------------------------------------ |
| ConsoleShell | `shared/components/ConsoleShell/` | 全局布局外壳Provider + Layout |
| FilterToolbar | `shared/components/FilterToolbar.tsx` | 统一筛选工具条Select 筛选 + 搜索 + 重置 + 操作按钮) |
| Sidebar | `shared/components/Sidebar/` | 侧边栏纯展示组件 |
| SidebarGroup | `shared/components/SidebarGroup/` | 可折叠日期分组(聊天室和收集箱共用) |
| ErrorBoundary | `shared/components/ErrorBoundary.tsx` | 生产环境错误边界 |
| 文件 | 导出 |
| --------------- | --------------------------------------------------------------------------------------------- |
| `utils/api.ts` | `handleResponse(response, extract)``handleVoidResponse(response)` |
| `utils/time.ts` | `formatCountdown``formatDurationUnit``formatRelativeTime``isOlderThan``subtractHours` |
### 共享 Hooks
| Hook | 路径 | 说明 |
| ------------------------ | --------------------------------------- | --------------------------------------------------------------------- |
| `use-page-search-params` | `shared/hooks/usePageSearchParams.ts` | URL 查询参数同步(筛选/分页/排序),批量更新 `setParams` 避免闭包覆盖 |
| `use-confirm-action` | `shared/hooks/useConfirmAction.ts` | 包装异步操作 + toast 成功/失败通知 |
| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`30s 轮询5s staleTime |
| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks |
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 |
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题与紧凑模式偏好管理localStorage + API 同步) |
| `use-settings` | `shared/hooks/use-settings.ts` | 平台设置读写react-query: GET/PUT /api/settings |
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
### 共享主题配置
| 文件 | 导出 |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `theme/theme-config.ts` | `buildThemeConfig({ compact, effectiveTheme })` — 构建 antd ThemeConfigalgorithm 数组、cssVar、token、components.Layoutcompact 时组合 compactAlgorithm 并降低 controlHeight |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享工具函数
| 文件 | 导出 |
| --------------------- | --------------------------------------------------------------------------------------------- |
| `utils/api.ts` | `handleResponse(response, extract)``handleVoidResponse(response)` |
| `utils/format.ts` | `formatDatetime(iso: string)` — 格式化 ISO 时间字符串为 `YYYY-MM-DD HH:mm` |
| `utils/time.ts` | `formatCountdown``formatDurationUnit``formatRelativeTime``isOlderThan``subtractHours` |
| `utils/date-group.ts` | `getDateGroup``groupByDate``GROUP_LABELS``GROUP_ORDER``DateGroup``DateGroupData` |
## 更新触发条件
修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引hooks/工具清单时,必须更新本文档
修改前端技术栈、组件边界、数据流、样式规则、测试环境、前端验证方式、运行时代码结构、页面组成、组件索引hooks/工具清单、目录结构或功能模块归属时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)
## 日志模块
### Logger 接口
`src/web/utils/logger.ts` 提供与后端镜像的 Logger 抽象:
`src/web/shared/utils/logger.ts` 提供与后端镜像的 Logger 抽象:
```typescript
export interface Logger {
@@ -85,7 +125,7 @@ export interface Logger {
**组件内(推荐):**
```typescript
import { useLogger } from "../hooks/use-logger";
import { useLogger } from "../../shared/hooks/use-logger";
function MyComponent() {
const logger = useLogger({ component: "MyComponent" });
@@ -98,7 +138,7 @@ function MyComponent() {
**非组件纯函数:**
```typescript
import { createConsoleLogger } from "../utils/logger";
import { createConsoleLogger } from "../../shared/utils/logger";
const logger = createConsoleLogger();
logger.debug("调试信息");

View File

@@ -1,17 +1,17 @@
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物验证结果和变更文档是否与 `design.md` 事实来源一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物验证结果是否与 `design.md` 事实来源一致,识别偏离、漏记和可优化点,按以下流程执行。
## 约束
- 先审查再修复;未经用户确认,不修改实际产物或变更文档
- 先审查再修复;未经用户确认,不修改实际产物
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
-`fast-drive` workflow 下,核心 artifacts 是 `design.md``tasks.md`;不要要求存在 `proposal.md``specs/*.md`
-`fast-drive` workflow 下,`design.md` 是范围、需求、决策、执行约束、执行方向和验证预期的事实来源,`tasks.md` 是 apply 进度和验证闭环的跟踪文件
- 禁止同步或修改 `openspec/specs/` 下的主规范;若实际 schema 包含 `specs/*.md`,也只允许修改本次 change 目录下实际存在的 spec artifacts主规范同步属于 archive 阶段,不在此提示词范围内
- 禁止同步或修改 `openspec/specs/` 下的主规范,该操作属于 archive 阶段,不在此提示词范围内
- 优先使用当前会话中的执行说明、验证结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
- 不要因为实际产物已经存在就自动以实际产物为准先判断差异属于“design 要求未完成”、“验证后新增修补”、“合理落地细化”还是“意外偏离/回归”
- 每批实际产物或文档修改执行前用提问工具获得用户确认
- 每批实际产物修改执行前用提问工具获得用户确认
- 删除/重写前用提问工具获得用户确认;若存在 git 仓库,不创建 `.bak` 备份文件,改用当前 `git status` / `git diff` 作为回退依据;仅在无版本控制或用户明确要求时,才将备份放到代码目录外的用户确认路径
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料、相关变更文档和必要的文档/沟通材料
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料
## 1. 收集
@@ -66,21 +66,20 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为“未验证”;检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | `design.md` 一致性 | 实际变更是否符合“需求”“目标 / 非目标”“执行约束”“决策”“执行计划”和“验证计划”;“待解决问题”是否已明确区分 blocking / non-blocking 或写出“无”;是否违反被明确否决的方案、用户偏好或约束 |
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证未完成任务是否仍然必要apply 或手动修补是否引入了需要补充的新任务验证任务或文档/沟通任务 |
| P3 | 文档回写完整性 | 已落地的实际变更、验证后新增修补、边界处理、异常路径、验证结论、实际触达产物是否已同步回 `design.md``tasks.md`;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再检查必要的文档/沟通材料是否同步 |
| P4 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
| P5 | Schema 兼容性 | 对实际存在的 artifacts 检查是否符合其 schema若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为“未验证”;检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | `design.md` 一致性 | 实际变更是否符合“需求”“目标 / 非目标”“执行约束”“决策”“执行计划”和“验证计划”;“待解决问题”是否已明确区分 blocking / non-blocking 或写出“无”;是否违反被明确否决的方案、用户偏好或约束 |
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证未完成任务是否仍然必要apply 或手动修补是否引入了需要补充的新任务验证任务 |
| P3 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
| P4 | Schema 兼容性 | 实际存在的 artifacts 检查是否符合其 schema若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
分析时区分四类差异:
- `design.md` 要求已明确,但实际变更未完成或完成不充分 → 需补充实际工作或验证
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需回写 `design.md` 和/或 `tasks.md`
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需确认是否需要补充验证
- 实际变更与 `design.md` 不一致,且无法判断应以哪边为准 → 列入待确认清单
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 修正任务状态或补充任务
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 列入任务状态问题清单
不要把以下情况直接视为合理修补:
@@ -91,14 +90,13 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
重点识别:
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理、文档/沟通更新或验证步骤
- 实际变更偏离目标 / 非目标”“执行约束”“决策”或“执行计划的地方
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化未同步到 `design.md`
- 影响范围与实际改动范围不一致,导致新会话无法复盘真实影响面
- 验证计划中要求的验证、质量检查、审阅、批准、沟通检查或人工检查未执行或未记录
- `tasks.md` 标记完成,但实际产物验证、文档或沟通未闭环
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理或验证步骤
- 实际变更偏离"目标 / 非目标""执行约束""决策"或"执行计划"的地方
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化
- "影响范围"与实际改动范围不一致,导致新会话无法复盘真实影响面
- "验证计划"中要求的验证、质量检查、审阅、批准、沟通检查或人工检查未执行或未记录
- `tasks.md` 标记完成,但实际产物验证未闭环
- `design.md``tasks.md` 仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...``TBD``TODO` 等未解决占位内容
- 必要的文档/沟通材料未同步影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果的变更
- 实际产物存在明显的重复、复杂度过高、表达不清、责任不明、风险处理薄弱、验证质量不足等问题
- `fast-drive` change 中仍错误依赖 `proposal.md``specs/*.md``Capabilities``Modified Capabilities` 的内容
@@ -107,13 +105,12 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
1. **问题总览表**:问题类型 × 涉及文件数
2. **实际变更与修补清单**:本次已落地的主要变更、后续修补和验证结论;若缺少验证结果,对未验证部分单独标记
3. **Design 偏离清单**:实际变更未完成、完成不充分或偏离 `design.md` 的内容
4. **需回写文档清单**:实际产物和验证中已确认、但 `design.md``tasks.md` 或相关文档/沟通材料未体现的变更、修补或约束变化
5. **方向待确认清单**实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
6. **任务状态问题清单**未真正完成、状态错误或需补充的新任务
7. **验证问题清单**缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
8. **质量/优化清单**:可优化的实际产物问题和建议
9. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
10. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
4. **方向待确认清单**:实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
5. **任务状态问题清单**未真正完成、状态错误或需补充的新任务
6. **验证问题清单**缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
7. **质量/优化清单**可优化的实际产物问题和建议
8. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
9. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
若所有清单均为空,输出“审查通过,未发现问题”,跳至步骤 5。
@@ -124,11 +121,9 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
再整理完整修复方案,按类别列出:
- 实际工作或验证补充:补完成、补异常处理、补回归验证、修复被弱化或绕过的验证
- Design 回写:同步 `design.md` 中遗漏或过时的需求、执行约束、影响范围、决策、执行计划、验证计划、风险或待解决问题
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
- 文档/沟通同步:同步行为、流程、接口、内容、数据、配置、责任边界或用户可见结果变化
- 质量优化:在不改变目标结果的前提下优化结构、表达、一致性、可维护性或移交质量
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明额外文档同步
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明需关注的差异
对每个拟修改的文件说明:
@@ -142,38 +137,26 @@ g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts
## 4. 执行
逐项执行已确认的实际产物验证和文档修复。
逐项执行已确认的实际产物验证修复。
若涉及删除或重写:
- 存在 git 仓库时,先记录当前 `git status` / `git diff`,不要在实际产物、文档或代码目录创建 `.bak` 文件
- 存在 git 仓库时,先记录当前 `git status` / `git diff`,不要在实际产物或代码目录创建 `.bak` 文件
- 不存在版本控制,或用户明确要求备份时,先用提问工具确认代码目录外的备份路径,再执行修改
若修改了实际产物或验证材料:
- 同步更新相关变更文档;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再同步必要的文档/沟通材料
- 运行或执行相关验证;若修补影响范围较大,再补充执行受影响的回归验证
若修改了文档
执行后重新读取所有被修改的实际产物和验证材料,并复核
- `fast-drive` workflow 下,确认 `design.md` 仍是事实来源,`tasks.md` 仍从 `design.md` 派生
- 确认 `design.md` 的需求、执行约束、影响范围、决策、执行计划、验证计划、风险和待解决问题与实际变更一致
- 确认 `tasks.md` 每个完成任务都有对应实际产物和必要验证,新增修补已补充任务或记录在合适任务中
- 禁止将本次 change 内容同步到 `openspec/specs/`,该操作属于 archive 阶段
-`fast-drive` workflow 下不创建 `proposal.md``specs/*.md`;若实际 schema 不是 `fast-drive`,则按实际 schema 的 required artifacts 创建或更新本次 change 目录下的 artifacts
执行后重新读取所有被修改的实际产物、验证材料和文档,并复核:
- “Design 偏离清单” 是否已清空或已标注保留原因
- “需回写文档清单” 是否已清空
- “方向待确认清单” 是否已清空或已记录用户决策
- “任务状态问题清单” 和 “验证问题清单” 是否已清空或已标注残留原因
- “质量/优化清单” 中哪些已处理,哪些有意延期
- 必要的文档/沟通材料是否已按影响范围同步
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
- "Design 偏离清单" 是否已清空或已标注保留原因
- "方向待确认清单" 是否已清空或已记录用户决策
- "任务状态问题清单" 和 "验证问题清单" 是否已清空或已标注残留原因
- "质量/优化清单" 中哪些已处理,哪些有意延期
## 5. 收尾
列出所有修改的文件、回退依据、验证命令或检查结果、文档同步摘要和剩余风险;若实际创建了备份,再列出备份文件。
列出所有修改的文件、回退依据、验证命令或检查结果和剩余风险;若实际创建了备份,再列出备份文件。
若本次因缺少验证结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。

View File

@@ -32,12 +32,13 @@ bun run dev config.yaml
## 功能介绍
| 功能 | 路径 | 说明 |
| -------- | ----------------------- | ---------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
| 功能 | 路径 | 说明 |
| -------- | ----------------------- | -------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 模型 | `/models` | 管理 AI 模型,供后续 AI 功能使用 |
| 供应商 | `/models/providers` | 配置 AI 供应商API Key、Base URL 等) |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
平台提供两个入口:
@@ -46,12 +47,14 @@ bun run dev config.yaml
从项目管理页面的 active 项目行可点击"工作台"跳转到对应项目的工作台。
## 模型管理
## 模型与供应商管理
在 Admin 侧栏进入 `/models` 后,页面通过两个标签页管理 AI 基础配置
在 Admin 侧栏的"模型管理"分组下包含两个独立页面
- **供应商**:新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`baseURL 和 API Key 由用户填写
- **模型**:为供应商新增模型,填写模型显示名称、实际调用用的 modelId、能力标签以及可选的上下文长度和最大输出 token
- **模型**`/models`):新增、编辑、删除 AI 模型。填写模型显示名称、实际调用用的 modelId、能力标签以及可选的上下文长度和最大输出 token。新建模型时下拉选择已配置的供应商
- **供应商**`/models/providers`):新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`baseURL 和 API Key 由用户填写
侧栏"模型管理"为分组标签,点击展开/收起子项,不直接导航。
供应商表单提供"测试连接"操作:系统先测试 Base URL 是否可达,再尝试请求 `/models` 验证 API Key 和模型列表接口。若服务不支持 `/models`,页面会提示接口可达但可能不支持模型列表;该结果只作为提醒,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。
@@ -67,4 +70,4 @@ bun run dev config.yaml
- **编辑**:最后一条用户消息可编辑,确认后重新发送
- **重新生成**:最后一条 AI 消息可重新生成回复
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
使用聊天功能前,需先在 Admin 管理台的模型和供应商页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。

View File

@@ -0,0 +1,12 @@
CREATE TABLE `materials` (
`associated_date` text NOT NULL,
`created_at` text NOT NULL,
`description` text NOT NULL,
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);

View File

@@ -0,0 +1,109 @@
-- DB schema standardization migration
-- 1. Rename columns
ALTER TABLE `projects` RENAME COLUMN `archived_at` TO `deleted_at`;
ALTER TABLE `models` RENAME COLUMN `model_id` TO `external_id`;
-- 2. Add deleted_at to remaining tables
ALTER TABLE `providers` ADD COLUMN `deleted_at` text;
ALTER TABLE `models` ADD COLUMN `deleted_at` text;
ALTER TABLE `conversations` ADD COLUMN `deleted_at` text;
ALTER TABLE `materials` ADD COLUMN `deleted_at` text;
ALTER TABLE `messages` ADD COLUMN `deleted_at` text;
-- 3. Add updated_at to messages
ALTER TABLE `messages` ADD COLUMN `updated_at` text NOT NULL DEFAULT '';
-- 4. Drop unique indexes (enforcement moves to app layer)
DROP INDEX IF EXISTS `projects_name_unique`;
DROP INDEX IF EXISTS `providers_name_unique`;
DROP INDEX IF EXISTS `models_provider_id_model_id_unique`;
-- 5. Rebuild messages table (FK cascade → no action, add updated_at + deleted_at in-table, add CHECK on role)
CREATE TABLE `messages_new` (
`id` text PRIMARY KEY NOT NULL,
`conversation_id` text NOT NULL,
`role` text NOT NULL CHECK (`role` IN ('assistant', 'system', 'user')),
`content` text NOT NULL DEFAULT '',
`parts` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL DEFAULT '',
`deleted_at` text,
FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `messages_new` (`id`, `conversation_id`, `role`, `content`, `parts`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `conversation_id`, `role`, `content`, `parts`, `created_at`, '', NULL FROM `messages`;
--> statement-breakpoint
DROP TABLE `messages`;
--> statement-breakpoint
ALTER TABLE `messages_new` RENAME TO `messages`;
--> statement-breakpoint
CREATE INDEX `messages_conversation_id_idx` ON `messages` (`conversation_id`);
--> statement-breakpoint
-- 6. Rebuild conversations table (model_id nullable, add deleted_at in-table)
CREATE TABLE `conversations_new` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`model_id` text,
`title` text NOT NULL DEFAULT '新会话',
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
FOREIGN KEY (`model_id`) REFERENCES `models`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `conversations_new` (`id`, `project_id`, `model_id`, `title`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `project_id`, `model_id`, `title`, `created_at`, `updated_at`, NULL FROM `conversations`;
--> statement-breakpoint
DROP TABLE `conversations`;
--> statement-breakpoint
ALTER TABLE `conversations_new` RENAME TO `conversations`;
--> statement-breakpoint
CREATE INDEX `conversations_project_id_idx` ON `conversations` (`project_id`);
--> statement-breakpoint
CREATE INDEX `conversations_model_id_idx` ON `conversations` (`model_id`);
--> statement-breakpoint
-- 7. Rebuild providers table (add deleted_at in-table, add CHECK on type)
CREATE TABLE `providers_new` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL DEFAULT 'openai-compatible' CHECK (`type` IN ('anthropic', 'openai', 'openai-compatible')),
`api_key` text NOT NULL,
`base_url` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text
);
--> statement-breakpoint
INSERT INTO `providers_new` (`id`, `name`, `type`, `api_key`, `base_url`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `name`, `type`, `api_key`, `base_url`, `created_at`, `updated_at`, NULL FROM `providers`;
--> statement-breakpoint
DROP TABLE `providers`;
--> statement-breakpoint
ALTER TABLE `providers_new` RENAME TO `providers`;
--> statement-breakpoint
-- 8. Rebuild materials table (add deleted_at in-table, add CHECK on status)
CREATE TABLE `materials_new` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`associated_date` text NOT NULL,
`description` text NOT NULL,
`status` text NOT NULL DEFAULT 'pending' CHECK (`status` IN ('pending', 'approved', 'discarded')),
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `materials_new` (`id`, `project_id`, `associated_date`, `description`, `status`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `project_id`, `associated_date`, `description`, `status`, `created_at`, `updated_at`, NULL FROM `materials`;
--> statement-breakpoint
DROP TABLE `materials`;
--> statement-breakpoint
ALTER TABLE `materials_new` RENAME TO `materials`;
--> statement-breakpoint
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);

View File

@@ -0,0 +1,7 @@
CREATE TABLE settings (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
data TEXT NOT NULL DEFAULT '{}'
);

View File

@@ -0,0 +1,24 @@
-- 扩展 materials 表:新增 material_type 和 processed_content 字段,更新 status CHECK 约束以支持处理流水线状态
CREATE TABLE `materials_new` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`associated_date` text NOT NULL,
`description` text NOT NULL,
`material_type` text NOT NULL DEFAULT 'general' CHECK (`material_type` IN ('general', 'meeting')),
`processed_content` text,
`status` text NOT NULL DEFAULT 'pending' CHECK (`status` IN ('pending', 'processing', 'review', 'approved', 'discarded', 'failed')),
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `materials_new` (`id`, `project_id`, `associated_date`, `description`, `material_type`, `processed_content`, `status`, `created_at`, `updated_at`, `deleted_at`)
SELECT `id`, `project_id`, `associated_date`, `description`, 'general', NULL, `status`, `created_at`, `updated_at`, `deleted_at` FROM `materials`;
--> statement-breakpoint
DROP TABLE `materials`;
--> statement-breakpoint
ALTER TABLE `materials_new` RENAME TO `materials`;
--> statement-breakpoint
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `entities` (
`id` text PRIMARY KEY NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
`project_id` text NOT NULL REFERENCES `projects`(`id`),
`name` text NOT NULL,
`type` text NOT NULL DEFAULT 'other',
`description` text NOT NULL DEFAULT '',
`aliases` text NOT NULL DEFAULT '[]'
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `entities_project_id_idx` ON `entities` (`project_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `entities_name_idx` ON `entities` (`name`);

View File

@@ -0,0 +1,499 @@
{
"version": "6",
"dialect": "sqlite",
"id": "340f6d1a-081b-413d-a289-f39592ece0a2",
"prevId": "da8963db-526e-46a1-a453-4027d5541db9",
"tables": {
"conversations": {
"name": "conversations",
"columns": {
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'新会话'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"conversations_project_id_idx": {
"name": "conversations_project_id_idx",
"columns": ["project_id"],
"isUnique": false
}
},
"foreignKeys": {
"conversations_model_id_models_id_fk": {
"name": "conversations_model_id_models_id_fk",
"tableFrom": "conversations",
"tableTo": "models",
"columnsFrom": ["model_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"conversations_project_id_projects_id_fk": {
"name": "conversations_project_id_projects_id_fk",
"tableFrom": "conversations",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"materials": {
"name": "materials",
"columns": {
"associated_date": {
"name": "associated_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"materials_project_id_idx": {
"name": "materials_project_id_idx",
"columns": ["project_id"],
"isUnique": false
}
},
"foreignKeys": {
"materials_project_id_projects_id_fk": {
"name": "materials_project_id_projects_id_fk",
"tableFrom": "materials",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parts": {
"name": "parts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"messages_conversation_id_idx": {
"name": "messages_conversation_id_idx",
"columns": ["conversation_id"],
"isUnique": false
}
},
"foreignKeys": {
"messages_conversation_id_conversations_id_fk": {
"name": "messages_conversation_id_conversations_id_fk",
"tableFrom": "messages",
"tableTo": "conversations",
"columnsFrom": ["conversation_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"models": {
"name": "models",
"columns": {
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"context_length": {
"name": "context_length",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"models_provider_id_model_id_unique": {
"name": "models_provider_id_model_id_unique",
"columns": ["provider_id", "model_id"],
"isUnique": true
},
"models_provider_id_idx": {
"name": "models_provider_id_idx",
"columns": ["provider_id"],
"isUnique": false
}
},
"foreignKeys": {
"models_provider_id_providers_id_fk": {
"name": "models_provider_id_providers_id_fk",
"tableFrom": "models",
"tableTo": "providers",
"columnsFrom": ["provider_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"projects_name_unique": {
"name": "projects_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"providers": {
"name": "providers",
"columns": {
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'openai-compatible'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"providers_name_unique": {
"name": "providers_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schema_migrations": {
"name": "schema_migrations",
"columns": {
"applied_at": {
"name": "applied_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,530 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b0da1e89-0647-40e1-9739-6bcd14cf5a2e",
"prevId": "340f6d1a-081b-413d-a289-f39592ece0a2",
"tables": {
"conversations": {
"name": "conversations",
"columns": {
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'新会话'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"conversations_project_id_idx": {
"name": "conversations_project_id_idx",
"columns": ["project_id"],
"isUnique": false
},
"conversations_model_id_idx": {
"name": "conversations_model_id_idx",
"columns": ["model_id"],
"isUnique": false
}
},
"foreignKeys": {
"conversations_model_id_models_id_fk": {
"name": "conversations_model_id_models_id_fk",
"tableFrom": "conversations",
"tableTo": "models",
"columnsFrom": ["model_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"conversations_project_id_projects_id_fk": {
"name": "conversations_project_id_projects_id_fk",
"tableFrom": "conversations",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"materials": {
"name": "materials",
"columns": {
"associated_date": {
"name": "associated_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"materials_project_id_idx": {
"name": "materials_project_id_idx",
"columns": ["project_id"],
"isUnique": false
}
},
"foreignKeys": {
"materials_project_id_projects_id_fk": {
"name": "materials_project_id_projects_id_fk",
"tableFrom": "materials",
"tableTo": "projects",
"columnsFrom": ["project_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parts": {
"name": "parts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
}
},
"indexes": {
"messages_conversation_id_idx": {
"name": "messages_conversation_id_idx",
"columns": ["conversation_id"],
"isUnique": false
}
},
"foreignKeys": {
"messages_conversation_id_conversations_id_fk": {
"name": "messages_conversation_id_conversations_id_fk",
"tableFrom": "messages",
"tableTo": "conversations",
"columnsFrom": ["conversation_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"models": {
"name": "models",
"columns": {
"capabilities": {
"name": "capabilities",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"context_length": {
"name": "context_length",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"models_provider_id_idx": {
"name": "models_provider_id_idx",
"columns": ["provider_id"],
"isUnique": false
}
},
"foreignKeys": {
"models_provider_id_providers_id_fk": {
"name": "models_provider_id_providers_id_fk",
"tableFrom": "models",
"tableTo": "providers",
"columnsFrom": ["provider_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"providers": {
"name": "providers",
"columns": {
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'openai-compatible'"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"schema_migrations": {
"name": "schema_migrations",
"columns": {
"applied_at": {
"name": "applied_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -22,6 +22,20 @@
"when": 1780162361636,
"tag": "0002_orange_black_knight",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1780463734721,
"tag": "0003_lying_cassandra_nova",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1780587528226,
"tag": "0004_db_schema_standardization",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,12 @@
import { enforceCatchType } from "./enforce-catch-type.js";
import { noEmptyFunction } from "./no-empty-function.js";
export default {
meta: {
name: "local-rules",
},
rules: {
"enforce-catch-type": enforceCatchType,
"no-empty-function": noEmptyFunction,
},
};

View File

@@ -1,151 +0,0 @@
import js from "@eslint/js";
import importPlugin from "eslint-plugin-import";
import perfectionist from "eslint-plugin-perfectionist";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
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: [
"node_modules/**",
"dist/**",
".build/**",
"*.bun-build",
"openspec/**",
".opencode/**",
".claude/**",
".codex/**",
".agents/**",
".worktrees/**",
"bin/**",
"bun.lock",
"data/**",
"eslint-rules/**",
],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
importPlugin.flatConfigs.recommended,
importPlugin.flatConfigs.typescript,
perfectionist.configs["recommended-natural"],
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
settings: {
"import/resolver": { node: true, typescript: true },
},
},
{
rules: {
"@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: {
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
},
},
{
files: ["src/server/**/*.ts"],
ignores: ["src/server/logger.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
message: noDirectConsoleMessage,
selector: "MemberExpression[object.name='console']",
},
],
},
},
{
files: ["src/web/**/*.{ts,tsx}"],
ignores: ["src/web/**/logger.ts"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
"../server/*",
"../server/**",
"../**/server/*",
"../**/server/**",
"../../server/*",
"../../server/**",
"src/server/*",
"src/server/**",
],
message: "前端不得导入 src/server 后端运行时实现;请改用 src/shared 类型或 HTTP API。",
},
],
},
],
"no-restricted-syntax": [
"error",
{
message: noDirectConsoleFrontendMessage,
selector: "MemberExpression[object.name='console']",
},
],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
eslintPluginPrettierRecommended,
);

View File

@@ -1,11 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"tdesign-mcp-server": {
"enabled": true,
"type": "local",
"command": ["bunx", "tdesign-mcp-server@latest"]
},
"antd": {
"enabled": true,
"type": "local",

View File

@@ -1,4 +1,4 @@
schema: fast-drive
schema: code-drive
context: |
## 项目概览
@@ -30,12 +30,7 @@ context: |
- 优先使用提问工具对用户确认
rules:
design:
- fast-drive的design.md章节标题和正文使用中文仅OpenSpec术语、文件名、schema字段名、命令和代码符号保留英文
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
tasks:
- fast-drive的tasks.md分组标题、任务描述和验证说明使用中文每个任务必须保留OpenSpec CLI可解析的单行checkbox格式
- 一行一个任务,严禁任务内容跨行
- 如果是代码存在更新必须
- 执行完整的测试、代码检查、格式检查等质量保障手段
- 执行文档影响分析,更新 README.md 和/或 docs/ 下对应文档

View File

@@ -0,0 +1,234 @@
# blocker-revise
当 OpenSpec `code-drive` 的 apply 阶段生成 `blocker.md` 并暂停时,按照本提示词修订规划 artifacts。目标是修正不成立的部分而不是强行继续实现。
## 目标
读取 `blocker.md` 及上下游 artifacts识别阻塞本质定位最上游的修订入口通过用户决策流程选定修订方向按 code-drive 各 artifact 的 instruction 逐层修订受影响的部分,最终让 apply 可以从修订后的待办任务继续。
本提示词是 code-drive 中除 requirements 之外**唯一允许向用户发起决策型提问**的入口,决策能力受"用户决策流程(强制协议)"约束。
## 输入
- 当前 OpenSpec change 目录
- `blocker.md`
- `requirements.md`
- `design.md`
- `plan.md`
- `tasks.md`
- `openspec/schemas/code-drive/schema.yaml`(用于读取各 artifact 的 instruction
- `openspec/schemas/code-drive/templates/*.md`(用于读取各 artifact 的模板结构)
## 工具使用(先决说明)
- **todo 工具**:用于跟踪步骤 5/6/7 内的子步骤进度;子步骤颗粒度(如 5.1 / 5.2 / 5.3)直接对应 todo 条目todo 只是过程跟踪,最终事实必须写回对应 artifact 文件
- **question / choice 工具**:触发用户决策流程时必须优先使用(如工具支持),按"用户决策格式(强制)"组织候选与说明
- **读写工具**:本提示词需要写入多个 artifact但写入前必须先读取对应 artifact 的当前内容;禁止凭印象重写
- **只读探索工具**:用于步骤 2 的代码库调查(仅在阻塞点涉及未被探索过的代码时使用)
## 用户决策流程(强制协议)
本协议是 blocker-revise 阶段的**唯一决策出口**。任何超出"修订执行细节"层级的方向选择必须走本协议,不得 AI 自决。
### 触发条件(命中即必须启动)
1. **核心修订方向存在多种可行路径**blocker.md 列出的可选方案 ≥ 2 个,或 AI 补充的方案与原方案构成真正的取舍(不是同一思路的两种写法)
2. **blocker.md 建议的修订方向会扩展本次业务范围或引入核心新依赖**:与原 requirements / design 不一致,必须显式征得用户同意
3. **修订入口在不同 artifact 之间犹豫**:例如 blocker 表面指向 plan但根因可能在 design 或 requirements需要用户判断修订起点
4. **blocker.md 未列出可选方案,仅描述了阻塞现象**AI 必须主动补充 2-3 个候选方向并请用户选择
未命中以上任何条件时,**不得**主动发起决策型提问AI 自决范围参见下文。
### AI 自决范围(无需启动用户决策流程)
以下类型的修订属于执行细节AI 自决后直接执行,无需启动用户决策流程:
1. **执行步骤的局部调整**plan.md 阶段内步骤的拆分、合并、重排
2. **任务粒度细化或合并**tasks.md checkbox 的拆分/合并,但不删除整个分组
3. **描述措辞修正**:澄清歧义、补充缺失细节、修正笔误
4. **已完成任务的保留决策**:阻塞未证明无效时,已完成 checkbox 必须保留
5. **修订记录的措辞与格式**blocker.md 末尾的修订记录按本提示词模板填写
6. **工具使用顺序**todo / 读写工具的使用顺序与时机
### 用户决策格式(强制)
每次启动用户决策流程时,输出必须包含:
1. **2-3 个候选选项**:每个选项含义明确,避免"方案 A / 方案 B"这类无信息标签
2. **推荐方案**AI 必须明确推荐其中一项,不得回避或"中立呈现"
3. **取舍说明**
- 每个非推荐方案:说明未选它的核心理由(一句话即可)
- 推荐方案:说明选择它的核心理由 + 主要代价
4. **影响范围预测**blocker-revise 特有):每个选项预测将影响哪些 artifact 需要修订,并粗估修订量(小改 / 中改 / 重写章节)
5. **使用工具**:优先使用 question/choice 工具;工具不可用时以 markdown 形式直接呈现
### 强制语义(不得跳过)
- 触发条件命中时**必须**启动用户决策流程,即使你倾向"自己决定"或"按 blocker.md 建议执行"
- 即使用户回复"你看着办",也必须回复"推荐方案 + 主要代价",请用户**显式确认**推荐方案,不得默认接受"你看着办"作为决策
- 决策方向涉及扩展本次业务范围或引入核心新依赖时(触发条件 2必须**额外显式提示**"本选项将扩展本次范围 / 引入新依赖",并征得用户的明确同意(不接受默认)
- 用户未决策前**不得**进入步骤 5 的实际修订
### 决策归档规则
用户给出决策后:
1. **决策结论融入对应 artifact 的相关章节**——不设独立的"决策记录"章节
2. **在 `blocker.md` 末尾追加"修订记录"段**:记录选择方案、选择理由、修改的 artifacts 列表、被取消勾选的 tasks——这是审计线索不是二次决策入口
3. **决策引发的修订如果触及 requirements / design 的关键决策**,按各 artifact instruction 中"决策归档规则"融入对应章节
4. **决策结论应可在 apply 恢复后直接使用**——明确到 apply 阶段无需重新发起决策
## 工作流
### 步骤 1: 阅读并理解阻塞
阅读 `blocker.md`,识别:
- 阻塞点的**本质**(不是症状)
- 当前位置:任务编号、`plan.md` 阶段、相关文件
- 已尝试的方案及失败原因(避免重复)
- `blocker.md` 建议的修订目标(如有)
完成本步骤后进入步骤 2。
### 步骤 2: 影响分析
根据 `blocker.md` 的影响范围,系统分析上下游影响链:
- 如果 `requirements.md` 需要修订 → 检查 `design.md` 的哪些决策依赖它,再检查 `plan.md` 的哪些阶段受影响,最后检查 `tasks.md` 的哪些 checkbox 需要取消
- 如果 `design.md` 需要修订 → 检查 `plan.md` 的哪些阶段依赖它,再检查 `tasks.md` 的哪些 checkbox 需要取消
- 如果 `plan.md` 需要修订 → 检查 `tasks.md` 的哪些 checkbox 依赖它,以及是否有下游阶段依赖被阻塞阶段的输出
- 如果 `tasks.md` 需要修订 → 只影响当前任务及其直接依赖
记录每个 artifact 的影响程度:**必须修订 / 可能受影响 / 无影响**。
如阻塞点涉及未被探索过的代码模块,使用只读探索工具补充上下文;否则不发起额外探索。
完成本步骤后进入步骤 3。
### 步骤 3: 确定修订入口
根据影响分析,确定需要修订的**最上游** artifact
- 需要修订 `requirements.md` → 从 requirements 开始,依次修订 design、plan、tasks
- 需要修订 `design.md` → 从 design 开始,依次修订 plan、tasks
- 需要修订 `plan.md` → 从 plan 开始,修订 tasks
- 只需要修订 `tasks.md` → 只修订 tasks
如果"修订入口在不同 artifact 之间犹豫"(触发条件 3先暂停并启动用户决策流程用户决策后回到本步骤。
完成本步骤后进入步骤 4。
### 步骤 4: 用户决策
检查是否命中"用户决策流程(强制协议)"中任一触发条件:
- **命中** → 按强制格式输出候选 + 推荐 + 取舍 + 影响范围预测,等待用户决策
- **未命中** → 直接进入步骤 5属于 AI 自决范围)
用户决策后或确认无需决策后,进入步骤 5。
### 步骤 5: 执行修订
从步骤 3 确定的修订入口开始,按 code-drive 正常流程逐层修订下游 artifacts。
**子步骤 5.1: 读取 instruction 与 template**
对每个待修订的 artifact
1. 读取 `schema.yaml` 中该 artifact 的 `instruction`
2. 读取该 artifact 的 `template`
3. 后续修订必须遵循 instruction 工作流和 template 结构
**子步骤 5.2: 局部修订**
按修订范围原则执行:
- 只改错误的部分,不重写整个章节(除非整个章节的基础假设不成立)
- 改了 `plan.md` 阶段的实现步骤时,同步更新 `tasks.md` 对应 checkbox 的描述
- 改了 `design.md` 的关键决策时,检查 `plan.md` 的代码模式是否需要同步,但不自动重写
- 改了 `requirements.md` 时,逐层向下检查影响,每层只改受影响的部分
- 如果修订导致 `tasks.md` 分组结构变化,重新对齐 plan → tasks 映射
**子步骤 5.3: 保留已完成任务**
按以下规则处理 tasks.md 中已完成任务:
- 已完成且**不受**阻塞影响的 tasks → 保留 checkbox
- 已完成但被阻塞证明**无效**的 tasks → 取消 checkbox并在修订记录中说明原因
- 未完成的 tasks → 根据修订结果更新描述或删除
- 如果阶段需要拆分 → 在 `plan.md` 中新增阶段,将已完成部分和待完成部分分开
完成本步骤后进入步骤 6。
### 步骤 6: 修订后验证
每个被修订的 artifact 完成后,按两层一致性检查。
**子步骤 6.1: Instruction 合规性检查**
- 每个被修订的 artifact 是否符合其 instruction 中的工作流和完成标准
- 每个被修订的 artifact 是否包含其 instruction / template 要求的章节
- 每个被修订的 artifact 是否符合其 template 结构
**子步骤 6.2: 上下游一致性检查**
- **需求覆盖**`requirements.md` 的每条需求是否仍有 `design.md` 决策覆盖
- **决策落地**`design.md` 的每个关键决策是否在 `plan.md` 中有实现路径
- **阶段覆盖**`plan.md` 的每个阶段是否在 `tasks.md` 中有对应分组
- **任务可追溯**`tasks.md` 的每个 checkbox 是否能回溯到 `plan.md` 的某个阶段
- **验证闭环**`design.md` 的"验证方向"是否在 `plan.md` 的"验证策略"中有体现
任一项失败 → 回到步骤 5 局部修复,修复后从该项重跑。
全部通过后进入步骤 7。
### 步骤 7: 处理 blocker.md
按以下子步骤追加修订记录并归档。
**子步骤 7.1: 追加修订记录**
`blocker.md` 末尾追加:
```markdown
## 修订记录
- 选择方案:
- 选择理由:
- 修改的 artifacts
- `xxx.md`:具体变更描述
- `xxx.md`:具体变更描述
- 被取消勾选的 tasks
- X.Y取消原因
```
**子步骤 7.2: 保留或归档**
按项目约定保留或归档 `blocker.md`(建议保留作为审计线索)。
完成本步骤后进入步骤 8。
### 步骤 8: 完成
告诉用户重新运行 `/opsx:apply <change-name>`apply 应跳过已完成 checkbox 并从修订后的待办任务继续。
## 完成标准
- 工作流步骤 1-8 全部走过
- 步骤 6 的 instruction 合规性 + 上下游一致性全部通过
- `blocker.md` 已追加修订记录,并被保留或归档
- 选定的修订方向已记录在受影响 artifact 中
- 每个被修订的 artifact 符合其 instruction 的工作流和 template 结构
- 已完成任务的 checkbox 被保留,除非明确失效
- 所有触发条件命中时都执行了用户决策流程(事后可被审计)
- 用户知道需要重新运行 apply 继续实现
## 规则速查
- 本提示词只负责**修订规划 artifacts**,除非用户明确要求,不要在本提示词中实现代码
- **最小修订**:只修订解决阻塞所需的最小上游点及其下游影响,不要默认重写全部 artifacts
- **不得擅自扩展**:未经用户确认不得新增需求、依赖、架构方向或范围
- **已完成任务保护**:不要取消已完成任务的勾选,除非阻塞证明该已完成工作不正确
- **遵循各 artifact 自身的 instruction**:每个被修订的 artifact 必须遵循其 instruction 和 template即使只是局部修订
- **todo 跟踪修订流程**:如果工具支持,使用 todo/plan 工具跟踪修订流程,但最终事实必须写回 artifacts

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
## 阻塞点
<!-- 简述阻塞的本质,不是症状而是根因 -->
## 当前位置
- 任务编号:
- plan.md 阶段:
- 相关文件:
## 已尝试
<!-- 列出已尝试的方案和失败原因,避免重复尝试 -->
| 方案 | 失败原因 |
| ---- | -------- |
| | |
## 影响范围
<!-- 阻塞对上下游 artifacts 的系统性影响 -->
| Artifact | 影响内容 | 影响程度 |
| -------- | -------- | -------- |
| | | 必须修订 / 可能受影响 / 无影响 |
## 可选方案
### 方案 1<!-- 语义化方案名称,例如“回退 design 调整接入方式” -->
- 描述:
- 需修订:
- 优势:
- 风险 / 代价:
### 方案 2<!-- 语义化方案名称 -->
- 描述:
- 需修订:
- 优势:
- 风险 / 代价:
### 方案 3<!-- 语义化方案名称,可选 -->
- 描述:
- 需修订:
- 优势:
- 风险 / 代价:
## 修订建议
<!-- 推荐方案及修订路径:从哪个 artifact 入口开始修订,下游需要同步哪些 -->

View File

@@ -0,0 +1,70 @@
## 代码库探索
<!-- 记录 requirements.md 未覆盖的技术探索发现,作为后续设计的依据。如果 requirements.md 中已有充分的探索结果,在此引用并仅补充缺失部分。 -->
### 已有代码与模式
<!-- 记录与本次变更相关的现有代码、组件、工具函数、架构模式requirements.md 已覆盖则写“见 requirements.md”无相关现有代码则写“无” -->
### 依赖状态
<!-- 记录相关依赖的安装状态、版本约束requirements.md 已覆盖则引用;未安装的依赖需标注 -->
### 开发规范约束
<!-- 记录项目规范、代码风格、命名约定、架构模式等对设计的约束;无特殊约束则写“无” -->
## 整体方案
### 架构概览
<!-- 描述本次变更涉及的模块、组件或流程如何组织和协作,用概要方式说明为什么该方案满足 requirements.md -->
### 关键交互流程
<!-- 如果变更涉及模块间交互、API 调用、数据流变化或状态转换,在此描述关键路径;无则写“无” -->
## 目标 / 非目标
**目标:**
<!-- 记录本次技术设计要达成的目标 -->
**非目标:**
<!-- 记录明确不在范围内的内容 -->
## 关键决策
<!-- 引用 requirements.md 中已确认的技术决策,仅补充 requirements.md 未覆盖的实现层决策(如具体 API 设计、数据结构、代码组织方式) -->
| 决策 | 来源 | 理由 |
| ---- | ---- | ---- |
| <!-- 决策 --> | <!-- requirements.md T1 / 本阶段新增 --> | <!-- 理由或补充说明 --> |
## 影响范围
<!-- 记录会受影响的模块、文件、配置、接口、文档、流程或外部依赖 -->
| 范围 | 变更类型 | 原因 |
| ---- | -------- | ---- |
| <!-- 范围 --> | <!-- 新增 / 修改 / 删除 / 验证 --> | <!-- 原因 --> |
### 关键实现路径
<!-- 按优先级列出 plan.md 应首先处理的核心文件、模块或流程;简单变更可写“同上” -->
## 依赖与约束
<!-- 记录依赖限制、兼容性约束、项目规则、质量门禁和禁止事项 -->
- 依赖:
- 兼容性:
- 质量门禁:
- 禁止事项:
## 风险 / 权衡
<!-- 格式:[风险] -> 缓解措施 -->
## 验证方向
<!-- 概要说明本次变更应从哪些角度验证,作为 plan.md “验证策略”的输入 -->

View File

@@ -0,0 +1,66 @@
## 实现概览
<!-- 概述实现阶段、依赖顺序,以及各阶段如何对应 requirements.md 和 design.md -->
## 涉及文件
<!-- 按阶段列出本次变更涉及的核心文件路径apply 阶段据此定位代码 -->
| 文件路径 | 变更类型 | 所属阶段 |
| -------- | -------- | -------- |
| <!-- 文件路径 --> | <!-- 新增 / 修改 / 删除 --> | <!-- 阶段编号 --> |
## 阶段 N: <!-- 阶段名称按实际阶段重复本块N 从 1 递增 -->
### 目标
<!-- 本阶段要完成什么 -->
### 前置条件
<!-- 本阶段开始前必须满足什么;没有则写“无” -->
### 详细实现步骤
<!-- 写清楚关键文件、函数、数据结构、流程或配置变化。不要使用 checkbox。 -->
### 关键代码模式
<!-- 记录本阶段的关键实现细节apply 据此编写代码。至少覆盖以下内容中适用的部分: -->
**新增 / 修改的函数或方法:**
<!-- 函数签名、参数、返回值、核心逻辑;无则写“无” -->
**新增 / 修改的数据结构:**
<!-- 类型定义、字段、约束;无则写“无” -->
**调用顺序 / 流程:**
<!-- 关键调用链、异步流程、状态转换;无则写“无” -->
**约定 / 模式:**
<!-- 命名规范、错误处理模式、日志规范等;无则写“无” -->
### 验证方式
<!-- 本阶段如何独立验证 -->
### 验收标准
<!-- 本阶段完成的可验证标准;与 requirements.md 验收标准对齐 -->
### 关联需求
<!-- 例如F1、F2 -->
## 验证策略
<!-- 汇总自动化测试、手动检查、文档检查、兼容性检查和验收方式 -->
## 回退 / 兼容性说明
<!-- 记录回退策略、错误处理策略、兼容性要求、迁移注意事项;没有则写“无” -->
- 回退策略:
- 错误处理:
- 兼容性:
- 迁移注意事项:

View File

@@ -0,0 +1,57 @@
## 背景与目标
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
## 讨论记录
<!-- 记录 explore 或前序讨论中后续 design/plan/apply 必须保留的关键结论 -->
### 已确认结论
- <!-- 结论 1 -->
- <!-- 结论 2 -->
### 用户偏好
- <!-- 偏好 1 -->
### 被否决方案
- <!-- 方案及否决原因 -->
## 功能需求
<!-- 每条功能需求必须有明确验收标准 -->
| 编号 | 需求 | 验收标准 |
| ---- | ---- | -------- |
| F1 | <!-- 需求 --> | <!-- 验收标准 --> |
## 非功能需求
<!-- 只记录与本次变更相关的非功能要求 -->
| 类别 | 要求 |
| ---- | ---- |
| <!-- 性能 / 兼容性 / 安全 / 可维护性 / 运维 / 文档 --> | <!-- 要求 --> |
## 技术需求
<!-- 记录需要确认的技术选型、架构方向、集成边界、代码约束或禁止事项;详细设计属于 design.md -->
| 编号 | 类别 | 决策 | 理由 | 被否决方案 |
| ---- | ---- | ---- | ---- | ---------- |
| T1 | <!-- 选型 / 架构 / 约束 / 集成 --> | <!-- 已确认的决策 --> | <!-- 理由 --> | <!-- 被否决方案及原因 --> |
## 全局审查
<!-- 从系统边界、既有行为、相邻模块、配置、文档、迁移、兼容性、安全、性能和用户流程角度审查当前需求 -->
### 与现有系统的关联
<!-- 记录相关模块、流程、配置、文档、外部接口或用户路径 -->
### 前置条件
<!-- 记录执行前必须满足的条件;没有则写"无" -->

View File

@@ -0,0 +1,16 @@
## X. <!-- 对应 plan.md 阶段 X 的名称按实际阶段重复本块X 与 plan 阶段编号一致 -->
- [ ] X.1 阅读 plan.md 阶段 X确认涉及文件、关键代码模式和验收标准
- [ ] X.2 <!-- 动词 + 文件路径:具体动作描述,详见 plan.md 阶段 X -->
- [ ] X.3 <!-- 动词 + 文件路径:具体动作描述,详见 plan.md 阶段 X -->
- [ ] X.4 <!-- 运行测试或验证命令,确认阶段 X 的关键行为 -->
- [ ] X.5 按 plan.md 阶段 X 的验收标准确认阶段完成
## N. 验证与收尾
- [ ] N.1 阅读 plan.md 验证策略,确认所有验证项已执行
- [ ] N.2 执行完整测试套件,确认无回归
- [ ] N.3 逐项对照 requirements.md 验收标准,确认全部满足
- [ ] N.4 检查 design.md 关键决策是否被正确实现
- [ ] N.5 如果行为、流程、接口、配置或使用方式发生变化,更新相关文档或交接说明
- [ ] N.6 确认所有任务已标记为 `[x]`,未完成或阻塞事项已记录

View File

@@ -1,150 +0,0 @@
name: fast-drive
version: 1
description: 快速 OpenSpec workflow - design -> tasks -> apply
artifacts:
- id: design
generates: design.md
description: 自包含的方案说明和执行计划
template: design.md
instruction: |
创建 design.md作为本次变更“改什么、为什么改、如何执行”的自包含事实来源。
本 workflow 不使用 proposal 或 specs artifacts。design.md MUST 保留前序探索和用户讨论中的重要结论,确保后续 apply 阶段即使经历上下文压缩或进入新会话,也能正确继续执行。
语言规则(强制):
- fast-drive 的 design.md 使用中文章节标题和中文正文仅文件名、OpenSpec 术语、schema 字段名、命令、代码符号和必要技术名词保留英文
- 最终 design.md 不得残留英文模板句子或英文占位内容,除非该英文是 OpenSpec 术语、文件名、schema 字段名、代码符号、命令或必要技术名词
面向看不到早期对话的人编写。简单变更保持精炼,但必须包含足够细节让执行无歧义。遇到以下情况时增加细节:
- 跨多个系统、团队、工作流或 artifacts 的横切变更
- 新增依赖、集成、供应商、工具、策略或外部输入
- 重要的信息模型、流程模型、数据模型或归属关系变化
- 涉及安全、隐私、合规、性能、运维或迁移复杂度
- 执行前需要先做决策才能降低歧义
- 前序讨论已经确认非显而易见的需求、约束或被否决方案
必需章节(建议使用以下中文章节标题):
- **背景**:问题、当前状态、相关参考资料,以及触发本次变更的用户请求
- **讨论记录**:探索或前序讨论中必须保留的关键点,包括已确认结论、用户偏好、约束和重要的被否决方案
- **需求**:预期结果、行为/流程/接口/内容变化、连续性要求和验收标准
- **目标 / 非目标**:本次变更要达成的目标,以及明确不在范围内的内容
- **执行约束**:必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界
- **影响范围**:与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项
- **决策**:关键选择及理由(为什么选 X 而不是 Y。每个重要决策都要包含考虑过的替代方案以及未选择它们的原因
- **执行计划**:主要工作流或待修改 artifacts、集成或交接点、执行顺序以及必要的发布/落地说明
- **验证计划**:用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查
- **风险 / 权衡**:已知限制和可能出错的事项
格式:[风险] -> 缓解措施
- **待解决问题**:执行前仍需解决的决策、假设或未知项。必须区分会阻塞 apply 的问题和非阻塞后续问题。没有未决问题时使用“无”
可选章节(相关时添加,建议使用中文章节标题):
- **迁移 / 发布计划**:发布步骤、沟通安排、归属、回滚或连续性策略
聚焦保留需求、理由、约束和方案。除非某个细节是讨论中明确做出的决策,否则避免逐行或逐步骤展开。
优先写可长期使用的摘要,而不是聊天记录转写。当具体 artifact 名称、数据/信息形状、示例、相关方、归属和边界场景会影响执行时,必须写清楚。
不要在 design.md 使用任务 checkboxcheckbox 只属于 tasks.md。
最终 design.md 不得包含未解决的模板注释、空表格行或占位文本。
如果信息缺失,写明假设和待解决问题,不要编造隐藏需求。不要依赖未写入文档的聊天上下文。
requires: []
- id: tasks
generates: tasks.md
description: 从 design.md 派生的可跟踪执行清单
template: tasks.md
instruction: |
创建 tasks.md将 design.md 拆解为可执行工作项。
**重要:必须遵守以下模板中的 checkbox 行格式。** apply 阶段会解析 checkbox 格式跟踪进度。未使用 `- [ ]` 的任务不会被跟踪。
语言规则(强制):
- fast-drive 的 tasks.md 使用中文分组标题和中文任务描述仅文件名、OpenSpec 术语、schema 字段名、命令、代码符号和必要技术名词保留英文
- 每个可跟踪任务必须保留 OpenSpec CLI 可解析的单行 checkbox 格式,例如 `- [ ] 1.1 任务描述` 或 `- [x] 1.1 已完成任务描述`
- 最终 tasks.md 不得残留英文模板任务或英文占位内容,除非该英文是 OpenSpec 术语、文件名、schema 字段名、代码符号、命令或必要技术名词
编写规则:
- 任务必须从 design.md 派生。不要依赖 proposal.md 或 specs artifacts任何相关前序讨论都必须已经记录在 design.md 中
- 相关任务按 `##` 编号标题分组,分组标题使用中文
- 每个任务 MUST 是单行 checkbox`- [ ] X.Y 任务描述`
- 任务粒度应足够小,能在一个会话内完成
- 按依赖顺序排序(先做必须先完成的事项)
- 当执行依赖执行约束、影响范围或待解决问题时,从上下文审查任务开始
- 需要时包含验证任务,覆盖检查、审查、批准、验收、文档、沟通和人工检查
- 除非仓库、版本控制或发布操作明确属于本次变更范围,否则不要包含这类任务
- 最终 tasks.md 不得包含未解决的模板注释、空表格行或占位任务文本
示例:
```
## 1. 上下文审查
- [ ] 1.1 阅读 design.md识别范围、需求、决策、执行约束和待解决问题
- [ ] 1.2 审查“影响范围”中列出的相关 artifacts 和参考资料
## 2. 执行
- [ ] 2.1 执行 design.md 中的第一个具体工作项
- [ ] 2.2 执行 design.md 中的下一个具体工作项
## 3. 验证
- [ ] 3.1 执行“验证计划”中要求的验证
- [ ] 3.2 执行项目或 workflow 要求的质量检查
- [ ] 3.3 执行“验证计划”中要求的人工审查或验收检查
## 4. 文档 / 沟通
- [ ] 4.1 如果行为、流程、接口、配置或使用方式发生变化更新相关文档、runbook、沟通材料或项目参考资料
```
以 design.md 中的范围、需求、决策、执行方向和验证预期为依据。
每个任务都应可验证:必须能明确判断任务何时完成。
requires:
- design
apply:
requires:
- design
- tasks
tracks: tasks.md
instruction: |
先阅读 design.md再阅读 tasks.md。
同时遵守 workflow context/configuration例如存在时读取 openspec/config.yaml以及 design.md 引用的相关项目或 workflow 文档。
将 design.md 视为范围、需求、决策、执行约束、执行方向和验证预期的事实来源。
按依赖顺序处理待办任务,并在完成后及时标记。
只有任务执行完成且必要验证完成后,才能标记任务完成。
如果 tasks 与 design.md 冲突、design.md 存在阻塞性待解决问题,或需要澄清,必须暂停。

View File

@@ -1,77 +0,0 @@
## 背景
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
## 讨论记录
<!-- 记录探索或前序讨论中 apply 阶段必须保留的关键结论 -->
- 已确认结论:
- 用户偏好:
- 约束:
- 被否决方案:
## 需求
<!-- 记录预期结果、行为/流程/接口/内容变化、连续性要求和验收标准 -->
| 需求 | 验收标准 |
| ---- | -------- |
| | |
## 目标 / 非目标
**目标:**
<!-- 记录本次 design 要达成的目标 -->
**非目标:**
<!-- 记录明确不在范围内的内容 -->
## 执行约束
<!-- 记录必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界 -->
- 依赖限制:
- 约束:
- 质量门禁:
- 相关方:
- 文档 / 沟通:
- 兼容性 / 连续性:
## 影响范围
<!-- 记录与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项 -->
| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 |
| ---- | -------------------- | -------- | ---- |
| <!-- 范围 --> | <!-- Artifacts / 参考资料 --> | <!-- 预期变更 --> | <!-- 备注 --> |
## 决策
<!-- 记录关键决策、理由和考虑过的替代方案 -->
| 决策 | 理由 | 已否决替代方案 |
| ---- | ---- | ---------------- |
| | | |
## 执行计划
<!-- 记录主要工作流或待修改 artifacts、集成或交接点、执行顺序以及必要的发布/落地说明 -->
## 验证计划
<!-- 记录用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查 -->
| 需求 / 风险 | 验证方式 |
| ----------- | -------- |
| | |
## 风险 / 权衡
<!-- 格式:[风险] -> 缓解措施 -->
## 待解决问题
| 状态 | 问题 | 所需决策 |
| ---- | ---- | -------- |
| 无 | 无待解决问题。 | 无需决策 |

View File

@@ -1,19 +0,0 @@
## 1. 上下文审查
- [ ] 1.1 阅读 design.md识别范围、需求、决策、执行约束和待解决问题
- [ ] 1.2 审查“影响范围”中列出的相关 artifacts 和参考资料
## 2. 执行
- [ ] 2.1 执行 design.md 中的第一个具体工作项
- [ ] 2.2 执行 design.md 中的下一个具体工作项
## 3. 验证
- [ ] 3.1 执行“验证计划”中要求的验证
- [ ] 3.2 执行项目或 workflow 要求的质量检查
- [ ] 3.3 执行“验证计划”中要求的人工审查或验收检查
## 4. 文档 / 沟通
- [ ] 4.1 如果行为、流程、接口、配置或使用方式发生变化更新相关文档、runbook、沟通材料或项目参考资料

View File

@@ -8,9 +8,9 @@
"dev:server": "bun --watch src/server/dev.ts",
"dev:web": "bunx --bun vite --host",
"build": "bun run scripts/build.ts",
"lint": "eslint .",
"format": "prettier . --write",
"format:check": "prettier . --check",
"lint": "oxlint --deny-warnings",
"format": "oxfmt",
"format:check": "oxfmt --check",
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
"schema": "bun run scripts/generate-config-schema.ts",
"schema:check": "bun run scripts/generate-config-schema.ts -- --check",
@@ -25,57 +25,49 @@
"version:set": "bun run scripts/bump-version.ts set"
},
"devDependencies": {
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@eslint/js": "^10.0.1",
"@tanstack/react-query-devtools": "^5.100.14",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@happy-dom/global-registrator": "^20.10.2",
"@tanstack/react-query-devtools": "^5.101.0",
"@testing-library/react": "^16.3.2",
"@types/bun": "^1.3.14",
"@types/jsdom": "^28.0.3",
"@types/react": "^19.2.15",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"drizzle-kit": "^0.31.10",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.5",
"prettier": "^3.8.3",
"lint-staged": "^17.0.7",
"oxfmt": "^0.53.0",
"oxlint": "^1.68.0",
"oxlint-tsgolint": "^0.23.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0",
"vite": "^8.0.14"
"vite": "^8.0.16"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.81",
"@ai-sdk/openai": "^3.0.66",
"@ai-sdk/openai": "^3.0.68",
"@ai-sdk/openai-compatible": "^2.0.48",
"@ai-sdk/react": "^3.0.195",
"@ant-design/icons": "^6.2.3",
"@ai-sdk/react": "^3.0.199",
"@ant-design/icons": "^6.2.5",
"@ant-design/x": "^2.7.0",
"@ant-design/x-markdown": "^2.7.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.14",
"ai": "^6.0.193",
"@tanstack/react-query": "^5.101.0",
"ai": "^6.0.197",
"ajv": "^8.20.0",
"antd": "^6.4.3",
"drizzle-orm": "^0.45.2",
"es-toolkit": "^1.47.0",
"markdown-to-jsx": "^9.8.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pino-roll": "^4.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.15.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^7.17.0",
"recharts": "^3.8.1",
"shiki": "^4.2.0",
"zod": "^4.4.3"
}
}

View File

@@ -5,7 +5,7 @@
"source": "vercel/ai",
"sourceType": "github",
"skillPath": "skills/use-ai-sdk/SKILL.md",
"computedHash": "c99d2a95b3a5f8fad218f288503f9e724ba0f12bf4e8aaf2c792a9f2bc318ab6"
"computedHash": "2249889eb47ef0f61c4dba4cf2afe01c1c8dd793bb4c24230347c1ab909bb7dd"
},
"ant-design": {
"source": "ant-design/antd-skill",
@@ -19,19 +19,61 @@
"skillPath": "skills/antd/SKILL.md",
"computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2"
},
"migrate-oxfmt": {
"source": "oxc-project/oxc",
"sourceType": "github",
"skillPath": ".agents/skills/migrate-oxfmt/SKILL.md",
"computedHash": "b313a891bc4173aac0bd948d5e8c40f5dd921fe67eb5f51184c364e2b869f0e7"
},
"migrate-oxlint": {
"source": "oxc-project/oxc",
"sourceType": "github",
"skillPath": ".agents/skills/migrate-oxlint/SKILL.md",
"computedHash": "7bc6dccdcc557cdb7d887fc16c6632e685b13860d202b9c9ce5da7504f0dbe18"
},
"performance-lint-rules": {
"source": "oxc-project/oxc",
"sourceType": "github",
"skillPath": ".agents/skills/performance-lint-rules/SKILL.md",
"computedHash": "82a2d3a72bc381c3552834008435635d7157f22ee4efc4b441a33ad6e838c828"
},
"react-router-data-mode": {
"source": "remix-run/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-router-data-mode/SKILL.md",
"computedHash": "76e3e0f70ff47b743bd90999e676515221e25fd7ee89cd9e5b340417b1a601e2"
},
"react-router-declarative-mode": {
"source": "remix-run/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-router-declarative-mode/SKILL.md",
"computedHash": "d7ebbf1ede90809618f02cb3b3d37b9871cdd6c88a81cf338e63de50a0df6a42"
},
"react-router-framework-mode": {
"source": "remix-run/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-router-framework-mode/SKILL.md",
"computedHash": "26c5bdac2f686c47eb4c4b48b6cb52401cde1dc833e6d26408ddfb22ea83c5ca"
},
"vercel-react-best-practices": {
"source": "vercel-labs/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-best-practices/SKILL.md",
"computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212"
},
"x-components": {
"source": "ant-design/x",
"ref": "main",
"sourceType": "github",
"skillPath": "packages/x-skill/skills/x-components/SKILL.md",
"computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2"
"computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
},
"x-markdown": {
"source": "ant-design/x",
"ref": "main",
"sourceType": "github",
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
"computedHash": "2d26b8eda1692929e99a8b6163ef8b206f1f096a4a84507b50dbe836a7ec041e"
"computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17"
}
}
}

View File

@@ -4,11 +4,13 @@ import type { RuntimeMode } from "../shared/api";
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
import type { MigrationRecord } from "./db/load-migrations";
import type { Logger } from "./logger";
import type { MaterialProcessor } from "./processing";
import type { StartServerOptions } from "./server";
import { loadServerConfig } from "./config";
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
import { createConsoleFallback, createRuntimeLogger } from "./logger";
import { startMaterialProcessor } from "./processing";
import { startServer } from "./server";
export interface BootstrapDependencies {
@@ -66,8 +68,11 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
const db = createDatabase(config.dataDir, logger!);
runMigrations(db, migrations, config.dataDir, logger!);
const processor: MaterialProcessor = startMaterialProcessor(db, logger!.child({ component: "processor" }));
const shutdown = () => {
logger?.info("收到退出信号,开始优雅关闭");
processor.stop();
db.close();
logger?.flush();
exit(0);

View File

@@ -1,5 +1,5 @@
import { isNumber, isString } from "es-toolkit";
import { dirname, isAbsolute, resolve } from "node:path";
import { isNumber, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "./config/issues";
import type { LoggingConfig, LogLevel, ResolvedConfig, ResolvedLoggingConfig, RotationFrequency } from "./config/types";

View File

@@ -1,15 +1,17 @@
import type { SQL } from "drizzle-orm";
import type { Column, SQL } from "drizzle-orm";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import Database from "bun:sqlite";
import { and, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { join } from "node:path";
import { and, eq, isNull, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import type { Logger } from "../logger";
const DB_FILENAME = "alfred.db";
export type DrizzleDB = ReturnType<typeof wrap>;
export interface PaginateResult<T> {
items: T[];
page: number;
@@ -30,6 +32,10 @@ export function createDatabase(dataDir: string, logger: Logger): Database {
return db;
}
export function notDeleted(table: { deletedAt: Column }): SQL {
return isNull(table.deletedAt);
}
export function paginateQuery<T extends SQLiteTable, R>(
raw: Database,
table: T,
@@ -39,11 +45,16 @@ export function paginateQuery<T extends SQLiteTable, R>(
orderBy?: (table: T) => SQL | undefined;
page: number;
pageSize: number;
softDelete?: Column;
},
): PaginateResult<R> {
const db = wrap(raw);
const where = options.conditions?.filter((c): c is SQL => c !== undefined);
const whereClause = where && where.length > 0 ? and(...where) : undefined;
const conditions = [...(options.conditions ?? [])];
if (options.softDelete) {
conditions.push(isNull(options.softDelete));
}
const where = conditions.filter((c): c is SQL => c !== undefined);
const whereClause = where.length > 0 ? and(...where) : undefined;
const countResult = db
.select({ count: sql<number>`count(*)` })
@@ -70,6 +81,24 @@ export function paginateQuery<T extends SQLiteTable, R>(
};
}
export function softDeleteRecord<T extends SQLiteTable>(
db: DrizzleDB,
table: T,
id: string,
): T["$inferSelect"] | undefined {
const now = timestamp();
return db
.update(table)
.set({ deletedAt: now, updatedAt: now } as Partial<T["$inferInsert"]>)
.where(eq((table as unknown as { id: Column }).id, id))
.returning()
.get();
}
export function timestamp(): string {
return new Date().toISOString();
}
export function wrap(raw: Database) {
return drizzle(raw);
}

View File

@@ -1,33 +1,36 @@
import type Database from "bun:sqlite";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq, isNull } from "drizzle-orm";
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
import { conversations, messages, models } from "./schema";
export function createConversation(
raw: Database,
projectId: string,
logger: Logger,
_logger: Logger,
defaultModelId?: string,
): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw);
let modelId = defaultModelId;
if (!modelId) {
const firstModel = db.select().from(models).limit(1).get();
if (!firstModel) return { error: "没有可用的模型,请先配置模型", status: 400 };
modelId = firstModel.id;
} else {
const model = db.select().from(models).where(eq(models.id, modelId)).get();
let modelId: null | string = defaultModelId ?? null;
if (defaultModelId) {
const model = db
.select()
.from(models)
.where(and(eq(models.id, defaultModelId), notDeleted(models)))
.get();
if (!model) return { error: "模型不存在", status: 400 };
} else {
const firstModel = db.select().from(models).where(notDeleted(models)).limit(1).get();
if (firstModel) modelId = firstModel.id;
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
const now = timestamp();
db.insert(conversations)
.values({
@@ -56,7 +59,7 @@ export function createMessage(
): Message {
const db = wrap(raw);
const id = crypto.randomUUID();
const now = new Date().toISOString();
const now = timestamp();
db.insert(messages)
.values({
@@ -66,6 +69,7 @@ export function createMessage(
id,
parts: data.parts ?? null,
role: data.role,
updatedAt: now,
})
.run();
@@ -84,7 +88,7 @@ export function createMessages(
_logger: Logger,
): Message[] {
const db = wrap(raw);
const now = new Date().toISOString();
const now = timestamp();
const results: Message[] = [];
for (const item of data) {
@@ -97,6 +101,7 @@ export function createMessages(
id,
parts: item.parts ?? null,
role: item.role,
updatedAt: now,
})
.run();
const row = db.select().from(messages).where(eq(messages.id, id)).get();
@@ -112,11 +117,23 @@ export function deleteConversation(
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
const existing = db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), notDeleted(conversations)))
.get();
if (!existing) return { error: "会话不存在", status: 404 };
db.delete(messages).where(eq(messages.conversationId, id)).run();
db.delete(conversations).where(eq(conversations.id, id)).run();
const now = timestamp();
db.transaction((tx) => {
tx.update(messages)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(messages.conversationId, id), isNull(messages.deletedAt)))
.run();
tx.update(conversations).set({ deletedAt: now, updatedAt: now }).where(eq(conversations.id, id)).run();
});
return { success: true };
}
@@ -125,7 +142,11 @@ export function getConversation(
id: string,
): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw);
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
const row = db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), notDeleted(conversations)))
.get();
if (!row) return { error: "会话不存在", status: 404 };
return { conversation: toConversation(row) };
}
@@ -141,6 +162,7 @@ export function listConversations(
orderBy: () => desc(conversations.updatedAt),
page: options.page,
pageSize: options.pageSize,
softDelete: conversations.deletedAt,
});
}
@@ -155,6 +177,7 @@ export function listMessages(
orderBy: () => desc(messages.createdAt),
page: options.page,
pageSize: options.pageSize,
softDelete: messages.deletedAt,
});
}
@@ -165,13 +188,21 @@ export function updateConversation(
_logger: Logger,
): { conversation: Conversation } | { error: string; status: number } {
const db = wrap(raw);
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
const existing = db
.select()
.from(conversations)
.where(and(eq(conversations.id, id), notDeleted(conversations)))
.get();
if (!existing) return { error: "会话不存在", status: 404 };
const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() };
const updates: { modelId?: null | string; title?: string; updatedAt: string } = { updatedAt: timestamp() };
if (data.modelId !== undefined) {
const model = db.select().from(models).where(eq(models.id, data.modelId)).get();
const model = db
.select()
.from(models)
.where(and(eq(models.id, data.modelId), notDeleted(models)))
.get();
if (!model) return { error: "模型不存在", status: 400 };
updates.modelId = data.modelId;
}
@@ -188,7 +219,7 @@ export function updateConversation(
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();
db.update(conversations).set({ updatedAt: timestamp() }).where(eq(conversations.id, id)).run();
}
function toConversation(row: typeof conversations.$inferSelect): Conversation {
@@ -210,5 +241,6 @@ function toMessage(row: typeof messages.$inferSelect): Message {
id: row.id,
parts: row.parts,
role: row.role,
updatedAt: row.updatedAt,
};
}

196
src/server/db/entities.ts Normal file
View File

@@ -0,0 +1,196 @@
import type Database from "bun:sqlite";
import { and, desc, eq } from "drizzle-orm";
import type { CreateEntityRequest, Entity, EntityType, UpdateEntityRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { entities, projects } from "./schema";
export function createEntity(
raw: Database,
projectId: string,
request: CreateEntityRequest,
_logger: Logger,
): { entity: Entity } | { error: string; status: number } {
const db = wrap(raw);
const project = db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), notDeleted(projects)))
.get();
if (!project) return { error: "项目不存在", status: 404 };
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
const name = request.name.trim();
if (!name) return { error: "实体名称不能为空", status: 400 };
const duplicate = db
.select({ id: entities.id })
.from(entities)
.where(and(eq(entities.projectId, projectId), eq(entities.name, name), notDeleted(entities)))
.get();
if (duplicate) return { error: "实体名称已存在", status: 409 };
const id = crypto.randomUUID();
const now = timestamp();
db.insert(entities)
.values({
aliases: JSON.stringify(request.aliases ?? []),
createdAt: now,
description: request.description?.trim() ?? "",
id,
name,
projectId,
type: request.type,
updatedAt: now,
})
.run();
const row = db.select().from(entities).where(eq(entities.id, id)).get();
return { entity: toEntity(row!) };
}
export function deleteEntity(
raw: Database,
projectId: string,
entityId: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const row = db
.select()
.from(entities)
.where(and(eq(entities.id, entityId), notDeleted(entities)))
.get();
if (!row) return { error: "实体不存在", status: 404 };
if (row.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
softDeleteRecord(db, entities, entityId);
return { success: true };
}
export function getEntity(
raw: Database,
projectId: string,
entityId: string,
): { entity: Entity } | { error: string; status: number } {
const db = wrap(raw);
const row = db
.select()
.from(entities)
.where(and(eq(entities.id, entityId), notDeleted(entities)))
.get();
if (!row) return { error: "实体不存在", status: 404 };
if (row.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
return { entity: toEntity(row) };
}
export function listEntities(
raw: Database,
projectId: string,
options: { page: number; pageSize: number; type?: EntityType },
): { items: Entity[]; page: number; pageSize: number; total: number } {
const conditions = [eq(entities.projectId, projectId)];
if (options.type) {
conditions.push(eq(entities.type, options.type));
}
return paginateQuery(raw, entities, {
conditions,
mapRow: toEntity,
orderBy: () => desc(entities.createdAt),
page: options.page,
pageSize: options.pageSize,
softDelete: entities.deletedAt,
});
}
export function listEntityNames(
raw: Database,
projectId: string,
): Array<{ aliases: string[]; id: string; name: string }> {
const db = wrap(raw);
const rows = db
.select({ aliases: entities.aliases, id: entities.id, name: entities.name })
.from(entities)
.where(and(eq(entities.projectId, projectId), notDeleted(entities)))
.all();
return rows.map((row) => ({
aliases: JSON.parse(row.aliases) as string[],
id: row.id,
name: row.name,
}));
}
export function updateEntity(
raw: Database,
projectId: string,
entityId: string,
request: UpdateEntityRequest,
_logger: Logger,
): { entity: Entity } | { error: string; status: number } {
const db = wrap(raw);
const existing = db
.select()
.from(entities)
.where(and(eq(entities.id, entityId), notDeleted(entities)))
.get();
if (!existing) return { error: "实体不存在", status: 404 };
if (existing.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
const updates: Partial<typeof entities.$inferInsert> = {
updatedAt: timestamp(),
};
const name = request.name?.trim();
if (name === "") return { error: "实体名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) {
const duplicate = db
.select({ id: entities.id })
.from(entities)
.where(and(eq(entities.projectId, projectId), eq(entities.name, name), notDeleted(entities)))
.get();
if (duplicate) return { error: "实体名称已存在", status: 409 };
updates.name = name;
}
if (request.type !== undefined) {
updates.type = request.type;
}
if (request.description !== undefined) {
updates.description = request.description.trim();
}
if (request.aliases !== undefined) {
updates.aliases = JSON.stringify(request.aliases);
}
if (Object.keys(updates).length === 1 && updates.updatedAt) {
return { entity: toEntity(existing) };
}
db.update(entities).set(updates).where(eq(entities.id, entityId)).run();
const updated = db.select().from(entities).where(eq(entities.id, entityId)).get();
return { entity: toEntity(updated!) };
}
function toEntity(row: typeof entities.$inferSelect): Entity {
return {
aliases: JSON.parse(row.aliases) as string[],
createdAt: row.createdAt,
description: row.description,
id: row.id,
name: row.name,
projectId: row.projectId,
type: row.type as EntityType,
updatedAt: row.updatedAt,
};
}

12
src/server/db/helpers.ts Normal file
View File

@@ -0,0 +1,12 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export { index, integer, sqliteTable, text, uniqueIndex };
export const baseColumns = {
createdAt: text("created_at").notNull(),
deletedAt: text("deleted_at"),
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
updatedAt: text("updated_at").notNull(),
};

View File

@@ -20,4 +20,5 @@ export {
listModels,
updateModel,
} from "./models";
export { conversations, messages, projects, schemaMigrations } from "./schema";
export { conversations, messages, projects, schemaMigrations, settings } from "./schema";
export { getSettings, updateSettings } from "./settings";

255
src/server/db/materials.ts Normal file
View File

@@ -0,0 +1,255 @@
import type Database from "bun:sqlite";
import { and, desc, eq } from "drizzle-orm";
import type {
CreateMaterialRequest,
EntityConfirmation,
Material,
MaterialStatus,
MaterialType,
ProcessingResult,
} from "../../shared/api";
import type { Logger } from "../logger";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { createEntity, updateEntity } from "./entities";
import { entities as schema_entities, materials, projects } from "./schema";
const ALLOWED_MATERIAL_TYPES: MaterialType[] = ["general", "meeting"];
export function approveMaterial(
raw: Database,
projectId: string,
materialId: string,
entityConfirmations: EntityConfirmation[],
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "review") return { error: "仅待审核素材可通过", status: 409 };
if (row.processedContent && entityConfirmations.length > 0) {
try {
const processingResult = JSON.parse(row.processedContent) as ProcessingResult;
for (const confirmation of entityConfirmations) {
const candidate = processingResult.candidateEntities[confirmation.candidateIndex];
if (!candidate) continue;
if (confirmation.action === "create") {
createEntity(
raw,
projectId,
{ description: candidate.context, name: candidate.name, type: candidate.type },
_logger,
);
} else if (confirmation.action === "merge" && confirmation.targetEntityId) {
const entityResult = getEntityForMerge(raw, confirmation.targetEntityId);
if (entityResult) {
const newAliases = [...entityResult.aliases];
if (!newAliases.includes(candidate.name)) {
newAliases.push(candidate.name);
}
updateEntity(raw, projectId, confirmation.targetEntityId, { aliases: newAliases }, _logger);
}
}
}
} catch {
// processedContent 解析失败时不阻塞审核通过
}
}
const now = timestamp();
db.update(materials).set({ status: "approved", updatedAt: now }).where(eq(materials.id, materialId)).run();
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
return { material: toMaterial(updated!) };
}
function getEntityForMerge(raw: Database, entityId: string): { aliases: string[] } | null {
const db = wrap(raw);
const row = db
.select({ aliases: schema_entities.aliases })
.from(schema_entities)
.where(and(eq(schema_entities.id, entityId), notDeleted(schema_entities)))
.get();
if (!row) return null;
return { aliases: JSON.parse(row.aliases) as string[] };
}
export function createMaterial(
raw: Database,
projectId: string,
request: CreateMaterialRequest,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const project = db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), notDeleted(projects)))
.get();
if (!project) return { error: "项目不存在", status: 404 };
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
const description = request.description.trim();
if (!description) return { error: "描述不能为空", status: 400 };
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(request.associatedDate)) {
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
}
const materialType: MaterialType = request.materialType ?? "general";
if (!ALLOWED_MATERIAL_TYPES.includes(materialType)) {
return { error: "materialType 无效,仅支持 general/meeting", status: 400 };
}
const id = crypto.randomUUID();
const now = timestamp();
db.insert(materials)
.values({
associatedDate: request.associatedDate,
createdAt: now,
description,
id,
materialType,
projectId,
status: "pending",
updatedAt: now,
})
.run();
const row = db.select().from(materials).where(eq(materials.id, id)).get();
return { material: toMaterial(row!) };
}
export function deleteMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status === "processing") {
return { error: "处理中的素材不可删除", status: 409 };
}
softDeleteRecord(db, materials, materialId);
return { success: true };
}
export function discardMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "review") return { error: "仅待审核素材可放弃", status: 409 };
const now = timestamp();
db.update(materials).set({ status: "discarded", updatedAt: now }).where(eq(materials.id, materialId)).run();
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
return { material: toMaterial(updated!) };
}
export function getMaterial(
raw: Database,
projectId: string,
materialId: string,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
return { material: toMaterial(row) };
}
export function listMaterials(
raw: Database,
projectId: string,
options: { page: number; pageSize: number; status?: MaterialStatus },
): { items: Material[]; page: number; pageSize: number; total: number } {
const conditions = [eq(materials.projectId, projectId)];
if (options.status) {
conditions.push(eq(materials.status, options.status));
}
return paginateQuery(raw, materials, {
conditions,
mapRow: toMaterial,
orderBy: () => desc(materials.createdAt),
page: options.page,
pageSize: options.pageSize,
softDelete: materials.deletedAt,
});
}
export function retryMaterial(
raw: Database,
projectId: string,
materialId: string,
_logger: Logger,
): { error: string; status: number } | { material: Material } {
const db = wrap(raw);
const row = db
.select()
.from(materials)
.where(and(eq(materials.id, materialId), notDeleted(materials)))
.get();
if (!row) return { error: "素材不存在", status: 404 };
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "failed") return { error: "仅失败素材可重试", status: 409 };
const now = timestamp();
db.update(materials)
.set({ processedContent: null, status: "pending", updatedAt: now })
.where(eq(materials.id, materialId))
.run();
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
return { material: toMaterial(updated!) };
}
function toMaterial(row: typeof materials.$inferSelect): Material {
return {
associatedDate: row.associatedDate,
createdAt: row.createdAt,
description: row.description,
id: row.id,
materialType: row.materialType,
processedContent: row.processedContent,
projectId: row.projectId,
status: row.status,
updatedAt: row.updatedAt,
};
}

View File

@@ -33,19 +33,29 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
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;
db.exec("PRAGMA foreign_keys = OFF");
try {
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;
}
}
}
})();
})();
} finally {
db.exec("PRAGMA foreign_keys = ON");
}
const violations = db.query("PRAGMA foreign_key_check").all();
if (violations.length > 0) {
logger.error({ violations }, "迁移后外键完整性检查失败");
}
logger.info({ count: pending.length }, "migration 全部执行完成");
}

View File

@@ -1,59 +1,61 @@
import type Database from "bun:sqlite";
import { desc, eq, like, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, isNull, like, ne, or, sql } from "drizzle-orm";
import type { CreateModelRequest, Model, ModelCapability, UpdateModelRequest } from "../../shared/api";
import type { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { models, providers } from "./schema";
export function createModel(
raw: Database,
request: CreateModelRequest,
logger: Logger,
_logger: Logger,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
const provider = db
.select()
.from(providers)
.where(and(eq(providers.id, request.providerId), notDeleted(providers)))
.get();
if (!provider) return { error: "供应商不存在", status: 400 };
const name = request.name.trim();
if (!name) return { error: "模型名称不能为空", status: 400 };
const modelId = request.modelId.trim();
if (!modelId) return { error: "模型 ID 不能为空", status: 400 };
const externalId = request.externalId.trim();
if (!externalId) return { error: "模型 ID 不能为空", status: 400 };
const capabilities = request.capabilities;
if (!capabilities || capabilities.length === 0) {
return { error: "至少选择一个能力标签", status: 400 };
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
const duplicate = db
.select({ id: models.id })
.from(models)
.where(and(eq(models.providerId, request.providerId), eq(models.externalId, externalId), notDeleted(models)))
.get();
if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 };
try {
db.insert(models)
.values({
capabilities: JSON.stringify(capabilities),
contextLength: request.contextLength ?? null,
createdAt: now,
id,
maxOutputTokens: request.maxOutputTokens ?? null,
modelId,
name,
providerId: request.providerId,
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败");
throw e;
}
const id = crypto.randomUUID();
const now = timestamp();
db.insert(models)
.values({
capabilities: JSON.stringify(capabilities),
contextLength: request.contextLength ?? null,
createdAt: now,
externalId,
id,
maxOutputTokens: request.maxOutputTokens ?? null,
name,
providerId: request.providerId,
updatedAt: now,
})
.run();
const row = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(row!) };
@@ -65,16 +67,24 @@ export function deleteModel(
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
const existing = db
.select()
.from(models)
.where(and(eq(models.id, id), notDeleted(models)))
.get();
if (!existing) return { error: "模型不存在", status: 404 };
db.delete(models).where(eq(models.id, id)).run();
softDeleteRecord(db, models, id);
return { success: true };
}
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const row = db.select().from(models).where(eq(models.id, id)).get();
const row = db
.select()
.from(models)
.where(and(eq(models.id, id), notDeleted(models)))
.get();
if (!row) return { error: "模型不存在", status: 404 };
return { model: toModel(row) };
@@ -85,7 +95,7 @@ export function getModelsByProviderId(raw: Database, providerId: string): number
const result = db
.select({ count: sql<number>`count(*)` })
.from(models)
.where(eq(models.providerId, providerId))
.where(and(eq(models.providerId, providerId), isNull(models.deletedAt)))
.get();
return Number(result?.count ?? 0);
}
@@ -96,20 +106,28 @@ export function getModelWithProvider(
):
| { error: string; status: number }
| {
model: { modelId: string; name: string; providerId: string };
model: { externalId: 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();
const row = db
.select()
.from(models)
.where(and(eq(models.id, modelId), notDeleted(models)))
.get();
if (!row) return { error: "模型不存在", status: 404 };
const providerRow = db.select().from(providers).where(eq(providers.id, row.providerId)).get();
const providerRow = db
.select()
.from(providers)
.where(and(eq(providers.id, row.providerId), notDeleted(providers)))
.get();
if (!providerRow) return { error: "供应商不存在", status: 404 };
return {
model: {
modelId: row.modelId,
externalId: row.externalId,
name: row.name,
providerId: row.providerId,
},
@@ -124,7 +142,15 @@ export function getModelWithProvider(
export function listModels(
raw: Database,
options: { keyword?: string; page: number; pageSize: number; providerId?: string },
options: {
capabilities?: string;
keyword?: string;
page: number;
pageSize: number;
providerId?: string;
sortBy?: string;
sortOrder?: SortOrder;
},
): { items: Model[]; page: number; pageSize: number; total: number } {
const conditions = [];
@@ -134,15 +160,22 @@ export function listModels(
if (options.keyword) {
const pattern = `%${options.keyword}%`;
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
conditions.push(or(like(models.name, pattern), like(models.externalId, pattern))!);
}
if (options.capabilities) {
conditions.push(like(models.capabilities, `%"${options.capabilities}"%`));
}
const orderByFn = buildModelOrderBy(options.sortBy, options.sortOrder);
return paginateQuery(raw, models, {
conditions,
mapRow: toModel,
orderBy: () => desc(models.createdAt),
orderBy: orderByFn,
page: options.page,
pageSize: options.pageSize,
softDelete: models.deletedAt,
});
}
@@ -150,14 +183,18 @@ export function updateModel(
raw: Database,
id: string,
request: UpdateModelRequest,
logger: Logger,
_logger: Logger,
): { error: string; status: number } | { model: Model } {
const db = wrap(raw);
const existing = db.select().from(models).where(eq(models.id, id)).get();
const existing = db
.select()
.from(models)
.where(and(eq(models.id, id), notDeleted(models)))
.get();
if (!existing) return { error: "模型不存在", status: 404 };
const updates: Partial<typeof models.$inferInsert> = {
updatedAt: new Date().toISOString(),
updatedAt: timestamp(),
};
const name = request.name?.trim();
@@ -166,14 +203,32 @@ export function updateModel(
updates.name = name;
}
const modelId = request.modelId?.trim();
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 };
if (modelId !== undefined) {
updates.modelId = modelId;
const externalId = request.externalId?.trim();
if (externalId === "") return { error: "模型 ID 不能为空", status: 400 };
if (externalId !== undefined) {
const providerId = request.providerId ?? existing.providerId;
const duplicate = db
.select({ id: models.id })
.from(models)
.where(
and(
eq(models.providerId, providerId),
eq(models.externalId, externalId),
notDeleted(models),
ne(models.id, id),
),
)
.get();
if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 };
updates.externalId = externalId;
}
if (request.providerId !== undefined) {
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
const provider = db
.select()
.from(providers)
.where(and(eq(providers.id, request.providerId), notDeleted(providers)))
.get();
if (!provider) return { error: "供应商不存在", status: 400 };
updates.providerId = request.providerId;
}
@@ -197,29 +252,31 @@ export function updateModel(
return { model: toModel(existing) };
}
try {
db.update(models).set(updates).where(eq(models.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "该供应商下模型 ID 已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "models" }, "数据库操作失败");
throw e;
}
db.update(models).set(updates).where(eq(models.id, id)).run();
const updated = db.select().from(models).where(eq(models.id, id)).get();
return { model: toModel(updated!) };
}
function buildModelOrderBy(
sortBy: string | undefined,
sortOrder: SortOrder | undefined,
): ((table: typeof models) => ReturnType<typeof desc>) | undefined {
if (!sortBy) return (t) => desc(t.createdAt);
return sortOrder === "asc"
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
}
function toModel(row: typeof models.$inferSelect): Model {
return {
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
contextLength: row.contextLength,
createdAt: row.createdAt,
externalId: row.externalId,
id: row.id,
maxOutputTokens: row.maxOutputTokens,
modelId: row.modelId,
name: row.name,
providerId: row.providerId,
updatedAt: row.updatedAt,

View File

@@ -1,12 +1,12 @@
import type Database from "bun:sqlite";
import { desc, eq, like, or } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, like, ne, or } from "drizzle-orm";
import type { CreateProjectRequest, Project, ProjectStatus, UpdateProjectRequest } from "../../shared/api";
import type { CreateProjectRequest, Project, ProjectStatus, SortOrder, UpdateProjectRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { projects } from "./schema";
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
import { conversations, materials, messages, projects } from "./schema";
export function archiveProject(
raw: Database,
@@ -14,12 +14,16 @@ export function archiveProject(
_logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "archived") return { error: "项目已归档", status: 409 };
const now = new Date().toISOString();
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
const now = timestamp();
db.update(projects).set({ status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) };
@@ -28,37 +32,34 @@ export function archiveProject(
export function createProject(
raw: Database,
request: CreateProjectRequest,
logger: Logger,
_logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const name = request.name.trim();
if (!name) return { error: "项目名称不能为空", status: 400 };
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const duplicate = db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, name), notDeleted(projects)))
.get();
if (duplicate) return { error: "项目名称已存在", status: 409 };
const description = (request.description ?? "").trim();
const id = crypto.randomUUID();
const now = new Date().toISOString();
const now = timestamp();
try {
db.insert(projects)
.values({
archivedAt: null,
createdAt: now,
description,
id,
name,
status: "active",
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "projects" }, "数据库操作失败");
throw e;
}
db.insert(projects)
.values({
createdAt: now,
description,
id,
name,
status: "active",
updatedAt: now,
})
.run();
const row = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(row!) };
@@ -70,17 +71,53 @@ export function deleteProject(
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 };
db.delete(projects).where(eq(projects.id, id)).run();
const now = timestamp();
db.transaction((tx) => {
const convIds = tx
.select({ id: conversations.id })
.from(conversations)
.where(and(eq(conversations.projectId, id), isNull(conversations.deletedAt)))
.all()
.map((r) => r.id);
if (convIds.length > 0) {
tx.update(messages)
.set({ deletedAt: now, updatedAt: now })
.where(and(inArray(messages.conversationId, convIds), isNull(messages.deletedAt)))
.run();
tx.update(conversations)
.set({ deletedAt: now, updatedAt: now })
.where(and(inArray(conversations.id, convIds), isNull(conversations.deletedAt)))
.run();
}
tx.update(materials)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(materials.projectId, id), isNull(materials.deletedAt)))
.run();
tx.update(projects).set({ deletedAt: now, updatedAt: now }).where(eq(projects.id, id)).run();
});
return { success: true };
}
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const row = db.select().from(projects).where(eq(projects.id, id)).get();
const row = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!row) return { error: "项目不存在", status: 404 };
return { project: toProject(row) };
@@ -88,7 +125,14 @@ export function getProject(raw: Database, id: string): { error: string; status:
export function listProjects(
raw: Database,
options: { keyword?: string; page: number; pageSize: number; status?: ProjectStatus },
options: {
keyword?: string;
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: SortOrder;
status?: ProjectStatus;
},
): { items: Project[]; page: number; pageSize: number; total: number } {
const conditions = [];
@@ -101,12 +145,15 @@ export function listProjects(
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
}
const orderByFn = buildProjectOrderBy(options.sortBy, options.sortOrder);
return paginateQuery(raw, projects, {
conditions,
mapRow: toProject,
orderBy: () => desc(projects.createdAt),
orderBy: orderByFn,
page: options.page,
pageSize: options.pageSize,
softDelete: projects.deletedAt,
});
}
@@ -116,12 +163,16 @@ export function restoreProject(
_logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
const now = new Date().toISOString();
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
const now = timestamp();
db.update(projects).set({ status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) };
@@ -131,10 +182,14 @@ export function updateProject(
raw: Database,
id: string,
request: UpdateProjectRequest,
logger: Logger,
_logger: Logger,
): { error: string; status: number } | { project: Project } {
const db = wrap(raw);
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
const existing = db
.select()
.from(projects)
.where(and(eq(projects.id, id), notDeleted(projects)))
.get();
if (!existing) return { error: "项目不存在", status: 404 };
if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
@@ -143,10 +198,16 @@ export function updateProject(
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
const updates: Partial<typeof projects.$inferInsert> = {
updatedAt: new Date().toISOString(),
updatedAt: timestamp(),
};
if (name !== undefined && name !== existing.name) {
const duplicate = db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.name, name), notDeleted(projects), ne(projects.id, id)))
.get();
if (duplicate) return { error: "项目名称已存在", status: 409 };
updates.name = name;
}
@@ -159,24 +220,25 @@ export function updateProject(
return { project: toProject(existing) };
}
try {
db.update(projects).set(updates).where(eq(projects.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "项目名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "projects" }, "数据库操作失败");
throw e;
}
db.update(projects).set(updates).where(eq(projects.id, id)).run();
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
return { project: toProject(updated!) };
}
function buildProjectOrderBy(
sortBy: string | undefined,
sortOrder: SortOrder | undefined,
): ((table: typeof projects) => ReturnType<typeof desc>) | undefined {
if (!sortBy) return (t) => desc(t.createdAt);
return sortOrder === "asc"
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
}
function toProject(row: typeof projects.$inferSelect): Project {
return {
archivedAt: row.archivedAt,
createdAt: row.createdAt,
description: row.description,
id: row.id,

View File

@@ -1,17 +1,23 @@
import type Database from "bun:sqlite";
import { desc, eq, like } from "drizzle-orm";
import { and, asc, desc, eq, isNull, like, ne } from "drizzle-orm";
import type { CreateProviderRequest, Provider, ProviderOption, UpdateProviderRequest } from "../../shared/api";
import type {
CreateProviderRequest,
Provider,
ProviderOption,
SortOrder,
UpdateProviderRequest,
} from "../../shared/api";
import type { Logger } from "../logger";
import { paginateQuery, wrap } from "./connection";
import { providers } from "./schema";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { models, providers } from "./schema";
export function createProvider(
raw: Database,
request: CreateProviderRequest,
logger: Logger,
_logger: Logger,
): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const name = request.name.trim();
@@ -23,29 +29,27 @@ export function createProvider(
const apiKey = request.apiKey.trim();
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
const id = crypto.randomUUID();
const now = new Date().toISOString();
const duplicate = db
.select({ id: providers.id })
.from(providers)
.where(and(eq(providers.name, name), notDeleted(providers)))
.get();
if (duplicate) return { error: "供应商名称已存在", status: 409 };
try {
db.insert(providers)
.values({
apiKey,
baseUrl,
createdAt: now,
id,
name,
type: request.type,
updatedAt: now,
})
.run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败");
throw e;
}
const id = crypto.randomUUID();
const now = timestamp();
db.insert(providers)
.values({
apiKey,
baseUrl,
createdAt: now,
id,
name,
type: request.type,
updatedAt: now,
})
.run();
const row = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(row!) };
@@ -57,16 +61,31 @@ export function deleteProvider(
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
const existing = db
.select()
.from(providers)
.where(and(eq(providers.id, id), notDeleted(providers)))
.get();
if (!existing) return { error: "供应商不存在", status: 404 };
db.delete(providers).where(eq(providers.id, id)).run();
const activeModels = db
.select({ id: models.id })
.from(models)
.where(and(eq(models.providerId, id), isNull(models.deletedAt)))
.get();
if (activeModels) return { error: "该供应商下仍有模型,无法删除", status: 409 };
softDeleteRecord(db, providers, id);
return { success: true };
}
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const row = db.select().from(providers).where(eq(providers.id, id)).get();
const row = db
.select()
.from(providers)
.where(and(eq(providers.id, id), notDeleted(providers)))
.get();
if (!row) return { error: "供应商不存在", status: 404 };
return { provider: toProvider(row) };
@@ -77,6 +96,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
const rows = db
.select({ id: providers.id, name: providers.name, type: providers.type })
.from(providers)
.where(notDeleted(providers))
.orderBy(desc(providers.createdAt))
.all();
@@ -85,7 +105,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
export function listProviders(
raw: Database,
options: { keyword?: string; page: number; pageSize: number },
options: { keyword?: string; page: number; pageSize: number; sortBy?: string; sortOrder?: SortOrder; type?: string },
): { items: Provider[]; page: number; pageSize: number; total: number } {
const conditions = [];
@@ -94,12 +114,19 @@ export function listProviders(
conditions.push(like(providers.name, pattern));
}
if (options.type) {
conditions.push(eq(providers.type, options.type as Provider["type"]));
}
const orderByFn = buildProviderOrderBy(options.sortBy, options.sortOrder);
return paginateQuery(raw, providers, {
conditions,
mapRow: toProvider,
orderBy: () => desc(providers.createdAt),
orderBy: orderByFn,
page: options.page,
pageSize: options.pageSize,
softDelete: providers.deletedAt,
});
}
@@ -107,19 +134,29 @@ export function updateProvider(
raw: Database,
id: string,
request: UpdateProviderRequest,
logger: Logger,
_logger: Logger,
): { error: string; status: number } | { provider: Provider } {
const db = wrap(raw);
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
const existing = db
.select()
.from(providers)
.where(and(eq(providers.id, id), notDeleted(providers)))
.get();
if (!existing) return { error: "供应商不存在", status: 404 };
const updates: Partial<typeof providers.$inferInsert> = {
updatedAt: new Date().toISOString(),
updatedAt: timestamp(),
};
const name = request.name?.trim();
if (name === "") return { error: "供应商名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) {
const duplicate = db
.select({ id: providers.id })
.from(providers)
.where(and(eq(providers.name, name), notDeleted(providers), ne(providers.id, id)))
.get();
if (duplicate) return { error: "供应商名称已存在", status: 409 };
updates.name = name;
}
@@ -143,21 +180,23 @@ export function updateProvider(
return { provider: toProvider(existing) };
}
try {
db.update(providers).set(updates).where(eq(providers.id, id)).run();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("UNIQUE constraint")) {
return { error: "供应商名称已存在", status: 409 };
}
logger.error({ error: msg, operation: "update", table: "providers" }, "数据库操作失败");
throw e;
}
db.update(providers).set(updates).where(eq(providers.id, id)).run();
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
return { provider: toProvider(updated!) };
}
function buildProviderOrderBy(
sortBy: string | undefined,
sortOrder: SortOrder | undefined,
): ((table: typeof providers) => ReturnType<typeof desc>) | undefined {
if (!sortBy) return (t) => desc(t.createdAt);
return sortOrder === "asc"
? (t) => asc(t[sortBy as keyof typeof t] as Parameters<typeof asc>[0])
: (t) => desc(t[sortBy as keyof typeof t] as Parameters<typeof desc>[0]);
}
function toProvider(row: typeof providers.$inferSelect): Provider {
return {
apiKey: row.apiKey,

View File

@@ -1,84 +1,114 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { baseColumns, index, integer, sqliteTable, text } from "./helpers";
export const projects = sqliteTable("projects", {
archivedAt: text("archived_at"),
createdAt: text("created_at").notNull(),
...baseColumns,
description: text("description").notNull().default(""),
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
name: text("name").notNull(),
status: text("status", { enum: ["active", "archived"] })
.notNull()
.default("active"),
updatedAt: text("updated_at").notNull(),
});
export const providers = sqliteTable("providers", {
...baseColumns,
apiKey: text("api_key").notNull(),
baseUrl: text("base_url").notNull(),
createdAt: text("created_at").notNull(),
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
name: text("name").notNull(),
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
.notNull()
.default("openai-compatible"),
updatedAt: text("updated_at").notNull(),
});
export const models = sqliteTable(
"models",
{
...baseColumns,
capabilities: text("capabilities").notNull(),
contextLength: integer("context_length"),
createdAt: text("created_at").notNull(),
id: text("id").primaryKey(),
externalId: text("external_id").notNull(),
maxOutputTokens: integer("max_output_tokens"),
modelId: text("model_id").notNull(),
name: text("name").notNull(),
providerId: text("provider_id")
.notNull()
.references(() => providers.id),
updatedAt: text("updated_at").notNull(),
},
(table) => [
uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId),
index("models_provider_id_idx").on(table.providerId),
],
(table) => [index("models_provider_id_idx").on(table.providerId)],
);
export const conversations = sqliteTable(
"conversations",
{
createdAt: text("created_at").notNull(),
id: text("id").primaryKey(),
modelId: text("model_id")
.notNull()
.references(() => models.id),
...baseColumns,
modelId: text("model_id").references(() => models.id),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
title: text("title").notNull().default("新会话"),
updatedAt: text("updated_at").notNull(),
},
(table) => [index("conversations_project_id_idx").on(table.projectId)],
(table) => [
index("conversations_project_id_idx").on(table.projectId),
index("conversations_model_id_idx").on(table.modelId),
],
);
export const materials = sqliteTable(
"materials",
{
...baseColumns,
associatedDate: text("associated_date").notNull(),
description: text("description").notNull(),
materialType: text("material_type", { enum: ["general", "meeting"] })
.notNull()
.default("general"),
processedContent: text("processed_content"),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
status: text("status", {
enum: ["pending", "processing", "review", "approved", "discarded", "failed"],
})
.notNull()
.default("pending"),
},
(table) => [index("materials_project_id_idx").on(table.projectId)],
);
export const messages = sqliteTable(
"messages",
{
...baseColumns,
content: text("content").notNull().default(""),
conversationId: text("conversation_id")
.notNull()
.references(() => conversations.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull(),
id: text("id").primaryKey(),
.references(() => conversations.id),
parts: text("parts"),
role: text("role", { enum: ["assistant", "system", "user"] }).notNull(),
},
(table) => [index("messages_conversation_id_idx").on(table.conversationId)],
);
export const entities = sqliteTable(
"entities",
{
...baseColumns,
aliases: text("aliases").notNull().default("[]"),
description: text("description").notNull().default(""),
name: text("name").notNull(),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
type: text("type").notNull().default("other"),
},
(table) => [index("entities_project_id_idx").on(table.projectId), index("entities_name_idx").on(table.name)],
);
export const schemaMigrations = sqliteTable("schema_migrations", {
appliedAt: text("applied_at").notNull(),
checksum: text("checksum").notNull(),
id: text("id").primaryKey(),
});
export const settings = sqliteTable("settings", {
...baseColumns,
data: text("data").notNull().default("{}"),
});

74
src/server/db/settings.ts Normal file
View File

@@ -0,0 +1,74 @@
import type Database from "bun:sqlite";
import { and, eq } from "drizzle-orm";
import type { SettingsData } from "../../shared/api";
import type { Logger } from "../logger";
import { notDeleted, timestamp, wrap } from "./connection";
import { settings } from "./schema";
const SETTINGS_ID = "default";
export function getSettings(raw: Database): SettingsData {
const db = wrap(raw);
const row = db
.select()
.from(settings)
.where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings)))
.get();
if (!row) {
return { compact: false, theme: "system" };
}
try {
const parsed = JSON.parse(row.data) as Partial<SettingsData>;
return {
compact: typeof parsed.compact === "boolean" ? parsed.compact : false,
defaultModels: parsed.defaultModels,
theme: parsed.theme === "dark" || parsed.theme === "light" || parsed.theme === "system" ? parsed.theme : "system",
};
} catch {
return { compact: false, theme: "system" };
}
}
export function updateSettings(raw: Database, data: Partial<SettingsData>, _logger: Logger): SettingsData {
const db = wrap(raw);
const existing = db
.select()
.from(settings)
.where(and(eq(settings.id, SETTINGS_ID), notDeleted(settings)))
.get();
let currentData: SettingsData = { compact: false, theme: "system" };
if (existing) {
try {
currentData = JSON.parse(existing.data) as SettingsData;
} catch {
// 解析失败时使用默认值
}
}
const merged: SettingsData = { ...currentData, ...data };
if (existing) {
db.update(settings)
.set({ data: JSON.stringify(merged), updatedAt: timestamp() })
.where(eq(settings.id, SETTINGS_ID))
.run();
} else {
const now = timestamp();
db.insert(settings)
.values({
createdAt: now,
data: JSON.stringify(merged),
id: SETTINGS_ID,
updatedAt: now,
})
.run();
}
return merged;
}

View File

@@ -1,2 +1,4 @@
export type { ParsedListParams } from "./list-params";
export { parseListParams } from "./list-params";
export { createApiError, createHeaders, createMetaResponse, formatDuration, jsonResponse } from "./response";
export { parseIdFromUrl } from "./url";

View File

@@ -0,0 +1,60 @@
import type { RuntimeMode, SortOrder } from "../../shared/api";
import { createApiError, jsonResponse } from "./index";
export interface ParsedListParams {
keyword?: string;
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: SortOrder;
}
export function parseListParams(
url: URL,
mode: RuntimeMode,
options?: { allowedSortBy?: string[] },
): ParsedListParams | Response {
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
let page = 1;
let pageSize = 20;
if (pageParam !== null) {
page = Number(pageParam);
if (!Number.isInteger(page) || page <= 0) {
return jsonResponse(createApiError("无效的 page 参数", 400), { mode, status: 400 });
}
}
if (pageSizeParam !== null) {
pageSize = Number(pageSizeParam);
if (!Number.isInteger(pageSize) || pageSize <= 0) {
return jsonResponse(createApiError("无效的 pageSize 参数", 400), { mode, status: 400 });
}
if (pageSize > 200) {
return jsonResponse(createApiError("pageSize 不能超过 200", 400), { mode, status: 400 });
}
}
const keywordRaw = url.searchParams.get("keyword");
const keyword = keywordRaw === "" ? undefined : (keywordRaw ?? undefined);
const sortBy = url.searchParams.get("sortBy") ?? undefined;
const sortOrderParam = url.searchParams.get("sortOrder") ?? undefined;
if (sortBy && options?.allowedSortBy && !options.allowedSortBy.includes(sortBy)) {
return jsonResponse(createApiError("无效的 sortBy 参数", 400), { mode, status: 400 });
}
let sortOrder: SortOrder | undefined;
if (sortOrderParam) {
if (sortOrderParam !== "asc" && sortOrderParam !== "desc") {
return jsonResponse(createApiError("无效的 sortOrder 参数", 400), { mode, status: 400 });
}
sortOrder = sortOrderParam;
}
return { keyword, page, pageSize, sortBy, sortOrder };
}

View File

@@ -0,0 +1,14 @@
import type Database from "bun:sqlite";
import type { Logger } from "../logger";
import { MaterialProcessor } from "./processor";
export { MaterialProcessor, type ProcessableMaterial } from "./processor";
export { getTemplate, type ProcessingTemplate } from "./templates";
export function startMaterialProcessor(db: Database, logger: Logger): MaterialProcessor {
const processor = new MaterialProcessor(db, logger);
processor.start();
return processor;
}

View File

@@ -0,0 +1,198 @@
import type Database from "bun:sqlite";
import { generateText } from "ai";
import type { MaterialType } from "../../shared/api";
import type { Logger } from "../logger";
import { and, asc, eq } from "drizzle-orm";
import { buildProviderRegistry } from "../ai/registry";
import { notDeleted, timestamp, wrap } from "../db/connection";
import { listEntityNames } from "../db/entities";
import { getModelWithProvider, listModels } from "../db/models";
import { materials } from "../db/schema";
import { getSettings } from "../db/settings";
import { getTemplate } from "./templates";
const MAX_RETRIES = 3;
const DEFAULT_INTERVAL_MS = 5000;
export interface ProcessableMaterial {
description: string;
id: string;
materialType: MaterialType;
projectId: string;
}
export class MaterialProcessor {
private readonly db: Database;
private readonly logger: Logger;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(db: Database, logger: Logger) {
this.db = db;
this.logger = logger.child({ component: "material-processor" });
}
recoverStuckMaterials(): number {
const db = wrap(this.db);
const now = timestamp();
const restored = db
.update(materials)
.set({ status: "pending", updatedAt: now })
.where(and(eq(materials.status, "processing"), notDeleted(materials)))
.returning({ id: materials.id })
.all();
const count = restored.length;
if (count > 0) {
this.logger.info({ count }, "恢复卡住的素材到 pending 状态");
}
return count;
}
start(intervalMs: number = DEFAULT_INTERVAL_MS): void {
const recovered = this.recoverStuckMaterials();
this.logger.info({ intervalMs, recovered }, "素材处理器启动");
this.timer = setInterval(() => {
void this.tick();
}, intervalMs);
}
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
this.running = false;
this.logger.info("素材处理器停止");
}
private async tick(): Promise<void> {
if (this.running) {
this.logger.debug("上一轮处理尚未完成,跳过本次扫描");
return;
}
this.running = true;
try {
await this.processNext();
} catch (error: unknown) {
this.logger.error({ error: error instanceof Error ? error.message : String(error) }, "处理过程中发生未捕获错误");
} finally {
this.running = false;
}
}
async processNext(): Promise<void> {
const db = wrap(this.db);
const row = db
.select()
.from(materials)
.where(and(eq(materials.status, "pending"), notDeleted(materials)))
.orderBy(asc(materials.createdAt))
.limit(1)
.get();
if (!row) {
this.logger.debug("无待处理素材");
return;
}
const processingAt = timestamp();
db.update(materials).set({ status: "processing", updatedAt: processingAt }).where(eq(materials.id, row.id)).run();
const material: ProcessableMaterial = {
description: row.description,
id: row.id,
materialType: row.materialType as MaterialType,
projectId: row.projectId,
};
let lastError: unknown;
let success = false;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const result = await this.processOne(material);
const finishedAt = timestamp();
db.update(materials)
.set({ processedContent: result, status: "review", updatedAt: finishedAt })
.where(eq(materials.id, row.id))
.run();
this.logger.info({ attempt, materialId: row.id }, "素材处理成功");
success = true;
break;
} catch (error: unknown) {
lastError = error;
this.logger.warn(
{
attempt,
error: error instanceof Error ? error.message : String(error),
materialId: row.id,
},
`素材处理第 ${attempt} 次失败`,
);
}
}
if (!success) {
const failedAt = timestamp();
db.update(materials).set({ status: "failed", updatedAt: failedAt }).where(eq(materials.id, row.id)).run();
this.logger.warn(
{
error: lastError instanceof Error ? lastError.message : String(lastError),
materialId: row.id,
},
`素材处理 ${MAX_RETRIES} 次均失败,标记为 failed`,
);
}
}
protected async processOne(material: ProcessableMaterial): Promise<string> {
const modelInfo = getDefaultTextModel(this.db);
if (!modelInfo) {
throw new Error("没有可用的文本模型,请在设置中配置默认模型或添加至少一个模型");
}
const registry = buildProviderRegistry(this.db);
const model = registry.languageModel(`${modelInfo.providerId}:${modelInfo.externalId}`);
const existingEntities = listEntityNames(this.db, material.projectId);
const template = getTemplate(material.materialType);
const userPrompt = template.buildUserPrompt(material.description, existingEntities);
const result = await generateText({
model,
prompt: userPrompt,
system: template.systemPrompt,
});
const processingResult = template.parseOutput(result.text);
return JSON.stringify(processingResult);
}
}
function getDefaultTextModel(db: Database): { externalId: string; providerId: string } | null {
try {
const settings = getSettings(db);
if (settings.defaultModels?.text) {
const result = getModelWithProvider(db, settings.defaultModels.text);
if (!("error" in result)) {
return { externalId: result.model.externalId, providerId: result.provider.id };
}
}
} catch {
// settings 不存在或解析失败,使用 fallback
}
const fallback = listModels(db, { page: 1, pageSize: 1 });
const firstModel = fallback.items[0];
if (!firstModel) return null;
const result = getModelWithProvider(db, firstModel.id);
if ("error" in result) return null;
return { externalId: result.model.externalId, providerId: result.provider.id };
}

View File

@@ -0,0 +1,79 @@
import type { ProcessingResult } from "../../../shared/api";
import type { ProcessingTemplate } from "./index";
const ENTITY_TYPES_DESC = `
实体类型说明:
- person: 人
- organization: 组织(公司/部门/客户/供应商等)
- system: 系统/软件
- feature: 功能/模块
- requirement: 需求
- issue: 问题/风险
- term: 术语/概念
- other: 其他`;
export const GENERAL_TEMPLATE: ProcessingTemplate = {
buildUserPrompt: (description: string, existingEntities: Array<{ aliases: string[]; id: string; name: string }>) => {
let entityList = "";
if (existingEntities.length > 0) {
entityList =
"当前项目下已有以下实体,请对照匹配(注意别名也是同一实体):\n" +
existingEntities
.map((e) => `- [${e.id}] ${e.name}${e.aliases.length > 0 ? `(别名:${e.aliases.join("、")}` : ""}`)
.join("\n") +
"\n如果识别到的实体与已有实体匹配请将 matchedEntityId 设置为对应的 ID。";
}
return `请处理以下文本素材:\n\n${description}${entityList ? `\n\n${entityList}` : ""}`;
},
parseOutput: (text: string): ProcessingResult => {
const cleaned = text
.replace(/```json\s*/g, "")
.replace(/```\s*/g, "")
.trim();
const parsed = JSON.parse(cleaned) as {
candidateEntities?: Array<{ context?: string; matchedEntityId?: null | string; name?: string; type?: string }>;
normalizedContent?: string;
summary?: string;
};
return {
candidateEntities: (parsed.candidateEntities ?? []).map((e) => ({
context: e.context ?? "",
matchedEntityId: e.matchedEntityId ?? null,
name: e.name ?? "",
type: (e.type ?? "other") as ProcessingResult["candidateEntities"][number]["type"],
})),
normalizedContent: parsed.normalizedContent ?? "",
summary: parsed.summary ?? "",
};
},
systemPrompt: `你是 Alfred 预处理助手,负责整理用户输入的文本素材。
## 任务
分析输入的文本,生成以下结构化输出(纯 JSON 格式,不要包含其他文字):
{
"summary": "内容概要1-2 句概括文本核心信息",
"normalizedContent": "规范化后的完整内容。保持原意,但修正口语化表达、去除冗余、统一格式。",
"candidateEntities": [
{
"name": "识别到的实体名称",
"type": "实体类型",
"context": "原文中相关的引用片段",
"matchedEntityId": "匹配到的已有实体 ID无匹配则为 null"
}
]
}
${ENTITY_TYPES_DESC}
## 规则
- 只输出 JSON 对象,不要有任何其他文字、注释或 markdown 标记
- 识别文本中提到的人名、组织、系统、术语等重要实体
- 仔细对照已有实体列表进行匹配(包括别名),如果名称或别名相似则设置 matchedEntityId
- 如果实体的别名中包含了某个说法,也应该匹配到该实体
- 不要编造文本中未提到的实体
- normalizedContent 应保持客观,不要添加原文中没有的信息`,
} as const;

View File

@@ -0,0 +1,22 @@
import type { MaterialType, ProcessingResult } from "../../../shared/api";
import { GENERAL_TEMPLATE } from "./general";
import { MEETING_TEMPLATE } from "./meeting";
export interface ProcessingTemplate {
buildUserPrompt: (
description: string,
existingEntities: Array<{ aliases: string[]; id: string; name: string }>,
) => string;
parseOutput: (text: string) => ProcessingResult;
systemPrompt: string;
}
const TEMPLATES: Record<MaterialType, ProcessingTemplate> = {
general: GENERAL_TEMPLATE,
meeting: MEETING_TEMPLATE,
};
export function getTemplate(type: MaterialType): ProcessingTemplate {
return TEMPLATES[type];
}

View File

@@ -0,0 +1,79 @@
import type { ProcessingResult } from "../../../shared/api";
import type { ProcessingTemplate } from "./index";
const ENTITY_TYPES_DESC = `
实体类型说明:
- person: 人
- organization: 组织(公司/部门/客户/供应商等)
- system: 系统/软件
- feature: 功能/模块
- requirement: 需求
- issue: 问题/风险
- term: 术语/概念
- other: 其他`;
export const MEETING_TEMPLATE: ProcessingTemplate = {
buildUserPrompt: (description: string, existingEntities: Array<{ aliases: string[]; id: string; name: string }>) => {
let entityList = "";
if (existingEntities.length > 0) {
entityList =
"当前项目下已有以下实体,请对照匹配(注意别名也是同一实体):\n" +
existingEntities
.map((e) => `- [${e.id}] ${e.name}${e.aliases.length > 0 ? `(别名:${e.aliases.join("、")}` : ""}`)
.join("\n") +
"\n如果识别到的实体与已有实体匹配请将 matchedEntityId 设置为对应的 ID。";
}
return `请处理以下会议相关文本素材:\n\n${description}${entityList ? `\n\n${entityList}` : ""}`;
},
parseOutput: (text: string): ProcessingResult => {
const cleaned = text
.replace(/```json\s*/g, "")
.replace(/```\s*/g, "")
.trim();
const parsed = JSON.parse(cleaned) as {
candidateEntities?: Array<{ context?: string; matchedEntityId?: null | string; name?: string; type?: string }>;
normalizedContent?: string;
summary?: string;
};
return {
candidateEntities: (parsed.candidateEntities ?? []).map((e) => ({
context: e.context ?? "",
matchedEntityId: e.matchedEntityId ?? null,
name: e.name ?? "",
type: (e.type ?? "other") as ProcessingResult["candidateEntities"][number]["type"],
})),
normalizedContent: parsed.normalizedContent ?? "",
summary: parsed.summary ?? "",
};
},
systemPrompt: `你是 Alfred 预处理助手,负责整理用户输入的会议相关文本素材。
## 任务
分析输入的文本,生成以下结构化输出(纯 JSON 格式,不要包含其他文字):
{
"summary": "会议内容概要1-2 句概括核心内容",
"normalizedContent": "规范化后的会议完整内容。保持原意,但修正口语化表达、去除冗余、结构化呈现。如包含参会者、讨论要点、决议等内容,保持这些结构。",
"candidateEntities": [
{
"name": "识别到的实体名称(包括会议参与者、讨论中提到的组织/系统/术语等)",
"type": "实体类型",
"context": "原文中相关的引用片段",
"matchedEntityId": "匹配到的已有实体 ID无匹配则为 null"
}
]
}
${ENTITY_TYPES_DESC}
## 规则
- 只输出 JSON 对象,不要有任何其他文字、注释或 markdown 标记
- 重点识别:参会人员、讨论中提到的组织/系统/术语/需求/问题
- 仔细对照已有实体列表进行匹配(包括别名)
- 如果实体的别名中包含了某个说法,也应该匹配到该实体
- 不要编造文本中未提到的信息
- normalizedContent 应保持客观,不要添加原文中没有的信息`,
} as const;

View File

@@ -13,7 +13,7 @@ import {
updateConversation,
updateConversationTimestamp,
} from "../../db/conversations";
import { getModelWithProvider } from "../../db/models";
import { getModelWithProvider, listModels } from "../../db/models";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
@@ -79,13 +79,23 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
let model;
try {
const result = getModelWithProvider(db, conversation.modelId);
let effectiveModelId = conversation.modelId;
if (!effectiveModelId) {
const fallback = listModels(db, { page: 1, pageSize: 1 });
const firstModel = fallback.items[0];
if (!firstModel) {
return jsonResponse(createApiError("没有可用的模型,请先配置模型", 400), { mode, status: 400 });
}
effectiveModelId = firstModel.id;
}
const result = getModelWithProvider(db, effectiveModelId);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
const registry = buildProviderRegistry(db);
model = registry.languageModel(`${result.provider.id}:${result.model.modelId}`);
model = registry.languageModel(`${result.provider.id}:${result.model.externalId}`);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });

View File

@@ -0,0 +1,41 @@
import type Database from "bun:sqlite";
import type { CreateEntityRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createEntity } from "../../db/entities";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleCreateEntity(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const projectIdStr = parseIdFromUrl(url);
const validated = validateIdParam(projectIdStr ?? "", mode);
if (validated instanceof Response) return validated;
let body: CreateEntityRequest;
try {
body = (await req.json()) as CreateEntityRequest;
} 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 || typeof body.name !== "string") {
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
const result = createEntity(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: result.entity.id, projectId: validated.id }, "实体创建成功");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteEntity } from "../../db/entities";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteEntity(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const entityIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
if (validatedEntity instanceof Response) return validatedEntity;
const result = deleteEntity(db, validatedProject.id, validatedEntity.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "实体删除成功");
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getEntity } from "../../db/entities";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetEntity(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const entityIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
if (validatedEntity instanceof Response) return validatedEntity;
const result = getEntity(db, validatedProject.id, validatedEntity.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "获取实体详情");
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,45 @@
import type Database from "bun:sqlite";
import type { EntityType, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listEntities } from "../../db/entities";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam, validatePagination } from "../../middleware";
export function handleListEntities(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const projectIdStr = parseIdFromUrl(url);
const validated = validateIdParam(projectIdStr ?? "", mode);
if (validated instanceof Response) return validated;
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const typeParam = url.searchParams.get("type");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const ALLOWED_TYPES = [
"person",
"organization",
"system",
"feature",
"requirement",
"issue",
"term",
"other",
] as const;
if (typeParam && !(ALLOWED_TYPES as readonly string[]).includes(typeParam)) {
return jsonResponse(createApiError("Invalid type parameter", 400), { mode, status: 400 });
}
const result = listEntities(db, validated.id, {
page: pagination.page,
pageSize: pagination.pageSize,
type: (typeParam as EntityType) ?? undefined,
});
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,42 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateEntityRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { updateEntity } from "../../db/entities";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateEntity(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const entityIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
if (validatedEntity instanceof Response) return validatedEntity;
let body: UpdateEntityRequest;
try {
body = (await req.json()) as UpdateEntityRequest;
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
const result = updateEntity(db, validatedProject.id, validatedEntity.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "实体更新成功");
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,42 @@
import type Database from "bun:sqlite";
import type { EntityConfirmation, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { approveMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleApproveMaterial(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
let entityConfirmations: EntityConfirmation[] = [];
try {
const body = (await req.json()) as { entityConfirmations?: EntityConfirmation[] };
entityConfirmations = body.entityConfirmations ?? [];
} catch {
// body 为空时使用默认空数组
}
const result = approveMaterial(db, validatedProject.id, validatedMaterial.id, entityConfirmations, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材审核通过");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,45 @@
import type Database from "bun:sqlite";
import type { CreateMaterialRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createMaterial } from "../../db/materials";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleCreateMaterial(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const projectIdStr = parseIdFromUrl(url);
const validated = validateIdParam(projectIdStr ?? "", mode);
if (validated instanceof Response) return validated;
let body: CreateMaterialRequest;
try {
body = (await req.json()) as CreateMaterialRequest;
} 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.description || typeof body.description !== "string") {
return jsonResponse(createApiError("description is required", 400), { mode, status: 400 });
}
if (!body.associatedDate || typeof body.associatedDate !== "string") {
return jsonResponse(createApiError("associatedDate is required", 400), { mode, status: 400 });
}
const result = createMaterial(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: result.material.id, projectId: validated.id }, "素材创建成功");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = deleteMaterial(db, validatedProject.id, validatedMaterial.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材删除成功");
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { discardMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDiscardMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = discardMaterial(db, validatedProject.id, validatedMaterial.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材已放弃");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,28 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetMaterial(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = getMaterial(db, validatedProject.id, validatedMaterial.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,36 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listMaterials } from "../../db/materials";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam, validatePagination } from "../../middleware";
export function handleListMaterials(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const projectIdStr = parseIdFromUrl(url);
const validated = validateIdParam(projectIdStr ?? "", mode);
if (validated instanceof Response) return validated;
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const statusParam = url.searchParams.get("status");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const ALLOWED_STATUSES = ["pending", "processing", "review", "approved", "discarded", "failed"] as const;
if (statusParam && !(ALLOWED_STATUSES as readonly string[]).includes(statusParam)) {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
}
const result = listMaterials(db, validated.id, {
page: pagination.page,
pageSize: pagination.pageSize,
status: (statusParam as (typeof ALLOWED_STATUSES)[number]) ?? undefined,
});
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { retryMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleRetryMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const materialIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial;
const result = retryMaterial(db, validatedProject.id, validatedMaterial.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材重试已触发");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -25,8 +25,8 @@ export async function handleCreateModel(
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
if (!body.modelId || typeof body.modelId !== "string") {
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
if (!body.externalId || typeof body.externalId !== "string") {
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
}
if (!body.providerId || typeof body.providerId !== "string") {

View File

@@ -4,24 +4,26 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listModels } from "../../db/models";
import { jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
import { jsonResponse, parseListParams } from "../../helpers";
const ALLOWED_SORT_BY = ["createdAt", "name"];
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");
const keyword = url.searchParams.get("keyword");
const providerId = url.searchParams.get("providerId");
const capabilities = url.searchParams.get("capabilities");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
if (parsed instanceof Response) return parsed;
const result = listModels(db, {
keyword: keyword ?? undefined,
page: pagination.page,
pageSize: pagination.pageSize,
capabilities: capabilities ?? undefined,
keyword: parsed.keyword,
page: parsed.page,
pageSize: parsed.pageSize,
providerId: providerId ?? undefined,
sortBy: parsed.sortBy,
sortOrder: parsed.sortOrder,
});
return jsonResponse(result, { mode });

View File

@@ -25,8 +25,8 @@ export async function handleTestModelConfig(
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
}
if (!body.modelId || typeof body.modelId !== "string") {
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
if (!body.externalId || typeof body.externalId !== "string") {
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
}
const providerResult = getProvider(db, body.providerId);
@@ -41,7 +41,7 @@ export async function handleTestModelConfig(
{
apiKey: providerResult.provider.apiKey,
baseUrl: providerResult.provider.baseUrl,
modelId: body.modelId,
modelId: body.externalId,
name: providerResult.provider.name,
type: providerResult.provider.type,
},
@@ -50,7 +50,7 @@ export async function handleTestModelConfig(
if (!testResult.ok) {
logger.warn(
{ message: testResult.message, modelId: body.modelId, providerId: body.providerId },
{ externalId: body.externalId, message: testResult.message, providerId: body.providerId },
"模型连接测试失败",
);
}

View File

@@ -4,27 +4,27 @@ 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";
import { createApiError, jsonResponse, parseListParams } from "../../helpers";
const ALLOWED_SORT_BY = ["createdAt", "name", "updatedAt"];
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");
const keyword = url.searchParams.get("keyword");
const statusParam = url.searchParams.get("status");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
if (statusParam && statusParam !== "active" && statusParam !== "archived") {
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
}
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
if (parsed instanceof Response) return parsed;
const result = listProjects(db, {
keyword: keyword ?? undefined,
page: pagination.page,
pageSize: pagination.pageSize,
keyword: parsed.keyword,
page: parsed.page,
pageSize: parsed.pageSize,
sortBy: parsed.sortBy,
sortOrder: parsed.sortOrder,
status: (statusParam as "active" | "archived") ?? undefined,
});

View File

@@ -4,22 +4,24 @@ import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listProviders } from "../../db/providers";
import { jsonResponse } from "../../helpers";
import { validatePagination } from "../../middleware";
import { jsonResponse, parseListParams } from "../../helpers";
const ALLOWED_SORT_BY = ["createdAt", "name"];
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");
const keyword = url.searchParams.get("keyword");
const typeParam = url.searchParams.get("type");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const parsed = parseListParams(url, mode, { allowedSortBy: ALLOWED_SORT_BY });
if (parsed instanceof Response) return parsed;
const result = listProviders(db, {
keyword: keyword ?? undefined,
page: pagination.page,
pageSize: pagination.pageSize,
keyword: parsed.keyword,
page: parsed.page,
pageSize: parsed.pageSize,
sortBy: parsed.sortBy,
sortOrder: parsed.sortOrder,
type: typeParam ?? undefined,
});
return jsonResponse(result, { mode });

View File

@@ -0,0 +1,42 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, SettingsData } from "../../shared/api";
import type { Logger } from "../logger";
import { getSettings, updateSettings } from "../db/settings";
import { createApiError, jsonResponse } from "../helpers";
export function handleGetSettings(_req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const data = getSettings(db);
return jsonResponse(data, { mode });
}
export async function handleUpdateSettings(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
let body: Partial<SettingsData>;
try {
body = (await req.json()) as Partial<SettingsData>;
} catch {
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (body.theme !== undefined && typeof body.theme !== "string") {
return jsonResponse(createApiError("theme must be a string", 400), { mode, status: 400 });
}
if (body.theme !== undefined && body.theme !== "dark" && body.theme !== "light" && body.theme !== "system") {
return jsonResponse(createApiError("theme 仅支持 dark、light、system", 400), { mode, status: 400 });
}
if (body.compact !== undefined && typeof body.compact !== "boolean") {
return jsonResponse(createApiError("compact 必须为布尔值", 400), { mode, status: 400 });
}
const result = updateSettings(db, body, logger);
logger.info({ data: result }, "设置已更新");
return jsonResponse(result, { mode });
}

View File

@@ -220,6 +220,116 @@ export function startServer(options: StartServerOptions) {
logger,
),
},
"/api/projects/:id/materials": {
GET: withErrorHandler(
async (req) => {
const { handleListMaterials } = await import("./routes/materials/list");
return handleListMaterials(req, db, mode, logger);
},
mode,
logger,
),
POST: withErrorHandler(
async (req) => {
const { handleCreateMaterial } = await import("./routes/materials/create");
return handleCreateMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid": {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteMaterial } = await import("./routes/materials/delete");
return handleDeleteMaterial(req, db, mode, logger);
},
mode,
logger,
),
GET: withErrorHandler(
async (req) => {
const { handleGetMaterial } = await import("./routes/materials/get");
return handleGetMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid/approve": {
POST: withErrorHandler(
async (req) => {
const { handleApproveMaterial } = await import("./routes/materials/approve");
return handleApproveMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid/discard": {
POST: withErrorHandler(
async (req) => {
const { handleDiscardMaterial } = await import("./routes/materials/discard");
return handleDiscardMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/materials/:mid/retry": {
POST: withErrorHandler(
async (req) => {
const { handleRetryMaterial } = await import("./routes/materials/retry");
return handleRetryMaterial(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/entities": {
GET: withErrorHandler(
async (req) => {
const { handleListEntities } = await import("./routes/entities/list");
return handleListEntities(req, db, mode, logger);
},
mode,
logger,
),
POST: withErrorHandler(
async (req) => {
const { handleCreateEntity } = await import("./routes/entities/create");
return handleCreateEntity(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/entities/:eid": {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteEntity } = await import("./routes/entities/delete");
return handleDeleteEntity(req, db, mode, logger);
},
mode,
logger,
),
GET: withErrorHandler(
async (req) => {
const { handleGetEntity } = await import("./routes/entities/get");
return handleGetEntity(req, db, mode, logger);
},
mode,
logger,
),
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateEntity } = await import("./routes/entities/update");
return handleUpdateEntity(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/restore": {
POST: withErrorHandler(
async (req) => {
@@ -294,6 +404,24 @@ export function startServer(options: StartServerOptions) {
logger,
),
},
"/api/settings": {
GET: withErrorHandler(
async (req) => {
const { handleGetSettings } = await import("./routes/settings");
return handleGetSettings(req, db, mode, logger);
},
mode,
logger,
),
PUT: withErrorHandler(
async (req) => {
const { handleUpdateSettings } = await import("./routes/settings");
return handleUpdateSettings(req, db, mode, logger);
},
mode,
logger,
),
},
},
});

View File

@@ -6,7 +6,7 @@ export interface ApiErrorResponse {
export interface Conversation {
createdAt: string;
id: string;
modelId: string;
modelId: null | string;
projectId: string;
title: string;
updatedAt: string;
@@ -28,11 +28,17 @@ export interface CreateConversationRequest {
title?: string;
}
export interface CreateMaterialRequest {
associatedDate: string;
description: string;
materialType?: MaterialType;
}
export interface CreateModelRequest {
capabilities: ModelCapability[];
contextLength?: null | number;
externalId: string;
maxOutputTokens?: null | number;
modelId: string;
name: string;
providerId: string;
}
@@ -49,10 +55,109 @@ export interface CreateProviderRequest {
type: ProviderType;
}
// ==========================================
// 在此定义你的业务类型
// 前后端共享的类型都放在这个文件中
// ==========================================
export interface ApproveMaterialRequest {
entityConfirmations?: EntityConfirmation[];
}
export interface CandidateEntity {
context: string;
matchedEntityId: null | string;
name: string;
type: EntityType;
}
export interface CreateEntityRequest {
aliases?: string[];
description?: string;
name: string;
type: EntityType;
}
export interface Entity {
aliases: string[];
createdAt: string;
description: string;
id: string;
name: string;
projectId: string;
type: EntityType;
updatedAt: string;
}
export interface EntityConfirmation {
action: "create" | "discard" | "merge" | "select";
candidateIndex: number;
targetEntityId?: string;
}
export interface EntityListResponse {
items: Entity[];
page: number;
pageSize: number;
total: number;
}
export interface EntityResponse {
entity: Entity;
}
export const ENTITY_TYPES = [
"person",
"organization",
"system",
"feature",
"requirement",
"issue",
"term",
"other",
] as const;
export type EntityType = (typeof ENTITY_TYPES)[number];
export interface ProcessingResult {
candidateEntities: CandidateEntity[];
normalizedContent: string;
summary: string;
}
export interface UpdateEntityRequest {
aliases?: string[];
description?: string;
name?: string;
type?: EntityType;
}
export interface ListSortParams {
sortBy?: string;
sortOrder?: SortOrder;
}
export interface Material {
associatedDate: string;
createdAt: string;
description: string;
id: string;
materialType: MaterialType;
processedContent: null | string;
projectId: string;
status: MaterialStatus;
updatedAt: string;
}
export interface MaterialListResponse {
items: Material[];
page: number;
pageSize: number;
total: number;
}
export interface MaterialResponse {
material: Material;
}
export type MaterialStatus = "approved" | "discarded" | "failed" | "pending" | "processing" | "review";
export type MaterialType = "general" | "meeting";
export interface Message {
content: string;
@@ -61,6 +166,7 @@ export interface Message {
id: string;
parts: null | string;
role: "assistant" | "system" | "user";
updatedAt: string;
}
export interface MessageListResponse {
@@ -81,9 +187,9 @@ export interface Model {
capabilities: ModelCapability[];
contextLength: null | number;
createdAt: string;
externalId: string;
id: string;
maxOutputTokens: null | number;
modelId: string;
name: string;
providerId: string;
updatedAt: string;
@@ -99,6 +205,8 @@ export type ModelCapability =
| "video-generation"
| "video-recognition";
export type SortOrder = "asc" | "desc";
export interface UpdateConversationRequest {
modelId?: string;
title?: string;
@@ -136,7 +244,6 @@ export interface ModelTestResultResponse {
}
export interface Project {
archivedAt: null | string;
createdAt: string;
description: string;
id: string;
@@ -202,16 +309,36 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
export type RuntimeMode = "development" | "production" | "test";
/** 模型能力到默认模型 ID 的映射,用于后台自动流程 */
export interface DefaultModelSettings {
/** 文本能力,覆盖 text + reasoning */
text?: string | null;
imageRecognition?: string | null;
audioRecognition?: string | null;
videoRecognition?: string | null;
imageGeneration?: string | null;
audioGeneration?: string | null;
videoGeneration?: string | null;
}
export interface SettingsData {
compact?: boolean;
defaultModels?: DefaultModelSettings;
theme: ThemePreference;
}
export interface TestModelRequest {
modelId: string;
externalId: string;
providerId: string;
}
export type ThemePreference = "dark" | "light" | "system";
export interface UpdateModelRequest {
capabilities?: ModelCapability[];
contextLength?: null | number;
externalId?: string;
maxOutputTokens?: null | number;
modelId?: string;
name?: string;
providerId?: string;
}

View File

@@ -1,5 +0,0 @@
import { Outlet } from "react-router";
export function ConsoleOutlet() {
return <Outlet />;
}

View File

@@ -1,36 +0,0 @@
import type { MenuProps } from "antd";
import { Menu } from "antd";
import { useLocation, useNavigate } from "react-router";
import type { MenuItemConfig } from "../../menu";
type MenuItem = Required<MenuProps>["items"][number];
interface SidebarProps {
menuItems: readonly MenuItemConfig[];
}
export function Sidebar({ menuItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const currentItem = menuItems.find((item) => item.path === currentPath);
const selectedKeys = currentItem ? [currentItem.value] : [];
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
icon: item.icon,
key: item.value,
label: item.label,
}));
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
const item = menuItems.find((i) => i.value === key);
if (item) {
void navigate(item.path);
}
};
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
}

View File

@@ -1,10 +0,0 @@
import { DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
{ icon: createElement(RobotOutlined), label: "模型管理", path: "/models", value: "models" },
] as const;

View File

@@ -1,5 +0,0 @@
import { createContext } from "react";
import type { Project } from "../../../shared/api";
export const ProjectContext = createContext<null | Project>(null);

View File

@@ -1,57 +0,0 @@
import { CopyOutlined } from "@ant-design/icons";
import { CodeHighlighter } from "@ant-design/x";
import { App, Button, Flex, Typography } from "antd";
import React from "react";
interface CodeBlockWithCopyProps {
block?: boolean;
children?: React.ReactNode;
className?: string;
lang?: string;
streamStatus?: "done" | "loading";
}
export function CodeBlockWithCopy({ block, children, className, lang }: CodeBlockWithCopyProps) {
const { message } = App.useApp();
if (!block) {
return <code className={className}>{children}</code>;
}
const codeText = extractText(children);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const displayLang = lang || "plaintext";
const handleCopy = () => {
void navigator.clipboard.writeText(codeText).then(() => {
void message.success("已复制");
});
};
const header = (
<Flex align="center" justify="space-between" style={{ padding: "0 4px" }}>
<Typography.Text style={{ color: "var(--ant-color-text-quaternary)", fontSize: 12 }}>
{displayLang}
</Typography.Text>
<Button
icon={<CopyOutlined />}
onClick={handleCopy}
size="small"
style={{ color: "var(--ant-color-text-quaternary)" }}
type="text"
/>
</Flex>
);
return (
<CodeHighlighter header={header} lang={displayLang}>
{codeText}
</CodeHighlighter>
);
}
function extractText(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => (typeof child === "string" ? child : ""))
.join("");
}

View File

@@ -1,93 +0,0 @@
import { DeleteOutlined, MoreOutlined } from "@ant-design/icons";
import { Conversations } from "@ant-design/x";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Spin } from "antd";
import { useState } from "react";
import type { Conversation } from "../../../../shared/api";
import { createConversation, deleteConversation, fetchConversations } from "../../../hooks/use-conversations";
import { useModelList } from "../../../hooks/use-models";
import { ChatPanel } from "../components/chat/ChatPanel";
import { useCurrentProject } from "../useCurrentProject";
export function ChatPage() {
const project = useCurrentProject();
const [activeConversationId, setActiveConversationId] = useState<null | string>(null);
const queryClient = useQueryClient();
const { message } = App.useApp();
const CONVERSATIONS_KEY = ["conversations", project.id] as const;
const { data, isLoading } = useQuery({
queryFn: () => fetchConversations(project.id),
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 deleteMutation = useMutation({
mutationFn: (id: string) => deleteConversation(project.id, id),
onError: (err: Error) => {
void message.error(`删除会话失败:${err.message}`);
},
onSuccess: (_data: void, id: string) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
if (activeConversationId === id) setActiveConversationId(null);
},
});
const conversations = (data?.items ?? []).map((c: Conversation) => ({
key: c.id,
label: c.title,
}));
return (
<div className="app-chat-page">
<div className="app-chat-conversations">
{isLoading ? (
<Spin />
) : (
<Conversations
activeKey={activeConversationId ?? ""}
creation={{
onClick: () => {
void createConversation(project.id, defaultModelId)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
},
}}
items={conversations}
menu={(conv) => ({
items: [
{
danger: true,
icon: <DeleteOutlined />,
key: "delete",
label: "删除",
onClick: () => {
deleteMutation.mutate(conv.key);
},
},
],
trigger: <MoreOutlined />,
})}
onActiveChange={(key) => setActiveConversationId(key)}
/>
)}
</div>
<ChatPanel
conversationId={activeConversationId}
onConversationCreated={setActiveConversationId}
projectId={project.id}
/>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { useMemo, useState } from "react";
import { createConversation, deleteConversation } from "../../shared/hooks/use-conversations";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useModelList } from "../../shared/hooks/use-models";
import { ChatPanel } from "./ChatPanel";
import { ConversationSidebar } from "./components/ConversationSidebar";
export function ChatPage() {
const project = useCurrentProject();
const [activeConversationId, setActiveConversationId] = useState<null | string>(null);
const queryClient = useQueryClient();
const { message } = App.useApp();
const CONVERSATIONS_KEY = ["conversations", project.id] as const;
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const defaultModelId = textModels[0]?.id ?? null;
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteConversation(project.id, id),
onError: (err: Error) => {
void message.error(`删除会话失败:${err.message}`);
},
onSuccess: (_data: void, id: string) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
if (activeConversationId === id) setActiveConversationId(null);
},
});
const handleAddConversation = () => {
void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
})
.catch((err: Error) => {
void message.error(`创建会话失败:${err.message}`);
});
};
return (
<div className="app-chat-page">
<ConversationSidebar
onAddClick={handleAddConversation}
onDelete={(id) => deleteMutation.mutate(id)}
onSelect={setActiveConversationId}
projectId={project.id}
selectedId={activeConversationId}
/>
<ChatPanel
conversationId={activeConversationId}
defaultModelId={defaultModelId}
onConversationCreated={setActiveConversationId}
projectId={project.id}
textModels={textModels}
/>
</div>
);
}

View File

@@ -4,16 +4,15 @@ import { Sender } from "@ant-design/x";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Divider, Flex, Input, Select, Spin, Typography } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createConversation,
fetchConversation,
fetchMessages,
updateConversation,
} from "../../../../hooks/use-conversations";
import { useLogger } from "../../../../hooks/use-logger";
import { useModelList } from "../../../../hooks/use-models";
} from "../../shared/hooks/use-conversations";
import { useLogger } from "../../shared/hooks/use-logger";
import { ChatScrollArea } from "./ChatScrollArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
@@ -21,11 +20,19 @@ import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
defaultModelId: null | string;
onConversationCreated: (id: string) => void;
projectId: string;
textModels: Array<{ id: string; name: string }>;
}
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
export function ChatPanel({
conversationId,
defaultModelId: _defaultModelId,
onConversationCreated,
projectId,
textModels,
}: ChatPanelProps) {
const { message } = App.useApp();
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
const queryClient = useQueryClient();
@@ -37,12 +44,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const fetchRef = useRef(fetchMessages);
const skipHistoryLoadRef = useRef<null | string>(null);
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({
@@ -178,6 +179,33 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
[sendMessage, conversationId, projectId, onConversationCreated, message, queryClient, displayModelId, logger],
);
const renderSenderFooter = useCallback(
(actionNode: ReactNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
),
[isLoading, handleModelChange, modelOptions, displayModelId],
);
const handleStop = useCallback(() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}, [stop, logger]);
const extractText = useCallback((msg: UIMessage) => {
return msg.parts
.filter((p) => p.type === "text")
@@ -304,29 +332,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
)}
footer={renderSenderFooter}
loading={isLoading}
onCancel={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
@@ -397,29 +405,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
)}
footer={renderSenderFooter}
loading={isLoading}
onCancel={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."

View File

@@ -38,7 +38,10 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
events={{ initialized: handleOsInitialized }}
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
scrollbars: {
autoHide: "move",
theme: "os-theme-custom",
},
}}
ref={osRef}
>

View File

@@ -0,0 +1,45 @@
import { DeleteOutlined } from "@ant-design/icons";
import { Button, Flex, Popconfirm, Typography } from "antd";
import type { Conversation } from "../../../../shared/api";
interface ConversationCardProps {
conversation: Conversation;
onDelete: () => void;
onSelect: () => void;
selected: boolean;
}
export function ConversationCard({ conversation, onDelete, onSelect, selected }: ConversationCardProps) {
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
return (
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
<Typography.Text ellipsis style={{ flex: 1, minWidth: 0 }}>
{conversation.title}
</Typography.Text>
<span className="app-sidebar-item-actions">
<Popconfirm
description="删除后不可恢复"
okButtonProps={{ danger: true }}
okText="删除"
onCancel={(e) => e?.stopPropagation()}
onConfirm={(e) => {
e?.stopPropagation();
onDelete();
}}
title="确认删除该对话?"
>
<Button
aria-label="删除"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
size="small"
type="text"
/>
</Popconfirm>
</span>
</Flex>
);
}

View File

@@ -0,0 +1,92 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty, Input, Skeleton } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useMemo, useState } from "react";
import type { Conversation } from "../../../../shared/api";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { ConversationCard } from "./ConversationCard";
interface ConversationListProps {
conversations: readonly Conversation[];
loading: boolean;
onAddClick: () => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
selectedId: null | string;
}
export function ConversationList({
conversations,
loading,
onAddClick,
onDelete,
onSelect,
selectedId,
}: ConversationListProps) {
const [inputText, setInputText] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const filteredConversations = useMemo(() => {
if (!appliedSearch) return conversations;
const lower = appliedSearch.toLowerCase();
return conversations.filter((c) => c.title.toLowerCase().includes(lower));
}, [conversations, appliedSearch]);
const groupedConversations = useMemo(() => groupByDate(filteredConversations, "updatedAt"), [filteredConversations]);
return (
<div className="app-sidebar-list" style={{ width: 260 }}>
<div className="app-sidebar-list-header">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Input.Search
allowClear
onChange={(e) => setInputText(e.target.value)}
onSearch={(value) => setAppliedSearch(value.trim())}
placeholder="搜索对话"
value={inputText}
/>
</div>
<OverlayScrollbarsComponent
className="app-sidebar-list-body"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: {
autoHide: "move",
theme: "os-theme-custom",
},
}}
>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : conversations.length === 0 ? (
<Empty description="暂无对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : filteredConversations.length === 0 ? (
<Empty description="无匹配对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
groupedConversations.map((group) => {
if (group.items.length === 0) return null;
return (
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
{group.items.map((conv) => (
<ConversationCard
conversation={conv}
key={conv.id}
onDelete={() => onDelete(conv.id)}
onSelect={() => onSelect(conv.id)}
selected={conv.id === selectedId}
/>
))}
</SidebarGroup>
);
})
)}
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { Result } from "antd";
import { fetchConversations } from "../../../shared/hooks/use-conversations";
import { ConversationList } from "./ConversationList";
interface ConversationSidebarProps {
onAddClick: () => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
projectId: string;
selectedId: null | string;
}
export function ConversationSidebar({
onAddClick,
onDelete,
onSelect,
projectId,
selectedId,
}: ConversationSidebarProps) {
const CONVERSATIONS_KEY = ["conversations", projectId] as const;
const { data, error, isLoading, refetch } = useQuery({
queryFn: () => fetchConversations(projectId),
queryKey: CONVERSATIONS_KEY,
});
if (error) {
return (
<div className="app-sidebar-list" style={{ width: 260 }}>
<Result
extra={<button onClick={() => void refetch()}></button>}
status="error"
subTitle="加载对话列表失败"
/>
</div>
);
}
return (
<ConversationList
conversations={data?.items ?? []}
loading={isLoading}
onAddClick={onAddClick}
onDelete={onDelete}
onSelect={onSelect}
selectedId={selectedId}
/>
);
}

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