| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120 |
- import { EventEmitter } from 'node:events'
- import path from 'node:path'
- import { eq } from 'drizzle-orm'
- import { z } from 'zod'
- import type { AppSettings, DeepPartial } from '@agent-server/shared'
- import { config } from '../config'
- import type { DB } from '../db'
- import { settings as settingsTable } from '../db/schema'
- import { badRequest } from '../lib/errors'
- import { bundledClaudePath } from '../providers/claude/cli'
- const KEY = 'app'
- export function defaultSettings(): AppSettings {
- const shared = config.sharedDir
- return {
- paths: {
- workspaceRoot: path.join(config.dataDir, 'workspaces'),
- skillsDir: path.join(shared, 'skills'),
- pythonRuntime: path.join(shared, 'runtime/py'),
- nodeModules: path.join(shared, 'runtime/node/node_modules'),
- matplotlibrc: path.join(shared, 'runtime/matplotlibrc'),
- },
- security: { globalDeny: [], defaultFileAccessMode: 'restricted' },
- network: { allowedDomains: ['pypi.org', 'files.pythonhosted.org'], blockPrivateFetch: true },
- limits: {
- concurrency: { global: 4, claude: 2, codex: 2 },
- interactionTimeoutSec: 600,
- uploadMaxMB: 50,
- workspaceMaxMB: 2048,
- },
- retention: { workspaceDays: 30 },
- providers: {
- claude: {
- enabled: true,
- cliPath: bundledClaudePath() ?? path.join(config.home, '.local/bin/claude'),
- defaultModel: 'sonnet',
- models: ['sonnet', 'opus', 'haiku', 'fable'],
- },
- codex: {
- enabled: false,
- cliPath: path.join(config.home, '.local/bin/codex'),
- defaultModel: '',
- models: [],
- configOverrides: [],
- },
- },
- }
- }
- const absPath = z.string().min(1).refine(p => path.isAbsolute(p), '必须是绝对路径')
- const domain = z.string().regex(/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i, '域名格式不正确')
- const settingsSchema: z.ZodType<AppSettings> = z.object({
- paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, nodeModules: absPath, matplotlibrc: absPath }),
- security: z.object({
- globalDeny: z.array(z.string().min(1)),
- defaultFileAccessMode: z.enum(['restricted', 'broad']),
- }),
- network: z.object({ allowedDomains: z.array(domain), blockPrivateFetch: z.boolean() }),
- limits: z.object({
- concurrency: z.object({
- global: z.number().int().min(1).max(32),
- claude: z.number().int().min(0).max(32),
- codex: z.number().int().min(0).max(32),
- }),
- interactionTimeoutSec: z.number().int().min(30).max(86400),
- uploadMaxMB: z.number().int().min(1).max(1024),
- workspaceMaxMB: z.number().int().min(10).max(102400),
- }),
- retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
- providers: z.object({
- claude: z.object({ enabled: z.boolean(), cliPath: absPath, defaultModel: z.string().min(1), models: z.array(z.string().min(1)) }),
- codex: z.object({
- enabled: z.boolean(),
- cliPath: absPath,
- defaultModel: z.string(),
- models: z.array(z.string().min(1)),
- configOverrides: z.array(z.string().min(1)),
- }),
- }),
- })
- function deepMerge<T>(base: T, patch: unknown): T {
- if (patch === undefined) return base
- if (typeof base !== 'object' || base === null || Array.isArray(base) || typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
- return patch as T
- }
- const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }
- for (const [k, v] of Object.entries(patch)) out[k] = deepMerge(out[k], v)
- return out as T
- }
- export class SettingsService extends EventEmitter<{ changed: [AppSettings] }> {
- private cache: AppSettings
- constructor(private db: DB) {
- super()
- const row = db.select().from(settingsTable).where(eq(settingsTable.key, KEY)).get()
- // 存储的值与默认值合并,以便新版本增加的配置项自动获得默认值
- this.cache = deepMerge(defaultSettings(), row?.value)
- }
- get(): AppSettings {
- return this.cache
- }
- update(patch: DeepPartial<AppSettings>): AppSettings {
- const merged = deepMerge(this.cache, patch)
- const parsed = settingsSchema.safeParse(merged)
- if (!parsed.success) throw badRequest('配置校验失败', z.treeifyError(parsed.error))
- this.db.insert(settingsTable)
- .values({ key: KEY, value: parsed.data })
- .onConflictDoUpdate({ target: settingsTable.key, set: { value: parsed.data, updatedAt: new Date().toISOString() } })
- .run()
- this.cache = parsed.data
- this.emit('changed', this.cache)
- return this.cache
- }
- }
|