From f5ea32a600b5f2757c54a702854e7ef673b9b679 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 09:19:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E7=94=A8=E6=88=B7=E7=BA=A7?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=EF=BC=88?= =?UTF-8?q?per-user=20=E8=90=BD=E5=BA=93=20+=20=E6=96=B0=E5=BB=BA=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=A5=97=E9=BB=98=E8=AE=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 settings 表(user_id PK,data JSON,单本地用户 'local')+ store.getSettings/putSettings (内置默认=核心策略子集)+ GET/PUT /api/settings 端点;POST /api/projects 创建时以用户 默认填充未显式传的字段(body 优先,自动放行/预算经 patchProject 套用)。前端弹框另提交。 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 40 ++++++++++++++++++++++++++++++++++------ src/store/index.ts | 2 +- src/store/schema.sql | 7 +++++++ src/store/store.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 852fb06..2a6b26a 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,6 +1,6 @@ import Fastify, { type FastifyInstance } from 'fastify'; import { WebSocketServer, type WebSocket } from 'ws'; -import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTaskInput } from '../store/index.js'; +import { Store, StoreError, type ActiveRun, type PatchProjectInput, type PatchTaskInput, type UserSettings } from '../store/index.js'; import type { Complexity } from '../model/complexity.js'; import { isComplexity } from '../model/complexity.js'; import type { TaskStatus } from '../model/status.js'; @@ -64,14 +64,25 @@ export function buildServer(opts: ApiOptions): FastifyInstance { app.post('/api/projects', (req) => { const b = req.body as Record; if (!b?.name || !b?.repoPath) throw new StoreError('name 与 repoPath 必填'); - return projectOut(store.createProject({ + // 用户级全局默认(核心策略子集):body 显式给则用 body,否则套默认 + const s = store.getSettings(); + const pick = (key: string, fallback: T): T => (b[key] !== undefined ? (b[key] as T) : fallback); + const created = store.createProject({ name: String(b.name), repoPath: String(b.repoPath), defaultBranch: b.defaultBranch ? String(b.defaultBranch) : undefined, verifyCmd: b.verifyCmd === undefined ? undefined : (b.verifyCmd === null ? null : String(b.verifyCmd)), - autonomy: b.autonomy as never, model: b.model === undefined ? undefined : (b.model === null ? null : String(b.model)), - concurrency: b.concurrency === undefined ? undefined : Number(b.concurrency), - maxRetries: b.maxRetries === undefined ? undefined : Number(b.maxRetries), - timeoutMs: b.timeoutMs === undefined ? undefined : Number(b.timeoutMs), + autonomy: pick('autonomy', s.autonomy) as never, + model: b.model !== undefined ? (b.model === null ? null : String(b.model)) : s.model, + concurrency: Number(pick('concurrency', s.concurrency)), + maxRetries: Number(pick('maxRetries', s.maxRetries)), + timeoutMs: Number(pick('timeoutMs', s.timeoutMs)), + }); + // createProject 不接受的默认字段(自动放行 / 预算)经 patchProject 套用 + return projectOut(store.patchProject(created.id, { + autoApprovePlan: Boolean(pick('autoApprovePlan', s.autoApprovePlan)), + autoApproveExec: Boolean(pick('autoApproveExec', s.autoApproveExec)), + budgetUsd: ((): number | null => { const v = pick('budgetUsd', s.budgetUsd); return v === null ? null : Number(v); })(), + budgetPeriod: pick('budgetPeriod', s.budgetPeriod) as 'day' | 'month', })); }); @@ -112,6 +123,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return store.reorderProjects(b.order.map(String)).map(projectOut); }); + // ---------- 用户级全局默认配置(per-user,新建项目默认值来源)---------- + app.get('/api/settings', () => store.getSettings()); + app.put('/api/settings', (req) => { + const b = (req.body ?? {}) as Record; + const patch: Partial = {}; + if (b.autonomy !== undefined) patch.autonomy = b.autonomy as Autonomy; + if (b.concurrency !== undefined) patch.concurrency = Number(b.concurrency); + if (b.maxRetries !== undefined) patch.maxRetries = Number(b.maxRetries); + if (b.timeoutMs !== undefined) patch.timeoutMs = Number(b.timeoutMs); + if (b.autoApprovePlan !== undefined) patch.autoApprovePlan = Boolean(b.autoApprovePlan); + if (b.autoApproveExec !== undefined) patch.autoApproveExec = Boolean(b.autoApproveExec); + if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd); + if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month'; + if (b.model !== undefined) patch.model = b.model === null || b.model === '' ? null : String(b.model); + return store.putSettings(patch); + }); + // 彻底删除项目(连带 tasks/runs/approvals/events,不可恢复) app.delete('/api/projects/:id', (req) => { const { id } = req.params as { id: string }; diff --git a/src/store/index.ts b/src/store/index.ts index aa9430f..be4ddec 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,5 +1,5 @@ export { Store, StoreError } from './store.js'; -export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun } from './store.js'; +export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun, UserSettings } from './store.js'; export type { Metrics, DurationStats, RunStats, ReviewStats, RetryStats, ModelUsage } from '../model/metrics.js'; export { openDb } from './db.js'; export type { DB } from './db.js'; diff --git a/src/store/schema.sql b/src/store/schema.sql index 8baa453..a8ea443 100644 --- a/src/store/schema.sql +++ b/src/store/schema.sql @@ -94,3 +94,10 @@ CREATE TABLE IF NOT EXISTS events ( at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, at); + +-- 用户级配置(per-user):新建项目默认值的单一来源。单本地用户 'local',多用户前向兼容。 +CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT PRIMARY KEY, + data TEXT NOT NULL DEFAULT '{}', -- 核心策略子集 JSON(autonomy/concurrency/max_retries/timeout_ms/auto_approve_*/budget_*/model) + updated_at TEXT NOT NULL +); diff --git a/src/store/store.ts b/src/store/store.ts index da67e8a..c9065c8 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -18,6 +18,24 @@ import { } from '../model/status.js'; const now = (): string => new Date().toISOString(); + +/** 用户级全局默认配置(新建项目默认值的单一来源)。核心策略子集。 */ +export interface UserSettings { + autonomy: Autonomy; + concurrency: number; + maxRetries: number; + timeoutMs: number; + autoApprovePlan: boolean; + autoApproveExec: boolean; + budgetUsd: number | null; + budgetPeriod: 'day' | 'month'; + model: string | null; +} +const SETTINGS_DEFAULT: UserSettings = { + autonomy: 'manual', concurrency: 1, maxRetries: 2, timeoutMs: 1_800_000, + autoApprovePlan: false, autoApproveExec: false, + budgetUsd: null, budgetPeriod: 'month', model: null, +}; const id = (prefix: string): string => `${prefix}_${nanoid(12)}`; /** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */ @@ -172,6 +190,29 @@ export class Store { return rowToProject(row); } + // ---------- Settings(用户级全局默认配置)---------- + /** 取用户配置(无行→内置默认;与默认 merge 保证字段齐全)。 */ + getSettings(userId = 'local'): UserSettings { + const row = this.db.prepare(`SELECT data FROM settings WHERE user_id = ?`).get(userId) as { data: string } | undefined; + let saved: Partial = {}; + if (row) { try { saved = JSON.parse(row.data) as Partial; } catch { saved = {}; } } + return { ...SETTINGS_DEFAULT, ...saved }; + } + + /** 写用户配置(upsert,仅白名单字段,缺省字段保持原值/默认)。 */ + putSettings(data: Partial, userId = 'local'): UserSettings { + const merged = { ...this.getSettings(userId), ...data }; + const clean = {} as UserSettings; + for (const k of Object.keys(SETTINGS_DEFAULT) as (keyof UserSettings)[]) { + (clean[k] as unknown) = merged[k]; + } + this.db.prepare( + `INSERT INTO settings (user_id, data, updated_at) VALUES (?,?,?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`, + ).run(userId, JSON.stringify(clean), now()); + return clean; + } + listProjects(): Project[] { const rows = this.db.prepare(`SELECT * FROM projects ORDER BY sort_order, created_at`).all() as ProjectRow[]; return rows.map(rowToProject);