settings.ts 5.2 KB

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