Compare commits
85 Commits
d33eb00377
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 12edf0b545 | |||
| 034496e946 | |||
| d02abce58d | |||
| 74266dc5cc | |||
| b4e05a4a16 | |||
| 2713897bdb | |||
| a1e2897364 | |||
| 90fdb44b20 | |||
| a389888eb4 | |||
| 1d82f4f961 | |||
| 074ea0bb1a | |||
| 43b14a94a3 | |||
| 1f0c7608f4 | |||
| 6c4d9affae | |||
| c0384f9a07 | |||
| e3a9a6b47f | |||
| dd2835bb94 | |||
| 09845e0515 | |||
| e0466f9b99 | |||
| eccc3f62d2 | |||
| 91ae52320b | |||
| f4318c7643 | |||
| b469662760 | |||
| 5c0f02f1f8 | |||
| 121c6f764f | |||
| 3f88e33bd1 | |||
| e2eba6dc1f | |||
| 72a71818e7 | |||
| 3b77041100 | |||
| 98712cf047 | |||
| 85abc2a515 | |||
| db40d04dc5 | |||
| e25b2537fd | |||
| 6f547560d1 | |||
| 61b479e2be | |||
| f67cfa84ef | |||
| dc7d9e83b8 | |||
| 525278870f | |||
| eb93de52d8 | |||
| 83cc28fe1b | |||
| ad10134c20 | |||
| ea9bc41e4c | |||
| a896091d27 | |||
| 1a7fd58553 | |||
| abe30ead6a | |||
| 714da2d633 | |||
| 21b557c255 | |||
| 02a202290f | |||
| 5b09a16bc3 | |||
| 297293cb61 | |||
| 2cdbe474ce | |||
| 83349bf01b | |||
| b1dec691e9 | |||
| 1f05f259d0 | |||
| ed97b30d51 | |||
| 26ecaadb26 | |||
| 9c9afbd108 | |||
| 628b592577 | |||
| 29bf61f7a3 | |||
| ab7b7fb189 | |||
| 0d60120219 | |||
| 459cc76edf | |||
| 844562303c | |||
| df5b60eb53 | |||
| b3f77e8ac6 | |||
| b225b0a0c7 | |||
| de51a817fb | |||
| 4c72754739 | |||
| d765f86b65 | |||
| 60843f7dbf | |||
| 897fad95eb | |||
| f34028368d | |||
| 8463274c4b | |||
| f2e3d84fb1 | |||
| 3e1f3b554d | |||
| 6eeb4ced7b | |||
| f83f434863 | |||
| 83cf9eab94 | |||
| 10b3928bee | |||
| 34e915ccf4 | |||
| 9241c782e6 | |||
| 48c76e6180 | |||
| 933c2133f0 | |||
| 2ea4bd4410 | |||
| 6cb378d7cb |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
@@ -415,6 +409,8 @@ data/
|
||||
backend/bin
|
||||
backend/server
|
||||
backend/desktop
|
||||
!src/**/*
|
||||
docs/superpowers
|
||||
|
||||
# Embedfs generated
|
||||
embedfs/assets/
|
||||
|
||||
@@ -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
18
.oxfmtrc.json
Normal 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
274
.oxlintrc.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tdesign-mcp-server": {
|
||||
"command": "bunx",
|
||||
"args": ["tdesign-mcp-server@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
|
||||
33
AGENTS.md
33
AGENTS.md
@@ -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 负责最终结论:去重、交叉验证、合并同根因问题
|
||||
- 优先使用提问工具对用户确认
|
||||
|
||||
245
bin/init-dev-branch.js
Normal file
245
bin/init-dev-branch.js
Normal file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 开发分支工作区初始化脚本
|
||||
*
|
||||
* 用于创建基于远端分支或新建的开发分支工作区。
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
function addWorktree(name, dir, base) {
|
||||
const args = ["worktree", "add", "-b", name, dir];
|
||||
if (base) args.push(base);
|
||||
git(args);
|
||||
}
|
||||
|
||||
function addWorktreeSafe(name, dir, base) {
|
||||
assertCanCreate(name, dir);
|
||||
addWorktree(name, dir, base);
|
||||
console.log(`工作区已创建于 ${dir}`);
|
||||
}
|
||||
|
||||
function ask(rl, prompt) {
|
||||
return new Promise((resolve) => rl.question(prompt, resolve));
|
||||
}
|
||||
|
||||
function assertCanCreate(name, dir) {
|
||||
if (existsSync(dir)) {
|
||||
throw new Error(`工作区已存在于 ${dir}`);
|
||||
}
|
||||
if (worktreeExists(dir)) {
|
||||
throw new Error(`工作区 '${name}' 已存在`);
|
||||
}
|
||||
if (localBranchExists(name)) {
|
||||
throw new Error(`本地分支 '${name}' 已存在`);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchRemote() {
|
||||
try {
|
||||
git(["fetch", "--quiet"]);
|
||||
} catch {
|
||||
console.warn("警告: 无法获取远端信息,继续使用本地数据");
|
||||
}
|
||||
}
|
||||
|
||||
function getRootDir() {
|
||||
try {
|
||||
return resolve(git(["rev-parse", "--show-toplevel"]).trim());
|
||||
} catch {
|
||||
console.error("错误: 不在 git 仓库中");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function git(args, opts) {
|
||||
return execFileSync("git", args, { encoding: "utf-8", stdio: "pipe", ...opts });
|
||||
}
|
||||
|
||||
async function handleWithName(name, worktreesDir) {
|
||||
const dir = resolve(worktreesDir, name);
|
||||
|
||||
try {
|
||||
assertCanCreate(name, dir);
|
||||
} catch (e) {
|
||||
console.error(`错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const remotes = matchingRemoteBranches(name);
|
||||
|
||||
try {
|
||||
if (remotes.length > 0) {
|
||||
const base = await selectFromList(remotes, "找到远端分支:", true);
|
||||
addWorktree(name, dir, base ?? undefined);
|
||||
} else {
|
||||
console.log("未找到远端分支,创建新分支");
|
||||
addWorktree(name, dir);
|
||||
}
|
||||
console.log(`工作区已创建于 ${dir}`);
|
||||
} catch (e) {
|
||||
console.error(`错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWithoutName(worktreesDir) {
|
||||
const remotes = listRemoteBranches();
|
||||
|
||||
if (remotes.length === 0) {
|
||||
console.log("未找到远端分支");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const selected = await selectFromList(remotes, "远端分支列表:", true);
|
||||
|
||||
if (selected) {
|
||||
const name = shortBranchName(selected);
|
||||
const dir = resolve(worktreesDir, name);
|
||||
try {
|
||||
addWorktreeSafe(name, dir, selected);
|
||||
} catch (e) {
|
||||
console.error(`错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const name = await inputBranchName();
|
||||
const dir = resolve(worktreesDir, name);
|
||||
try {
|
||||
addWorktreeSafe(name, dir);
|
||||
} catch (e) {
|
||||
console.error(`错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inputBranchName() {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
let cancelled = false;
|
||||
rl.on("close", () => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const name = (await ask(rl, "请输入新分支名称: ")).trim();
|
||||
if (cancelled) {
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
if (name) {
|
||||
rl.close();
|
||||
return name;
|
||||
}
|
||||
console.log("错误: 分支名称不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
function listRemoteBranches() {
|
||||
try {
|
||||
return git(["branch", "-r"])
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.includes(" -> "));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function localBranchExists(name) {
|
||||
try {
|
||||
git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function matchingRemoteBranches(name) {
|
||||
return listRemoteBranches().filter((l) => l.endsWith(`/${name}`));
|
||||
}
|
||||
|
||||
async function selectFromList(items, prompt, allowCreate) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
console.log(prompt);
|
||||
items.forEach((item, i) => console.log(` ${i + 1}\t${item}`));
|
||||
if (allowCreate) console.log(` ${items.length + 1}\t创建新分支`);
|
||||
console.log();
|
||||
|
||||
const max = allowCreate ? items.length + 1 : items.length;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
let cancelled = false;
|
||||
rl.on("close", () => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const raw = await ask(rl, `请选择 (1-${max}): `);
|
||||
if (cancelled) {
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > max) {
|
||||
console.log(`错误: 请输入 1-${max} 之间的数字`);
|
||||
continue;
|
||||
}
|
||||
if (n <= items.length) {
|
||||
const sel = items[n - 1];
|
||||
console.log(`已选择: ${sel}`);
|
||||
rl.close();
|
||||
return sel;
|
||||
}
|
||||
rl.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortBranchName(remoteRef) {
|
||||
const idx = remoteRef.indexOf("/");
|
||||
return idx === -1 ? remoteRef : remoteRef.slice(idx + 1);
|
||||
}
|
||||
|
||||
function worktreeExists(worktreeDir) {
|
||||
try {
|
||||
const out = git(["worktree", "list"]);
|
||||
const target = resolve(worktreeDir);
|
||||
return out.split(/\r?\n/).some((line) => {
|
||||
const fields = line.trim().split(/\s+/);
|
||||
return fields.length > 0 && resolve(fields[0]) === target;
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => process.exit(1));
|
||||
|
||||
async function main() {
|
||||
const branchName = process.argv[2];
|
||||
|
||||
const rootDir = getRootDir();
|
||||
const worktreesDir = resolve(rootDir, ".worktrees");
|
||||
mkdirSync(worktreesDir, { recursive: true });
|
||||
|
||||
console.log("正在从远端获取最新分支信息...");
|
||||
fetchRemote();
|
||||
|
||||
if (branchName) {
|
||||
await handleWithName(branchName, worktreesDir);
|
||||
} else {
|
||||
await handleWithoutName(worktreesDir);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
preload = ["./tests/happydom.ts", "./tests/setup.ts"]
|
||||
exclude = ["./tests/e2e/**"]
|
||||
|
||||
@@ -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
|
||||
@@ -24,57 +25,61 @@ docs/
|
||||
prompt-smart-merge.md
|
||||
prompt-proposal-review.md
|
||||
prompt-apply-review.md
|
||||
prompt-code-review.md
|
||||
```
|
||||
|
||||
`docs/prompts/` 是提示词资产目录,不属于常规开发流程和用户使用文档。代码、配置或部署变更不需要更新该目录,除非任务明确要求维护提示词资产。
|
||||
|
||||
## 入口文档
|
||||
|
||||
| 入口 | 定位 |
|
||||
| --------------------------------- | -------------------------------------- |
|
||||
| [项目 README](../README.md) | 项目整体介绍、快速开始、文档引导 |
|
||||
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
|
||||
| [用户文档](user/README.md) | 使用、配置、部署、排障入口 |
|
||||
| 入口 | 定位 |
|
||||
| --------------------------------- | -------------------------------------------------------- |
|
||||
| [项目 README](../README.md) | 项目整体介绍、快速开始、文档引导 |
|
||||
| [开发文档](development/README.md) | 开发入口、**全部开发规范**、常用命令、质量门禁、专题索引 |
|
||||
| [用户文档](user/README.md) | 使用、配置、部署、排障入口 |
|
||||
|
||||
## 按任务阅读路径
|
||||
|
||||
| 任务 | 必读文档 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| 修改项目介绍或快速开始 | [项目 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) |
|
||||
|
||||
## 文档归属矩阵
|
||||
|
||||
| 变更类型 | 默认更新位置 |
|
||||
| --------------------------------------------------------- | ---------------------------------------- |
|
||||
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
|
||||
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md`、`openspec/config.yaml` |
|
||||
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
|
||||
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
|
||||
| 后端 API、配置加载、logger、helpers、类型规范、后端测试 | `docs/development/backend.md` |
|
||||
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
|
||||
| 构建、发布、脚本、前后端静态资源集成 | `docs/development/release.md` |
|
||||
| 快速开始、安装配置 | `docs/user/usage.md` |
|
||||
| YAML 配置、变量语法、server/storage/logging、JSON Schema | `docs/user/config.md` |
|
||||
| 生产构建、可执行文件运行、运行时配置 | `docs/user/deploy.md` |
|
||||
| 常见运行问题、配置校验、变量解析、构建失败 | `docs/user/troubleshoot.md` |
|
||||
| 变更类型 | 默认更新位置 |
|
||||
| -------------------------------------------------------- | ---------------------------------------- |
|
||||
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
|
||||
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md`、`openspec/config.yaml` |
|
||||
| 开发规范(全局规则、库优先级、类型规范、样式、测试等) | `docs/development/README.md` |
|
||||
| 开发入口、常用命令、质量门禁、目录边界、OpenSpec 约定 | `docs/development/README.md` |
|
||||
| 架构边界、启动流程、运行时流程、前后端边界 | `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` |
|
||||
| 生产构建、可执行文件运行、运行时配置 | `docs/user/deploy.md` |
|
||||
| 常见运行问题、配置校验、变量解析、构建失败 | `docs/user/troubleshoot.md` |
|
||||
|
||||
## development 文档如何更新
|
||||
|
||||
开发文档解释"如何实现和维护"。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
|
||||
|
||||
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`。
|
||||
- **全部开发规范**(全局规则、后端规范、前端通用规范、样式规范、测试规范)统一维护在 `docs/development/README.md`。
|
||||
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`。
|
||||
- 后端 API、配置加载、logger、helpers、类型规范和后端测试规范更新到 `docs/development/backend.md`。
|
||||
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`。
|
||||
- 后端模块 API、工具函数索引、数据库 schema、AI 层实现、日志模块更新到 `docs/development/backend.md`。
|
||||
- 前端运行时代码结构、组件索引、页面组成、hooks/工具清单更新到 `docs/development/frontend.md`。
|
||||
- 构建、脚本和发布验证更新到 `docs/development/release.md`。
|
||||
- 不新增"杂项"开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`。
|
||||
|
||||
|
||||
@@ -1,109 +1,380 @@
|
||||
# 开发文档
|
||||
# 开发规范
|
||||
|
||||
本文档是 alfred 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题。
|
||||
AI 工具必须严格遵守以下全部约束。
|
||||
|
||||
适用场景:修改源码、测试、构建脚本、开发流程、架构边界或项目工程规则。
|
||||
## 专题文档
|
||||
|
||||
## 专题索引
|
||||
| 文档 | 内容 |
|
||||
| ---------------------------------- | ---------------------------------- |
|
||||
| [architecture.md](architecture.md) | 项目结构、启动流程、前后端边界 |
|
||||
| [backend.md](backend.md) | 模块 API、数据访问函数、AI 层说明 |
|
||||
| [frontend.md](frontend.md) | 组件索引、页面组成、hooks/工具清单 |
|
||||
| [release.md](release.md) | 开发服务、构建、脚本、环境变量 |
|
||||
|
||||
| 文档 | 内容 |
|
||||
| ---------------------------------- | ---------------------------------------------------------------- |
|
||||
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
|
||||
| [backend.md](backend.md) | 后端库优先级、API 路由、共享工具、类型规范、配置契约、日志、测试 |
|
||||
| [frontend.md](frontend.md) | React、Ant Design、TanStack Query、组件、样式和前端测试规范 |
|
||||
| [release.md](release.md) | 开发服务、前后端集成、构建、脚本、环境变量 |
|
||||
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
|
||||
---
|
||||
|
||||
## 一、全局规则
|
||||
|
||||
### 语言与环境
|
||||
|
||||
- 使用中文编写注释、文档和交流内容。
|
||||
- 仅使用 bun 作为包管理器和 bunx 作为工具运行器;禁止 npm、pnpm、yarn、npx、pnpx。
|
||||
- 无需考虑向前兼容。
|
||||
|
||||
### 依赖引入
|
||||
|
||||
**后端**优先级(上层已有方案则不得引入新依赖):
|
||||
|
||||
1. Bun 内置 API(Bun.serve、bun:sqlite 等)
|
||||
2. es-toolkit
|
||||
3. 标准 Web API(fetch、Headers 等)
|
||||
4. 已批准三方库:pino、@sinclair/typebox、ajv、drizzle-orm、ai、@ai-sdk/\*
|
||||
5. 自行实现(仅以上都不满足时)
|
||||
|
||||
**前端**:优先复用已有组件/hooks/依赖库;确需新增依赖时先说明原因。
|
||||
|
||||
**Zod**:AI 工具层(`src/server/ai/tools/`)使用 Zod 定义 `tool()` 的 `inputSchema`,以满足 AI SDK 对 `ZodSchema` 的类型推断要求,属于框架级约束而非项目选型冲突。配置校验层使用 TypeBox + Ajv,两层级各司其职,不混用。
|
||||
|
||||
### 目录边界
|
||||
|
||||
| 目录 | 约束 |
|
||||
| ------------------------ | --------------------------------------------------- |
|
||||
| `src/server/` | 后端,禁止 import src/web/ |
|
||||
| `src/server/db/` | 数据库层:schema、connection、migration、DAO |
|
||||
| `src/server/ai/` | AI Provider Registry + Agent + 工具 |
|
||||
| `src/server/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/ ← 通用 hooks(use-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,确认复用后再提升)
|
||||
|
||||
### 类型与配置
|
||||
|
||||
- 共享类型唯一源头:`src/shared/api.ts`;应用常量唯一源头:`src/shared/app.ts`;版本号唯一源头:`package.json`。
|
||||
- 配置加载流程:unknown → AuthoringConfig → NormalizedConfig → ValidatedConfig → ResolvedConfig。
|
||||
- 配置系统入口:`src/server/config.ts`(统一配置加载、运行时校验、默认值解析)。
|
||||
- Ajv 严格拒绝模式:不类型转换、不注入默认值、不删除未知字段。
|
||||
- 新增/修改配置字段必须同步更新 TypeBox schema、`config.schema.json`、测试和用户文档。
|
||||
|
||||
### 后端日志
|
||||
|
||||
- 运行时代码通过 Logger 接口输出,禁止 `console.*`(仅 `logger.ts` 实现类内部可用)。
|
||||
- 敏感字段(authorization、cookie、password 等)自动 redact。
|
||||
|
||||
### API 路由
|
||||
|
||||
- 每个端点一个文件:`src/server/routes/{资源}/{操作}.ts`。
|
||||
- 路由在 `server.ts` 的 `Bun.serve({ routes })` 中声明式注册。
|
||||
- 新增路由:创建 handler → 在 `server.ts` 注册 → 在 `tests/server/` 添加测试。
|
||||
|
||||
### 前端数据层
|
||||
|
||||
- 统一使用 `fetch`,不引入 axios。
|
||||
- 错误抛异常,TanStack Query error 状态承接。
|
||||
- 返回类型必须匹配后端 JSON 形状;包装对象(如 `{ project }`)须在 hook 内提取业务对象。
|
||||
- 服务端状态用 TanStack Query,组件内状态用 `useState`,不引入额外状态管理库。
|
||||
|
||||
### 禁止事项
|
||||
|
||||
- 禁止前端 import `src/server/`
|
||||
- 禁止后端使用 `console.*`
|
||||
- 禁止组件内联 `style` 属性
|
||||
- 禁止 CSS 覆盖 `.ant-*` 内部类名
|
||||
- 禁止 `!important`
|
||||
- 禁止硬编码色值(使用 `var(--ant-*)` CSS 变量)
|
||||
- 禁止路由 handler 直接操作 `bun:sqlite` / Drizzle ORM(须通过 DAO 层)
|
||||
- 禁止跳过或忽略测试
|
||||
- 禁止在 `Modal onOk` 中直接执行异步提交(用 `Form onFinish`)
|
||||
|
||||
---
|
||||
|
||||
## 二、后端红线
|
||||
|
||||
### 路由 handler
|
||||
|
||||
- 签名:`(req: Request, db: Database, mode: RuntimeMode, logger?: Logger): Promise<Response>`。
|
||||
- 业务错误:`jsonResponse(createApiError(msg, status), { mode, status })`;未知异常直接 throw,`withErrorHandler` 兜底。
|
||||
- body 解析后立即校验必填字段和类型,失败返回 400。
|
||||
- ID 参数走 `validateIdParam`,分页参数走 `validatePagination`。
|
||||
- 路由注册:`withErrorHandler` 包裹 + 动态 `await import()` 加载 handler。
|
||||
|
||||
### 数据访问层
|
||||
|
||||
- handler 通过 `src/server/db/*.ts` 函数操作数据库,禁止直接使用 `bun:sqlite` 或 Drizzle。
|
||||
- DAO 函数第一个参数接收原始 `Database`,内部 `wrap(db)` 转 Drizzle。
|
||||
- 返回联合类型:成功 `{ resource: T }`,失败 `{ error: string; status: number }`。
|
||||
- 输入输出类型来自 `src/shared/api.ts`。
|
||||
- 列表查询使用 `paginateQuery()`,不重复实现分页。
|
||||
- 列名 snake_case,TS 类型 camelCase,Drizzle 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 调用层
|
||||
|
||||
- Provider 实例:`buildProviderRegistry(db)`,每次从 DB 重建,不缓存。
|
||||
- Agent 实例:`createAlfredAgent(model)` 工厂。
|
||||
- SSE 响应:`createAgentUIStreamResponse`,`onFinish` 持久化完整 parts。
|
||||
- 工具定义:`src/server/ai/tools/`。
|
||||
|
||||
### 错误处理
|
||||
|
||||
- 业务异常用 `AppError(statusCode)` 或 `jsonResponse(createApiError(...))`。
|
||||
- handler 外层 `withErrorHandler` 兜底,不手动 try-catch 整个 handler。
|
||||
- 生产模式错误响应不暴露内部细节。
|
||||
|
||||
---
|
||||
|
||||
## 三、前端红线
|
||||
|
||||
### 组件规范
|
||||
|
||||
- 优先 antd 默认能力 + props,不额外改写视觉。
|
||||
- `ConfigProvider(zhCN)` 配置中文 locale 和主题,不在 CSS 硬编码亮/暗分支。
|
||||
- 应用级能力(message、modal、notification)通过 `AntApp` + `App.useApp()` 获取。
|
||||
- 页面保持编排职责,组合 hooks + 展示组件;复杂页面按功能边界拆分。
|
||||
|
||||
### 能力优先级(上层满足则不用下层)
|
||||
|
||||
1. antd 组件/props
|
||||
2. antd 布局组件(Layout、Space、Flex)
|
||||
3. antd theme token + CSS 变量
|
||||
4. TanStack Query + useState
|
||||
5. 已有 hooks(`shared/hooks/use-*.ts`)和工具函数(`shared/utils/`)
|
||||
6. CSS Modules(就近放置在 feature 内部)
|
||||
7. 引入新依赖(需说明原因)
|
||||
|
||||
### 前端代码组织
|
||||
|
||||
- 新增页面在 `features/` 下创建功能目录,不使用 `pages/`。
|
||||
- 新增组件/hook/tool 默认放在所属 feature 内部;跨 feature 复用时提升到 `shared/`。
|
||||
- 布局组件放 `layouts/`,布局与页面通过 `routes.tsx` 组合,不互相导入。
|
||||
- 详细规则见上方「前端目录使用规范」章节。
|
||||
|
||||
### 样式红线
|
||||
|
||||
- 严禁内联 `style`、覆盖 `.ant-*`、`!important`、硬编码色值。
|
||||
- 颜色使用 `var(--ant-*)` CSS 变量。
|
||||
- 不引入 Tailwind/Sass/Less/CSS-in-JS 等额外样式方案。
|
||||
- 全局 CSS 类名用 `app-*` 前缀,禁止泛名。样式增长后用 CSS Modules 就近维护。
|
||||
|
||||
### 表单与交互
|
||||
|
||||
- Modal + Form:`Form onFinish` 处理提交,`Modal onOk` 只调 `form.submit()`。
|
||||
- 必填文本字段同时配 `required` + `whitespace`。
|
||||
- 操作确认用 `Popconfirm`,反馈用 antd message。
|
||||
|
||||
### 错误边界
|
||||
|
||||
- 生产入口必须启用 `ErrorBoundary`。`ReactQueryDevtools` 仅 `DEV` 模式渲染。
|
||||
|
||||
---
|
||||
|
||||
## 四、测试规范
|
||||
|
||||
### 后端
|
||||
|
||||
- 路由测试通过真实 `startServer` 覆盖路由注册、HTTP method、fallback、header 和核心错误路径。
|
||||
- SQLite 测试复用 `tests/helpers.ts`,不分散实现临时目录清理。
|
||||
- DAO/路由测试优先用真实 migration 初始化。
|
||||
- logger/bootstrap fallback 输出须捕获断言,正常测试不污染 stdout/stderr。
|
||||
|
||||
### 前端
|
||||
|
||||
- 目录 `tests/web/`,结构对应 `src/web/`。
|
||||
- 用 happy-dom + `@testing-library/react` 测试用户行为,断言基于可见文本/role/按钮。
|
||||
- 系统边界复用 `tests/web/test-utils.tsx`。
|
||||
- 数据页面覆盖:请求参数、成功可见结果、关键错误路径。
|
||||
- ErrorBoundary/hooks/fetch helper 用单元测试覆盖异常,页面测试只保留用户路径。
|
||||
|
||||
---
|
||||
|
||||
## 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| -------------------------------- | -------------------------------------- |
|
||||
| `bun install` | 安装依赖 |
|
||||
| `bun run dev config.yaml` | 启动双进程开发环境 |
|
||||
| `bun run dev:server config.yaml` | 仅启动后端 API server |
|
||||
| `bun run dev:web` | 仅启动 Vite dev server |
|
||||
| `bun run schema` | 生成 config.schema.json |
|
||||
| `bun run schema:check` | 检查导出 schema 是否同步 |
|
||||
| `bun run typecheck` | TypeScript 类型检查 |
|
||||
| `bun run lint` | ESLint 和 Prettier 格式检查 |
|
||||
| `bun run format` | Prettier 自动格式化 |
|
||||
| `bun test` | 运行全部测试 |
|
||||
| `bun run dev:server config.yaml` | 仅后端 API server |
|
||||
| `bun run dev:web` | 仅 Vite dev server |
|
||||
| `bun run check` | schema:check + typecheck + lint + test |
|
||||
| `bun run build` | 构建生产可执行文件 |
|
||||
| `bun run verify` | check + build 完整验证 |
|
||||
| `bun run clean` | 清理构建缓存与临时文件 |
|
||||
| `bun run version:patch` | 升迁 patch 版本(x.y.Z) |
|
||||
| `bun run version:minor` | 升迁 minor 版本(x.Y.0) |
|
||||
| `bun run version:major` | 升迁 major 版本(X.0.0) |
|
||||
| `bun run build` | 构建生产可执行文件 |
|
||||
| `bun run schema` | 生成 config.schema.json |
|
||||
| `bun run schema:check` | 检查 schema 同步 |
|
||||
| `bun run typecheck` | TypeScript 类型检查 |
|
||||
| `bun run lint` | oxlint 检查(--deny-warnings) |
|
||||
| `bun run format` | oxfmt 格式化 |
|
||||
| `bun test` | 运行全部测试 |
|
||||
| `bun run clean` | 清理构建缓存 |
|
||||
| `bun run version:patch` | 升迁 patch 版本 |
|
||||
| `bun run version:minor` | 升迁 minor 版本 |
|
||||
| `bun run version:major` | 升迁 major 版本 |
|
||||
| `bun run version:set` | 显式设置版本号 |
|
||||
|
||||
## 质量门禁
|
||||
|
||||
代码变更必须按影响范围执行验证。
|
||||
| 变更类型 | 必跑命令 |
|
||||
| ----------------------- | --------------------------------------------------------- |
|
||||
| 常规代码变更 | `bun run check` |
|
||||
| 构建、部署、集成变更 | `bun run verify` |
|
||||
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||
| SQLite 测试基础设施变化 | 相关测试 + SQLite 聚焦 `--rerun-each` + `bun run check` |
|
||||
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||
|
||||
| 变更类型 | 必跑命令 |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| 常规代码变更 | `bun run check` |
|
||||
| 构建、部署、前后端集成变更 | `bun run verify` |
|
||||
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||
正式提交优先运行 `bun run verify`。无法执行时须在收尾说明中记录。
|
||||
|
||||
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
||||
## 已知设计决策
|
||||
|
||||
## 全局工程规则
|
||||
|
||||
- 使用中文编写注释、文档和项目内交流内容。
|
||||
- 仅使用 bun 作为包管理器,禁止使用 npm、pnpm、yarn。
|
||||
- 运行工具使用 bunx,禁止使用 npx、pnpx。
|
||||
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
|
||||
- 后端优先使用 Bun 内置 API,其次是 es-toolkit、标准 Web API、主流三方库,最后才自行实现。
|
||||
- 前端优先使用 Ant Design 组件默认能力和组件 props 组合界面,具体组件、样式、数据流和测试细节见 [frontend.md](frontend.md)。
|
||||
- 当前项目无需考虑向前兼容。
|
||||
|
||||
## 包管理、依赖与提交
|
||||
|
||||
- 仅使用 bun 安装依赖和运行项目脚本,锁文件为 bun.lock。
|
||||
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
|
||||
- Git 提交信息使用中文,格式为"类型: 简短描述"。
|
||||
- 提交类型限定为 feat、fix、refactor、docs、style、test、chore。
|
||||
- 多行提交描述时,标题和正文之间空一行。
|
||||
|
||||
## 目录边界
|
||||
|
||||
| 目录 | 约定 |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `src/server/` | Bun 后端代码,不能 import src/web/,HTML import 集成除外 |
|
||||
| `src/server/db/` | SQLite 数据库模块,包含 schema、connection、migration 和 data access |
|
||||
| `src/web/` | React 前端,不能 import src/server/ 运行时实现 |
|
||||
| `src/shared/` | 前后端共享 TypeScript 类型 |
|
||||
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
||||
| `drizzle/` | Drizzle Kit 生成的 SQL migration 文件(开发期产出) |
|
||||
| `tests/` | 测试目录,结构镜像 src/ |
|
||||
| `docs/user/` | 用户使用、配置、部署和排障文档 |
|
||||
| `docs/development/` | 架构、后端、前端、发布开发文档 |
|
||||
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
||||
| 决策 | 原因 |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Provider.apiKey 返回给前端 | 个人项目,apiKey 非严格密码,前端需要展示和编辑。如需保护,应改为返回脱敏值或仅后端存储。 |
|
||||
|
||||
## 文档影响分析
|
||||
|
||||
每次代码变更都必须执行文档影响分析。
|
||||
| 变更影响 | 更新 |
|
||||
| -------------------------------------- | ------------------------------------------ |
|
||||
| 用户可见行为、配置、部署、运行行为 | `docs/user/` 对应文档 |
|
||||
| 开发流程、架构、测试、构建发布流程 | `docs/development/` 对应文档 |
|
||||
| 项目定位、快速开始、核心能力、文档导航 | `README.md` |
|
||||
| 文档同步规则或归属矩阵 | `docs/README.md` 和 `openspec/config.yaml` |
|
||||
| 开发规范变化 | 本文档对应章节 |
|
||||
|
||||
| 如果变更影响 | 更新 |
|
||||
| ------------------------------------------ | ------------------------------------------ |
|
||||
| 用户可见行为、配置、部署、运行行为 | `docs/user/` 对应文档 |
|
||||
| 开发流程、架构、测试、构建发布流程 | `docs/development/` 对应文档 |
|
||||
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
|
||||
| 文档同步规则或文档归属矩阵 | `docs/README.md` 和 `openspec/config.yaml` |
|
||||
|
||||
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
|
||||
|
||||
## 事实来源
|
||||
|
||||
| 主题 | 事实来源 |
|
||||
| -------------- | -------------------------------------------------- |
|
||||
| 代码结构和实现 | `src/`、`scripts/`、`tests/` |
|
||||
| 配置 schema | TypeBox fragments、config.schema.json、schema 测试 |
|
||||
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
|
||||
无需更新文档时,须在收尾说明中说明原因。
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改常用命令、质量门禁、全局工程规则、目录边界或开发文档索引时,必须更新本文档。
|
||||
修改常用命令、质量门禁、开发规范(任何章节)、目录边界或开发文档索引时,必须更新本文档。
|
||||
|
||||
### 文档编撰规范
|
||||
|
||||
本节开发文档面向 AI 工具阅读,编撰时遵循以下原则:
|
||||
|
||||
**精简原则**
|
||||
|
||||
- 删除引导语、适用场景、过渡句等装饰性文字。AI 无需"应首先阅读"或"本文档说明…"类引导。
|
||||
- 不重复项目结构树,AI 可通过 glob 获取目录结构。
|
||||
- 不重复已在 README.md 声明的规范细节,专题文档只记录实现层面的函数签名、API 端点、模块职责等索引信息。
|
||||
- 表格标题自明时不再加说明段落。
|
||||
|
||||
**信息完整性**
|
||||
|
||||
- 所有函数签名、API 端点(方法+路径+说明)、数据访问函数清单必须完整列举,不可用"等"省略。
|
||||
- 页面行为描述须包含关键交互逻辑(如 Tab 切换、默认值、条件跳转、测试不阻止保存等),不可只写组件名。
|
||||
- 配置文件列表必须完整,不可遗漏已有文件。
|
||||
|
||||
**结构规范**
|
||||
|
||||
- 每个专题文档末尾保留 `## 更新触发条件` 章节,明确列出哪些变更必须更新该文档。
|
||||
- 用表格和编号列表替代散文段落,减少 token 消耗。
|
||||
- 同一信息只在一处维护,避免多处重复导致不一致。
|
||||
|
||||
@@ -1,102 +1,75 @@
|
||||
# 架构与边界
|
||||
|
||||
本文档说明 alfred 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
|
||||
|
||||
适用场景:修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
server/
|
||||
bootstrap.ts 统一启动引导(loadServerConfig -> DB 初始化 -> startServer)
|
||||
config.ts CLI 参数解析与配置文件加载 facade
|
||||
config/ 配置解析模块(types、issues、variables、normalizer、schema)
|
||||
db/ SQLite 数据库模块
|
||||
schema.ts Drizzle ORM schema 定义
|
||||
connection.ts 数据库连接与 PRAGMA 设置
|
||||
load-migrations.ts 从文件系统加载 migration SQL
|
||||
migrate.ts migration 执行器(备份 + 事务应用)
|
||||
projects.ts 项目数据访问函数
|
||||
dev.ts 开发模式启动入口
|
||||
main.ts 生产模式启动入口
|
||||
server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由)
|
||||
static.ts 生产模式静态资源服务
|
||||
helpers.ts 共享响应格式化工具
|
||||
middleware.ts API 参数校验中间件
|
||||
logger.ts 结构化日志(基于 pino + pino-roll)
|
||||
version.ts 运行时版本号读取
|
||||
routes/ API 路由处理器
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型定义
|
||||
app.ts 应用全局常量(name、title、subtitle、description)
|
||||
web/ React 前端(通过 Vite 构建)
|
||||
index.html HTML 入口
|
||||
main.tsx React 入口
|
||||
app.tsx 根组件
|
||||
routes.tsx 路由配置
|
||||
styles.css 全局样式
|
||||
pages/ 页面组件
|
||||
components/ UI 组件
|
||||
hooks/ React Hooks
|
||||
utils/ 前端工具函数
|
||||
scripts/ 独立运行脚本
|
||||
tests/ 测试文件(镜像 src 目录结构)
|
||||
docs/ 项目文档
|
||||
openspec/ OpenSpec 规格、变更与 fast-drive workflow schema
|
||||
```
|
||||
|
||||
## 启动流程
|
||||
|
||||
```text
|
||||
dev.ts / main.ts
|
||||
-> parseRuntimeArgs(cli args)
|
||||
-> 必须指定 config.yaml
|
||||
-> parseRuntimeArgs(cli args) — 必须指定 config.yaml
|
||||
-> bootstrap({ configPath, mode })
|
||||
-> loadServerConfig(configPath)
|
||||
-> createRuntimeLogger(config.logging)
|
||||
-> 确保 dataDir 就绪(mkdirSync)
|
||||
-> 加载 migrations(生产:嵌入的 bytes;开发:磁盘 drizzle/ 目录)
|
||||
-> createDatabase(dataDir)
|
||||
-> runMigrations(db, migrations)(pending migration 存在时先备份 DB)
|
||||
-> startServer({ config, logger, db })
|
||||
-> logger 记录启动成功
|
||||
-> loadServerConfig(configPath) — 加载并校验配置(config.ts)
|
||||
-> createRuntimeLogger(config.logging) — 创建 Logger 实例
|
||||
-> mkdirSync(dataDir)
|
||||
-> 加载 migrations(生产:嵌入 bytes;开发:磁盘 drizzle/)
|
||||
-> createDatabase(dataDir)
|
||||
-> runMigrations(db, migrations) — pending 时先备份 DB
|
||||
-> startServer({ config, logger, db })
|
||||
-> SIGINT/SIGTERM -> db.close() -> logger.flush() -> exit
|
||||
```
|
||||
|
||||
## HTTP 请求流程
|
||||
|
||||
```text
|
||||
Request
|
||||
-> Bun.serve routes 声明式匹配
|
||||
-> routes/*.ts handler
|
||||
-> helpers.ts 响应格式化
|
||||
-> Response
|
||||
Request -> Bun.serve routes 声明式匹配 -> routes/*.ts handler -> helpers/ 响应格式化 -> Response
|
||||
```
|
||||
|
||||
生产模式下,非 API 路径由 fetch fallback 处理:有文件扩展名的返回静态资源或 404,无扩展名的返回 SPA index.html。
|
||||
|
||||
开发模式下,Vite proxy 将 /api 请求转发到 Bun API server。
|
||||
- 生产模式:非 API 路径由 fetch fallback 处理,有扩展名返回静态资源或 404,无扩展名返回 SPA index.html。
|
||||
- 开发模式:Vite proxy 将 /api 转发到 Bun。
|
||||
|
||||
## 前后端边界
|
||||
|
||||
- 前端只通过 HTTP 调用后端,API 路径为 /api/\*。
|
||||
- 共享类型放在 src/shared/。
|
||||
- 前端不得 import src/server/ 的运行时实现。
|
||||
- 后端不得依赖 src/web/ 运行时代码,HTML import 集成除外。
|
||||
- 前端只通过 HTTP /api/\* 调用后端。
|
||||
- 共享类型在 `src/shared/`。
|
||||
- 前端禁止 import `src/server/` 运行时实现;后端禁止依赖 `src/web/` 运行时代码(HTML import 集成除外)。
|
||||
|
||||
## 主要模块职责
|
||||
## 配置定位
|
||||
|
||||
| 模块 | 职责 |
|
||||
| ------------------------- | --------------------------------------------- |
|
||||
| `src/server/bootstrap.ts` | 统一启动引导、DB 初始化和 shutdown 编排 |
|
||||
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
|
||||
| `src/server/routes/` | API handler,按端点拆分 |
|
||||
| `src/server/db/` | SQLite 连接、schema、migration 和 data access |
|
||||
| `src/server/config/` | 配置解析模块(types、variables、schema) |
|
||||
| `src/web/` | React 前端 |
|
||||
| `src/shared/api.ts` | 前后端共享 API 类型 |
|
||||
| `src/shared/app.ts` | 应用全局常量 |
|
||||
| 配置类型 | 来源 | 内容 | 可变性 |
|
||||
| -------- | ------------------------------------------------------------------------ | ------------------------------ | ---------- |
|
||||
| 启动配置 | 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/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/` |
|
||||
| settings | `/api/settings` | `routes/settings.ts` |
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
# 后端开发
|
||||
|
||||
本文档说明 alfred 后端的 API、配置加载、日志、版本管理和后端测试开发约定。
|
||||
|
||||
适用场景:修改 src/server/、src/shared/api.ts、后端测试、配置契约、API 响应或日志模块。
|
||||
|
||||
## 库使用优先级
|
||||
|
||||
| 优先级 | 来源 | 典型用途 |
|
||||
| ------ | ------------ | ---------------------------------------------------- |
|
||||
| 1 | Bun 内置 API | Bun.serve、Bun.file、Bun.YAML、Bun.spawn、bun:sqlite |
|
||||
| 2 | es-toolkit | 类型判断、深度比较、并发控制 |
|
||||
| 3 | 标准 Web API | Headers、fetch、AbortController |
|
||||
| 4 | 主流三方库 | pino、@sinclair/typebox、ajv、drizzle-orm |
|
||||
| 5 | 自行实现 | 仅在以上都无法满足时 |
|
||||
|
||||
新增依赖前必须先检查上述每一层是否已有可用方案。
|
||||
|
||||
## API 路由开发
|
||||
|
||||
路由文件位于 src/server/routes/,每个端点一个文件。路由通过 server.ts 的 Bun.serve({ routes }) 声明式注册。
|
||||
|
||||
新增路由步骤:
|
||||
|
||||
1. 在 src/server/routes/ 下创建 <name>.ts
|
||||
2. 实现 handler 函数并 export
|
||||
3. 在 server.ts 的 routes 对象中注册路径和 method handler
|
||||
4. 在 tests/server/ 中添加对应测试
|
||||
开发规范见 [开发规范文档](README.md)。
|
||||
|
||||
## 共享工具
|
||||
|
||||
helpers.ts 提供跨路由共用的响应工具:
|
||||
`src/server/helpers/`:
|
||||
|
||||
- createApiError(error, status) — 构造 API 错误体
|
||||
- createHeaders(mode, init) — 创建响应 Headers
|
||||
- jsonResponse(body, options) — JSON 响应构造
|
||||
- `response.ts`:`createApiError(error, status)`、`createHeaders(mode, init)`、`createMetaResponse(version)`、`formatDuration(ms)`、`jsonResponse(body, options)`
|
||||
- `url.ts`:`parseIdFromUrl(url)`
|
||||
- `list-params.ts`:`parseListParams(url, mode, options?)` — 统一校验分页/排序参数,替代 validatePagination
|
||||
- `pagination.ts`:`paginateQuery()` — Drizzle 分页查询封装
|
||||
|
||||
middleware.ts 提供 API 参数校验函数:
|
||||
`src/server/middleware/`:
|
||||
|
||||
- validateIdParam(idStr, mode) — 校验 ID 参数格式
|
||||
- validatePagination(pageParam, pageSizeParam, mode) — 校验分页参数
|
||||
- validateTimeRange(from, to, mode) — 校验时间范围参数
|
||||
- `validate.ts`:`validateIdParam(idStr, mode)` — 校验 ID 格式(字母数字 + `_-`);`validatePagination(pageParam, pageSizeParam, mode)` — page≥1, pageSize≤200;`validateTimeRange(from, to, mode)`
|
||||
- `error-handler.ts`:`AppError` — 业务异常类(含 statusCode);`withErrorHandler(fn, mode, logger?)` — 包裹 handler 捕获异常
|
||||
|
||||
## 数据库
|
||||
|
||||
项目使用 SQLite 作为存储后端,通过 bun:sqlite + Drizzle ORM 实现类型安全的数据访问。
|
||||
SQLite + bun:sqlite + Drizzle ORM。
|
||||
|
||||
### schema 定义
|
||||
- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 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`,PRAGMA:foreign_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/`,事务中执行(迁移期间临时关闭外键检查),失败回滚。
|
||||
|
||||
`src/server/db/schema.ts` 使用 Drizzle ORM 定义表结构,列名使用 snake_case,TypeScript 类型使用 camelCase,Drizzle schema 负责映射。
|
||||
### 软删除
|
||||
|
||||
### 数据库连接
|
||||
所有业务表(projects、providers、models、conversations、materials、messages)使用 `deleted_at` 列实现软删除,不暴露给 API 层。DAO 查询通过 `notDeleted(table)` 或 `paginateQuery({ softDelete })` 自动过滤已删除行。唯一性校验在应用层完成(同名 + `deleted_at IS NULL`),无数据库级 UNIQUE 约束。级联软删除:删除项目 → 级联软删除会话(→ 消息)+ 素材;删除会话 → 级联软删除消息;删除供应商 → 需无未删除模型。
|
||||
|
||||
`src/server/db/connection.ts` 的 `createDatabase(dataDir, logger)` 打开 `<dataDir>/alfred.db`,设置 PRAGMA(foreign_keys=ON、journal_mode=WAL、busy_timeout=5000)。
|
||||
### 数据访问函数
|
||||
|
||||
### migration 机制
|
||||
| 文件 | 函数 |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `projects.ts` | createProject、getProject、listProjects、updateProject、deleteProject、archiveProject、restoreProject |
|
||||
| `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 |
|
||||
|
||||
- 开发期:使用 `drizzle-kit generate` 从 TS schema 生成 SQL migration 文件到 `drizzle/` 目录
|
||||
- 生产期:构建时将 `drizzle/*.sql` 嵌入可执行文件,启动时自动应用 pending migrations
|
||||
- 每次 migration 前自动备份现有 DB 到 `<dataDir>/backups/alfred-<timestamp>.db`
|
||||
- migration 在事务中执行,失败则回滚并停止启动
|
||||
输入输出类型来自 `src/shared/api.ts`。
|
||||
|
||||
### 数据访问
|
||||
## AI 服务层
|
||||
|
||||
`src/server/db/projects.ts` 提供项目数据访问函数,输入输出使用 `src/shared/api.ts` 的类型。函数内部使用 Drizzle query builder 包装 `bun:sqlite` Database。
|
||||
- `src/server/ai/types.ts`:`AIProviderConfig`(name、type、baseUrl、apiKey)、`AIModelConfig`(providerId、modelId、capabilities)。注:AI 层 `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` 接口
|
||||
- `testModelConnection(config, logger)` — 测试模型连通性(需传入含 modelId 的合并配置)
|
||||
- `src/server/ai/agents/alfred-agent.ts`:`createAlfredAgent(model)` — ToolLoopAgent + `stepCountIs(20)` + `getCurrentTime` 工具。
|
||||
- `src/server/ai/tools/`:AI 工具定义。
|
||||
|
||||
## 类型规范
|
||||
### 供应商类型
|
||||
|
||||
- 共享类型以 src/shared/api.ts 为唯一源头
|
||||
- 应用常量以 src/shared/app.ts 为唯一源头
|
||||
- 版本号以 package.json.version 为唯一源头
|
||||
- 前端不得 import src/server/ 下的任何文件
|
||||
- 严格联合类型优先于宽类型
|
||||
| type | AI SDK factory |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| `openai` | `createOpenAI({ apiKey, baseURL })` |
|
||||
| `anthropic` | `createAnthropic({ apiKey, baseURL })` |
|
||||
| `openai-compatible` | `createOpenAICompatible({ name, apiKey, baseURL })` |
|
||||
|
||||
## 配置契约
|
||||
### 连通性测试
|
||||
|
||||
配置加载流程固定为:unknown -> AuthoringConfig -> NormalizedConfig -> ValidatedConfig -> ServerConfig。
|
||||
- `POST /api/providers/test` — 用未保存配置测试,不写入 DB,不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false`;`/models` 不支持返回 `ok: true` + 提示。
|
||||
- `POST /api/models/test` — 用模型关联供应商 + externalId 测试。
|
||||
|
||||
Ajv 保持严格拒绝模式:allErrors: true、不启用类型强制转换、不注入默认值、不自动删除未知字段。
|
||||
## 素材 API
|
||||
|
||||
新增或修改配置字段时必须同步更新 TypeBox schema fragments、config.schema.json、测试和对应用户文档。
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------------------------ | ------------------------------- |
|
||||
| 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`。
|
||||
|
||||
后端运行时代码统一通过 Logger 接口输出日志,禁止直接使用 console.\*。
|
||||
素材类型:`general`(通用)和 `meeting`(会议),创建时可选,默认 `general`。
|
||||
|
||||
| 实现 | 用途 |
|
||||
| --------------------- | ------------------------ |
|
||||
| PinoLoggerWrapper | 生产运行时 |
|
||||
| ConsoleFallbackLogger | 配置加载失败前的降级日志 |
|
||||
| NoopLogger | 静默丢弃日志 |
|
||||
| MemoryLogger | 测试替身 |
|
||||
校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active 且未删除,素材归属校验不匹配返回 403。processing 状态禁止删除(409)。approve/discard 仅限 review 状态(409),retry 仅限 failed 状态(409)。
|
||||
|
||||
敏感信息会自动 redact authorization、cookie、password 等字段。
|
||||
## 素材处理引擎
|
||||
|
||||
`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
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ----------------------------------------------- | ------------------ |
|
||||
| GET | `/api/projects/:id/conversations` | 列出项目下所有会话 |
|
||||
| POST | `/api/projects/:id/conversations` | 创建新会话 |
|
||||
| GET | `/api/projects/:id/conversations/:cid` | 获取会话详情 |
|
||||
| PATCH | `/api/projects/:id/conversations/:cid` | 更新会话 |
|
||||
| DELETE | `/api/projects/:id/conversations/:cid` | 删除会话及消息 |
|
||||
| GET | `/api/projects/:id/conversations/:cid/messages` | 获取消息列表 |
|
||||
| POST | `/api/projects/:id/chat` | 发送消息,SSE 回复 |
|
||||
|
||||
`send.ts`:验证会话归属 → 保存用户消息 → `createAlfredAgent` → `createAgentUIStreamResponse` → `onFinish` 持久化 AI 回复。
|
||||
|
||||
## 日志
|
||||
|
||||
| 实现 | 用途 |
|
||||
| --------------------- | -------------- |
|
||||
| PinoLoggerWrapper | 生产运行时 |
|
||||
| ConsoleFallbackLogger | 配置加载前降级 |
|
||||
| NoopLogger | 静默丢弃 |
|
||||
| MemoryLogger | 测试替身 |
|
||||
|
||||
## 版本管理
|
||||
|
||||
项目使用 package.json.version 作为版本号唯一来源。
|
||||
|
||||
版本获取方式:
|
||||
|
||||
- 开发模式:src/server/version.ts 运行时从 package.json 读取
|
||||
- 生产模式:scripts/build.ts 在构建时将版本号烘焙为字面量注入
|
||||
|
||||
版本升迁命令:
|
||||
|
||||
```bash
|
||||
bun run version:patch # 升迁 patch 版本
|
||||
bun run version:minor # 升迁 minor 版本
|
||||
bun run version:major # 升迁 major 版本
|
||||
bun run version:set # 显式设置版本号
|
||||
```
|
||||
|
||||
## 后端测试
|
||||
|
||||
| 变更类型 | 测试重点 |
|
||||
| ------------------ | --------------------------------- |
|
||||
| API 路由 | tests/server/app.test.ts 集成行为 |
|
||||
| 配置 schema | schema 导出、合法/非法配置 |
|
||||
| helpers/middleware | 单元测试 |
|
||||
唯一来源:`package.json`。开发模式从 package.json 运行时读取;生产模式构建时烘焙为字面量。
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改后端 API、共享类型、配置契约、日志模块、版本管理或后端测试规范时,必须更新本文档。
|
||||
修改后端模块 API、共享工具、数据库 schema、AI 服务层、聊天 API 或列表查询参数解析时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。
|
||||
|
||||
167
docs/development/crud.md
Normal file
167
docs/development/crud.md
Normal 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 页 |
|
||||
| 分页 | 点击分页器 | 更新 URL(page+pageSize) |
|
||||
| 排序 | 点击列头排序 | 重置到第 1 页,更新 URL |
|
||||
| 删除 | Popconfirm 确认后 | toast 通知,刷新列表 |
|
||||
@@ -1,152 +1,180 @@
|
||||
# 前端开发
|
||||
|
||||
本文档说明 alfred 前端的 React、Ant Design、TanStack Query、组件、样式和前端测试约定。
|
||||
开发规范见 [开发规范文档](README.md)。
|
||||
|
||||
适用场景:修改 src/web/、前端共享类型使用方式、组件结构、样式规则或前端测试。
|
||||
## 布局架构
|
||||
|
||||
## 技术栈
|
||||
两个布局入口共享 ConsoleShell(`src/web/shared/components/ConsoleShell/`):
|
||||
|
||||
| 层面 | 技术 | 用途 |
|
||||
| ------ | --------------------------------------------------- | ------------------------ |
|
||||
| 框架 | React 19 | UI 组件开发 |
|
||||
| 构建 | Vite(开发)+ Bun compile(生产) | 开发服务 HMR 与生产构建 |
|
||||
| 语言 | TypeScript | 类型安全 |
|
||||
| UI 库 | Ant Design (antd) + @ant-design/icons | UI 组件与图标 |
|
||||
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动刷新 |
|
||||
| 路由 | React Router v7 (Declarative mode) | SPA 路由与页面导航 |
|
||||
- **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 项目渲染。
|
||||
|
||||
不引入额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 useState。
|
||||
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/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
|
||||
|
||||
- 每个 React 组件一个 .tsx 文件,文件名使用 PascalCase
|
||||
- 组件 props 定义为 interface XxxProps,紧邻组件函数声明
|
||||
- 类型从 src/shared/api.ts 导入,使用 import type
|
||||
- 展示组件放在 components/,通过 props 接收数据,通过回调返回事件;页面专属展示组件可就近放在 pages/\*/components/
|
||||
- 容器逻辑放在 hooks 中,组件只做数据消费;全局共享查询可提取为独立 hook(如 use-meta)
|
||||
- 工具函数放在 utils/,保持纯函数无副作用
|
||||
## 功能模块
|
||||
|
||||
页面组件保持编排职责,组合 hooks 和展示组件;当页面同时承担查询、筛选、分页、表格列、弹窗表单和 mutation 时,应按工具栏、表格、表单弹窗等功能边界拆分。拆分以降低职责复杂度为目标,避免为了拆分而拆分。
|
||||
| 功能模块 | 路径 | 说明 |
|
||||
| -------- | --------------------- | --------------------------- |
|
||||
| 仪表盘 | `features/dashboard/` | 总览页面 |
|
||||
| 项目管理 | `features/projects/` | 项目 CRUD、归档、搜索、排序 |
|
||||
| 模型管理 | `features/models/` | 供应商/模型管理、连通性测试 |
|
||||
| 聊天 | `features/chat/` | 会话管理、消息渲染、AI 对话 |
|
||||
| 收集箱 | `features/inbox/` | 素材 CRUD、持久化、列表管理 |
|
||||
| 设置 | `features/settings/` | 平台业务设置,卡片式布局 |
|
||||
|
||||
## Ant Design 使用规范
|
||||
## 页面
|
||||
|
||||
- 优先使用 antd 组件默认状态和官方交互模式;没有明确产品定制需求时,不额外改写组件视觉。
|
||||
- 优先通过组件 props 配置行为和外观,例如 `collapsible`、`theme`、`scroll`、`locale`、`status`、`variant`、`color`。
|
||||
- 全局使用 `ConfigProvider` 配置 antd 中文 locale;从 `antd/locale/zh_CN` 导入 `zhCN`。
|
||||
- 需要 message、modal、notification 等 antd 应用级能力时,在 `ConfigProvider` 内包裹 `App`(代码中可别名为 `AntApp`),组件内通过 `App.useApp()` 获取。
|
||||
- 状态页、异常页、空结果优先使用 `Result`、`Empty`、`Alert`、`Spin` 等 antd 组件。
|
||||
- 信息展示优先使用 `Typography`、`Card`、`Descriptions`、`Table` 等 antd 组件,避免用原生标签加自定义 CSS 复刻。
|
||||
- 搜索输入优先使用 `Input.Search`,保持回车搜索、按钮搜索和清空行为一致。
|
||||
- 表格在窄屏有挤压风险时必须提供明确的 `scroll` 或响应式列策略。
|
||||
| 页面 | 路径 | 入口 |
|
||||
| -------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 总览 | `/` | `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` |
|
||||
|
||||
## 样式开发规范
|
||||
### 聊天页面
|
||||
|
||||
前端基于 Ant Design 构建 UI。样式管理目标是让 antd 继续承担主视觉系统,项目 CSS 只补足页面外壳、局部布局和自有组件视觉,不另起一套与 antd 竞争的样式体系。
|
||||
`ChatPage` = `ConversationSidebar`(自定义组件)+ `ChatPanel`。
|
||||
|
||||
样式开发优先级:
|
||||
- **ConversationSidebar**:会话侧边栏数据加载层(useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除(Popconfirm)。
|
||||
- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart(markdown-to-jsx 渲染,流式优化)、ToolPart(四态,入参/出参分层卡片展示,通过 HighlightBlock 提供 Shiki 语法高亮和复制按钮)。支持编辑重发、重新生成、复制。
|
||||
- **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。
|
||||
|
||||
1. antd 组件默认能力,例如 `Button`、`Card`、`Table`、`Form`、`Result`、`Empty`。
|
||||
2. antd 组件 props,例如 `size`、`type`、`variant`、`color`、`status`、`layout`、`scroll`、`gutter`。
|
||||
3. antd 布局组件,例如 `Layout`、`Flex`、`Space`、`Row`、`Col`,避免为普通排列关系新增 CSS。
|
||||
4. `ConfigProvider` theme token 和 antd 组件 token,处理主题级或组件级统一调整。
|
||||
5. antd CSS 变量(`--ant-*`),用于项目自有 CSS 中引用颜色、间距、字体、圆角和阴影等设计值。
|
||||
6. 全局 CSS,仅承载应用外壳、全局基础样式和少量明确复用的工具类。
|
||||
7. CSS Modules,用于页面专属布局或项目自有组件视觉;仅在局部样式增长到需要就近维护时使用。
|
||||
8. 自行开发视觉组件,仅在 antd 组件和组合方式无法表达明确产品需求时使用。
|
||||
## 共享代码
|
||||
|
||||
红线:
|
||||
### 共享组件
|
||||
|
||||
- 严禁在组件中使用 `style` 属性内联调整样式。
|
||||
- 严禁通过 CSS 覆盖 antd 组件内部类名,例如 `.ant-*`。
|
||||
- 严禁使用 `!important`。
|
||||
- 颜色统一使用 antd Design Token / CSS 变量,不使用硬编码色值。
|
||||
- 默认不引入 Tailwind、UnoCSS、Sass、Less、CSS-in-JS 或额外 PostCSS 插件;确需引入时必须先说明现有 antd + CSS Modules 无法满足的具体问题、影响范围和迁移成本。
|
||||
| 组件 | 路径 | 说明 |
|
||||
| ------------- | ------------------------------------- | ------------------------------------------------------ |
|
||||
| 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` | 生产环境错误边界 |
|
||||
|
||||
默认状态原则:如果 antd 组件默认样式已经满足当前需求,不为其增加额外 CSS 类;不要通过外层 CSS 修改 Sider、Menu、Table、Modal 等组件内部结构样式。
|
||||
### 共享 Hooks
|
||||
|
||||
全局 CSS 归属:
|
||||
| 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` | 当前是否暗色主题 |
|
||||
|
||||
- 当前入口保留 `src/web/styles.css`;当文件继续增长时,优先拆分为 `src/web/styles/global.css`、`src/web/styles/app-shell.css`、`src/web/styles/utilities.css` 等按职责命名的文件,再由入口样式文件集中导入。
|
||||
- `global.css` 仅放 `html`、`body`、`:root`、字体渲染、全局背景等应用级基础样式。
|
||||
- `app-shell.css` 仅放应用外壳样式,例如 `app-layout`、`app-header`、`app-content`、Header 内容分布和主内容间距。
|
||||
- `utilities.css` 只放至少两处复用、语义稳定、不会与 antd props 重叠的工具类;只有一处使用时优先改为 antd 布局组件或局部 CSS Modules。
|
||||
- 全局类名必须带有明确前缀,应用外壳使用 `app-*`,工具类使用 `u-*`;禁止新增 `.container`、`.title`、`.content` 等容易跨页面冲突的泛名类。
|
||||
### 共享主题配置
|
||||
|
||||
CSS Modules 归属:
|
||||
| 文件 | 导出 |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `theme/theme-config.ts` | `buildThemeConfig({ compact, effectiveTheme })` — 构建 antd ThemeConfig(algorithm 数组、cssVar、token、components.Layout),compact 时组合 compactAlgorithm 并降低 controlHeight |
|
||||
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext(需在 ProjectProvider 内) |
|
||||
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUD(create/delete/fetch/list + Query hooks) |
|
||||
|
||||
- 页面专属样式与页面就近放置,例如 `src/web/pages/projects/projects.module.css`。
|
||||
- 自有组件样式与组件就近放置,例如 `src/web/components/FooCard/foo-card.module.css`。
|
||||
- CSS Modules 中类名使用职责语义,例如 `.root`、`.toolbar`、`.summaryCard`、`.emptyState`;通过导入对象绑定到组件,避免字符串拼写散落。
|
||||
- 同一类样式只服务当前页面或组件;一旦被多处复用,应先判断能否用 antd 组件或 props 表达,再考虑提取共享组件,而不是直接提升为全局 CSS。
|
||||
- 首次实际使用 `*.module.css` 时,同步补全 TypeScript 声明和必要测试,确保类型检查与构建链路稳定。
|
||||
### 共享工具函数
|
||||
|
||||
antd 定制边界:
|
||||
|
||||
- 优先使用官方 props、theme token 和组件 token;不要因为视觉微调直接写 CSS。
|
||||
- antd v6 组件暴露 `classNames` 语义插槽时,可以把项目自有类绑定到官方稳定插槽;仍然不得选择 `.ant-*` 内部 DOM 类名。
|
||||
- 避免使用 antd `styles` 语义插槽写内联样式;如果必须使用,应先评估是否可以通过 token、CSS 变量或 CSS Modules 表达。
|
||||
- 弹窗、下拉、表格、菜单等复杂组件不依赖内部 DOM 结构做布局修补;发现必须修补时,优先调整组件组合或交互设计。
|
||||
|
||||
token 和 CSS 变量规则:
|
||||
|
||||
- 颜色使用 `var(--ant-color-*)`,例如文本、边框、背景和状态色。
|
||||
- 间距、字号、圆角和阴影优先使用 `var(--ant-padding-*)`、`var(--ant-margin-*)`、`var(--ant-font-size-*)`、`var(--ant-border-radius*)`、`var(--ant-box-shadow*)` 等 antd 变量。
|
||||
- 项目自定义 CSS 变量只能定义在 `:root` 或清晰的主题容器上,并且必须基于 antd token 派生;不要创建与 antd 平行的颜色、间距、字号体系。
|
||||
- 主题切换统一通过 `ConfigProvider` theme algorithm 和 token 控制,不在 CSS 中硬编码亮色或暗色分支。
|
||||
|
||||
响应式规则:
|
||||
|
||||
- 页面必须在桌面和移动端正常加载和可读。
|
||||
- 优先使用 antd `Flex`、`Grid`、`Table scroll`、响应式列配置处理布局收缩。
|
||||
- 媒体查询只处理页面或自有组件的布局断点;不要用媒体查询覆盖 antd 内部结构。
|
||||
- 移动端适配优先保证内容可访问、操作可点击和横向溢出可控,不追求与桌面完全一致的排版。
|
||||
|
||||
新增样式前检查:
|
||||
|
||||
1. 这个需求是否可以由 antd 组件或 props 完成?
|
||||
2. 这个样式是否属于主题级统一调整,应该放到 `ConfigProvider` theme token?
|
||||
3. 这个样式是否只服务页面外壳,应该留在全局 CSS?
|
||||
4. 这个样式是否只服务单个页面或自有组件,应该使用 CSS Modules?
|
||||
5. 这个样式是否在覆盖 antd 内部结构?如果是,应重新设计组件组合。
|
||||
6. 这个样式是否引入了硬编码色值、`style`、`.ant-*` 或 `!important`?如果是,不应合入。
|
||||
|
||||
## 表单与交互规范
|
||||
|
||||
- Modal + Form 提交使用 `Form onFinish` 处理业务提交,`Modal onOk` 只触发 `form.submit()`。
|
||||
- 不在 `Modal onOk` 中直接执行异步 `validateFields` 和提交逻辑,也不通过 lint disable 绕过该问题。
|
||||
- 文本必填字段同时配置 `required: true` 和 `whitespace: true`,保持前端校验与后端 trim 后校验一致。
|
||||
- 提交中状态传给 antd 组件的 loading/confirmLoading 等 props,避免自行实现重复状态样式。
|
||||
- 操作确认优先使用 `Popconfirm`,成功/失败反馈优先使用 antd message。
|
||||
|
||||
## 运行时外壳规范
|
||||
|
||||
- 生产入口必须启用 `ErrorBoundary`,运行时渲染异常使用 antd `Result status="500"` 或等价组件展示。
|
||||
- `ReactQueryDevtools` 仅在 `import.meta.env.DEV` 条件下渲染,不进入生产渲染路径。
|
||||
- 主题切换统一通过 `ConfigProvider` 的 antd theme algorithm 控制,不使用硬编码主题色。
|
||||
|
||||
## TanStack Query 规范
|
||||
|
||||
- Query key 使用 structured array,使用 as const 保持字面量类型
|
||||
- 全局面板级查询可持续刷新,详情级查询必须按状态条件启用
|
||||
- 多处页面使用同一后端资源时,应提取共享 hook,避免重复定义 fetch 函数。
|
||||
|
||||
## fetch 封装
|
||||
|
||||
统一使用 fetch,不引入 axios。错误抛异常,由 TanStack Query 的 error 状态承接。
|
||||
|
||||
前后端共享的请求和响应类型定义在 src/shared/api.ts。前端 fetch 函数的返回类型必须匹配后端真实 JSON 形状;如果后端返回包装对象,例如 `{ project: Project }`,应声明对应响应类型并在 hook 内提取业务对象。
|
||||
|
||||
## 前端测试
|
||||
|
||||
- 测试目录为 tests/web/,结构对应 src/web/
|
||||
- 单元测试重点覆盖 utils/ 和 hooks 中的纯逻辑
|
||||
- 组件测试使用 jsdom 和 @testing-library/react
|
||||
- 测试用户行为而非实现细节
|
||||
- 只 mock 系统边界,使用真实的 QueryClientProvider 包裹组件
|
||||
- 组件测试环境由 tests/setup.ts 和 bunfig.toml preload 提供
|
||||
- 断言优先基于用户可见文本、role、按钮和交互结果,不依赖 `.ant-*` 内部类名。
|
||||
- 对 antd 组件只断言本项目传入的可观察行为或配置结果,避免把 antd 内部 DOM 结构当作稳定契约。
|
||||
| 文件 | 导出 |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `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/工具清单、目录结构或功能模块归属时,必须更新本文档。管理页面 CRUD 通用模式的详细约定见 [crud.md](crud.md)。
|
||||
|
||||
## 日志模块
|
||||
|
||||
### Logger 接口
|
||||
|
||||
`src/web/shared/utils/logger.ts` 提供与后端镜像的 Logger 抽象:
|
||||
|
||||
```typescript
|
||||
export interface Logger {
|
||||
child(bindings: Record<string, unknown>): Logger;
|
||||
debug(message: string, data?: unknown): void;
|
||||
error(message: string, data?: unknown): void;
|
||||
info(message: string, data?: unknown): void;
|
||||
setLevel(level: LogLevel): void;
|
||||
warn(message: string, data?: unknown): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 实现
|
||||
|
||||
| 实现 | 工厂函数 | 用途 |
|
||||
| ----------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `DefaultLogger` + Sinks | `useLogger(bindings?)` / `createDefaultLogger()` | 组件内使用,ConsoleSink + AntdMessageSink 双流;传入 bindings 自动创建带作用域的子 Logger |
|
||||
| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数(ErrorBoundary、工具函数),仅 ConsoleSink |
|
||||
| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 |
|
||||
| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 |
|
||||
|
||||
### 使用方式
|
||||
|
||||
**组件内(推荐):**
|
||||
|
||||
```typescript
|
||||
import { useLogger } from "../../shared/hooks/use-logger";
|
||||
|
||||
function MyComponent() {
|
||||
const logger = useLogger({ component: "MyComponent" });
|
||||
logger.info("数据加载完成", { count: 42 });
|
||||
logger.warn("即将超时");
|
||||
logger.error("操作失败", { error: new Error("...") });
|
||||
}
|
||||
```
|
||||
|
||||
**非组件纯函数:**
|
||||
|
||||
```typescript
|
||||
import { createConsoleLogger } from "../../shared/utils/logger";
|
||||
|
||||
const logger = createConsoleLogger();
|
||||
logger.debug("调试信息");
|
||||
```
|
||||
|
||||
**作用域绑定:**
|
||||
|
||||
组件内直接通过 `useLogger` 的 `bindings` 参数传入,hook 内部保证引用稳定(值不变时多次渲染返回同一 Logger):
|
||||
|
||||
```typescript
|
||||
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
|
||||
logger.info("页面加载"); // [Alfred:INFO] 页面加载 [component=ChatPanel][page=workbench]
|
||||
```
|
||||
|
||||
非组件场景仍可使用 `logger.child()`:
|
||||
|
||||
```typescript
|
||||
const pageLogger = logger.child({ page: "projects" });
|
||||
pageLogger.info("页面加载"); // [Alfred:INFO] 页面加载 [page=projects]
|
||||
```
|
||||
|
||||
### notification 红线
|
||||
|
||||
- `AntdMessageSink` 仅对 **warn**(`message.warning`)和 **error**(`message.error`)触发用户可见通知。
|
||||
- `debug` 和 `info` 级别绝不对用户弹出 notification,仅在开发者控制台输出。
|
||||
- 错误详情通过 `data` 参数传入(如 `logger.error("提交失败", { error })`),`data` 不经序列化透传,保留 Error 堆栈展开能力。
|
||||
|
||||
### 生产环境行为
|
||||
|
||||
生产环境(`import.meta.env["PROD"]`)自动将 ConsoleSink 最小级别设为 `warn`,屏蔽 debug/info 输出。`useLogger()` 和 `createConsoleLogger()` 自动处理此逻辑,调用方无需关心环境判断。
|
||||
|
||||
### ErrorBoundary 特殊说明
|
||||
|
||||
`ErrorBoundary` 是 class 组件,无法使用 `useLogger()` hook。它以 `createConsoleLogger()` 直接创建独立的 ConsoleLogger 实例,仅输出到控制台不触发用户通知。
|
||||
|
||||
### 测试
|
||||
|
||||
- 单元测试使用 `createMemoryLogger()` 断言日志记录,使用 `createNoopLogger()` 静默无关日志。
|
||||
- `createDefaultLogger(sinks, isProduction)` 接受 `isProduction` 参数,测试中可显式控制级别过滤行为,不依赖 `import.meta.env`。
|
||||
|
||||
@@ -1,97 +1,60 @@
|
||||
# 构建与发布
|
||||
|
||||
本文档说明开发服务、前后端集成、生产构建、脚本维护和环境变量。
|
||||
## 开发运行
|
||||
|
||||
适用场景:修改 scripts/、构建流程、静态资源集成或环境变量。
|
||||
- `bun run dev config.yaml` — 双进程(Bun API server --watch + Vite dev server HMR)
|
||||
- `bun run dev:server config.yaml` — 仅后端
|
||||
- `bun run dev:web` — 仅前端
|
||||
|
||||
## 开发期运行
|
||||
|
||||
```bash
|
||||
bun run dev config.yaml
|
||||
```
|
||||
|
||||
scripts/dev.ts 同时启动两个进程:
|
||||
|
||||
| 进程 | 用途 |
|
||||
| --------------- | --------------------------------------- |
|
||||
| Bun API server | 后端 API 服务,--watch 监听变更自动重启 |
|
||||
| Vite dev server | 前端 SPA、HMR 热更新 |
|
||||
|
||||
也可以单独启动:
|
||||
|
||||
```bash
|
||||
bun run dev:server config.yaml # 仅启动后端 API server
|
||||
bun run dev:web # 仅启动 Vite dev server
|
||||
```
|
||||
开发模式 Vite proxy 将 /api/\* 转发到 Bun。
|
||||
|
||||
## 前后端集成
|
||||
|
||||
开发模式下,Vite 通过 proxy 将 /api/\* 转发到 Bun。
|
||||
生产模式:Vite 构建为静态资源 → `import with { type: "file" }` 嵌入 Bun 可执行文件 → 非 API 路径 fetch fallback 处理。
|
||||
|
||||
生产模式下,前端通过 Vite 构建为静态资源,通过 import with { type: "file" } 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理。
|
||||
|
||||
路由优先级:Bun routes 具体路径 > 通配符。/api/meta 优先于 /api/\*。
|
||||
路由优先级:Bun routes 具体路径 > 通配符。`/api/meta` 优先于 `/api/*`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
`bun run build` 流程:
|
||||
|
||||
构建流程:
|
||||
1. `Vite build` → `dist/web/`
|
||||
2. `Code generation` → `.build/static-assets.ts` + `.build/migrations-data.ts` + `.build/server-entry.ts`
|
||||
3. `Bun compile` → `dist/alfred`
|
||||
4. `Cleanup` → 清理 `.build/`
|
||||
|
||||
```text
|
||||
1. Vite build -> dist/web/
|
||||
2. Code generation -> .build/static-assets.ts + .build/migrations-data.ts + .build/server-entry.ts
|
||||
3. Bun compile -> dist/alfred
|
||||
4. Cleanup -> 清理 .build/ 临时目录
|
||||
```
|
||||
构建参数:`BUN_TARGET` / `BUILD_TARGET` — 交叉编译目标平台。
|
||||
|
||||
构建参数:
|
||||
## 脚本
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| ------------------------- | ---------------- |
|
||||
| BUN_TARGET / BUILD_TARGET | 交叉编译目标平台 |
|
||||
| 脚本 | 文件 | 说明 |
|
||||
| ----------------------------- | --------------------------------- | ------------------------ |
|
||||
| dev | scripts/dev.ts | 双进程开发服务 |
|
||||
| dev:server | src/server/dev.ts | 仅后端 |
|
||||
| dev:web | Vite CLI | 仅前端 |
|
||||
| build | scripts/build.ts | Vite → codegen → compile |
|
||||
| schema | scripts/generate-config-schema.ts | 生成 JSON Schema |
|
||||
| schema:check | (同上) | 检查 Schema 同步 |
|
||||
| clean | scripts/clean.ts | 清理构建缓存 |
|
||||
| version:patch/minor/major/set | scripts/bump-version.ts | 版本升迁 |
|
||||
|
||||
## 脚本说明
|
||||
内部辅助:`scripts/bump-version-logic.ts`(版本逻辑)、`scripts/generate-migrations-data.ts`(构建时嵌入 SQL)。
|
||||
|
||||
| 脚本 | 文件 | 说明 |
|
||||
| --------------------- | --------------------------------- | ------------------------------ |
|
||||
| bun run dev | scripts/dev.ts | 双进程开发服务 |
|
||||
| bun run dev:server | src/server/dev.ts | 仅启动后端 API server |
|
||||
| bun run dev:web | Vite CLI | 仅启动 Vite dev server |
|
||||
| bun run build | scripts/build.ts | Vite -> codegen -> Bun compile |
|
||||
| bun run schema | scripts/generate-config-schema.ts | 生成配置 JSON Schema |
|
||||
| bun run schema:check | scripts/generate-config-schema.ts | 检查配置 JSON Schema 同步 |
|
||||
| bun run clean | scripts/clean.ts | 清理构建缓存与临时文件 |
|
||||
| bun run version:patch | scripts/bump-version.ts | 升迁 patch 版本 |
|
||||
| bun run version:minor | scripts/bump-version.ts | 升迁 minor 版本 |
|
||||
| bun run version:major | scripts/bump-version.ts | 升迁 major 版本 |
|
||||
| bun run version:set | scripts/bump-version.ts | 显式设置版本号 |
|
||||
## 配置文件
|
||||
|
||||
## 项目配置文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------- | --------------------------- |
|
||||
| package.json | 项目信息、脚本、依赖声明 |
|
||||
| tsconfig.json | TypeScript 配置 |
|
||||
| eslint.config.js | ESLint 规则 |
|
||||
| commitlint.config.js | commitlint 提交信息格式校验 |
|
||||
| .prettierrc.json | Prettier 格式化规则 |
|
||||
| .lintstagedrc.json | lint-staged 配置 |
|
||||
| config.example.yaml | 配置文件示例 |
|
||||
| config.schema.json | 配置文件 JSON Schema |
|
||||
| vite.config.ts | Vite 构建配置 |
|
||||
| bunfig.toml | Bun 配置 |
|
||||
|
||||
## 验证期望
|
||||
|
||||
| 变更类型 | 验证方式 |
|
||||
| ---------------- | -------------------- |
|
||||
| 构建脚本 | bun run verify |
|
||||
| 静态资源集成 | bun run build |
|
||||
| 配置 schema 同步 | bun run schema:check |
|
||||
| 发布前完整验证 | bun run verify |
|
||||
| 文件 | 用途 |
|
||||
| -------------------- | -------------------- |
|
||||
| package.json | 项目信息、脚本、依赖 |
|
||||
| tsconfig.json | TypeScript 配置 |
|
||||
| eslint.config.js | ESLint 规则 |
|
||||
| commitlint.config.js | 提交信息格式校验 |
|
||||
| .prettierrc.json | Prettier 规则 |
|
||||
| .lintstagedrc.json | lint-staged 配置 |
|
||||
| config.example.yaml | 配置示例 |
|
||||
| config.schema.json | 配置 JSON Schema |
|
||||
| vite.config.ts | Vite 构建配置 |
|
||||
| bunfig.toml | Bun 配置 |
|
||||
| drizzle.config.ts | Drizzle ORM 配置 |
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
|
||||
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 fast-drive design/tasks 与讨论、实际状态、OpenSpec workflow 的一致性 |
|
||||
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后实际产物、验证、design/tasks 的一致性,并补齐遗漏或回写文档 |
|
||||
| [prompt-code-review.md](prompt-code-review.md) | 审查项目代码质量,覆盖架构、实现、复用、测试、依赖、工程与文档闭环 |
|
||||
|
||||
## 边界说明
|
||||
|
||||
@@ -87,7 +88,7 @@
|
||||
- 是否默认按某个 workflow 执行
|
||||
- 是否以代码、文档、讨论或用户确认为准
|
||||
- 何时必须使用提问工具确认
|
||||
- 删除、重写前是否必须备份
|
||||
- 删除、重写前是否必须记录回退依据;存在 git 仓库时不要创建 `.bak` 文件
|
||||
- 改动后是否必须同步 README、测试、变更文档
|
||||
|
||||
### 4. 计划与执行分离
|
||||
@@ -102,7 +103,7 @@
|
||||
- 执行后必须重新读取受影响对象并复核
|
||||
- 对代码修改要说明测试或验证方式
|
||||
- 对文档修改要检查相关文档之间是否同步一致
|
||||
- 收尾时要列出修改文件、备份文件、验证结果和残留风险
|
||||
- 收尾时要列出修改文件、回退依据、验证结果和残留风险
|
||||
|
||||
## 各章节写法
|
||||
|
||||
@@ -190,14 +191,14 @@
|
||||
|
||||
- 明确顺序执行还是可并行执行
|
||||
- 明确每批执行前是否确认
|
||||
- 明确删除、重写、回退前是否要备份或创建锚点
|
||||
- 明确删除、重写、回退前是否要记录 git diff、创建锚点或在代码目录外备份
|
||||
- 明确执行后最少要复核哪些点
|
||||
|
||||
推荐写法:
|
||||
|
||||
- “逐批执行”或“逐项执行”
|
||||
- “每批执行后重新读取受影响文件并复核”
|
||||
- “若涉及删除或重写,先创建备份文件 `{file}.bak.{timestamp}`”
|
||||
- “若存在 git 仓库,不创建 `.bak` 文件;先记录 `git status` / `git diff` 作为回退依据”
|
||||
|
||||
### 清理 / 收尾
|
||||
|
||||
@@ -206,7 +207,7 @@
|
||||
建议包含:
|
||||
|
||||
- 修改文件清单
|
||||
- 备份文件清单
|
||||
- 回退依据;若实际创建了备份,再列出备份文件清单
|
||||
- 测试 / 构建 / 验证命令与结果
|
||||
- 文档同步摘要
|
||||
- 残留问题、未验证项、待确认事项
|
||||
@@ -238,7 +239,7 @@
|
||||
|
||||
对高风险流程,提示词应提供至少一种回退机制:
|
||||
|
||||
- 备份文件
|
||||
- git diff / 安全锚点 / 代码目录外备份文件
|
||||
- 安全锚点 tag
|
||||
- `abort` 路径
|
||||
- 终止后的现场说明
|
||||
@@ -269,24 +270,24 @@
|
||||
- 把历史变更文档直接当成稳定规范来源
|
||||
- 用抽象表述代替可执行动作
|
||||
- 把多个危险动作打包成一次默认授权
|
||||
- 没有备份、没有锚点、没有终止路径
|
||||
- 没有回退依据、没有锚点、没有终止路径
|
||||
- 只要求“完成修改”,不要求复核和收尾
|
||||
|
||||
## 编写检查清单
|
||||
|
||||
编写完一份提示词后,至少自检以下问题:
|
||||
|
||||
| 检查项 | 说明 |
|
||||
| ---------------- | ------------------------------------------------- |
|
||||
| 目标是否单句明确 | 是否能一眼看出任务对象、目标和范围 |
|
||||
| 约束是否集中 | 全局规则是否只在 `## 约束` 中声明 |
|
||||
| 数据源是否具体 | 是否明确读哪些文档、代码、测试、命令结果 |
|
||||
| 是否先分析再执行 | 是否存在独立的分析和计划阶段 |
|
||||
| 是否有确认节点 | 高风险动作前是否要求提问工具确认 |
|
||||
| 是否有降级路径 | change 不明、规则不明、上下文不足时是否有处理方式 |
|
||||
| 是否可操作 | 是否给出命令、工具、路径或结构化动作 |
|
||||
| 是否可验证 | 执行后是否定义复核或测试方式 |
|
||||
| 是否能收尾 | 是否要求输出修改清单、备份、验证结果、残留风险 |
|
||||
| 检查项 | 说明 |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| 目标是否单句明确 | 是否能一眼看出任务对象、目标和范围 |
|
||||
| 约束是否集中 | 全局规则是否只在 `## 约束` 中声明 |
|
||||
| 数据源是否具体 | 是否明确读哪些文档、代码、测试、命令结果 |
|
||||
| 是否先分析再执行 | 是否存在独立的分析和计划阶段 |
|
||||
| 是否有确认节点 | 高风险动作前是否要求提问工具确认 |
|
||||
| 是否有降级路径 | change 不明、规则不明、上下文不足时是否有处理方式 |
|
||||
| 是否可操作 | 是否给出命令、工具、路径或结构化动作 |
|
||||
| 是否可验证 | 执行后是否定义复核或测试方式 |
|
||||
| 是否能收尾 | 是否要求输出修改清单、回退依据、验证结果、残留风险 |
|
||||
|
||||
## 维护原则
|
||||
|
||||
|
||||
@@ -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 要求未完成”、“验证后新增修补”、“合理落地细化”还是“意外偏离/回归”
|
||||
- 每批实际产物或文档修改执行前用提问工具获得用户确认
|
||||
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
||||
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料、相关变更文档和必要的文档/沟通材料
|
||||
- 每批实际产物修改执行前用提问工具获得用户确认
|
||||
- 删除/重写前用提问工具获得用户确认;若存在 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. 执行
|
||||
|
||||
逐项执行已确认的实际产物、验证和文档修复。
|
||||
逐项执行已确认的实际产物和验证修复。
|
||||
|
||||
若涉及删除或重写:
|
||||
|
||||
- 先创建备份文件 `{file}.bak.{timestamp}`
|
||||
- 再执行修改
|
||||
- 存在 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. 收尾
|
||||
|
||||
列出所有修改的文件、备份文件、验证命令或检查结果、文档同步摘要和剩余风险。
|
||||
列出所有修改的文件、回退依据、验证命令或检查结果和剩余风险;若实际创建了备份,再列出备份文件。
|
||||
|
||||
若本次因缺少验证结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。
|
||||
|
||||
230
docs/prompts/prompt-code-review.md
Normal file
230
docs/prompts/prompt-code-review.md
Normal file
@@ -0,0 +1,230 @@
|
||||
审查项目代码质量,基于仓库规则、实际代码、测试、配置、依赖和文档证据,识别架构、实现、复用、测试覆盖、三方库使用和工程质量风险。默认只输出审查结论和建议,不修改任何文件;只有当用户明确要求制定修复计划或执行修复时,才进入计划和执行阶段。
|
||||
|
||||
## 约束
|
||||
|
||||
- 先审查再修复;未经用户确认,不修改代码、测试、配置、依赖或文档
|
||||
- 不得虚构已执行的命令、已读取的文件、subagent 结论、测试结果或用户确认;所有结论必须能追溯到实际读取内容、命令输出,或明确标记为假设、观察建议或信息不足
|
||||
- 优先遵守仓库已定义的开发规范、质量门禁、架构边界、包管理器规则、测试规则和文档同步规则
|
||||
- 不进行全仓库无差别逐文件精读;先建立目录、依赖、脚本、测试和变更范围索引,再定向读取高风险、高复杂度、核心链路和近期改动相关文件
|
||||
- 不以代码现状作为唯一真相;代码必须同时对照 README、开发文档、配置、测试、脚本、实际运行入口、近期提交和用户确认过的约束
|
||||
- 不因发现问题就自动新增依赖、重写架构、迁移技术栈或扩大功能范围;此类动作必须单独列为候选方案并获得用户确认
|
||||
- 质量审查默认不创建提交、不推送、不变更分支、不执行 destructive git 操作
|
||||
- 每批修复执行前必须获得用户明确确认;若环境提供提问工具,优先使用提问工具,否则以普通对话确认
|
||||
- 删除、重写、批量迁移、大范围格式化、依赖变更、数据库/配置 schema 变更、构建产物变更前必须单独确认;若环境提供提问工具,优先使用提问工具,否则以普通对话确认;若存在 git 仓库,不创建 `.bak` 备份文件,改用当前 `git status` / `git diff` 作为回退依据;仅在无版本控制或用户明确要求时,才将备份放到代码目录外的用户确认路径
|
||||
- 若修复影响用户可见行为、配置、部署、运行行为、架构边界、开发流程、测试方式或构建发布流程,必须同步更新对应文档
|
||||
- 若环境限制导致验证命令无法执行,记录未执行项、原因和残留风险,不得声称已验证
|
||||
- 审查阶段不得运行会格式化、修复、生成、迁移、安装、删除、写入受版本控制路径或修改工作区的命令;如不确定命令是否只读,先询问用户
|
||||
- 若环境支持 subagent,可仅用于收集和只读分析;若不支持 subagent,由主 agent 按相同分工顺序串行执行最小必要分析;任何 subagent 都不得修改代码、测试、配置、依赖或文档,不得提交、推送、切换分支、安装依赖或执行 destructive git 操作;所有修复必须由主 agent 在用户确认后执行
|
||||
|
||||
## 1. 收集
|
||||
|
||||
读取约束:
|
||||
|
||||
- 优先使用当前会话信息、仓库文档、配置文件、脚本、测试和版本控制状态
|
||||
- 可并行读取互不依赖的文件、命令结果和 subagent 只读分析结果;并行前先划分范围,避免重复读取、结论冲突和上下文膨胀;不支持并行时按同样范围串行执行
|
||||
- 不原样输出大段文件内容,仅在审查报告和收尾中输出必要结论
|
||||
|
||||
并行或分工分析策略:
|
||||
|
||||
- 当审查范围较大、文件类型明显分层、多个分析维度互不依赖,或需要同时核对代码、测试、配置、文档和依赖证据时,优先使用可用的并行能力或 subagent 只读分析
|
||||
- 范围较小、问题集中、需要连续推理、需要用户确认、需要修改文件或存在高风险命令时,不使用 subagent,避免额外协调成本;若环境不支持 subagent,则由主 agent 串行执行并说明未使用原因
|
||||
- 适合拆分为架构边界、正确性/安全、前端专项、后端专项、测试验证、依赖与包管理、脚本/构建、文档闭环等子任务
|
||||
- 先由主 agent 建立共享基线,包括仓库规则、用户指定范围、目录结构、脚本、质量门禁、git 状态和候选高风险区域
|
||||
- 每个 subagent 只接收最小必要上下文,明确任务目标、允许读取范围、禁止操作、输出格式和必须引用的证据类型
|
||||
- 每个文件或目录默认只分配给一个主责 subagent;确需交叉复核时,明确一个为主责、一个只做抽样或专项核验
|
||||
- subagent 不重复读取共享基线文件,除非需要核对具体证据;共享基线由主 agent 统一读取并摘要分发
|
||||
- subagent 不输出大段源码,只输出文件路径、行号、问题摘要、证据、影响、建议、风险等级、建议验证方式和不确定项
|
||||
- 无 subagent 环境的降级方式:主 agent 先建立共享基线,再按架构边界、正确性/安全、前端、后端、测试验证、依赖与包管理、脚本/构建、文档闭环的顺序串行抽查;每轮只读取当前问题所需的最小文件集合,避免一次性展开全仓库
|
||||
|
||||
分步收集:
|
||||
|
||||
a) 先读取项目规则和入口,确定审查范围:
|
||||
|
||||
- 先读取最小必要规则入口:根 README、文档路由或仓库说明中明确指向的开发规范;只有发现审查对象涉及对应领域时,再读取架构、前端、后端、发布等专题文档
|
||||
- 包管理和脚本配置,例如 `package.json`、锁文件、构建配置、测试配置、lint/format/typecheck 配置
|
||||
- OpenSpec 或仓库级工程规则,例如存在时读取 `openspec/config.yaml`
|
||||
- 若用户指定了审查范围、模块、分支、PR、change 或文件列表,以用户指定范围为主;否则默认采用标准审查:读取仓库规则、目标范围入口、相关测试和配置,并抽查核心链路,不声称覆盖全仓库
|
||||
- 若审查对象是 PR、分支、change 或近期改动,先确定审查基线:base 分支、head 提交、变更文件、变更类型和受影响入口;发现问题时区分“本次引入问题”和“既有问题被触发/暴露”
|
||||
|
||||
b) 建立项目索引,先确认而不是假设:
|
||||
|
||||
- 目录结构、主要入口、前后端边界、共享类型、脚本目录、测试目录和文档目录
|
||||
- 运行脚本、质量门禁脚本、构建脚本、测试脚本和生成脚本
|
||||
- 依赖清单、直接依赖用途、devDependencies 用途、成熟工具库能力、统一 UI 框架及项目已有组件封装、锁文件状态和是否存在多包管理器痕迹
|
||||
- 版本控制状态、当前工作区改动、近期提交或用户指定 diff 范围
|
||||
|
||||
c) 定向读取代码和测试:
|
||||
|
||||
- 核心业务链路、应用入口、路由/API、状态管理、数据访问、配置加载、错误处理、日志、权限/安全边界、构建集成点
|
||||
- 近期改动文件、复杂度明显偏高文件、重复实现、跨层 import、公共工具、共享类型、测试薄弱或失败相关文件
|
||||
- 与用户指定问题、模块、PR、change 或质量目标直接相关的代码、测试和文档
|
||||
|
||||
d) 收集验证证据:
|
||||
|
||||
- 先识别已定义的 typecheck、lint、format check、test、build、schema check、verify 等质量命令;只在命令明确只读、不会安装依赖、不会写入数据库/远端资源/受版本控制路径且预计成本可接受时执行;可能修改文件、生成产物、访问外部服务、耗时较长或副作用不明的命令,必须先说明影响并获得用户确认
|
||||
- 当前已执行过的验证结果、失败日志、跳过原因和未验证范围
|
||||
- 测试目录结构、测试覆盖的关键行为、边界条件、异常路径和回归场景
|
||||
|
||||
e) 若无法确定范围或规则:
|
||||
|
||||
- 先确认审查对象、审查深度和是否允许执行验证命令;若环境提供提问工具,优先使用提问工具,否则以普通对话确认
|
||||
- 若仍无法确认,降级为“仓库规则 + 代码静态审查 + 可定位测试审查”,并明确不做未读取范围的结论
|
||||
|
||||
## 2. 分析
|
||||
|
||||
按以下维度检查;维度编号只表示检查顺序,不代表问题严重度,每个发现必须单独按“阻断/高/中/低”评级:
|
||||
|
||||
| 检查顺序 | 维度 | 检查点 |
|
||||
| -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| D0 | 正确性与安全风险 | 是否存在会导致数据丢失、权限绕过、注入、敏感信息泄露、错误配置、不可恢复失败、生产不可用、构建失败或测试误判的问题 |
|
||||
| D1 | 架构与边界 | 模块职责是否清晰;前后端、共享类型、脚本、测试和文档边界是否被破坏;是否出现循环依赖、跨层调用、隐式全局状态、启动链路不一致或设计与文档冲突 |
|
||||
| D2 | 实现质量 | 命名、类型、错误处理、并发/异步、资源释放、数据校验、状态流转、边界条件、异常路径、可读性、复杂度、可维护性是否达标 |
|
||||
| D3 | 代码复用 | 是否重复实现已有 helper、组件、schema、类型、测试工具或三方库能力;是否已引入成熟工具库却自行实现等价能力;公共抽象是否过早、过泛、泄漏业务语义或反而增加耦合 |
|
||||
| D4 | 测试覆盖与验证 | 关键路径、边界条件、失败路径、回归场景、配置/schema、前端交互、后端 API、脚本和构建是否有有效测试或可执行验证;测试是否真实断言行为而不是只覆盖实现细节 |
|
||||
| D5 | 三方库与依赖 | 依赖是否必要、用途清晰、导入方式正确、运行时兼容、体积和安全风险可接受;是否存在已引入但未优先使用的成熟库、重复依赖、未使用依赖、错误包管理器痕迹或可用内建能力替代的新增依赖 |
|
||||
| D6 | 性能与资源 | 是否存在明显 N+1、重复计算、阻塞主线程、无界缓存、内存泄漏、未关闭资源、过大 bundle、无效重渲染、低效 IO 或可避免的启动成本 |
|
||||
| D7 | 前端质量 | 组件边界、数据流、表单/交互状态、可访问性、响应式、样式体系、设计系统和 UI 框架官方实践使用、错误/加载/空状态和用户可见文案是否符合项目规范 |
|
||||
| D8 | 后端质量 | API 契约、输入输出校验、错误响应、日志、配置加载、数据库访问、迁移、事务、并发控制、外部服务调用和测试隔离是否符合项目规范 |
|
||||
| D9 | 工程与文档闭环 | lint/format/typecheck/build/test/schema 等质量门禁是否可运行;脚本与文档是否一致;用户可见行为、配置、开发流程或架构变化是否已同步文档 |
|
||||
|
||||
风险分级:
|
||||
|
||||
| 等级 | 判定规则 | 处理要求 |
|
||||
| ---- | --------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| 阻断 | 会导致错误结果、数据损坏、安全漏洞、构建/启动失败、核心测试失败或已确认需求不可用 | 必须优先修复或获得用户明确接受风险 |
|
||||
| 高 | 影响核心维护性、架构边界、测试可信度、依赖安全、关键异常路径或主要用户场景 | 应纳入近期修复计划 |
|
||||
| 中 | 局部重复、复杂度偏高、命名/类型/边界不清、测试覆盖不足但未直接阻断 | 可批量修复或进入后续优化计划 |
|
||||
| 低 | 风格、表达、轻微文档不同步、局部可读性问题 | 可顺手修复或记录为建议 |
|
||||
|
||||
分析时区分以下情况:
|
||||
|
||||
- 明确缺陷:当前实现与需求、文档、测试或运行结果冲突
|
||||
- 质量风险:当前未必出错,但复杂度、边界、测试或依赖使用会提高维护成本
|
||||
- 合理取舍:实现与理想状态不同,但符合仓库约束、上下文决策或当前阶段目标
|
||||
- 信息不足:缺少运行结果、业务规则、覆盖数据或用户决策,无法给出确定结论
|
||||
- 观察建议:缺少明确规则或可复现影响,只能说明潜在收益、成本和适用前提,不得列为缺陷
|
||||
|
||||
证据与误报控制:
|
||||
|
||||
- 每个问题必须包含具体位置、触发条件、实际影响、证据来源、为什么现有实现不满足项目规则或需求,以及建议验证方式
|
||||
- 无法定位到具体代码、配置、文档、测试或命令证据的问题,只能列为“观察建议”或“信息不足”
|
||||
- 不得把个人偏好、未被项目规范要求的最佳实践、未证实的性能猜测直接列为缺陷
|
||||
- 每个问题标记置信度:高表示有代码、测试或命令证据;中表示有代码证据但缺少运行验证;低表示依赖业务假设或上下文不足;低置信度问题不得列为阻断
|
||||
- 对 diff、PR 或 change 审查,区分本次变更直接引入、本次变更扩大影响、既有问题但与本次风险相关、完全无关既有问题;无关既有问题只在附录中列出
|
||||
|
||||
重点识别:
|
||||
|
||||
- 架构边界被破坏、模块职责错位、入口链路和文档描述不一致
|
||||
- 同一行为在多个位置重复实现,或公共抽象没有明确复用收益
|
||||
- 依赖中已引入成熟工具库,例如 `es-toolkit` 等,但实现时没有优先复用其稳定能力,而是自行实现等价工具函数、集合处理、对象处理、比较、节流防抖、深拷贝或类型判断等逻辑
|
||||
- 代码过度复杂、隐式副作用多、错误处理缺失、边界条件未处理
|
||||
- 类型定义与运行时校验脱节,配置 schema、共享类型、API 契约和测试不一致
|
||||
- 测试只验证 happy path、测试断言过弱、mock 掩盖真实问题、缺少回归验证
|
||||
- 三方库用途不明确、引入成本大于收益、替代已有能力、存在包管理器或运行时兼容风险
|
||||
- 前端使用统一 UI 框架或设计系统时,没有优先使用官方组件、props、组合模式、表单校验、主题 token、样式扩展点和官方推荐实践
|
||||
- 前端通过覆盖内部 class、硬编码样式、`!important`、DOM 结构假设、额外 wrapper hack 或自造组件方式实现 UI 框架已提供的能力,导致行为、可访问性、响应式、主题一致性或维护性下降
|
||||
- 前端绕过设计系统、样式规则、可访问性或响应式要求
|
||||
- 后端缺少输入校验、错误响应、日志上下文、事务边界、资源释放或测试隔离
|
||||
- 质量门禁命令缺失、失败、未纳入文档,或文档中的命令与实际脚本不一致
|
||||
- 用户可见行为、配置、部署、架构或开发流程变化未同步文档
|
||||
|
||||
主 agent 汇总 subagent 结果时遵循:
|
||||
|
||||
- 主 agent 对最终结论负责,不直接转述 subagent 结论;必须去重、校准风险等级、合并同根因问题,并剔除缺少证据或超出范围的判断
|
||||
- 对不同 subagent 的冲突结论,优先采用有明确文件路径、行号、测试结果、配置或文档依据的一方;仍无法判断时标记为“信息不足”或列入“待确认清单”
|
||||
- 对阻断和高风险问题,主 agent 必须亲自复核关键证据,必要时重新读取最小相关文件或命令结果
|
||||
- 对重复问题,按根因合并为一项,在影响范围中列出涉及文件,不按文件机械重复输出
|
||||
- 对跨层问题,主 agent 统一判断责任边界,避免前端、后端、测试或文档 subagent 各自给出互相矛盾的修复方向
|
||||
- 对 subagent 提出的修改建议,主 agent 只纳入改进计划,不直接执行;执行仍必须经过用户确认
|
||||
- 最终“审查范围与证据”中列出 subagent 分工、覆盖范围、未覆盖范围和主 agent 复核过的关键证据
|
||||
|
||||
输出审查结果:
|
||||
|
||||
1. **审查范围与证据**:已读取的规则、代码区域、测试、配置、依赖、命令结果、未覆盖范围和未执行验证
|
||||
2. **关键结论**:按“阻断/高/中/低”汇总问题数量;若无明确问题,说明“在本次审查范围和证据内未发现问题”
|
||||
3. **问题清单**:每个问题只在一个主分类完整展开,包含位置、等级、置信度、证据、影响、建议和建议验证方式;其他分类只引用问题 ID 或汇总计数,避免重复描述和等级漂移
|
||||
4. **待确认事项**:需要用户判断目标、取舍、范围或授权的问题
|
||||
5. **可选改进**:无明确缺陷但有潜在收益的观察建议,说明适用前提和成本
|
||||
6. **后续计划**:仅当用户要求修复计划时输出;否则不主动展开执行批次
|
||||
|
||||
无问题的分类可合并为“未发现明确问题的维度”,不要为了满足模板输出大量空清单或重复清单。
|
||||
|
||||
若在已覆盖范围内未发现问题,输出“在本次审查范围和证据内未发现问题”,并明确列出未覆盖范围、未执行验证和残留风险;不得声称全仓库审查通过。
|
||||
|
||||
## 3. 计划(用户确认)
|
||||
|
||||
若用户只要求审查,到步骤 5 收尾,不主动请求执行修复。只有用户明确要求继续处理、制定修复计划或执行修复时,才进入本节。
|
||||
|
||||
先针对“待确认清单”逐项向用户确认;若环境提供提问工具,优先使用提问工具,否则以普通对话确认。
|
||||
|
||||
再整理改进计划,按批次列出:
|
||||
|
||||
- 批次目标:阻断修复、高风险修复、测试补强、依赖清理、架构边界整理、文档同步、低风险清理
|
||||
- 拟修改文件或对象
|
||||
- 动作类型:修复、补测试、重构、拆分、合并、删除、迁移、依赖调整、文档同步、脚本修正
|
||||
- 修改原因和证据
|
||||
- 预期影响
|
||||
- 风险与回退方式
|
||||
- 验证命令或人工检查方式
|
||||
|
||||
计划规则:
|
||||
|
||||
- 优先处理阻断和高风险问题
|
||||
- 将纯格式化、命名清理和大范围重构与功能性修复分开
|
||||
- 将依赖变更、架构迁移、数据库/配置 schema 变更、删除和重写拆成单独批次
|
||||
- 对信息不足的问题,先补验证或补调研,不直接修复
|
||||
- 对存在多种方案的问题,列出方案差异和适用前提,由用户确认
|
||||
|
||||
展示完整改进计划并等待用户确认;若环境提供提问工具,优先使用提问工具,否则以普通对话确认。确认计划不等于授权执行所有批次;每批执行仍需单独确认,高风险动作仍需按约束单独确认。
|
||||
|
||||
## 4. 执行
|
||||
|
||||
逐批执行已确认的修复。
|
||||
|
||||
执行规则:
|
||||
|
||||
- 每批只处理该批明确授权的问题,不顺手扩大范围
|
||||
- 优先做最小正确修改,不引入不必要的新抽象、新依赖或兼容层
|
||||
- 修复代码时同步补充或调整对应测试
|
||||
- 修复测试时确保测试断言真实行为,不通过降低断言、跳过测试或扩大 mock 来制造通过
|
||||
- 修复依赖问题时同步检查锁文件、导入位置、构建影响和文档影响
|
||||
- 修复文档或脚本时同步检查相关索引、命令和质量门禁描述
|
||||
- 修复后验证同样遵守只读优先;有副作用、耗时高、依赖外部服务或会修改工作区的命令需先确认,无法执行则记录原因和风险
|
||||
|
||||
若涉及删除或重写:
|
||||
|
||||
- 存在 git 仓库时,先记录当前 `git status` / `git diff`,识别并保护既有未提交改动,不要在源码、测试、配置或文档目录创建 `.bak` 文件
|
||||
- 回退方案必须说明如何仅回退本批修改;不得执行 `reset`、`checkout`、`clean` 等 destructive git 操作,除非用户单独确认
|
||||
- 不存在版本控制,或用户明确要求备份时,先确认代码目录外的备份路径;若环境提供提问工具,优先使用提问工具,否则以普通对话确认
|
||||
- 修改后确认回退依据;若实际创建了备份,再列出备份文件清单和恢复方式
|
||||
|
||||
执行后重新读取所有被修改的代码、测试、配置、依赖和文档,并复核:
|
||||
|
||||
- 原问题是否已解决,是否产生新的架构边界、复用、测试或依赖问题
|
||||
- 新增或修改的测试是否覆盖原风险点和关键回归路径
|
||||
- 质量门禁命令是否按影响范围执行并记录结果
|
||||
- 文档影响分析是否完成,必要文档是否同步
|
||||
- 未处理问题是否有明确保留原因、风险等级和后续建议
|
||||
|
||||
## 5. 收尾
|
||||
|
||||
列出最终结果。
|
||||
|
||||
若只执行审查,收尾列出:
|
||||
|
||||
- 审查范围与证据
|
||||
- 发现问题和风险等级
|
||||
- 未覆盖范围、未执行验证和原因
|
||||
- 残留风险和建议下一步
|
||||
|
||||
若用户授权并执行了修复,收尾列出:
|
||||
|
||||
- 修改文件清单
|
||||
- 回退依据;若实际创建了备份,再列出备份文件清单
|
||||
- 已修复问题清单
|
||||
- 未处理问题、保留原因和风险等级
|
||||
- 执行的验证命令、结果和未执行原因
|
||||
- 文档同步摘要;若无需更新文档,说明原因
|
||||
- 残留风险和建议后续批次
|
||||
|
||||
若本次因范围不明、验证无法执行、上下文不足或用户未授权而降级执行,单独说明降级范围和不可下结论的内容。最终只能说“在本次范围内已处理/已验证”,不得泛称“审查通过”“全部修复完成”或“全量质量通过”。
|
||||
@@ -8,7 +8,7 @@
|
||||
- 在 `fast-drive` workflow 下,`design.md` 是范围、需求、决策、执行约束、执行方向和验证预期的事实来源,`tasks.md` 必须从 `design.md` 派生
|
||||
- 优先使用当前会话中的讨论、explore/propose 阶段结论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
|
||||
- 每批文档修改建议执行前用提问工具获得用户确认
|
||||
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
|
||||
- 删除/重写前用提问工具获得用户确认;若存在 git 仓库,不创建 `.bak` 备份文件,改用当前 `git status` / `git diff` 作为回退依据;仅在无版本控制或用户明确要求时,才将备份放到代码目录外的用户确认路径
|
||||
|
||||
## 1. 收集
|
||||
|
||||
@@ -118,8 +118,8 @@ d) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts;
|
||||
|
||||
若涉及删除或重写:
|
||||
|
||||
- 先创建备份文件 `{file}.bak.{timestamp}`
|
||||
- 再执行修改
|
||||
- 存在 git 仓库时,先记录当前 `git status` / `git diff`,不要在文档或代码目录创建 `.bak` 文件
|
||||
- 不存在版本控制,或用户明确要求备份时,先用提问工具确认代码目录外的备份路径,再执行修改
|
||||
|
||||
执行后重新读取所有被修改的文档,并复核:
|
||||
|
||||
@@ -134,6 +134,6 @@ d) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts;
|
||||
|
||||
## 5. 收尾
|
||||
|
||||
列出所有修改的文件、备份文件和变更摘要。
|
||||
列出所有修改的文件、回退依据和变更摘要;若实际创建了备份,再列出备份文件。
|
||||
|
||||
若本次因缺少讨论记录而降级执行,或有问题因信息不足暂未处理,单独说明。
|
||||
|
||||
@@ -15,7 +15,6 @@ bun run build
|
||||
| ------------------------------ | ------------------- |
|
||||
| http://127.0.0.1:3000/ | 返回前端 SPA |
|
||||
| http://127.0.0.1:3000/api/meta | 返回应用元信息 JSON |
|
||||
| http://127.0.0.1:3000/health | 返回健康检查 |
|
||||
|
||||
## 构建流程
|
||||
|
||||
@@ -23,8 +22,9 @@ scripts/build.ts 执行三步流水线:
|
||||
|
||||
```text
|
||||
1. Vite build -> dist/web/(前端静态资源,含 code splitting)
|
||||
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts(含版本号字面量注入)
|
||||
2. Code generation -> .build/static-assets.ts + .build/migrations-data.ts + .build/server-entry.ts(含版本号字面量注入)
|
||||
3. Bun compile -> dist/alfred(单可执行文件)
|
||||
4. Cleanup -> 清理 .build/ 临时目录
|
||||
```
|
||||
|
||||
- Vite 构建前端资源到 dist/web/,自动 code splitting(vendor-react、vendor-antd、vendor-chart)
|
||||
|
||||
@@ -32,9 +32,42 @@ bun run dev config.yaml
|
||||
|
||||
## 功能介绍
|
||||
|
||||
| 功能 | 路径 | 说明 |
|
||||
| -------- | ----------- | ------------------------------------ |
|
||||
| 仪表盘 | `/` | 应用总览,展示运行时元信息 |
|
||||
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
||||
| 用户管理 | `/users` | 页面建设中 |
|
||||
| 系统设置 | `/settings` | 页面建设中 |
|
||||
| 功能 | 路径 | 说明 |
|
||||
| -------- | ----------------------- | -------------------------------------- |
|
||||
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
|
||||
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
|
||||
| 模型 | `/models` | 管理 AI 模型,供后续 AI 功能使用 |
|
||||
| 供应商 | `/models/providers` | 配置 AI 供应商(API Key、Base URL 等) |
|
||||
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
|
||||
|
||||
平台提供两个入口:
|
||||
|
||||
- **Admin(管理台)**:全局管理视角,包含总览和项目管理。默认入口,访问 `/` 即可进入。
|
||||
- **Workbench(工作台)**:项目维度视角,通过 `/workbench/:projectId` 进入指定项目的工作台。默认进入聊天室页面,可与已配置的 AI 模型进行对话。URL 可保存为浏览器书签,下次直接进入。仅 active 状态的项目可进入工作台,archived 项目不可访问。
|
||||
|
||||
从项目管理页面的 active 项目行可点击"工作台"跳转到对应项目的工作台。
|
||||
|
||||
## 模型与供应商管理
|
||||
|
||||
在 Admin 侧栏的"模型管理"分组下包含两个独立页面:
|
||||
|
||||
- **模型**(`/models`):新增、编辑、删除 AI 模型。填写模型显示名称、实际调用用的 modelId、能力标签,以及可选的上下文长度和最大输出 token。新建模型时下拉选择已配置的供应商。
|
||||
- **供应商**(`/models/providers`):新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`,baseURL 和 API Key 由用户填写。
|
||||
|
||||
侧栏"模型管理"为分组标签,点击展开/收起子项,不直接导航。
|
||||
|
||||
供应商表单提供"测试连接"操作:系统先测试 Base URL 是否可达,再尝试请求 `/models` 验证 API Key 和模型列表接口。若服务不支持 `/models`,页面会提示接口可达但可能不支持模型列表;该结果只作为提醒,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。
|
||||
|
||||
## 聊天室
|
||||
|
||||
在 Workbench 工作台中,默认进入聊天室页面。左侧为会话列表,可新建和删除会话;右侧为聊天面板,输入消息后 AI 将流式回复。
|
||||
|
||||
未选择会话时,聊天面板显示欢迎页面,用户可直接输入消息发送,系统会自动创建新会话。会话标题在首次对话后自动生成。
|
||||
|
||||
消息支持以下操作(仅限最后一条消息):
|
||||
|
||||
- **复制**:所有消息均支持复制文本内容
|
||||
- **编辑**:最后一条用户消息可编辑,确认后重新发送
|
||||
- **重新生成**:最后一条 AI 消息可重新生成回复
|
||||
|
||||
使用聊天功能前,需先在 Admin 管理台的模型和供应商页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
|
||||
|
||||
30
drizzle/0001_wooden_rocket_raccoon.sql
Normal file
30
drizzle/0001_wooden_rocket_raccoon.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE `providers` (
|
||||
`api_key` text NOT NULL,
|
||||
`base_url` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`enabled` integer DEFAULT 1 NOT NULL,
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text DEFAULT 'openai-compatible' NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `providers_name_unique` ON `providers` (`name`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `models` (
|
||||
`capabilities` text NOT NULL,
|
||||
`context_length` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`enabled` integer DEFAULT 1 NOT NULL,
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`max_output_tokens` integer,
|
||||
`model_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`provider_id`) REFERENCES `providers`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `models_provider_id_model_id_unique` ON `models` (`provider_id`,`model_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `models_provider_id_idx` ON `models` (`provider_id`);
|
||||
24
drizzle/0002_orange_black_knight.sql
Normal file
24
drizzle/0002_orange_black_knight.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE `conversations` (
|
||||
`created_at` text NOT NULL,
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`model_id` text NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`title` text DEFAULT '新会话' NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
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
|
||||
CREATE INDEX `conversations_project_id_idx` ON `conversations` (`project_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `messages` (
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`conversation_id` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`parts` text,
|
||||
`role` text NOT NULL,
|
||||
FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `messages_conversation_id_idx` ON `messages` (`conversation_id`);
|
||||
3
drizzle/0002_remove_model_management_enabled.sql
Normal file
3
drizzle/0002_remove_model_management_enabled.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `providers` DROP COLUMN `enabled`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `models` DROP COLUMN `enabled`;
|
||||
12
drizzle/0003_lying_cassandra_nova.sql
Normal file
12
drizzle/0003_lying_cassandra_nova.sql
Normal 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`);
|
||||
109
drizzle/0004_db_schema_standardization.sql
Normal file
109
drizzle/0004_db_schema_standardization.sql
Normal 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`);
|
||||
7
drizzle/0005_add_settings.sql
Normal file
7
drizzle/0005_add_settings.sql
Normal 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 '{}'
|
||||
);
|
||||
24
drizzle/0006_material_processing.sql
Normal file
24
drizzle/0006_material_processing.sql
Normal 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`);
|
||||
15
drizzle/0007_create_entities.sql
Normal file
15
drizzle/0007_create_entities.sql
Normal 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`);
|
||||
292
drizzle/meta/0001_snapshot.json
Normal file
292
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,292 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "3ce8499a-1b73-4fbd-a2ec-0f872646136a",
|
||||
"prevId": "7c940c6c-2dd6-4509-aad1-90aa48887cb9",
|
||||
"tables": {
|
||||
"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
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"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
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
423
drizzle/meta/0002_snapshot.json
Normal file
423
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "da8963db-526e-46a1-a453-4027d5541db9",
|
||||
"prevId": "3ce8499a-1b73-4fbd-a2ec-0f872646136a",
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
499
drizzle/meta/0003_snapshot.json
Normal file
499
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
530
drizzle/meta/0004_snapshot.json
Normal file
530
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,34 @@
|
||||
"when": 1779873780188,
|
||||
"tag": "0000_cheerful_switch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1780018783514,
|
||||
"tag": "0001_wooden_rocket_raccoon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
60
eslint-rules/enforce-catch-type.js
Normal file
60
eslint-rules/enforce-catch-type.js
Normal file
@@ -0,0 +1,60 @@
|
||||
export const enforceCatchType = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"强制 catch 子句使用 e: unknown,并用 instanceof Error 提取错误信息;空的 catch 块应添加注释说明原因",
|
||||
},
|
||||
messages: {
|
||||
missingTypeAnnotation:
|
||||
"catch 子句缺少类型注解。请使用 catch (e: unknown),然后用 e instanceof Error ? e.message : String(e) 提取错误信息。",
|
||||
nonUnknownType:
|
||||
"catch 的类型注解应为 unknown,切勿使用 any。请改为 catch (e: unknown),然后用 e instanceof Error ? e.message : String(e) 提取错误信息。",
|
||||
emptyCatchNoComment:
|
||||
"空的 catch 块应添加注释说明为什么忽略此异常。如果确有理由静默吞掉错误,请在该 catch body 内添加注释。",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
function isUnknownType(typeAnnotation) {
|
||||
if (!typeAnnotation) return false;
|
||||
const type = typeAnnotation.typeAnnotation;
|
||||
if (type?.type === "TSTypeReference") {
|
||||
return type.typeName?.name === "unknown";
|
||||
}
|
||||
if (type?.type === "TSUnknownKeyword") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasCommentsInBody(body) {
|
||||
if (!body) return false;
|
||||
return sourceCode.getCommentsInside(body).length > 0;
|
||||
}
|
||||
|
||||
function check(node) {
|
||||
const { param, body } = node;
|
||||
|
||||
if (param) {
|
||||
const typeAnnotation = param.typeAnnotation;
|
||||
if (!typeAnnotation) {
|
||||
context.report({ node: param, messageId: "missingTypeAnnotation" });
|
||||
} else if (!isUnknownType(typeAnnotation)) {
|
||||
context.report({ node: typeAnnotation, messageId: "nonUnknownType" });
|
||||
}
|
||||
}
|
||||
|
||||
if (body && body.type === "BlockStatement" && body.body.length === 0 && !hasCommentsInBody(body)) {
|
||||
context.report({ node: body, messageId: "emptyCatchNoComment" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
CatchClause: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
12
eslint-rules/local-rules.js
Normal file
12
eslint-rules/local-rules.js
Normal 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,
|
||||
},
|
||||
};
|
||||
59
eslint-rules/no-empty-function.js
Normal file
59
eslint-rules/no-empty-function.js
Normal file
@@ -0,0 +1,59 @@
|
||||
export const noEmptyFunction = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "禁止空函数体。修复方式:在函数体内添加注释说明为何为空实现(如接口契约、测试桩、noop)。",
|
||||
},
|
||||
messages: {
|
||||
unexpected: "空函数体禁止使用。请在 {} 内添加注释说明原因,例如:/* 实现 Logger 接口契约,有意静默丢弃 */。",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
const allowedFunctionTypes = new Set(["ArrowFunctionExpression", "FunctionDeclaration", "FunctionExpression"]);
|
||||
|
||||
function isEmptyBody(body) {
|
||||
return (
|
||||
body.type === "BlockStatement" && body.body.length === 0 && sourceCode.getCommentsInside(body).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasDecorator(node) {
|
||||
return Array.isArray(node.decorators) && node.decorators.length > 0;
|
||||
}
|
||||
|
||||
function isPrivateOrProtectedConstructor(node) {
|
||||
if (node.parent?.type !== "MethodDefinition") return false;
|
||||
if (node.parent.kind !== "constructor") return false;
|
||||
const accessibility = node.parent.accessibility;
|
||||
return accessibility === "private" || accessibility === "protected";
|
||||
}
|
||||
|
||||
function isOverrideMethod(node) {
|
||||
if (node.parent?.type !== "MethodDefinition") return false;
|
||||
return node.parent.override === true;
|
||||
}
|
||||
|
||||
function check(node) {
|
||||
if (!allowedFunctionTypes.has(node.type)) return;
|
||||
if (!isEmptyBody(node.body)) return;
|
||||
if (hasDecorator(node)) return;
|
||||
if (isPrivateOrProtectedConstructor(node)) return;
|
||||
if (isOverrideMethod(node)) return;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: "unexpected",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ArrowFunctionExpression: check,
|
||||
FunctionDeclaration: check,
|
||||
FunctionExpression: check,
|
||||
};
|
||||
},
|
||||
};
|
||||
110
eslint.config.js
110
eslint.config.js
@@ -1,110 +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";
|
||||
|
||||
const noDirectConsoleMessage =
|
||||
"后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
".build/**",
|
||||
"*.bun-build",
|
||||
"openspec/**",
|
||||
".opencode/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".agents/**",
|
||||
"bun.lock",
|
||||
"data/**",
|
||||
],
|
||||
},
|
||||
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-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-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
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}"],
|
||||
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。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
schema: fast-drive
|
||||
schema: code-drive
|
||||
|
||||
context: |
|
||||
## 项目概览
|
||||
- 本项目为 Bun 全栈应用(Alfred·阿福),Bun 是唯一包管理器和运行时,严禁使用 npm、pnpm、yarn、npx、pnpx
|
||||
- docs/user/ 记录用户使用方法,docs/development/ 记录开发技术细节
|
||||
- 使用中文(注释、文档、交流),面向中文开发者
|
||||
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
|
||||
- **其次阅读 docs/development/README.md** 获取开发规范、常用命令、质量门禁和全局规则
|
||||
- 文档文件名优先使用单个英文单词(usage.md、config.md、deploy.md、troubleshoot.md),目录上下文足以消歧时不在文件名重复限定词
|
||||
- 每次代码变更必须执行文档影响分析:
|
||||
- 用户可见行为、配置、部署、运行行为变更 → 更新 docs/user/ 对应文档
|
||||
- 开发流程、架构、测试、构建发布流程变更 → 更新 docs/development/ 对应文档
|
||||
- 项目定位、快速开始、核心能力列表、文档导航变更 → 更新 README.md
|
||||
- 文档同步规则或文档归属矩阵变更 → 更新 docs/README.md 和本文件
|
||||
- 无需更新文档时必须在收尾说明中说明原因
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
|
||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||
- src/server目录下是基于bun实现的后端代码
|
||||
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
||||
- src/web目录下是基于Bun HTML import、React、Ant Design实现的前端代码
|
||||
- 前端最高规约:优先使用 antd 组件默认能力和组件 props 组合界面,具体组件、样式、数据流和测试细节遵循 docs/development/frontend.md
|
||||
- 前端样式管理:antd 组件/props/token 优先,AppShell 使用最小全局 CSS,页面和自有组件样式增长后使用就近 CSS Modules,默认不引入 Tailwind、UnoCSS、Sass、Less、CSS-in-JS 等额外样式体系
|
||||
- 前端样式红线:禁止组件内联 style、覆盖 antd 内部类名、使用 !important、硬编码色值
|
||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
- 优先使用提问工具对用户进行提问
|
||||
- 本项目为 Bun 全栈应用(Alfred·阿福),docs/user/ 记录用户使用方法,docs/development/ 记录开发技术细节
|
||||
- 本项目无需考虑向前兼容性
|
||||
|
||||
## 文档入口(按顺序阅读)
|
||||
- **优先阅读 docs/README.md** 获取文档路由、归属矩阵和影响分析规则
|
||||
- **其次阅读 docs/development/README.md** 获取完整开发规范、常用命令和质量门禁
|
||||
|
||||
## 全局红线
|
||||
- 前端禁止导入 src/server/ 的后端运行时实现
|
||||
- 后端运行时代码禁止直接使用 console.*,通过 Logger 实例输出
|
||||
- 新增逻辑必须编写完善的测试,不允许跳过任何测试
|
||||
- 每次代码变更必须执行文档影响分析(详见 docs/README.md)
|
||||
- 新增代码优先复用已有组件、工具、依赖库,不轻易引入新依赖
|
||||
|
||||
## Git 规范
|
||||
- 提交信息中文,格式"类型: 简短描述",类型:feat/fix/refactor/docs/style/test/chore
|
||||
- 禁止创建 git 操作 task
|
||||
|
||||
## 工作方式
|
||||
- 积极使用 subagent 并行独立子任务,节省上下文空间;能并行的步骤明确并行
|
||||
- subagent 仅用于只读收集和分析,禁止用于文件修改、代码生成、git 操作或依赖安装
|
||||
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
||||
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
||||
- 优先使用提问工具对用户确认
|
||||
|
||||
rules:
|
||||
design:
|
||||
- fast-drive的design.md章节标题和正文使用中文;仅OpenSpec术语、文件名、schema字段名、命令和代码符号保留英文
|
||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||
tasks:
|
||||
- fast-drive的tasks.md分组标题、任务描述和验证说明使用中文;每个任务必须保留OpenSpec CLI可解析的单行checkbox格式
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
- 如果是代码存在更新必须
|
||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||
- 执行文档影响分析,更新 README.md 和/或 docs/ 下对应文档
|
||||
|
||||
234
openspec/schemas/code-drive/prompts/blocker-revise.md
Normal file
234
openspec/schemas/code-drive/prompts/blocker-revise.md
Normal 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
|
||||
1087
openspec/schemas/code-drive/schema.yaml
Normal file
1087
openspec/schemas/code-drive/schema.yaml
Normal file
File diff suppressed because it is too large
Load Diff
52
openspec/schemas/code-drive/templates/blocker.md
Normal file
52
openspec/schemas/code-drive/templates/blocker.md
Normal file
@@ -0,0 +1,52 @@
|
||||
## 阻塞点
|
||||
|
||||
<!-- 简述阻塞的本质,不是症状而是根因 -->
|
||||
|
||||
## 当前位置
|
||||
|
||||
- 任务编号:
|
||||
- plan.md 阶段:
|
||||
- 相关文件:
|
||||
|
||||
## 已尝试
|
||||
|
||||
<!-- 列出已尝试的方案和失败原因,避免重复尝试 -->
|
||||
|
||||
| 方案 | 失败原因 |
|
||||
| ---- | -------- |
|
||||
| | |
|
||||
|
||||
## 影响范围
|
||||
|
||||
<!-- 阻塞对上下游 artifacts 的系统性影响 -->
|
||||
|
||||
| Artifact | 影响内容 | 影响程度 |
|
||||
| -------- | -------- | -------- |
|
||||
| | | 必须修订 / 可能受影响 / 无影响 |
|
||||
|
||||
## 可选方案
|
||||
|
||||
### 方案 1:<!-- 语义化方案名称,例如“回退 design 调整接入方式” -->
|
||||
|
||||
- 描述:
|
||||
- 需修订:
|
||||
- 优势:
|
||||
- 风险 / 代价:
|
||||
|
||||
### 方案 2:<!-- 语义化方案名称 -->
|
||||
|
||||
- 描述:
|
||||
- 需修订:
|
||||
- 优势:
|
||||
- 风险 / 代价:
|
||||
|
||||
### 方案 3:<!-- 语义化方案名称,可选 -->
|
||||
|
||||
- 描述:
|
||||
- 需修订:
|
||||
- 优势:
|
||||
- 风险 / 代价:
|
||||
|
||||
## 修订建议
|
||||
|
||||
<!-- 推荐方案及修订路径:从哪个 artifact 入口开始修订,下游需要同步哪些 -->
|
||||
70
openspec/schemas/code-drive/templates/design.md
Normal file
70
openspec/schemas/code-drive/templates/design.md
Normal 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 “验证策略”的输入 -->
|
||||
66
openspec/schemas/code-drive/templates/plan.md
Normal file
66
openspec/schemas/code-drive/templates/plan.md
Normal file
@@ -0,0 +1,66 @@
|
||||
## 实现概览
|
||||
|
||||
<!-- 概述实现阶段、依赖顺序,以及各阶段如何对应 requirements.md 和 design.md -->
|
||||
|
||||
## 涉及文件
|
||||
|
||||
<!-- 按阶段列出本次变更涉及的核心文件路径,apply 阶段据此定位代码 -->
|
||||
|
||||
| 文件路径 | 变更类型 | 所属阶段 |
|
||||
| -------- | -------- | -------- |
|
||||
| <!-- 文件路径 --> | <!-- 新增 / 修改 / 删除 --> | <!-- 阶段编号 --> |
|
||||
|
||||
## 阶段 N: <!-- 阶段名称;按实际阶段重复本块,N 从 1 递增 -->
|
||||
|
||||
### 目标
|
||||
|
||||
<!-- 本阶段要完成什么 -->
|
||||
|
||||
### 前置条件
|
||||
|
||||
<!-- 本阶段开始前必须满足什么;没有则写“无” -->
|
||||
|
||||
### 详细实现步骤
|
||||
|
||||
<!-- 写清楚关键文件、函数、数据结构、流程或配置变化。不要使用 checkbox。 -->
|
||||
|
||||
### 关键代码模式
|
||||
|
||||
<!-- 记录本阶段的关键实现细节,apply 据此编写代码。至少覆盖以下内容中适用的部分: -->
|
||||
|
||||
**新增 / 修改的函数或方法:**
|
||||
<!-- 函数签名、参数、返回值、核心逻辑;无则写“无” -->
|
||||
|
||||
**新增 / 修改的数据结构:**
|
||||
<!-- 类型定义、字段、约束;无则写“无” -->
|
||||
|
||||
**调用顺序 / 流程:**
|
||||
<!-- 关键调用链、异步流程、状态转换;无则写“无” -->
|
||||
|
||||
**约定 / 模式:**
|
||||
<!-- 命名规范、错误处理模式、日志规范等;无则写“无” -->
|
||||
|
||||
### 验证方式
|
||||
|
||||
<!-- 本阶段如何独立验证 -->
|
||||
|
||||
### 验收标准
|
||||
|
||||
<!-- 本阶段完成的可验证标准;与 requirements.md 验收标准对齐 -->
|
||||
|
||||
### 关联需求
|
||||
|
||||
<!-- 例如:F1、F2 -->
|
||||
|
||||
## 验证策略
|
||||
|
||||
<!-- 汇总自动化测试、手动检查、文档检查、兼容性检查和验收方式 -->
|
||||
|
||||
## 回退 / 兼容性说明
|
||||
|
||||
<!-- 记录回退策略、错误处理策略、兼容性要求、迁移注意事项;没有则写“无” -->
|
||||
|
||||
- 回退策略:
|
||||
- 错误处理:
|
||||
- 兼容性:
|
||||
- 迁移注意事项:
|
||||
57
openspec/schemas/code-drive/templates/requirements.md
Normal file
57
openspec/schemas/code-drive/templates/requirements.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## 背景与目标
|
||||
|
||||
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
|
||||
|
||||
## 讨论记录
|
||||
|
||||
<!-- 记录 explore 或前序讨论中后续 design/plan/apply 必须保留的关键结论 -->
|
||||
|
||||
### 已确认结论
|
||||
|
||||
- <!-- 结论 1 -->
|
||||
- <!-- 结论 2 -->
|
||||
|
||||
### 用户偏好
|
||||
|
||||
- <!-- 偏好 1 -->
|
||||
|
||||
### 被否决方案
|
||||
|
||||
- <!-- 方案及否决原因 -->
|
||||
|
||||
## 功能需求
|
||||
|
||||
<!-- 每条功能需求必须有明确验收标准 -->
|
||||
|
||||
| 编号 | 需求 | 验收标准 |
|
||||
| ---- | ---- | -------- |
|
||||
| F1 | <!-- 需求 --> | <!-- 验收标准 --> |
|
||||
|
||||
## 非功能需求
|
||||
|
||||
<!-- 只记录与本次变更相关的非功能要求 -->
|
||||
|
||||
| 类别 | 要求 |
|
||||
| ---- | ---- |
|
||||
| <!-- 性能 / 兼容性 / 安全 / 可维护性 / 运维 / 文档 --> | <!-- 要求 --> |
|
||||
|
||||
## 技术需求
|
||||
|
||||
<!-- 记录需要确认的技术选型、架构方向、集成边界、代码约束或禁止事项;详细设计属于 design.md -->
|
||||
|
||||
| 编号 | 类别 | 决策 | 理由 | 被否决方案 |
|
||||
| ---- | ---- | ---- | ---- | ---------- |
|
||||
| T1 | <!-- 选型 / 架构 / 约束 / 集成 --> | <!-- 已确认的决策 --> | <!-- 理由 --> | <!-- 被否决方案及原因 --> |
|
||||
|
||||
## 全局审查
|
||||
|
||||
<!-- 从系统边界、既有行为、相邻模块、配置、文档、迁移、兼容性、安全、性能和用户流程角度审查当前需求 -->
|
||||
|
||||
### 与现有系统的关联
|
||||
|
||||
<!-- 记录相关模块、流程、配置、文档、外部接口或用户路径 -->
|
||||
|
||||
### 前置条件
|
||||
|
||||
<!-- 记录执行前必须满足的条件;没有则写"无" -->
|
||||
|
||||
16
openspec/schemas/code-drive/templates/tasks.md
Normal file
16
openspec/schemas/code-drive/templates/tasks.md
Normal 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]`,未完成或阻塞事项已记录
|
||||
@@ -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 使用任务 checkbox;checkbox 只属于 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 存在阻塞性待解决问题,或需要澄清,必须暂停。
|
||||
@@ -1,77 +0,0 @@
|
||||
## 背景
|
||||
|
||||
<!-- 记录问题、当前状态、相关参考资料,以及触发本次变更的用户请求 -->
|
||||
|
||||
## 讨论记录
|
||||
|
||||
<!-- 记录探索或前序讨论中 apply 阶段必须保留的关键结论 -->
|
||||
|
||||
- 已确认结论:
|
||||
- 用户偏好:
|
||||
- 约束:
|
||||
- 被否决方案:
|
||||
|
||||
## 需求
|
||||
|
||||
<!-- 记录预期结果、行为/流程/接口/内容变化、连续性要求和验收标准 -->
|
||||
|
||||
| 需求 | 验收标准 |
|
||||
| ---- | -------- |
|
||||
| | |
|
||||
|
||||
## 目标 / 非目标
|
||||
|
||||
**目标:**
|
||||
<!-- 记录本次 design 要达成的目标 -->
|
||||
|
||||
**非目标:**
|
||||
<!-- 记录明确不在范围内的内容 -->
|
||||
|
||||
## 执行约束
|
||||
|
||||
<!-- 记录必须遵守的约束、禁止的做法、需保持的行为/流程、依赖限制,以及项目或 workflow 特有边界 -->
|
||||
|
||||
- 依赖限制:
|
||||
- 约束:
|
||||
- 质量门禁:
|
||||
- 相关方:
|
||||
- 文档 / 沟通:
|
||||
- 兼容性 / 连续性:
|
||||
|
||||
## 影响范围
|
||||
|
||||
<!-- 记录与本次变更相关的具体 artifacts、参考资料、相关方、系统、工作流、文档、配置、资产或交接事项 -->
|
||||
|
||||
| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 |
|
||||
| ---- | -------------------- | -------- | ---- |
|
||||
| <!-- 范围 --> | <!-- Artifacts / 参考资料 --> | <!-- 预期变更 --> | <!-- 备注 --> |
|
||||
|
||||
## 决策
|
||||
|
||||
<!-- 记录关键决策、理由和考虑过的替代方案 -->
|
||||
|
||||
| 决策 | 理由 | 已否决替代方案 |
|
||||
| ---- | ---- | ---------------- |
|
||||
| | | |
|
||||
|
||||
## 执行计划
|
||||
|
||||
<!-- 记录主要工作流或待修改 artifacts、集成或交接点、执行顺序,以及必要的发布/落地说明 -->
|
||||
|
||||
## 验证计划
|
||||
|
||||
<!-- 记录用于证明变更完成所需的验证检查、审查、批准、验收检查、文档检查、沟通检查和人工检查 -->
|
||||
|
||||
| 需求 / 风险 | 验证方式 |
|
||||
| ----------- | -------- |
|
||||
| | |
|
||||
|
||||
## 风险 / 权衡
|
||||
|
||||
<!-- 格式:[风险] -> 缓解措施 -->
|
||||
|
||||
## 待解决问题
|
||||
|
||||
| 状态 | 问题 | 所需决策 |
|
||||
| ---- | ---- | -------- |
|
||||
| 无 | 无待解决问题。 | 无需决策 |
|
||||
@@ -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、沟通材料或项目参考资料
|
||||
57
package.json
57
package.json
@@ -8,8 +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",
|
||||
"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",
|
||||
@@ -24,47 +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": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ai-sdk/anthropic": "^3.0.81",
|
||||
"@ai-sdk/openai": "^3.0.68",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@ai-sdk/react": "^3.0.199",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@ant-design/x": "^2.7.0",
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@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",
|
||||
"recharts": "^3.8.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"
|
||||
}
|
||||
}
|
||||
|
||||
114
scripts/build.ts
114
scripts/build.ts
@@ -11,6 +11,67 @@ const buildDir = join(projectRoot, ".build");
|
||||
const executablePath = join(projectRoot, `dist/${APP.name}`);
|
||||
const packageJsonPath = join(projectRoot, "package.json");
|
||||
|
||||
export function createMigrationsDataSource(records: Array<{ checksum: string; id: string; sql: string }>): string {
|
||||
return [
|
||||
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
|
||||
``,
|
||||
`export const MIGRATIONS: MigrationRecord[] = [`,
|
||||
...records.map(
|
||||
(r) =>
|
||||
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
|
||||
),
|
||||
`];`,
|
||||
``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function createServerEntrySource(version: string): string {
|
||||
return [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { createConsoleFallback } from "../src/server/logger";`,
|
||||
`import { MIGRATIONS } from "./migrations-data";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function createStaticAssetsSource({
|
||||
fileEntries,
|
||||
importLines,
|
||||
indexHtmlVar,
|
||||
}: {
|
||||
fileEntries: string[];
|
||||
importLines: string[];
|
||||
indexHtmlVar: string;
|
||||
}): string {
|
||||
return [
|
||||
`import type { StaticAssets } from "../src/server/static";`,
|
||||
"",
|
||||
...importLines,
|
||||
"",
|
||||
`export const staticAssets: StaticAssets = {`,
|
||||
` files: {`,
|
||||
...fileEntries,
|
||||
` },`,
|
||||
` indexHtml: Bun.file(${indexHtmlVar}),`,
|
||||
`};`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
await viteBuild();
|
||||
@@ -98,42 +159,11 @@ async function codeGeneration() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const staticAssetsTs = [
|
||||
`import type { StaticAssets } from "../src/server/static";`,
|
||||
"",
|
||||
...importLines,
|
||||
"",
|
||||
`export const staticAssets: StaticAssets = {`,
|
||||
` files: {`,
|
||||
...fileEntries,
|
||||
` },`,
|
||||
` indexHtml: Bun.file(${indexHtmlVar}),`,
|
||||
`};`,
|
||||
"",
|
||||
].join("\n");
|
||||
const staticAssetsTs = createStaticAssetsSource({ fileEntries, importLines, indexHtmlVar });
|
||||
|
||||
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
|
||||
|
||||
const serverEntryTs = [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { createConsoleFallback } from "../src/server/logger";`,
|
||||
`import { MIGRATIONS } from "./migrations-data";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
const serverEntryTs = createServerEntrySource(version);
|
||||
|
||||
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
|
||||
}
|
||||
@@ -159,17 +189,7 @@ async function generateMigrationsData() {
|
||||
return { checksum, id, sql: sql.trim() };
|
||||
});
|
||||
|
||||
const lines = [
|
||||
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
|
||||
``,
|
||||
`export const MIGRATIONS: MigrationRecord[] = [`,
|
||||
...records.map(
|
||||
(r) =>
|
||||
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
|
||||
),
|
||||
`];`,
|
||||
``,
|
||||
].join("\n");
|
||||
const lines = createMigrationsDataSource(records);
|
||||
|
||||
await writeFile(join(buildDir, "migrations-data.ts"), lines);
|
||||
console.log(`Embedded ${records.length} migration(s)`);
|
||||
@@ -195,7 +215,7 @@ function toImportSpecifier(fromDir: string, targetPath: string) {
|
||||
}
|
||||
|
||||
async function viteBuild() {
|
||||
console.log("Step 1/3: Vite build...");
|
||||
console.log("Step 1/4: Vite build...");
|
||||
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
|
||||
cwd: projectRoot,
|
||||
stderr: "inherit",
|
||||
@@ -208,4 +228,6 @@ async function viteBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
await build();
|
||||
if (import.meta.main) {
|
||||
await build();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"ai-sdk": {
|
||||
"source": "vercel/ai",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/use-ai-sdk/SKILL.md",
|
||||
"computedHash": "2249889eb47ef0f61c4dba4cf2afe01c1c8dd793bb4c24230347c1ab909bb7dd"
|
||||
},
|
||||
"ant-design": {
|
||||
"source": "ant-design/antd-skill",
|
||||
"sourceType": "github",
|
||||
@@ -12,6 +18,62 @@
|
||||
"sourceType": "github",
|
||||
"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": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
|
||||
},
|
||||
"x-markdown": {
|
||||
"source": "ant-design/x",
|
||||
"ref": "main",
|
||||
"sourceType": "github",
|
||||
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
|
||||
"computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
src/server/ai/agents/alfred-agent.ts
Normal file
22
src/server/ai/agents/alfred-agent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { type LanguageModel, stepCountIs, ToolLoopAgent } from "ai";
|
||||
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { createGetCurrentTime } from "../tools/get-current-time";
|
||||
|
||||
const SYSTEM_PROMPT = `你是 Alfred,一个 AI 助手。
|
||||
|
||||
## 输出规范
|
||||
- 使用中文回复
|
||||
- 代码块用 Markdown 围栏语法,标注语言
|
||||
- 给出结论时简洁直接,不要长篇铺垫
|
||||
- 不确定的事明确说"不确定"`;
|
||||
|
||||
export function createAlfredAgent(model: LanguageModel, logger?: Logger) {
|
||||
return new ToolLoopAgent({
|
||||
instructions: SYSTEM_PROMPT,
|
||||
model,
|
||||
stopWhen: stepCountIs(20),
|
||||
tools: { getCurrentTime: createGetCurrentTime(logger) },
|
||||
});
|
||||
}
|
||||
176
src/server/ai/registry.ts
Normal file
176
src/server/ai/registry.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { createProviderRegistry, generateText } from "ai";
|
||||
|
||||
import type { Logger } from "../logger";
|
||||
import type { AIProviderConfig } from "./types";
|
||||
|
||||
export function buildProviderRegistry(db: Database) {
|
||||
const providers = getProviders(db);
|
||||
|
||||
const providerEntries: Record<string, ReturnType<typeof createProvider>> = {};
|
||||
for (const p of providers) {
|
||||
providerEntries[p.id] = createProvider({
|
||||
apiKey: p.api_key,
|
||||
baseUrl: p.base_url,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
});
|
||||
}
|
||||
|
||||
return createProviderRegistry(providerEntries);
|
||||
}
|
||||
|
||||
export async function testModelConnection(
|
||||
config: AIProviderConfig & { modelId: string },
|
||||
logger: Logger,
|
||||
): Promise<{ message: string; ok: boolean }> {
|
||||
try {
|
||||
const provider = createProvider(config);
|
||||
await generateText({
|
||||
maxOutputTokens: 10,
|
||||
model: provider.languageModel(config.modelId),
|
||||
prompt: "Hi",
|
||||
});
|
||||
return { message: "模型连接成功", ok: true };
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn({ error: msg, modelId: config.modelId, providerType: config.type }, "模型连接测试失败");
|
||||
return { message: `模型连接失败:${msg}`, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testProviderConnection(
|
||||
config: AIProviderConfig,
|
||||
logger: Logger,
|
||||
): Promise<{ message: string; ok: boolean }> {
|
||||
const baseUrlResult = await probeBaseUrl(config.baseUrl, logger);
|
||||
if (!baseUrlResult.ok) return baseUrlResult;
|
||||
|
||||
const modelsUrl = buildModelsUrl(config.baseUrl);
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsUrl, {
|
||||
headers: buildModelsHeaders(config),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { message: "Base URL 可连接,但 API Key 无效或权限不足。", ok: false };
|
||||
}
|
||||
|
||||
if ([404, 405, 501].includes(response.status)) {
|
||||
return {
|
||||
message: "Base URL 可连接,但可能不支持 /models 接口;可检查 URL 或忽略此提示。",
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
message: `Base URL 可连接,但 /models 请求失败(HTTP ${response.status});可检查 URL 或忽略此提示。`,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
const modelCount = countModels(body);
|
||||
if (modelCount !== null) {
|
||||
return { message: `连接成功,/models 返回 ${modelCount} 个模型。`, ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Base URL 可连接,但 /models 返回格式不兼容,可能不支持 /models;可检查 URL 或忽略此提示。",
|
||||
ok: true,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn({ error: msg, providerType: config.type }, "供应商 /models 请求异常");
|
||||
return { message: `Base URL 可连接,但 /models 请求异常:${msg};可检查 URL 或忽略此提示。`, ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelsHeaders(config: AIProviderConfig): HeadersInit {
|
||||
if (config.type === "anthropic") {
|
||||
return {
|
||||
accept: "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": config.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${config.apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModelsUrl(baseUrl: string): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/models`;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function countModels(body: unknown): null | number {
|
||||
if (Array.isArray(body)) return body.length;
|
||||
if (!body || typeof body !== "object") return null;
|
||||
|
||||
const data = (body as { data?: unknown }).data;
|
||||
if (Array.isArray(data)) return data.length;
|
||||
|
||||
const models = (body as { models?: unknown }).models;
|
||||
if (Array.isArray(models)) return models.length;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createProvider(config: AIProviderConfig) {
|
||||
switch (config.type) {
|
||||
case "anthropic":
|
||||
return createAnthropic({ apiKey: config.apiKey, baseURL: config.baseUrl });
|
||||
case "openai":
|
||||
return createOpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
|
||||
case "openai-compatible":
|
||||
return createOpenAICompatible({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
name: config.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getProviders(db: Database): Array<{
|
||||
api_key: string;
|
||||
base_url: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: "anthropic" | "openai" | "openai-compatible";
|
||||
}> {
|
||||
const stmt = db.prepare("SELECT id, name, type, base_url, api_key FROM providers");
|
||||
return stmt.all() as Array<{
|
||||
api_key: string;
|
||||
base_url: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: "anthropic" | "openai" | "openai-compatible";
|
||||
}>;
|
||||
}
|
||||
|
||||
async function probeBaseUrl(baseUrl: string, logger: Logger): Promise<{ message: string; ok: boolean }> {
|
||||
try {
|
||||
await fetch(baseUrl, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return { message: "Base URL 可连接", ok: true };
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn({ baseUrl, error: msg }, "Base URL 不可达");
|
||||
return { message: `Base URL 不可达:${msg}`, ok: false };
|
||||
}
|
||||
}
|
||||
43
src/server/ai/tools/get-current-time.ts
Normal file
43
src/server/ai/tools/get-current-time.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
export function createGetCurrentTime(logger?: Logger) {
|
||||
return tool({
|
||||
description: "获取当前日期和时间。可选指定时区,默认返回本地时间。",
|
||||
execute: ({ timezone }) => Promise.resolve(formatCurrentTime(timezone, logger)),
|
||||
inputSchema: z.object({
|
||||
timezone: z.string().optional().describe("IANA 时区名称,如 'Asia/Shanghai'、'America/New_York'"),
|
||||
}),
|
||||
metadata: { displayName: "获取当前时间" },
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCurrentTime(timezone?: string, logger?: Logger) {
|
||||
const now = new Date();
|
||||
const iso = now.toISOString();
|
||||
const timestamp = now.getTime();
|
||||
|
||||
let local: string;
|
||||
if (timezone) {
|
||||
try {
|
||||
local = new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "full",
|
||||
timeStyle: "long",
|
||||
timeZone: timezone,
|
||||
}).format(now);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger?.warn({ error: msg, timezone }, "无效时区,使用默认格式");
|
||||
local = now.toString();
|
||||
}
|
||||
} else {
|
||||
local = new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "full",
|
||||
timeStyle: "long",
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
return { iso, local, timestamp };
|
||||
}
|
||||
21
src/server/ai/types.ts
Normal file
21
src/server/ai/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ModelCapability, ProviderType } from "../../shared/api";
|
||||
|
||||
export type { ModelCapability, ProviderType };
|
||||
|
||||
export interface AIModelConfig {
|
||||
capabilities: ModelCapability[];
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface AIProviderConfig {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
name: string;
|
||||
type: ProviderType;
|
||||
}
|
||||
|
||||
export interface AIRegistryConfig {
|
||||
models: AIModelConfig[];
|
||||
providers: AIProviderConfig[];
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -47,7 +49,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
||||
|
||||
try {
|
||||
logger = await buildLogger(config.logging, options.mode, options.version);
|
||||
} catch (logInitError) {
|
||||
} catch (logInitError: unknown) {
|
||||
createFallback().fatal(
|
||||
`日志初始化失败: ${logInitError instanceof Error ? logInitError.message : String(logInitError)}`,
|
||||
);
|
||||
@@ -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);
|
||||
@@ -83,7 +88,7 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
||||
staticAssets: options.staticAssets,
|
||||
version: options.version,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
if (logger) {
|
||||
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
|
||||
logger.flush();
|
||||
|
||||
@@ -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";
|
||||
@@ -192,7 +192,7 @@ function validateLoggingConfig(logging: LoggingConfig | undefined, issues: Confi
|
||||
if (bytes <= 0) {
|
||||
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { Column, SQL } from "drizzle-orm";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import Database from "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;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function createDatabase(dataDir: string, logger: Logger): Database {
|
||||
const dbPath = join(dataDir, DB_FILENAME);
|
||||
const db = new Database(dbPath);
|
||||
@@ -17,3 +31,74 @@ 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,
|
||||
options: {
|
||||
conditions?: Array<SQL | undefined>;
|
||||
mapRow: (row: T["$inferSelect"]) => R;
|
||||
orderBy?: (table: T) => SQL | undefined;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
softDelete?: Column;
|
||||
},
|
||||
): PaginateResult<R> {
|
||||
const db = wrap(raw);
|
||||
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(*)` })
|
||||
.from(table)
|
||||
.where(whereClause)
|
||||
.get();
|
||||
|
||||
const total = Number(countResult?.count ?? 0);
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(whereClause)
|
||||
.orderBy(options.orderBy?.(table) ?? sql`1`)
|
||||
.limit(options.pageSize)
|
||||
.offset((options.page - 1) * options.pageSize)
|
||||
.all();
|
||||
|
||||
return {
|
||||
items: rows.map(options.mapRow),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
246
src/server/db/conversations.ts
Normal file
246
src/server/db/conversations.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||
import { conversations, messages, models } from "./schema";
|
||||
|
||||
export function createConversation(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
_logger: Logger,
|
||||
defaultModelId?: string,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
|
||||
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 = timestamp();
|
||||
|
||||
db.insert(conversations)
|
||||
.values({
|
||||
createdAt: now,
|
||||
id,
|
||||
modelId,
|
||||
projectId,
|
||||
title: "新会话",
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||
return { conversation: toConversation(row!) };
|
||||
}
|
||||
|
||||
export function createMessage(
|
||||
raw: Database,
|
||||
data: {
|
||||
content: string;
|
||||
conversationId: string;
|
||||
parts?: string;
|
||||
role: "assistant" | "system" | "user";
|
||||
},
|
||||
_logger: Logger,
|
||||
): Message {
|
||||
const db = wrap(raw);
|
||||
const id = crypto.randomUUID();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(messages)
|
||||
.values({
|
||||
content: data.content,
|
||||
conversationId: data.conversationId,
|
||||
createdAt: now,
|
||||
id,
|
||||
parts: data.parts ?? null,
|
||||
role: data.role,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(messages).where(eq(messages.id, id)).get();
|
||||
return toMessage(row!);
|
||||
}
|
||||
|
||||
export function createMessages(
|
||||
raw: Database,
|
||||
data: Array<{
|
||||
content: string;
|
||||
conversationId: string;
|
||||
parts?: string;
|
||||
role: "assistant" | "system" | "user";
|
||||
}>,
|
||||
_logger: Logger,
|
||||
): Message[] {
|
||||
const db = wrap(raw);
|
||||
const now = timestamp();
|
||||
const results: Message[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(messages)
|
||||
.values({
|
||||
content: item.content,
|
||||
conversationId: item.conversationId,
|
||||
createdAt: now,
|
||||
id,
|
||||
parts: item.parts ?? null,
|
||||
role: item.role,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
const row = db.select().from(messages).where(eq(messages.id, id)).get();
|
||||
results.push(toMessage(row!));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function deleteConversation(
|
||||
raw: Database,
|
||||
id: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, id), notDeleted(conversations)))
|
||||
.get();
|
||||
if (!existing) return { error: "会话不存在", status: 404 };
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function getConversation(
|
||||
raw: Database,
|
||||
id: string,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
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) };
|
||||
}
|
||||
|
||||
export function listConversations(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
options: { page: number; pageSize: number },
|
||||
): { items: Conversation[]; page: number; pageSize: number; total: number } {
|
||||
return paginateQuery(raw, conversations, {
|
||||
conditions: [eq(conversations.projectId, projectId)],
|
||||
mapRow: toConversation,
|
||||
orderBy: () => desc(conversations.updatedAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: conversations.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
raw: Database,
|
||||
conversationId: string,
|
||||
options: { page: number; pageSize: number },
|
||||
): { items: Message[]; page: number; pageSize: number; total: number } {
|
||||
return paginateQuery(raw, messages, {
|
||||
conditions: [eq(messages.conversationId, conversationId)],
|
||||
mapRow: toMessage,
|
||||
orderBy: () => desc(messages.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: messages.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateConversation(
|
||||
raw: Database,
|
||||
id: string,
|
||||
data: UpdateConversationRequest,
|
||||
_logger: Logger,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, id), notDeleted(conversations)))
|
||||
.get();
|
||||
if (!existing) return { error: "会话不存在", status: 404 };
|
||||
|
||||
const updates: { modelId?: null | string; title?: string; updatedAt: string } = { updatedAt: timestamp() };
|
||||
|
||||
if (data.modelId !== undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (data.title !== undefined) {
|
||||
updates.title = data.title;
|
||||
}
|
||||
|
||||
db.update(conversations).set(updates).where(eq(conversations.id, id)).run();
|
||||
|
||||
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||
return { conversation: toConversation(row!) };
|
||||
}
|
||||
|
||||
export function updateConversationTimestamp(raw: Database, id: string): void {
|
||||
const db = wrap(raw);
|
||||
db.update(conversations).set({ updatedAt: timestamp() }).where(eq(conversations.id, id)).run();
|
||||
}
|
||||
|
||||
function toConversation(row: typeof conversations.$inferSelect): Conversation {
|
||||
return {
|
||||
createdAt: row.createdAt,
|
||||
id: row.id,
|
||||
modelId: row.modelId,
|
||||
projectId: row.projectId,
|
||||
title: row.title,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toMessage(row: typeof messages.$inferSelect): Message {
|
||||
return {
|
||||
content: row.content,
|
||||
conversationId: row.conversationId,
|
||||
createdAt: row.createdAt,
|
||||
id: row.id,
|
||||
parts: row.parts,
|
||||
role: row.role,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
196
src/server/db/entities.ts
Normal file
196
src/server/db/entities.ts
Normal 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
12
src/server/db/helpers.ts
Normal 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(),
|
||||
};
|
||||
@@ -1,4 +1,24 @@
|
||||
export { createDatabase } from "./connection";
|
||||
export {
|
||||
createConversation,
|
||||
createMessage,
|
||||
createMessages,
|
||||
deleteConversation,
|
||||
getConversation,
|
||||
listConversations,
|
||||
listMessages,
|
||||
updateConversationTimestamp,
|
||||
} from "./conversations";
|
||||
export { loadMigrationsFromDir, type MigrationRecord } from "./load-migrations";
|
||||
export { runMigrations } from "./migrate";
|
||||
export { projects, schemaMigrations } from "./schema";
|
||||
export {
|
||||
createModel,
|
||||
deleteModel,
|
||||
getModel,
|
||||
getModelsByProviderId,
|
||||
getModelWithProvider,
|
||||
listModels,
|
||||
updateModel,
|
||||
} from "./models";
|
||||
export { conversations, messages, projects, schemaMigrations, settings } from "./schema";
|
||||
export { getSettings, updateSettings } from "./settings";
|
||||
|
||||
255
src/server/db/materials.ts
Normal file
255
src/server/db/materials.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -33,13 +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) {
|
||||
logger.info({ id: migration.id }, "执行 migration");
|
||||
db.exec(migration.sql);
|
||||
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
|
||||
}
|
||||
})();
|
||||
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 全部执行完成");
|
||||
}
|
||||
|
||||
284
src/server/db/models.ts
Normal file
284
src/server/db/models.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { and, asc, desc, eq, isNull, like, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
import type { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { models, providers } from "./schema";
|
||||
|
||||
export function createModel(
|
||||
raw: Database,
|
||||
request: CreateModelRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { model: Model } {
|
||||
const db = wrap(raw);
|
||||
|
||||
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 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 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 };
|
||||
|
||||
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!) };
|
||||
}
|
||||
|
||||
export function deleteModel(
|
||||
raw: Database,
|
||||
id: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, id), notDeleted(models)))
|
||||
.get();
|
||||
if (!existing) return { error: "模型不存在", status: 404 };
|
||||
|
||||
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(and(eq(models.id, id), notDeleted(models)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "模型不存在", status: 404 };
|
||||
return { model: toModel(row) };
|
||||
}
|
||||
|
||||
export function getModelsByProviderId(raw: Database, providerId: string): number {
|
||||
const db = wrap(raw);
|
||||
const result = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(models)
|
||||
.where(and(eq(models.providerId, providerId), isNull(models.deletedAt)))
|
||||
.get();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
export function getModelWithProvider(
|
||||
raw: Database,
|
||||
modelId: string,
|
||||
):
|
||||
| { error: string; status: number }
|
||||
| {
|
||||
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(and(eq(models.id, modelId), notDeleted(models)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "模型不存在", status: 404 };
|
||||
|
||||
const providerRow = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, row.providerId), notDeleted(providers)))
|
||||
.get();
|
||||
if (!providerRow) return { error: "供应商不存在", status: 404 };
|
||||
|
||||
return {
|
||||
model: {
|
||||
externalId: row.externalId,
|
||||
name: row.name,
|
||||
providerId: row.providerId,
|
||||
},
|
||||
provider: {
|
||||
apiKey: providerRow.apiKey,
|
||||
baseUrl: providerRow.baseUrl,
|
||||
id: providerRow.id,
|
||||
type: providerRow.type,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function listModels(
|
||||
raw: Database,
|
||||
options: {
|
||||
capabilities?: string;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
providerId?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: SortOrder;
|
||||
},
|
||||
): { items: Model[]; page: number; pageSize: number; total: number } {
|
||||
const conditions = [];
|
||||
|
||||
if (options.providerId) {
|
||||
conditions.push(eq(models.providerId, options.providerId));
|
||||
}
|
||||
|
||||
if (options.keyword) {
|
||||
const pattern = `%${options.keyword}%`;
|
||||
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: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: models.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateModel(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateModelRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { model: Model } {
|
||||
const db = wrap(raw);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, id), notDeleted(models)))
|
||||
.get();
|
||||
if (!existing) return { error: "模型不存在", status: 404 };
|
||||
|
||||
const updates: Partial<typeof models.$inferInsert> = {
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
const name = request.name?.trim();
|
||||
if (name === "") return { error: "模型名称不能为空", status: 400 };
|
||||
if (name !== undefined && name !== existing.name) {
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
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(and(eq(providers.id, request.providerId), notDeleted(providers)))
|
||||
.get();
|
||||
if (!provider) return { error: "供应商不存在", status: 400 };
|
||||
updates.providerId = request.providerId;
|
||||
}
|
||||
|
||||
if (request.capabilities !== undefined) {
|
||||
if (request.capabilities.length === 0) {
|
||||
return { error: "至少选择一个能力标签", status: 400 };
|
||||
}
|
||||
updates.capabilities = JSON.stringify(request.capabilities);
|
||||
}
|
||||
|
||||
if (request.contextLength !== undefined) {
|
||||
updates.contextLength = request.contextLength;
|
||||
}
|
||||
|
||||
if (request.maxOutputTokens !== undefined) {
|
||||
updates.maxOutputTokens = request.maxOutputTokens;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 1 && updates.updatedAt) {
|
||||
return { model: toModel(existing) };
|
||||
}
|
||||
|
||||
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,
|
||||
name: row.name,
|
||||
providerId: row.providerId,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { and, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
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 { projects } from "./schema";
|
||||
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||
import { conversations, materials, messages, projects } from "./schema";
|
||||
|
||||
export function archiveProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
export function archiveProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
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!) };
|
||||
@@ -23,52 +32,92 @@ export function archiveProject(raw: Database, id: string): { error: string; stat
|
||||
export function createProject(
|
||||
raw: Database,
|
||||
request: CreateProjectRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
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 };
|
||||
}
|
||||
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!) };
|
||||
}
|
||||
|
||||
export function deleteProject(raw: Database, id: string): { error: string; status: number } | { success: true } {
|
||||
export function deleteProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
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) };
|
||||
@@ -76,9 +125,15 @@ 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 db = wrap(raw);
|
||||
const conditions = [];
|
||||
|
||||
if (options.status) {
|
||||
@@ -90,41 +145,34 @@ export function listProjects(
|
||||
conditions.push(or(like(projects.name, pattern), like(projects.description, pattern))!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const orderByFn = buildProjectOrderBy(options.sortBy, options.sortOrder);
|
||||
|
||||
const countResult = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(projects)
|
||||
.where(where)
|
||||
.get();
|
||||
|
||||
const total = Number(countResult?.count ?? 0);
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(where)
|
||||
.orderBy(desc(projects.createdAt))
|
||||
.limit(options.pageSize)
|
||||
.offset((options.page - 1) * options.pageSize)
|
||||
.all();
|
||||
|
||||
return {
|
||||
items: rows.map(toProject),
|
||||
return paginateQuery(raw, projects, {
|
||||
conditions,
|
||||
mapRow: toProject,
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
total,
|
||||
};
|
||||
softDelete: projects.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
export function restoreProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
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!) };
|
||||
@@ -134,20 +182,32 @@ export function updateProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProjectRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
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 name = request.name?.trim();
|
||||
if (name === "") return { error: "项目名称不能为空", status: 400 };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -160,23 +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 };
|
||||
}
|
||||
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,
|
||||
@@ -185,7 +247,3 @@ function toProject(row: typeof projects.$inferSelect): Project {
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function wrap(raw: Database) {
|
||||
return drizzle(raw);
|
||||
}
|
||||
|
||||
210
src/server/db/providers.ts
Normal file
210
src/server/db/providers.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { and, asc, desc, eq, isNull, like, ne } from "drizzle-orm";
|
||||
|
||||
import type {
|
||||
CreateProviderRequest,
|
||||
Provider,
|
||||
ProviderOption,
|
||||
SortOrder,
|
||||
UpdateProviderRequest,
|
||||
} from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { models, providers } from "./schema";
|
||||
|
||||
export function createProvider(
|
||||
raw: Database,
|
||||
request: CreateProviderRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { provider: Provider } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
if (!name) return { error: "供应商名称不能为空", status: 400 };
|
||||
|
||||
const baseUrl = request.baseUrl.trim();
|
||||
if (!baseUrl) return { error: "Base URL 不能为空", status: 400 };
|
||||
|
||||
const apiKey = request.apiKey.trim();
|
||||
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
|
||||
|
||||
const duplicate = db
|
||||
.select({ id: providers.id })
|
||||
.from(providers)
|
||||
.where(and(eq(providers.name, name), notDeleted(providers)))
|
||||
.get();
|
||||
if (duplicate) return { error: "供应商名称已存在", status: 409 };
|
||||
|
||||
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!) };
|
||||
}
|
||||
|
||||
export function deleteProvider(
|
||||
raw: Database,
|
||||
id: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, id), notDeleted(providers)))
|
||||
.get();
|
||||
if (!existing) return { error: "供应商不存在", status: 404 };
|
||||
|
||||
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(and(eq(providers.id, id), notDeleted(providers)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "供应商不存在", status: 404 };
|
||||
return { provider: toProvider(row) };
|
||||
}
|
||||
|
||||
export function listProviderOptions(raw: Database): ProviderOption[] {
|
||||
const db = wrap(raw);
|
||||
const rows = db
|
||||
.select({ id: providers.id, name: providers.name, type: providers.type })
|
||||
.from(providers)
|
||||
.where(notDeleted(providers))
|
||||
.orderBy(desc(providers.createdAt))
|
||||
.all();
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function listProviders(
|
||||
raw: Database,
|
||||
options: { keyword?: string; page: number; pageSize: number; sortBy?: string; sortOrder?: SortOrder; type?: string },
|
||||
): { items: Provider[]; page: number; pageSize: number; total: number } {
|
||||
const conditions = [];
|
||||
|
||||
if (options.keyword) {
|
||||
const pattern = `%${options.keyword}%`;
|
||||
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: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: providers.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProvider(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProviderRequest,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { provider: Provider } {
|
||||
const db = wrap(raw);
|
||||
const existing = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, id), notDeleted(providers)))
|
||||
.get();
|
||||
if (!existing) return { error: "供应商不存在", status: 404 };
|
||||
|
||||
const updates: Partial<typeof providers.$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: 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;
|
||||
}
|
||||
|
||||
const baseUrl = request.baseUrl?.trim();
|
||||
if (baseUrl === "") return { error: "Base URL 不能为空", status: 400 };
|
||||
if (baseUrl !== undefined) {
|
||||
updates.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
const apiKey = request.apiKey?.trim();
|
||||
if (apiKey === "") return { error: "API Key 不能为空", status: 400 };
|
||||
if (apiKey !== undefined) {
|
||||
updates.apiKey = apiKey;
|
||||
}
|
||||
|
||||
if (request.type !== undefined) {
|
||||
updates.type = request.type;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 1 && updates.updatedAt) {
|
||||
return { provider: toProvider(existing) };
|
||||
}
|
||||
|
||||
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,
|
||||
baseUrl: row.baseUrl,
|
||||
createdAt: row.createdAt,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,114 @@
|
||||
import { sqliteTable, text } 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(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
|
||||
.notNull()
|
||||
.default("openai-compatible"),
|
||||
});
|
||||
|
||||
export const models = sqliteTable(
|
||||
"models",
|
||||
{
|
||||
...baseColumns,
|
||||
capabilities: text("capabilities").notNull(),
|
||||
contextLength: integer("context_length"),
|
||||
externalId: text("external_id").notNull(),
|
||||
maxOutputTokens: integer("max_output_tokens"),
|
||||
name: text("name").notNull(),
|
||||
providerId: text("provider_id")
|
||||
.notNull()
|
||||
.references(() => providers.id),
|
||||
},
|
||||
(table) => [index("models_provider_id_idx").on(table.providerId)],
|
||||
);
|
||||
|
||||
export const conversations = sqliteTable(
|
||||
"conversations",
|
||||
{
|
||||
...baseColumns,
|
||||
modelId: text("model_id").references(() => models.id),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
title: text("title").notNull().default("新会话"),
|
||||
},
|
||||
(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),
|
||||
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
74
src/server/db/settings.ts
Normal 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;
|
||||
}
|
||||
4
src/server/helpers/index.ts
Normal file
4
src/server/helpers/index.ts
Normal file
@@ -0,0 +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";
|
||||
60
src/server/helpers/list-params.ts
Normal file
60
src/server/helpers/list-params.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../shared/api";
|
||||
import type { ApiErrorResponse, MetaResponse, RuntimeMode } from "../../shared/api";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { APP } from "../../shared/app";
|
||||
|
||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||
return { error, status };
|
||||
3
src/server/helpers/url.ts
Normal file
3
src/server/helpers/url.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function parseIdFromUrl(url: URL): string | undefined {
|
||||
return url.pathname.split("/")[3];
|
||||
}
|
||||
37
src/server/middleware/error-handler.ts
Normal file
37
src/server/middleware/error-handler.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeMode } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { createApiError, jsonResponse } from "../helpers";
|
||||
|
||||
type RouteHandler = (req: Request) => Promise<Response> | Response;
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export function withErrorHandler(fn: RouteHandler, mode: RuntimeMode, logger?: Logger): RouteHandler {
|
||||
return async (req) => {
|
||||
try {
|
||||
return await fn(req);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonResponse(createApiError(error.message, error.statusCode), {
|
||||
mode,
|
||||
status: error.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
logger?.error({ error }, "未处理的路由异常");
|
||||
return jsonResponse(createApiError("服务器内部错误", 500), {
|
||||
mode,
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/server/middleware/index.ts
Normal file
2
src/server/middleware/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppError, withErrorHandler } from "./error-handler";
|
||||
export { validateIdParam, validatePagination, validateTimeRange } from "./validate";
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { RuntimeMode } from "../shared/api";
|
||||
import type { RuntimeMode } from "../../shared/api";
|
||||
|
||||
import { createApiError, jsonResponse } from "./helpers";
|
||||
import { createApiError, jsonResponse } from "../helpers";
|
||||
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
export function validateIdParam(idStr: string, mode: RuntimeMode): Response | { id: string } {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
|
||||
return jsonResponse(createApiError("Invalid ID parameter", 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError("无效的 ID 参数", 400), { mode, status: 400 });
|
||||
}
|
||||
return { id: idStr };
|
||||
}
|
||||
@@ -22,17 +22,17 @@ export function validatePagination(
|
||||
if (pageParam !== null) {
|
||||
page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page <= 0) {
|
||||
return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError("无效的 page 参数", 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (pageSizeParam !== null) {
|
||||
pageSize = Number(pageSizeParam);
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError("无效的 pageSize 参数", 400), { mode, status: 400 });
|
||||
}
|
||||
if (pageSize > MAX_PAGE_SIZE) {
|
||||
return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError(`pageSize 不能超过 ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,18 +45,18 @@ export function validateTimeRange(
|
||||
mode: RuntimeMode,
|
||||
): Response | { from: string; to: string } {
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError("from 和 to 参数为必填项", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError("无效的 from 或 to 参数格式", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (fromDate.getTime() > toDate.getTime()) {
|
||||
return jsonResponse(createApiError("from must be earlier than to", 400), { mode, status: 400 });
|
||||
return jsonResponse(createApiError("from 必须早于 to", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
return { from: fromDate.toISOString(), to: toDate.toISOString() };
|
||||
14
src/server/processing/index.ts
Normal file
14
src/server/processing/index.ts
Normal 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;
|
||||
}
|
||||
198
src/server/processing/processor.ts
Normal file
198
src/server/processing/processor.ts
Normal 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 };
|
||||
}
|
||||
79
src/server/processing/templates/general.ts
Normal file
79
src/server/processing/templates/general.ts
Normal 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;
|
||||
22
src/server/processing/templates/index.ts
Normal file
22
src/server/processing/templates/index.ts
Normal 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];
|
||||
}
|
||||
79
src/server/processing/templates/meeting.ts
Normal file
79
src/server/processing/templates/meeting.ts
Normal 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;
|
||||
37
src/server/routes/chat/create.ts
Normal file
37
src/server/routes/chat/create.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { CreateConversationRequest, RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { createConversation } from "../../db/conversations";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleCreateConversation(
|
||||
req: Request,
|
||||
db: Database,
|
||||
mode: RuntimeMode,
|
||||
logger: Logger,
|
||||
): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(projectId ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: CreateConversationRequest = {};
|
||||
try {
|
||||
body = (await req.json()) as CreateConversationRequest;
|
||||
} catch (e: unknown) {
|
||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
|
||||
// empty body is ok, defaults will be used
|
||||
}
|
||||
|
||||
const result = createConversation(db, validated.id, logger, body.modelId);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ conversationId: result.conversation.id, projectId: validated.id }, "会话创建成功");
|
||||
return jsonResponse({ conversation: result.conversation }, { mode, status: 201 });
|
||||
}
|
||||
37
src/server/routes/chat/delete.ts
Normal file
37
src/server/routes/chat/delete.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { deleteConversation, getConversation } from "../../db/conversations";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDeleteConversation(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
|
||||
const parts = new URL(req.url).pathname.split("/");
|
||||
const projectId = parts[3];
|
||||
const conversationId = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectId ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedConv = validateIdParam(conversationId ?? "", mode);
|
||||
if (validatedConv instanceof Response) return validatedConv;
|
||||
|
||||
const convResult = getConversation(db, validatedConv.id);
|
||||
if ("error" in convResult) {
|
||||
return jsonResponse(createApiError(convResult.error, convResult.status), { mode, status: convResult.status });
|
||||
}
|
||||
|
||||
if (convResult.conversation.projectId !== validatedProject.id) {
|
||||
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
|
||||
}
|
||||
|
||||
const result = deleteConversation(db, validatedConv.id, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ conversationId: validatedConv.id }, "会话删除成功");
|
||||
return jsonResponse({ success: true }, { mode });
|
||||
}
|
||||
31
src/server/routes/chat/get.ts
Normal file
31
src/server/routes/chat/get.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { getConversation } from "../../db/conversations";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleGetConversation(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const parts = new URL(req.url).pathname.split("/");
|
||||
const projectId = parts[3];
|
||||
const conversationId = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectId ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedConv = validateIdParam(conversationId ?? "", mode);
|
||||
if (validatedConv instanceof Response) return validatedConv;
|
||||
|
||||
const result = getConversation(db, validatedConv.id);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
if (result.conversation.projectId !== validatedProject.id) {
|
||||
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
|
||||
}
|
||||
|
||||
return jsonResponse({ conversation: result.conversation }, { mode });
|
||||
}
|
||||
29
src/server/routes/chat/list.ts
Normal file
29
src/server/routes/chat/list.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { listConversations } from "../../db/conversations";
|
||||
import { jsonResponse } from "../../helpers";
|
||||
import { validateIdParam, validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListConversations(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const url = new URL(req.url);
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(projectId ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
const result = listConversations(db, validated.id, {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
43
src/server/routes/chat/messages.ts
Normal file
43
src/server/routes/chat/messages.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { getConversation, listMessages } from "../../db/conversations";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam, validatePagination } from "../../middleware";
|
||||
|
||||
export function handleListMessages(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
|
||||
const parts = new URL(req.url).pathname.split("/");
|
||||
const projectId = parts[3];
|
||||
const conversationId = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectId ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedConv = validateIdParam(conversationId ?? "", mode);
|
||||
if (validatedConv instanceof Response) return validatedConv;
|
||||
|
||||
const convResult = getConversation(db, validatedConv.id);
|
||||
if ("error" in convResult) {
|
||||
return jsonResponse(createApiError(convResult.error, convResult.status), { mode, status: convResult.status });
|
||||
}
|
||||
|
||||
if (convResult.conversation.projectId !== validatedProject.id) {
|
||||
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
const result = listMessages(db, validatedConv.id, {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
}
|
||||
174
src/server/routes/chat/send.ts
Normal file
174
src/server/routes/chat/send.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { createAgentUIStreamResponse, generateText, type UIMessage } from "ai";
|
||||
|
||||
import type { RuntimeMode } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { createAlfredAgent } from "../../ai/agents/alfred-agent";
|
||||
import { buildProviderRegistry } from "../../ai/registry";
|
||||
import {
|
||||
createMessage,
|
||||
getConversation,
|
||||
updateConversation,
|
||||
updateConversationTimestamp,
|
||||
} from "../../db/conversations";
|
||||
import { getModelWithProvider, listModels } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleSendChat(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const projectId = url.pathname.split("/")[3];
|
||||
|
||||
const validated = validateIdParam(projectId ?? "", mode);
|
||||
if (validated instanceof Response) return validated;
|
||||
|
||||
let body: { conversationId?: string; messages?: UIMessage[] };
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} 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.conversationId || typeof body.conversationId !== "string") {
|
||||
return jsonResponse(createApiError("conversationId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
||||
return jsonResponse(createApiError("messages is required and must be a non-empty array", 400), {
|
||||
mode,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const conversationResult = getConversation(db, body.conversationId);
|
||||
if ("error" in conversationResult) {
|
||||
return jsonResponse(createApiError(conversationResult.error, conversationResult.status), {
|
||||
mode,
|
||||
status: conversationResult.status,
|
||||
});
|
||||
}
|
||||
|
||||
const conversation = conversationResult.conversation;
|
||||
if (conversation.projectId !== validated.id) {
|
||||
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
|
||||
}
|
||||
|
||||
const lastMsg = body.messages[body.messages.length - 1];
|
||||
if (lastMsg?.role === "user") {
|
||||
const content =
|
||||
lastMsg.parts
|
||||
?.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("") ?? "";
|
||||
createMessage(
|
||||
db,
|
||||
{
|
||||
content,
|
||||
conversationId: conversation.id,
|
||||
parts: JSON.stringify(lastMsg.parts ?? []),
|
||||
role: "user",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
updateConversationTimestamp(db, conversation.id);
|
||||
|
||||
let model;
|
||||
try {
|
||||
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.externalId}`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const firstUserMsg = body.messages.find((m) => m.role === "user");
|
||||
const firstUserText =
|
||||
firstUserMsg?.parts
|
||||
?.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("") ?? "";
|
||||
|
||||
if (conversation.title === "新会话" && firstUserText) {
|
||||
generateConversationTitle(firstUserText, model, db, conversation.id, logger);
|
||||
}
|
||||
|
||||
const agent = createAlfredAgent(model);
|
||||
return await createAgentUIStreamResponse({
|
||||
agent,
|
||||
onFinish: ({ responseMessage }) => {
|
||||
const text = responseMessage.parts
|
||||
.filter((p): p is { text: string; type: "text" } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
createMessage(
|
||||
db,
|
||||
{
|
||||
content: text,
|
||||
conversationId: conversation.id,
|
||||
parts: JSON.stringify(responseMessage.parts),
|
||||
role: "assistant",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
updateConversationTimestamp(db, conversation.id);
|
||||
},
|
||||
uiMessages: body.messages,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return jsonResponse(createApiError(`AI 调用失败:${msg}`, 500), { mode, status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function generateConversationTitle(
|
||||
firstUserText: string,
|
||||
model: ReturnType<ReturnType<typeof buildProviderRegistry>["languageModel"]>,
|
||||
db: Database,
|
||||
conversationId: string,
|
||||
logger: Logger,
|
||||
): void {
|
||||
if (firstUserText.length <= 5) {
|
||||
updateConversation(db, conversationId, { title: firstUserText }, logger);
|
||||
return;
|
||||
}
|
||||
|
||||
void generateText({
|
||||
model,
|
||||
prompt: `请根据以下对话开头生成一个简短标题(不超过10个字):${firstUserText}`,
|
||||
system: "你是一个标题生成助手,只返回标题文本,不要解释。",
|
||||
})
|
||||
.then((result) => {
|
||||
const title = result.text.trim().slice(0, 10);
|
||||
updateConversation(db, conversationId, { title: title || firstUserText.slice(0, 10) }, logger);
|
||||
})
|
||||
.catch((titleError: unknown) => {
|
||||
const titleMsg = titleError instanceof Error ? titleError.message : String(titleError);
|
||||
logger.error({ conversationId, error: titleMsg }, "标题生成失败");
|
||||
try {
|
||||
updateConversation(db, conversationId, { title: firstUserText.slice(0, 10) }, logger);
|
||||
} catch {
|
||||
logger.error({ conversationId }, "标题兜底更新失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
55
src/server/routes/chat/update.ts
Normal file
55
src/server/routes/chat/update.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { RuntimeMode, UpdateConversationRequest } from "../../../shared/api";
|
||||
import type { Logger } from "../../logger";
|
||||
|
||||
import { getConversation, updateConversation } from "../../db/conversations";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export async function handleUpdateConversation(
|
||||
req: Request,
|
||||
db: Database,
|
||||
mode: RuntimeMode,
|
||||
logger: Logger,
|
||||
): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/");
|
||||
const projectId = parts[3];
|
||||
const conversationId = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectId ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedConv = validateIdParam(conversationId ?? "", mode);
|
||||
if (validatedConv instanceof Response) return validatedConv;
|
||||
|
||||
const existing = getConversation(db, validatedConv.id);
|
||||
if ("error" in existing) {
|
||||
return jsonResponse(createApiError(existing.error, existing.status), { mode, status: existing.status });
|
||||
}
|
||||
|
||||
if (existing.conversation.projectId !== validatedProject.id) {
|
||||
return jsonResponse(createApiError("会话不属于该项目", 403), { mode, status: 403 });
|
||||
}
|
||||
|
||||
let body: UpdateConversationRequest;
|
||||
try {
|
||||
body = (await req.json()) as UpdateConversationRequest;
|
||||
} catch (e: unknown) {
|
||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
|
||||
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (body.modelId === undefined && body.title === undefined) {
|
||||
return jsonResponse(createApiError("至少需要传 modelId 或 title", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = updateConversation(db, validatedConv.id, body, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ conversationId: result.conversation.id }, "会话更新成功");
|
||||
return jsonResponse({ conversation: result.conversation }, { mode });
|
||||
}
|
||||
41
src/server/routes/entities/create.ts
Normal file
41
src/server/routes/entities/create.ts
Normal 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 });
|
||||
}
|
||||
29
src/server/routes/entities/delete.ts
Normal file
29
src/server/routes/entities/delete.ts
Normal 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 });
|
||||
}
|
||||
29
src/server/routes/entities/get.ts
Normal file
29
src/server/routes/entities/get.ts
Normal 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 });
|
||||
}
|
||||
45
src/server/routes/entities/list.ts
Normal file
45
src/server/routes/entities/list.ts
Normal 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 });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user