| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- // Profile、资料库、Agent Key、审计:后台管理的配置类数据
- import crypto from 'node:crypto'
- import { and, desc, eq, isNull, ne } from 'drizzle-orm'
- import { z } from 'zod'
- import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProfileSnapshot, ProfileVersionDto, ProviderId } from '@agent-server/shared'
- import type { DB } from '../db'
- import { apiKeys, auditLogs, knowledgeRoots, mcpServers, profiles, profileVersions } from '../db/schema'
- import { AppError, badRequest, notFound } from '../lib/errors'
- import { newId, nowIso } from '../lib/ids'
- import { checkKnowledgePath } from './access-policy'
- import type { SettingsService } from './settings'
- type ProfileRow = typeof profiles.$inferSelect
- const pathRule = z.union([z.object({ knowledgeRootId: z.string() }), z.object({ path: z.string().min(1) })])
- const fileAccessSchema: z.ZodType<FileAccessPolicy> = z.object({
- mode: z.enum(['restricted', 'broad']),
- read: z.array(pathRule),
- write: z.array(pathRule),
- deny: z.array(z.string().min(1)),
- })
- export const profileInputSchema: z.ZodType<ProfileInput> = z.object({
- name: z.string().trim().min(1).max(60),
- description: z.string().max(500),
- provider: z.enum(['claude', 'codex']),
- model: z.string().min(1),
- systemPrompt: z.string().max(20000),
- permissionMode: z.enum(['ask', 'auto', 'readonly']),
- allowedTools: z.array(z.string()),
- disallowedTools: z.array(z.string()),
- skills: z.union([z.literal('all'), z.array(z.string())]),
- fileAccess: fileAccessSchema,
- knowledgeRootIds: z.array(z.string()),
- mcpServerIds: z.array(z.string()).default([]),
- isDefault: z.boolean(),
- })
- const toProfileDto = (r: ProfileRow): ProfileDto => ({
- id: r.id, name: r.name, description: r.description, provider: r.provider, model: r.model, systemPrompt: r.systemPrompt,
- permissionMode: r.permissionMode, allowedTools: r.allowedTools, disallowedTools: r.disallowedTools, skills: r.skills,
- 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,
- })
- const SNAPSHOT_KEYS = ['name', 'description', 'provider', 'model', 'systemPrompt', 'permissionMode', 'allowedTools', 'disallowedTools', 'skills', 'fileAccess', 'knowledgeRootIds', 'mcpServerIds'] as const satisfies readonly (keyof ProfileSnapshot)[]
- function snapshotOf(p: ProfileSnapshot): ProfileSnapshot {
- // 早期版本的快照没有 mcpServerIds,按空列表比较
- return Object.fromEntries(SNAPSHOT_KEYS.map(k => [k, k === 'mcpServerIds' ? p[k] ?? [] : p[k]])) as ProfileSnapshot
- }
- function changedFields(a: ProfileSnapshot, b: ProfileSnapshot): (keyof ProfileSnapshot)[] {
- return SNAPSHOT_KEYS.filter(k => JSON.stringify(a[k]) !== JSON.stringify(b[k]))
- }
- /** 内置的标准 Profile:每个引擎一个"通用助手",不可删除,引擎不可修改 */
- const BUILTIN_NAMES: Record<ProviderId, string> = { claude: '通用助手(Claude)', codex: '通用助手(Codex)' }
- const FALLBACK_MODELS: Record<ProviderId, string> = { claude: 'sonnet', codex: 'gpt-6.1-sol' }
- export class ProfileService {
- constructor(private db: DB, private settings: SettingsService) {
- this.ensureBuiltins()
- }
- /** 补齐每个引擎的内置 Profile;旧版本的"通用助手"改名为 Claude 版本 */
- private ensureBuiltins() {
- const hasAny = !!this.db.select({ id: profiles.id }).from(profiles).limit(1).get()
- for (const provider of ['claude', 'codex'] as const) {
- const existing = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider), isNull(profiles.builtinKey))).get()
- if (existing) {
- if (existing.name === '通用助手') this.db.update(profiles).set({ name: BUILTIN_NAMES[provider] }).where(eq(profiles.id, existing.id)).run()
- continue
- }
- const s = this.settings.get()
- this.create({
- name: BUILTIN_NAMES[provider],
- description: `数据分析、资料查找与文档生成(${provider === 'claude' ? 'Claude' : 'Codex'} 标准版)`,
- provider,
- // 设置中的默认模型可能为空(例如保存过空值),回落到内置默认
- model: s.providers[provider].defaultModel || FALLBACK_MODELS[provider],
- systemPrompt: '',
- permissionMode: 'ask',
- allowedTools: [],
- disallowedTools: [],
- skills: 'all',
- fileAccess: { mode: s.security.defaultFileAccessMode, read: [], write: [], deny: [] },
- knowledgeRootIds: [],
- // 全新安装时 Claude 版本为默认 Profile
- isDefault: provider === 'claude' && !hasAny,
- }, true)
- }
- }
- list(): ProfileDto[] {
- return this.db.select().from(profiles).orderBy(desc(profiles.isDefault), profiles.name).all().map(toProfileDto)
- }
- get(id: string): ProfileDto {
- const row = this.db.select().from(profiles).where(eq(profiles.id, id)).get()
- if (!row) throw notFound('Profile')
- return toProfileDto(row)
- }
- getDefault(): ProfileDto {
- const row = this.db.select().from(profiles).where(eq(profiles.isDefault, true)).get()
- ?? this.db.select().from(profiles).limit(1).get()
- if (!row) throw notFound('默认 Profile')
- return toProfileDto(row)
- }
- /** Profile 只能引用已登记的 MCP 服务 */
- private checkMcp(ids: string[] | undefined) {
- if (!ids?.length) return
- const known = new Set(this.db.select({ id: mcpServers.id }).from(mcpServers).all().map(r => r.id))
- const missing = ids.filter(id => !known.has(id))
- if (missing.length) throw badRequest(`MCP 服务不存在:${missing.join('、')}`)
- }
- create(input: ProfileInput, builtin = false, builtinKey: 'lead' | null = null): ProfileDto {
- const data = parse(profileInputSchema, input)
- this.checkMcp(data.mcpServerIds)
- const id = newId('prf')
- this.db.transaction(tx => {
- if (data.isDefault) tx.update(profiles).set({ isDefault: false }).run()
- tx.insert(profiles).values({ id, ...data, builtin, builtinKey }).run()
- tx.insert(profileVersions).values({ profileId: id, version: 1, snapshot: snapshotOf(data) }).run()
- })
- return this.get(id)
- }
- /** 保存修改:内容有变化时版本号加一并保存快照;只改"是否默认"不产生新版本 */
- update(id: string, input: ProfileInput): ProfileDto {
- const prev = this.get(id)
- const data = parse(profileInputSchema, input)
- this.checkMcp(data.mcpServerIds)
- if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
- if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
- const snapshot = snapshotOf(data)
- const changed = changedFields(snapshotOf(prev), snapshot).length > 0
- const version = changed ? prev.version + 1 : prev.version
- this.db.transaction(tx => {
- if (data.isDefault) tx.update(profiles).set({ isDefault: false }).where(ne(profiles.id, id)).run()
- tx.update(profiles).set({ ...data, version, updatedAt: nowIso() }).where(eq(profiles.id, id)).run()
- if (changed) tx.insert(profileVersions).values({ profileId: id, version, snapshot }).run()
- })
- return this.get(id)
- }
- /** 历史版本(新的在前),附带与上一个版本相比改动的字段 */
- versions(id: string): ProfileVersionDto[] {
- this.get(id)
- const rows = this.db.select().from(profileVersions).where(eq(profileVersions.profileId, id)).orderBy(profileVersions.version).all()
- return rows.map((r, i) => ({
- version: r.version, createdAt: r.createdAt, snapshot: r.snapshot,
- changed: i === 0 ? [] : changedFields(rows[i - 1]!.snapshot, r.snapshot),
- })).reverse()
- }
- /** 恢复到某个历史版本:以该版本的内容保存为一个新版本(历史只追加) */
- restore(id: string, version: number): ProfileDto {
- const current = this.get(id)
- const row = this.db.select().from(profileVersions).where(and(eq(profileVersions.profileId, id), eq(profileVersions.version, version))).get()
- if (!row) throw notFound(`Profile 的版本 ${version}`)
- return this.update(id, { ...row.snapshot, provider: current.builtin ? current.provider : row.snapshot.provider, isDefault: current.isDefault })
- }
- getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
- const row = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider), isNull(profiles.builtinKey))).get()
- return row ? toProfileDto(row) : this.getDefault()
- }
- /** 内置的组长 Profile(没有时返回 undefined) */
- findBuiltinLead(provider: ProviderId): ProfileDto | undefined {
- const row = this.db.select().from(profiles).where(and(eq(profiles.builtinKey, 'lead'), eq(profiles.provider, provider))).get()
- return row ? toProfileDto(row) : undefined
- }
- delete(id: string) {
- const p = this.get(id)
- if (p.builtin) throw badRequest('内置的 Profile 不能删除')
- if (p.isDefault) throw badRequest('不能删除默认 Profile')
- this.db.transaction(tx => {
- tx.delete(profiles).where(eq(profiles.id, id)).run()
- tx.delete(profileVersions).where(eq(profileVersions.profileId, id)).run()
- })
- }
- }
- const knowledgeInputSchema = z.object({
- name: z.string().trim().min(1).max(60),
- path: z.string().trim().min(1),
- description: z.string().max(1000).default(''),
- access: z.enum(['read', 'write']).default('read'),
- enabled: z.boolean().default(true),
- })
- export type KnowledgeInput = z.input<typeof knowledgeInputSchema>
- export class KnowledgeService {
- constructor(private db: DB, private settings: SettingsService) {}
- list(): KnowledgeRootDto[] {
- return this.db.select().from(knowledgeRoots).orderBy(knowledgeRoots.name).all()
- .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()) }))
- }
- get(id: string): KnowledgeRootDto {
- const k = this.list().find(r => r.id === id)
- if (!k) throw notFound('资料库')
- return k
- }
- create(input: KnowledgeInput): KnowledgeRootDto {
- const data = this.validate(input)
- const id = newId('kb')
- this.db.insert(knowledgeRoots).values({ id, ...data }).run()
- return this.get(id)
- }
- update(id: string, input: KnowledgeInput): KnowledgeRootDto {
- this.get(id)
- const data = this.validate(input)
- this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run()
- return this.get(id)
- }
- delete(id: string) {
- this.get(id)
- this.db.delete(knowledgeRoots).where(eq(knowledgeRoots.id, id)).run()
- }
- private validate(input: KnowledgeInput) {
- const data = parse(knowledgeInputSchema, input)
- const check = checkKnowledgePath(data.path, this.settings.get())
- if (check.denied || !check.exists || !check.isDirectory || !check.readable) throw badRequest(check.message ?? '路径不可用', check)
- return data
- }
- }
- export const hashKey = (key: string) => crypto.createHash('sha256').update(key).digest('hex')
- export class ApiKeyService {
- private lastTouched = new Map<string, number>()
- constructor(private db: DB, private profiles: ProfileService) {}
- list(): ApiKeyDto[] {
- return this.db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)).all()
- .map(r => ({
- id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedProfileIds: r.allowedProfileIds,
- defaultProfileId: r.defaultProfileId, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt,
- }))
- }
- /** 返回明文 Key,只在创建时出现一次 */
- create(name: string, access: ApiKeyProfiles): { key: string; apiKey: ApiKeyDto } {
- if (!name.trim()) throw badRequest('名称不能为空')
- const checked = this.checkAccess(access)
- const key = `ask_${crypto.randomBytes(24).toString('base64url')}`
- const id = newId('key')
- this.db.insert(apiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 10), ...checked }).run()
- return { key, apiKey: this.list().find(k => k.id === id)! }
- }
- setProfiles(id: string, access: ApiKeyProfiles) {
- const checked = this.checkAccess(access)
- const r = this.db.update(apiKeys).set(checked).where(eq(apiKeys.id, id)).run()
- if (r.changes === 0) throw notFound('Agent Key')
- }
- /** 平台可用的 Profile 与默认 Profile */
- access(id: string): ApiKeyProfiles {
- const row = this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).get()
- if (!row) throw notFound('Agent Key')
- const allowed = row.allowedProfileIds
- return { allowedProfileIds: allowed, defaultProfileId: row.defaultProfileId ?? allowed[0] ?? '' }
- }
- /** 平台要使用某个 Profile(省略时用默认),无权使用时抛 403 */
- resolveProfile(id: string, requested?: string | null): ProfileDto {
- const acc = this.access(id)
- const target = requested || acc.defaultProfileId
- if (!target || !acc.allowedProfileIds.includes(target)) {
- throw new AppError(403, 'profile_not_allowed', requested ? '该平台无权使用这个 Profile' : '该平台没有可用的 Profile,请在后台为 Agent Key 配置')
- }
- return this.profiles.get(target)
- }
- /** 引用了某个 Profile 的 Key 名称(删除 Profile 前检查) */
- referencing(profileId: string): string[] {
- return this.list().filter(k => k.allowedProfileIds.includes(profileId)).map(k => k.name)
- }
- private checkAccess(access: ApiKeyProfiles) {
- const data = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), access)
- const allowed = [...new Set(data.allowedProfileIds)]
- if (!allowed.length) throw badRequest('至少选择一个 Profile')
- if (!data.defaultProfileId) throw badRequest('请选择默认 Profile')
- for (const pid of allowed) this.profiles.get(pid)
- if (!allowed.includes(data.defaultProfileId)) throw badRequest('默认 Profile 必须在可用的 Profile 中')
- return { allowedProfileIds: allowed, defaultProfileId: data.defaultProfileId }
- }
- setEnabled(id: string, enabled: boolean) {
- const r = this.db.update(apiKeys).set({ enabled }).where(eq(apiKeys.id, id)).run()
- if (r.changes === 0) throw notFound('Agent Key')
- }
- delete(id: string) {
- const r = this.db.delete(apiKeys).where(eq(apiKeys.id, id)).run()
- if (r.changes === 0) throw notFound('Agent Key')
- }
- verify(key: string): string | null {
- const row = this.db.select().from(apiKeys).where(and(eq(apiKeys.keyHash, hashKey(key)), eq(apiKeys.enabled, true))).get()
- if (!row) return null
- this.touch(row.id)
- return row.id
- }
- /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */
- isActive(id: string): boolean {
- const row = this.db.select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.id, id), eq(apiKeys.enabled, true))).get()
- if (row) this.touch(id)
- return !!row
- }
- /** 最近使用时间每分钟最多写一次 */
- private touch(id: string) {
- const now = Date.now()
- if ((this.lastTouched.get(id) ?? 0) < now - 60_000) {
- this.lastTouched.set(id, now)
- this.db.update(apiKeys).set({ lastUsedAt: nowIso() }).where(eq(apiKeys.id, id)).run()
- }
- }
- }
- export type AuditKind = typeof auditLogs.$inferInsert['kind']
- export class AuditService {
- constructor(private db: DB) {}
- log(entry: { actor: string; sessionId?: string | null; kind: AuditKind; summary: string; detail?: unknown }) {
- this.db.insert(auditLogs).values({ ...entry, sessionId: entry.sessionId ?? null, detail: entry.detail ?? null }).run()
- }
- list(opts: { kind?: AuditKind; sessionId?: string; limit?: number }) {
- const where = and(opts.kind ? eq(auditLogs.kind, opts.kind) : undefined, opts.sessionId ? eq(auditLogs.sessionId, opts.sessionId) : undefined)
- return this.db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.id)).limit(Math.min(opts.limit ?? 200, 1000)).all()
- }
- }
- export function parse<T>(schema: z.ZodType<T>, input: unknown): T {
- const r = schema.safeParse(input)
- if (!r.success) throw badRequest('参数校验失败', z.treeifyError(r.error))
- return r.data
- }
|