settings.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. import { EventEmitter } from 'node:events'
  2. import path from 'node:path'
  3. import { eq } from 'drizzle-orm'
  4. import { z } from 'zod'
  5. import type { AppSettings, DeepPartial } from '@agent-server/shared'
  6. import { config } from '../config'
  7. import type { DB } from '../db'
  8. import { settings as settingsTable } from '../db/schema'
  9. import { badRequest } from '../lib/errors'
  10. import { bundledClaudePath } from '../providers/claude/cli'
  11. const KEY = 'app'
  12. export function defaultSettings(): AppSettings {
  13. const shared = config.sharedDir
  14. return {
  15. paths: {
  16. workspaceRoot: path.join(config.dataDir, 'workspaces'),
  17. skillsDir: path.join(shared, 'skills'),
  18. pythonRuntime: path.join(shared, 'runtime/py'),
  19. nodeModules: path.join(shared, 'runtime/node/node_modules'),
  20. matplotlibrc: path.join(shared, 'runtime/matplotlibrc'),
  21. },
  22. security: { globalDeny: [], defaultFileAccessMode: 'restricted' },
  23. network: { allowedDomains: ['pypi.org', 'files.pythonhosted.org'], blockPrivateFetch: true },
  24. limits: {
  25. concurrency: { global: 4, claude: 2, codex: 2 },
  26. interactionTimeoutSec: 600,
  27. uploadMaxMB: 50,
  28. workspaceMaxMB: 2048,
  29. },
  30. retention: { workspaceDays: 30 },
  31. providers: {
  32. claude: {
  33. enabled: true,
  34. cliPath: bundledClaudePath() ?? path.join(config.home, '.local/bin/claude'),
  35. defaultModel: 'sonnet',
  36. models: ['sonnet', 'opus', 'haiku', 'fable'],
  37. },
  38. codex: {
  39. enabled: false,
  40. cliPath: path.join(config.home, '.local/bin/codex'),
  41. defaultModel: '',
  42. models: [],
  43. configOverrides: [],
  44. },
  45. },
  46. }
  47. }
  48. const absPath = z.string().min(1).refine(p => path.isAbsolute(p), '必须是绝对路径')
  49. const domain = z.string().regex(/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i, '域名格式不正确')
  50. const settingsSchema: z.ZodType<AppSettings> = z.object({
  51. paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, nodeModules: absPath, matplotlibrc: absPath }),
  52. security: z.object({
  53. globalDeny: z.array(z.string().min(1)),
  54. defaultFileAccessMode: z.enum(['restricted', 'broad']),
  55. }),
  56. network: z.object({ allowedDomains: z.array(domain), blockPrivateFetch: z.boolean() }),
  57. limits: z.object({
  58. concurrency: z.object({
  59. global: z.number().int().min(1).max(32),
  60. claude: z.number().int().min(0).max(32),
  61. codex: z.number().int().min(0).max(32),
  62. }),
  63. interactionTimeoutSec: z.number().int().min(30).max(86400),
  64. uploadMaxMB: z.number().int().min(1).max(1024),
  65. workspaceMaxMB: z.number().int().min(10).max(102400),
  66. }),
  67. retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
  68. providers: z.object({
  69. claude: z.object({ enabled: z.boolean(), cliPath: absPath, defaultModel: z.string().min(1), models: z.array(z.string().min(1)) }),
  70. codex: z.object({
  71. enabled: z.boolean(),
  72. cliPath: absPath,
  73. defaultModel: z.string(),
  74. models: z.array(z.string().min(1)),
  75. configOverrides: z.array(z.string().min(1)),
  76. }),
  77. }),
  78. })
  79. function deepMerge<T>(base: T, patch: unknown): T {
  80. if (patch === undefined) return base
  81. if (typeof base !== 'object' || base === null || Array.isArray(base) || typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
  82. return patch as T
  83. }
  84. const out: Record<string, unknown> = { ...(base as Record<string, unknown>) }
  85. for (const [k, v] of Object.entries(patch)) out[k] = deepMerge(out[k], v)
  86. return out as T
  87. }
  88. export class SettingsService extends EventEmitter<{ changed: [AppSettings] }> {
  89. private cache: AppSettings
  90. constructor(private db: DB) {
  91. super()
  92. const row = db.select().from(settingsTable).where(eq(settingsTable.key, KEY)).get()
  93. // 存储的值与默认值合并,以便新版本增加的配置项自动获得默认值
  94. this.cache = deepMerge(defaultSettings(), row?.value)
  95. }
  96. get(): AppSettings {
  97. return this.cache
  98. }
  99. update(patch: DeepPartial<AppSettings>): AppSettings {
  100. const merged = deepMerge(this.cache, patch)
  101. const parsed = settingsSchema.safeParse(merged)
  102. if (!parsed.success) throw badRequest('配置校验失败', z.treeifyError(parsed.error))
  103. this.db.insert(settingsTable)
  104. .values({ key: KEY, value: parsed.data })
  105. .onConflictDoUpdate({ target: settingsTable.key, set: { value: parsed.data, updatedAt: new Date().toISOString() } })
  106. .run()
  107. this.cache = parsed.data
  108. this.emit('changed', this.cache)
  109. return this.cache
  110. }
  111. }