catalog.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // Profile、资料库、Agent Key、审计:后台管理的配置类数据
  2. import crypto from 'node:crypto'
  3. import { and, desc, eq, isNull, ne } from 'drizzle-orm'
  4. import { z } from 'zod'
  5. import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared'
  6. import type { DB } from '../db'
  7. import { apiKeys, auditLogs, knowledgeRoots, mcpServers, profiles, profileVersions } from '../db/schema'
  8. import { AppError, badRequest, notFound } from '../lib/errors'
  9. import { newId, nowIso } from '../lib/ids'
  10. import { checkKnowledgePath } from './access-policy'
  11. import type { SettingsService } from './settings'
  12. type ProfileRow = typeof profiles.$inferSelect
  13. const pathRule = z.union([z.object({ knowledgeRootId: z.string() }), z.object({ path: z.string().min(1) })])
  14. const fileAccessSchema: z.ZodType<FileAccessPolicy> = z.object({
  15. mode: z.enum(['restricted', 'broad']),
  16. read: z.array(pathRule),
  17. write: z.array(pathRule),
  18. deny: z.array(z.string().min(1)),
  19. })
  20. export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
  21. name: z.string().trim().min(1).max(60),
  22. description: z.string().max(500),
  23. provider: z.enum(['claude', 'codex']),
  24. model: z.string().min(1),
  25. systemPrompt: z.string().max(20000),
  26. permissionMode: z.enum(['ask', 'auto', 'readonly']),
  27. allowedTools: z.array(z.string()),
  28. disallowedTools: z.array(z.string()),
  29. skills: z.union([z.literal('all'), z.array(z.string())]),
  30. fileAccess: fileAccessSchema,
  31. knowledgeRootIds: z.array(z.string()),
  32. mcpServerIds: z.array(z.string()).default([]),
  33. isDefault: z.boolean(),
  34. })
  35. const toProfileDto = (r: ProfileRow): ProfileDto => ({
  36. id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
  37. permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
  38. fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, mcpServerIds: r.mcpServerIds, isDefault: r.isDefault, builtin: r.builtin, builtinKey: r.builtinKey ?? null, version: r.version, updatedAt: r.updatedAt,
  39. })
  40. const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds', 'mcpServerIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
  41. function snapshotOf(p: ProfileSnapshot): ProfileSnapshot {
  42. // 早期版本的快照没有 mcpServerIds,按空列表比较
  43. return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, k === 'mcpServerIds' ? p[k] ?? [] : p[k]])) as ProfileSnapshot
  44. }
  45. function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] {
  46. return SNAPSHOT_KEYS.filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k]))
  47. }
  48. /** 内置的标准 Profile:每个引擎一个"通用助手",不可删除,引擎不可修改 */
  49. const BUILTIN_NAMES: Record<ProviderId, string> = { claude: '通用助手(Claude)', codex: '通用助手(Codex)' }
  50. const FALLBACK_MODELS: Record<ProviderId, string> = { claude: 'sonnet', codex: 'gpt-6.1-sol' }
  51. export class ProfileService {
  52. constructor(private db: DB, private settings: SettingsService) {
  53. this.ensureBuiltins()
  54. }
  55. /** 补齐每个引擎的内置 Profile;旧版本的"通用助手"改名为 Claude 版本 */
  56. private ensureBuiltins() {
  57. const hasAny = !!this.db.select({ id: profiles.id }).from(profiles).limit(1).get()
  58. for (const provider of ['claude', 'codex'] as const) {
  59. const existing = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider), isNull(profiles.builtinKey))).get()
  60. if (existing) {
  61. if (existing.name === '通用助手') this.db.update(profiles).set({ name: BUILTIN_NAMES[provider] }).where(eq(profiles.id, existing.id)).run()
  62. continue
  63. }
  64. const s = this.settings.get()
  65. this.create({
  66. name: BUILTIN_NAMES[provider],
  67. description: `数据分析、资料查找与文档生成(${provider === 'claude' ? 'Claude' : 'Codex'} 标准版)`,
  68. provider,
  69. // 设置中的默认模型可能为空(例如保存过空值),回落到内置默认
  70. model: s.providers[provider].defaultModel || FALLBACK_MODELS[provider],
  71. systemPrompt: '',
  72. permissionMode: 'ask',
  73. allowedTools: [],
  74. disallowedTools: [],
  75. skills: 'all',
  76. fileAccess: { mode: s.security.defaultFileAccessMode, read: [], write: [], deny: [] },
  77. knowledgeRootIds: [],
  78. // 全新安装时 Claude 版本为默认 Profile
  79. isDefault: provider === 'claude' && !hasAny,
  80. }, true)
  81. }
  82. }
  83. list(): ProfileDto[] {
  84. return this.db.select().from(profiles).orderBy(desc(profiles.isDefault), profiles.name).all().map(toProfileDto)
  85. }
  86. get(id: string): ProfileDto {
  87. const row = this.db.select().from(profiles).where(eq(profiles.id, id)).get()
  88. if (!row) throw notFound('Profile')
  89. return toProfileDto(row)
  90. }
  91. getDefault(): ProfileDto {
  92. const row = this.db.select().from(profiles).where(eq(profiles.isDefault, true)).get()
  93. ?? this.db.select().from(profiles).limit(1).get()
  94. if (!row) throw notFound('默认 Profile')
  95. return toProfileDto(row)
  96. }
  97. /** Profile 只能引用已登记的 MCP 服务 */
  98. private checkMcp(ids: string[] | undefined) {
  99. if (!ids?.length) return
  100. const known = new Set(this.db.select({ id: mcpServers.id }).from(mcpServers).all().map(r => r.id))
  101. const missing = ids.filter(id => !known.has(id))
  102. if (missing.length) throw badRequest(`MCP 服务不存在:${missing.join('、')}`)
  103. }
  104. create(input: ProfileInput, builtin = false, builtinKey: 'lead' | null = null): ProfileDto {
  105. const data = parse(profileInputSchema, input)
  106. this.checkMcp(data.mcpServerIds)
  107. const id = newId('prf')
  108. this.db.transaction(tx => {
  109. if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
  110. tx.insert(profiles).values({ id, ...data, builtin, builtinKey }).run()
  111. tx.insert(profileVersions).values({ profileId: id, version: 1, snapshot: snapshotOf(data) }).run()
  112. })
  113. return this.get(id)
  114. }
  115. /** 保存修改:内容有变化时版本号加一并保存快照;只改"是否默认"不产生新版本 */
  116. update(id: string, input: ProfileInput): ProfileDto {
  117. const prev = this.get(id)
  118. const data = parse(profileInputSchema, input)
  119. this.checkMcp(data.mcpServerIds)
  120. if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
  121. if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
  122. const snapshot = snapshotOf(data)
  123. const changed = changedFields(snapshotOf(prev), snapshot).length > 0
  124. const version = changed ? prev.version + 1 : prev.version
  125. this.db.transaction(tx => {
  126. if (data.isDefault) tx.update(profiles).set({ isDefault: false }).where(ne(profiles.id, id)).run()
  127. tx.update(profiles).set({ ...data, version, updatedAt: nowIso() }).where(eq(profiles.id, id)).run()
  128. if (changed) tx.insert(profileVersions).values({ profileId: id, version, snapshot }).run()
  129. })
  130. return this.get(id)
  131. }
  132. /** 历史版本(新的在前),附带与上一个版本相比改动的字段 */
  133. versions(id: string): ProfileVersionDto[] {
  134. this.get(id)
  135. const rows = this.db.select().from(profileVersions).where(eq(profileVersions.profileId, id)).orderBy(profileVersions.version).all()
  136. return rows.map((r, i) => ({
  137. version: r.version, createdAt: r.createdAt, snapshot: r.snapshot,
  138. changed: i === 0 ? [] : changedFields(rows[i - 1]!.snapshot, r.snapshot),
  139. })).reverse()
  140. }
  141. /** 恢复到某个历史版本:以该版本的内容保存为一个新版本(历史只追加) */
  142. restore(id: string, version: number): ProfileDto {
  143. const current = this.get(id)
  144. const row = this.db.select().from(profileVersions).where(and(eq(profileVersions.profileId, id), eq(profileVersions.version, version))).get()
  145. if (!row) throw notFound(`Profile 的版本 ${version}`)
  146. return this.update(id, { ...row.snapshot, provider: current.builtin ? current.provider : row.snapshot.provider, isDefault: current.isDefault })
  147. }
  148. getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
  149. const row = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider), isNull(profiles.builtinKey))).get()
  150. return row ? toProfileDto(row) : this.getDefault()
  151. }
  152. /** 内置的组长 Profile(没有时返回 undefined) */
  153. findBuiltinLead(provider: ProviderId): ProfileDto | undefined {
  154. const row = this.db.select().from(profiles).where(and(eq(profiles.builtinKey, 'lead'), eq(profiles.provider, provider))).get()
  155. return row ? toProfileDto(row) : undefined
  156. }
  157. delete(id: string) {
  158. const p = this.get(id)
  159. if (p.builtin) throw badRequest('内置的 Profile 不能删除')
  160. if (p.isDefault) throw badRequest('不能删除默认 Profile')
  161. this.db.transaction(tx => {
  162. tx.delete(profiles).where(eq(profiles.id, id)).run()
  163. tx.delete(profileVersions).where(eq(profileVersions.profileId, id)).run()
  164. })
  165. }
  166. }
  167. const knowledgeInputSchema = z.object({
  168. name: z.string().trim().min(1).max(60),
  169. path: z.string().trim().min(1),
  170. description: z.string().max(1000).default(''),
  171. access: z.enum(['read', 'write']).default('read'),
  172. enabled: z.boolean().default(true),
  173. })
  174. export type KnowledgeInput = z.input<typeof knowledgeInputSchema>
  175. export class KnowledgeService {
  176. constructor(private db: DB, private settings: SettingsService) {}
  177. list(): KnowledgeRootDto[] {
  178. return this.db.select().from(knowledgeRoots).orderBy(knowledgeRoots.name).all()
  179. .map(r => ({ id: r.id, name: r.name, path: r.path, description: r.description, access: r.access, enabled: r.enabled, check: checkKnowledgePath(r.path, this.settings.get()) }))
  180. }
  181. get(id: string): KnowledgeRootDto {
  182. const k = this.list().find(r => r.id === id)
  183. if (!k) throw notFound('资料库')
  184. return k
  185. }
  186. create(input: KnowledgeInput): KnowledgeRootDto {
  187. const data = this.validate(input)
  188. const id = newId('kb')
  189. this.db.insert(knowledgeRoots).values({ id, ...data }).run()
  190. return this.get(id)
  191. }
  192. update(id: string, input: KnowledgeInput): KnowledgeRootDto {
  193. this.get(id)
  194. const data = this.validate(input)
  195. this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run()
  196. return this.get(id)
  197. }
  198. delete(id: string) {
  199. this.get(id)
  200. this.db.delete(knowledgeRoots).where(eq(knowledgeRoots.id, id)).run()
  201. }
  202. private validate(input: KnowledgeInput) {
  203. const data = parse(knowledgeInputSchema, input)
  204. const check = checkKnowledgePath(data.path, this.settings.get())
  205. if (check.denied || !check.exists || !check.isDirectory || !check.readable) throw badRequest(check.message ?? '路径不可用', check)
  206. return data
  207. }
  208. }
  209. export const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
  210. export class ApiKeyService {
  211. private lastTouched = new Map<string, number>()
  212. constructor(private db: DB, private profiles: ProfileService) {}
  213. list(): ApiKeyDto[] {
  214. return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all()
  215. .map(r => ({
  216. id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedProfileIds: r.allowedProfileIds,
  217. defaultProfileId: r.defaultProfileId, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt,
  218. }))
  219. }
  220. /** 返回明文 Key,只在创建时出现一次 */
  221. create(name: string, access: ApiKeyProfiles): { key: string; apiKey: ApiKeyDto } {
  222. if (!name.trim()) throw badRequest('名称不能为空')
  223. const checked = this.checkAccess(access)
  224. const key = `ask_${crypto.randomBytes(24).toString('base64url')}`
  225. const id = newId('key')
  226. this.db.insert(apiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 10), ...checked }).run()
  227. return { key, apiKey: this.list().find(k => k.id === id)! }
  228. }
  229. setProfiles(id: string, access: ApiKeyProfiles) {
  230. const checked = this.checkAccess(access)
  231. const r = this.db.update(apiKeys).set(checked).where(eq(apiKeys.id, id)).run()
  232. if (r.changes === 0) throw notFound('Agent Key')
  233. }
  234. /** 平台可用的 Profile 与默认 Profile */
  235. access(id: string): ApiKeyProfiles {
  236. const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get()
  237. if (!row) throw notFound('Agent Key')
  238. const allowed = row.allowedProfileIds
  239. return { allowedProfileIds: allowed, defaultProfileId: row.defaultProfileId ?? allowed[0] ?? '' }
  240. }
  241. /** 平台要使用某个 Profile(省略时用默认),无权使用时抛 403 */
  242. resolveProfile(id: string, requested?: string | null): ProfileDto {
  243. const acc = this.access(id)
  244. const target = requested || acc.defaultProfileId
  245. if (!target || !acc.allowedProfileIds.includes(target)) {
  246. throw new AppError(403, 'profile_not_allowed', requested ? '该平台无权使用这个 Profile' : '该平台没有可用的 Profile,请在后台为 Agent Key 配置')
  247. }
  248. return this.profiles.get(target)
  249. }
  250. /** 引用了某个 Profile 的 Key 名称(删除 Profile 前检查) */
  251. referencing(profileId: string): string[] {
  252. return this.list().filter(k => k.allowedProfileIds.includes(profileId)).map(k => k.name)
  253. }
  254. private checkAccess(access: ApiKeyProfiles) {
  255. const data = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), access)
  256. const allowed = [...new Set(data.allowedProfileIds)]
  257. if (!allowed.length) throw badRequest('至少选择一个 Profile')
  258. if (!data.defaultProfileId) throw badRequest('请选择默认 Profile')
  259. for (const pid of allowed) this.profiles.get(pid)
  260. if (!allowed.includes(data.defaultProfileId)) throw badRequest('默认 Profile 必须在可用的 Profile 中')
  261. return { allowedProfileIds: allowed, defaultProfileId: data.defaultProfileId }
  262. }
  263. setEnabled(id: string, enabled: boolean) {
  264. const r = this.db.update(apiKeys).set({ enabled }).where(eq(apiKeys.id, id)).run()
  265. if (r.changes === 0) throw notFound('Agent Key')
  266. }
  267. delete(id: string) {
  268. const r = this.db.delete(apiKeys).where(eq(apiKeys.id, id)).run()
  269. if (r.changes === 0) throw notFound('Agent Key')
  270. }
  271. verify(key: string): string | null {
  272. const row = this.db.select().from(apiKeys).where(and(eq(apiKeys.keyHash, hashKey(key)), eq(apiKeys.enabled, true))).get()
  273. if (!row) return null
  274. this.touch(row.id)
  275. return row.id
  276. }
  277. /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */
  278. isActive(id: string): boolean {
  279. const row = this.db.select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.enabled, true))).get()
  280. if (row) this.touch(id)
  281. return !!row
  282. }
  283. /** 最近使用时间每分钟最多写一次 */
  284. private touch(id: string) {
  285. const now = Date.now()
  286. if ((this.lastTouched.get(id) ?? 0) < now - 60_000) {
  287. this.lastTouched.set(id, now)
  288. this.db.update(apiKeys).set({ lastUsedAt: nowIso() }).where(eq(apiKeys.id, id)).run()
  289. }
  290. }
  291. }
  292. export type AuditKind = typeof auditLogs.$inferInsert['kind']
  293. export class AuditService {
  294. constructor(private db: DB) {}
  295. log(entry: { actor: string; sessionId?: string | null; kind: AuditKind; summary: string; detail?: unknown }) {
  296. this.db.insert(auditLogs).values({ ...entry, sessionId: entry.sessionId ?? null, detail: entry.detail ?? null }).run()
  297. }
  298. list(opts: { kind?: AuditKind; sessionId?: string; limit?: number }) {
  299. const where = and(opts.kind ? eq(auditLogs.kind, opts.kind) : undefined, opts.sessionId ? eq(auditLogs.sessionId, opts.sessionId) : undefined)
  300. return this.db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.id)).limit(Math.min(opts.limit ?? 200, 1000)).all()
  301. }
  302. }
  303. export function parse<T>(schema: z.ZodType<T>, input: unknown): T {
  304. const r = schema.safeParse(input)
  305. if (!r.success) throw badRequest('参数校验失败', z.treeifyError(r.error))
  306. return r.data
  307. }