Răsfoiți Sursa

feat: 引擎由 Profile 决定,通用助手提供 Claude 与 Codex 两个标准版本

- 平台与后台都不能单独指定引擎:创建或修改会话时传 provider 返回 400;
  会话引擎取自 Profile,更换 Profile(尚未开始对话时)引擎随之改变
- 内置 Profile 每个引擎一个:"通用助手(Claude)"(默认,sonnet)与
  "通用助手(Codex)"(gpt-6.1-sol),不可删除、引擎不可修改;
  旧版"通用助手"启动时自动改名为 Claude 版本
- 设置中默认模型为空时,内置 Profile 回落到内置默认模型
  (修复:部署时因此无法启动的问题)
- 后台 Profiles 页锁定内置 Profile 的引擎选项
- 文档:ADR-0015、04-api 补充"引擎由 Profile 决定"

测试:51 个通过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 săptămână în urmă
părinte
comite
39d2f7e1e5

+ 1 - 1
apps/admin/src/views/ProfilesView.vue

@@ -139,7 +139,7 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
             <div><label class="label" for="p-desc">说明</label><input id="p-desc" v-model="draft.description" class="field"></div>
             <div>
               <label class="label" for="p-provider">引擎</label>
-              <select id="p-provider" v-model="draft.provider" class="field">
+              <select id="p-provider" v-model="draft.provider" class="field" :disabled="!!current?.builtin" :title="current?.builtin ? '内置 Profile 的引擎不能修改' : ''">
                 <option v-for="p in providers" :key="p.id" :value="p.id" :disabled="!p.enabled">{{ p.id }}{{ p.enabled ? '' : '(未启用)' }}</option>
               </select>
             </div>

+ 36 - 19
apps/server/src/services/catalog.ts

@@ -2,7 +2,7 @@
 import crypto from 'node:crypto'
 import { and, desc, eq, ne } from 'drizzle-orm'
 import { z } from 'zod'
-import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput } from '@agent-server/shared'
+import type { ApiKeyDto, ApiKeyProfiles, FileAccessPolicy, KnowledgeRootDto, ProfileDto, ProfileInput, ProviderId } from '@agent-server/shared'
 import type { DB } from '../db'
 import { apiKeys, auditLogs, knowledgeRoots, profiles } from '../db/schema'
 import { AppError, badRequest, notFound } from '../lib/errors'
@@ -41,26 +41,42 @@ const toProfileDto = (r: ProfileRow): ProfileDto => ({
   fileAccess: r.fileAccess, knowledgeRootIds: r.knowledgeRootIds, isDefault: r.isDefault, builtin: r.builtin, version: r.version, updatedAt: r.updatedAt,
 })
 
+/** 内置的标准 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) {
-    if (!db.select({ id: profiles.id }).from(profiles).limit(1).get()) this.seed()
+    this.ensureBuiltins()
   }
 
-  private seed() {
-    this.create({
-      name: '通用助手',
-      description: '数据分析、资料查找与文档生成',
-      provider: 'claude',
-      model: this.settings.get().providers.claude.defaultModel,
-      systemPrompt: '',
-      permissionMode: 'ask',
-      allowedTools: [],
-      disallowedTools: [],
-      skills: 'all',
-      fileAccess: { mode: this.settings.get().security.defaultFileAccessMode, read: [], write: [], deny: [] },
-      knowledgeRootIds: [],
-      isDefault: true,
-    }, true)
+  /** 补齐每个引擎的内置 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))).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[] {
@@ -94,6 +110,7 @@ export class ProfileService {
     const prev = this.get(id)
     const data = parse(profileInputSchema, input)
     if (prev.isDefault && !data.isDefault) throw badRequest('请先把另一个 Profile 设为默认')
+    if (prev.builtin && data.provider !== prev.provider) throw badRequest('内置 Profile 的引擎不能修改')
     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: prev.version + 1, updatedAt: nowIso() }).where(eq(profiles.id, id)).run()
@@ -101,8 +118,8 @@ export class ProfileService {
     return this.get(id)
   }
 
-  getBuiltin(): ProfileDto {
-    const row = this.db.select().from(profiles).where(eq(profiles.builtin, true)).get()
+  getBuiltin(provider: ProviderId = 'claude'): ProfileDto {
+    const row = this.db.select().from(profiles).where(and(eq(profiles.builtin, true), eq(profiles.provider, provider))).get()
     return row ? toProfileDto(row) : this.getDefault()
   }
 

+ 14 - 10
apps/server/src/services/sessions.ts

@@ -15,7 +15,6 @@ export type SessionRow = typeof sessions.$inferSelect
 
 const createSchema: z.ZodType<CreateSessionBody> = z.object({
   profileId: z.string().optional(),
-  provider: z.enum(['claude', 'codex']).optional(),
   model: z.string().min(1).optional(),
   title: z.string().max(200).optional(),
   permissionMode: z.enum(['ask', 'auto', 'readonly']).optional(),
@@ -30,7 +29,6 @@ const patchSchema = z.object({
   // null 表示恢复为跟随 Profile(仅后台)
   permissionMode: z.enum(['ask', 'auto', 'readonly']).nullable().optional(),
   systemPromptAppend: z.string().max(20000).optional(),
-  provider: z.enum(['claude', 'codex']).optional(),
   profileId: z.string().optional(),
 })
 
@@ -50,16 +48,17 @@ export class SessionService {
   ) {}
 
   async create(source: string, body: CreateSessionBody): Promise<SessionRow> {
+    rejectProvider(body)
     const data = parse(createSchema, body)
     const isAdmin = source === 'admin'
     // 权限模式由 Profile 决定,平台不能指定(ADR-0015)
     if (!isAdmin && data.permissionMode !== undefined) throw badRequest('权限模式由 Profile 决定,不能在创建会话时指定')
     const profile = this.profileFor(source, data.profileId)
-    const provider: ProviderId = data.provider ?? profile.provider
+    // 引擎由 Profile 决定(ADR-0015)
+    const provider: ProviderId = profile.provider
     const s = this.settings.get()
-    const providerSettings = s.providers[provider]
-    if (!providerSettings.enabled) throw badRequest(`引擎 ${provider} 未启用`)
-    const model = data.model ?? (provider === profile.provider ? profile.model : providerSettings.defaultModel)
+    if (!s.providers[provider].enabled) throw badRequest(`引擎 ${provider} 未启用`)
+    const model = data.model ?? profile.model
     this.checkModel(provider, model, profile)
 
     const id = newId('ses')
@@ -146,16 +145,16 @@ export class SessionService {
 
   patch(id: string, source: string, body: unknown): SessionRow {
     const row = this.getRow(id, source)
+    rejectProvider(body)
     const data = parse(patchSchema, body)
     if (source !== 'admin' && data.permissionMode !== undefined) throw badRequest('权限模式由 Profile 决定,不能修改')
     const changesRunConfig = data.model !== undefined || data.permissionMode !== undefined || data.systemPromptAppend !== undefined
-      || data.provider !== undefined || data.profileId !== undefined
+      || data.profileId !== undefined
     if (changesRunConfig && this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请等待完成后再修改')
-    const switching = (data.provider && data.provider !== row.provider) || (data.profileId && data.profileId !== row.profileId)
-    if (switching && row.providerSessionId) throw badRequest('会话已经开始对话,不能再更换引擎或 Profile')
+    if (data.profileId && data.profileId !== row.profileId && row.providerSessionId) throw badRequest('会话已经开始对话,不能再更换 Profile')
 
     const profile = data.profileId ? this.profileFor(source, data.profileId) : this.effectiveProfile(row).profile
-    const provider = data.provider ?? (data.profileId ? profile.provider : row.provider)
+    const provider = data.profileId ? profile.provider : row.provider
     if (!this.settings.get().providers[provider].enabled) throw badRequest(`引擎 ${provider} 未启用`)
     // 更换 Profile 且没有指定模型时,改用新 Profile 的默认模型
     const model = data.model ?? (data.profileId ? profile.model : row.model)
@@ -191,3 +190,8 @@ export class SessionService {
     await this.files.removeWorkspace(row.workspaceDir, this.settings.get().paths.workspaceRoot)
   }
 }
+
+/** 引擎由 Profile 决定,不能单独指定(明确报错,避免被静默忽略) */
+function rejectProvider(body: unknown) {
+  if (body && typeof body === 'object' && 'provider' in body) throw badRequest('引擎由 Profile 决定,不能单独指定;请选择对应引擎的 Profile')
+}

+ 1 - 1
apps/server/src/services/settings.ts

@@ -41,7 +41,7 @@ export function defaultSettings(): AppSettings {
       codex: {
         enabled: false,
         cliPath: bundledCodexPath() ?? path.join(config.home, '.local/bin/codex'),
-        defaultModel: '',
+        defaultModel: 'gpt-6.1-sol',
         models: [],
         configOverrides: [],
       },

+ 52 - 0
apps/server/test/api.test.ts

@@ -244,3 +244,55 @@ describe('API Key 可用的 Profile', () => {
     expect(del.json().error.message).toContain('平台D')
   })
 })
+
+describe('引擎由 Profile 决定', () => {
+  it('每个引擎一个内置通用助手:不可删除,引擎不可修改', () => {
+    const claude = app.profiles.getBuiltin('claude')
+    const codex = app.profiles.getBuiltin('codex')
+    expect(claude).toMatchObject({ name: '通用助手(Claude)', provider: 'claude', builtin: true })
+    expect(codex).toMatchObject({ name: '通用助手(Codex)', provider: 'codex', builtin: true })
+    expect(() => app.profiles.delete(codex.id)).toThrow(/内置/)
+    const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = codex
+    expect(() => app.profiles.update(codex.id, { ...input, provider: 'claude' })).toThrow(/引擎不能修改/)
+  })
+
+  it('平台和后台都不能单独指定引擎;会话引擎取自 Profile', async () => {
+    const claude = app.profiles.getBuiltin('claude')
+    const codex = app.profiles.getBuiltin('codex')
+    const { key } = app.apiKeys.create('双引擎平台', { allowedProfileIds: [claude.id, codex.id], defaultProfileId: claude.id })
+    const h = { authorization: `Bearer ${key}` }
+
+    const bad = await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { provider: 'codex' } })
+    expect(bad.statusCode).toBe(400)
+    expect(bad.json().error.message).toContain('引擎由 Profile 决定')
+    await expect(app.sessions.create('admin', { provider: 'codex' } as never)).rejects.toThrow(/引擎由 Profile 决定/)
+
+    // Codex 未启用时不能建会话;启用后按 Profile 使用 codex
+    expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: codex.id } })).statusCode).toBe(400)
+    app.settings.update({ providers: { codex: { enabled: true } } })
+    const s = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: codex.id } })).json()
+    expect(s.provider).toBe('codex')
+    expect(s.model).toBe(codex.model)
+
+    // 换 Profile 时引擎随之改变(尚未开始对话)
+    const patched = (await server.inject({ method: 'PATCH', url: `/v1/sessions/${s.id}`, headers: h, payload: { profileId: claude.id } })).json()
+    expect(patched.provider).toBe('claude')
+    expect((await server.inject({ method: 'PATCH', url: `/v1/sessions/${s.id}`, headers: h, payload: { provider: 'codex' } })).statusCode).toBe(400)
+  })
+})
+
+describe('内置 Profile 的兜底', () => {
+  it('设置中的默认模型为空时,仍能创建内置 Profile', async () => {
+    const { createDb } = await import('../src/db')
+    const { SettingsService } = await import('../src/services/settings')
+    const { ProfileService } = await import('../src/services/catalog')
+    const os = await import('node:os')
+    const db = createDb(path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'seed-')), 'db.sqlite'))
+    const { migrateDb } = await import('../src/db')
+    migrateDb(db)
+    const settings = new SettingsService(db)
+    settings.update({ providers: { codex: { defaultModel: '' } } })
+    const profiles = new ProfileService(db, settings)
+    expect(profiles.getBuiltin('codex').model).toBe('gpt-6.1-sol')
+  })
+})

+ 1 - 0
docs/adr/0015-api-key-profiles.md

@@ -6,6 +6,7 @@
 - 每个 API Key 配置"可用的 Profile"(至少一个)和一个"默认 Profile"(必须在可用列表中),没有其他配置项
 - `GET /v1/profiles` 只返回该 Key 可用的 Profile,`isDefault` 表示该平台的默认 Profile
 - 创建会话:不指定 Profile 时用该 Key 的默认 Profile;指定了不可用的 Profile 返回 `403 profile_not_allowed`
+- **引擎**:完全由 Profile 决定,平台和后台都不能单独指定(传 `provider` 返回 400);更换 Profile(尚未开始对话时)引擎随之改变。每个引擎有一个内置的标准 Profile:"通用助手(Claude)"与"通用助手(Codex)",不可删除、引擎不可修改(2026-10-23 补充)
 - **模型**:平台可以为会话自选模型(创建或修改会话时),范围是设置中该引擎的可选模型加上 Profile 的模型;Profile 只决定默认模型
 - **权限模式**:完全由 Profile 决定。平台创建或修改会话时传 `permissionMode` 返回 400;会话在每次运行时读取 Profile 的当前设置,修改 Profile 立即生效。只有后台管理员可以为单个会话临时覆盖(Playground 中可以选回"跟随 Profile")
 - 每次发送消息前检查会话的 Profile 是否仍对该 Key 开放,撤销后返回 403,需要新建会话

+ 1 - 1
docs/design/04-api.md

@@ -12,7 +12,7 @@
 ### 会话
 | 方法 | 路径 | 说明 |
 |---|---|---|
-| POST | `/v1/sessions` | 创建:`{ profileId?, model?, title?, systemPromptAppend? }`。Profile 必须是该 API Key 可用的(省略时用其默认 Profile,否则 403 `profile_not_allowed`);模型可自选(限可选模型);**不能指定权限模式**,由 Profile 决定(ADR-0015) |
+| POST | `/v1/sessions` | 创建:`{ profileId?, model?, title?, systemPromptAppend? }`。Profile 必须是该 API Key 可用的(省略时用其默认 Profile,否则 403 `profile_not_allowed`);模型可自选(限可选模型);**不能指定引擎和权限模式**,都由 Profile 决定(ADR-0015) |
 | GET | `/v1/sessions?cursor=&limit=` | 当前 API Key 的会话列表(`?scope=all` 可列出所有 Key 的会话,需要 Key 具备该权限) |
 | GET | `/v1/sessions/:id` | 详情:元数据 + 消息摘要 + 未决交互 |
 | PATCH | `/v1/sessions/:id` | 改标题、归档 |

+ 1 - 1
packages/client/src/index.ts

@@ -69,7 +69,7 @@ export class AgentClient {
       this.request<Page<SessionDto>>('GET', `/v1/sessions${query(q)}`),
     get: (id: string) => this.request<SessionDetailDto>('GET', `/v1/sessions/${id}`),
     /** permissionMode 只有后台可以修改(null 表示恢复为跟随 Profile) */
-    update: (id: string, body: Partial<Pick<SessionDetailDto, 'title' | 'archived' | 'model' | 'systemPromptAppend' | 'provider' | 'profileId'>> & { permissionMode?: PermissionMode | null }) => this.request<SessionDto>('PATCH', `/v1/sessions/${id}`, body),
+    update: (id: string, body: Partial<Pick<SessionDetailDto, 'title' | 'archived' | 'model' | 'systemPromptAppend' | 'profileId'>> & { permissionMode?: PermissionMode | null }) => this.request<SessionDto>('PATCH', `/v1/sessions/${id}`, body),
     remove: (id: string) => this.request<void>('DELETE', `/v1/sessions/${id}`),
     events: (id: string, after = 0) => this.request<{ items: AgentEvent[]; lastSeq: number }>('GET', `/v1/sessions/${id}/events?after=${after}`),
     send: (id: string, body: SendMessageBody) => this.request<RunDto>('POST', `/v1/sessions/${id}/messages`, body),

+ 0 - 1
packages/shared/src/api.ts

@@ -31,7 +31,6 @@ export interface SessionDetailDto extends SessionDto {
 
 export interface CreateSessionBody {
   profileId?: string
-  provider?: ProviderId
   model?: string
   title?: string
   permissionMode?: PermissionMode