|
@@ -1,12 +1,12 @@
|
|
|
import path from 'node:path'
|
|
import path from 'node:path'
|
|
|
import { and, desc, eq, lt, or } from 'drizzle-orm'
|
|
import { and, desc, eq, lt, or } from 'drizzle-orm'
|
|
|
import { z } from 'zod'
|
|
import { z } from 'zod'
|
|
|
-import type { CreateSessionBody, Page, ProviderId, SessionDto } from '@agent-server/shared'
|
|
|
|
|
|
|
+import type { CreateSessionBody, Page, PermissionMode, ProfileDto, ProviderId, SessionDto } from '@agent-server/shared'
|
|
|
import type { DB } from '../db'
|
|
import type { DB } from '../db'
|
|
|
import { events, interactions, runs, sessions } from '../db/schema'
|
|
import { events, interactions, runs, sessions } from '../db/schema'
|
|
|
-import { badRequest, conflict, notFound } from '../lib/errors'
|
|
|
|
|
|
|
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
|
|
|
import { newId, nowIso } from '../lib/ids'
|
|
import { newId, nowIso } from '../lib/ids'
|
|
|
-import { parse, type ProfileService } from './catalog'
|
|
|
|
|
|
|
+import { parse, type ApiKeyService, type ProfileService } from './catalog'
|
|
|
import type { EventStore } from './event-store'
|
|
import type { EventStore } from './event-store'
|
|
|
import type { FileService } from './files'
|
|
import type { FileService } from './files'
|
|
|
import type { SettingsService } from './settings'
|
|
import type { SettingsService } from './settings'
|
|
@@ -27,7 +27,8 @@ const patchSchema = z.object({
|
|
|
archived: z.boolean().optional(),
|
|
archived: z.boolean().optional(),
|
|
|
// 以下只能在空闲时修改;引擎与 Profile 只能在还没有对话时修改
|
|
// 以下只能在空闲时修改;引擎与 Profile 只能在还没有对话时修改
|
|
|
model: z.string().min(1).optional(),
|
|
model: z.string().min(1).optional(),
|
|
|
- permissionMode: z.enum(['ask', 'auto', 'readonly']).optional(),
|
|
|
|
|
|
|
+ // null 表示恢复为跟随 Profile(仅后台)
|
|
|
|
|
+ permissionMode: z.enum(['ask', 'auto', 'readonly']).nullable().optional(),
|
|
|
systemPromptAppend: z.string().max(20000).optional(),
|
|
systemPromptAppend: z.string().max(20000).optional(),
|
|
|
provider: z.enum(['claude', 'codex']).optional(),
|
|
provider: z.enum(['claude', 'codex']).optional(),
|
|
|
profileId: z.string().optional(),
|
|
profileId: z.string().optional(),
|
|
@@ -41,32 +42,68 @@ export class SessionService {
|
|
|
private db: DB,
|
|
private db: DB,
|
|
|
private settings: SettingsService,
|
|
private settings: SettingsService,
|
|
|
private profiles: ProfileService,
|
|
private profiles: ProfileService,
|
|
|
|
|
+ private apiKeys: ApiKeyService,
|
|
|
private files: FileService,
|
|
private files: FileService,
|
|
|
private events: EventStore,
|
|
private events: EventStore,
|
|
|
) {}
|
|
) {}
|
|
|
|
|
|
|
|
async create(source: string, body: CreateSessionBody): Promise<SessionRow> {
|
|
async create(source: string, body: CreateSessionBody): Promise<SessionRow> {
|
|
|
const data = parse(createSchema, body)
|
|
const data = parse(createSchema, body)
|
|
|
- const profile = data.profileId ? this.profiles.get(data.profileId) : this.profiles.getDefault()
|
|
|
|
|
|
|
+ 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
|
|
const provider: ProviderId = data.provider ?? profile.provider
|
|
|
const s = this.settings.get()
|
|
const s = this.settings.get()
|
|
|
const providerSettings = s.providers[provider]
|
|
const providerSettings = s.providers[provider]
|
|
|
if (!providerSettings.enabled) throw badRequest(`引擎 ${provider} 未启用`)
|
|
if (!providerSettings.enabled) throw badRequest(`引擎 ${provider} 未启用`)
|
|
|
const model = data.model ?? (provider === profile.provider ? profile.model : providerSettings.defaultModel)
|
|
const model = data.model ?? (provider === profile.provider ? profile.model : providerSettings.defaultModel)
|
|
|
- if (!model) throw badRequest('未指定模型')
|
|
|
|
|
|
|
+ this.checkModel(provider, model, profile)
|
|
|
|
|
|
|
|
const id = newId('ses')
|
|
const id = newId('ses')
|
|
|
const workspaceDir = path.join(s.paths.workspaceRoot, id)
|
|
const workspaceDir = path.join(s.paths.workspaceRoot, id)
|
|
|
await this.files.createWorkspace(workspaceDir)
|
|
await this.files.createWorkspace(workspaceDir)
|
|
|
this.db.insert(sessions).values({
|
|
this.db.insert(sessions).values({
|
|
|
id, source, profileId: profile.id, provider, model, workspaceDir,
|
|
id, source, profileId: profile.id, provider, model, workspaceDir,
|
|
|
- permissionMode: data.permissionMode ?? profile.permissionMode,
|
|
|
|
|
|
|
+ // 为空表示跟随 Profile;只有后台可以为单个会话覆盖
|
|
|
|
|
+ permissionMode: isAdmin ? data.permissionMode ?? null : null,
|
|
|
systemPromptAppend: data.systemPromptAppend ?? '',
|
|
systemPromptAppend: data.systemPromptAppend ?? '',
|
|
|
title: data.title ?? '',
|
|
title: data.title ?? '',
|
|
|
}).run()
|
|
}).run()
|
|
|
return this.getRow(id)
|
|
return this.getRow(id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 会话来源可以使用的 Profile:后台不受限;平台只能用 API Key 上配置的 Profile */
|
|
|
|
|
+ profileFor(source: string, requested?: string | null): ProfileDto {
|
|
|
|
|
+ if (source === 'admin') return requested ? this.profiles.get(requested) : this.profiles.getDefault()
|
|
|
|
|
+ return this.apiKeys.resolveProfile(source, requested)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 会话当前的 Profile(已被删除时回落到默认)与生效的权限模式 */
|
|
|
|
|
+ effectiveProfile(row: SessionRow): { profile: ProfileDto; permissionMode: PermissionMode; overridden: boolean } {
|
|
|
|
|
+ let profile: ProfileDto
|
|
|
|
|
+ try {
|
|
|
|
|
+ profile = row.profileId ? this.profiles.get(row.profileId) : this.profiles.getDefault()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ profile = this.profiles.getDefault()
|
|
|
|
|
+ }
|
|
|
|
|
+ return { profile, permissionMode: row.permissionMode ?? profile.permissionMode, overridden: row.permissionMode !== null }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 平台的会话在每次运行前检查 Profile 是否仍对它开放 */
|
|
|
|
|
+ assertProfileAllowed(row: SessionRow) {
|
|
|
|
|
+ if (row.source === 'admin') return
|
|
|
|
|
+ const acc = this.apiKeys.access(row.source)
|
|
|
|
|
+ if (!row.profileId || !acc.allowedProfileIds.includes(row.profileId)) {
|
|
|
|
|
+ throw new AppError(403, 'profile_not_allowed', '该会话使用的 Profile 已不再对此平台开放,请新建会话')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private checkModel(provider: ProviderId, model: string, profile: ProfileDto) {
|
|
|
|
|
+ const allowed = new Set([...this.settings.get().providers[provider].models, ...(profile.provider === provider ? [profile.model] : [])])
|
|
|
|
|
+ if (!allowed.has(model)) throw badRequest(`不可用的模型:${model}(可选:${[...allowed].join('、')})`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
getRow(id: string, source?: string): SessionRow {
|
|
getRow(id: string, source?: string): SessionRow {
|
|
|
const row = this.db.select().from(sessions).where(eq(sessions.id, id)).get()
|
|
const row = this.db.select().from(sessions).where(eq(sessions.id, id)).get()
|
|
|
// 非后台调用只能访问自己创建的会话
|
|
// 非后台调用只能访问自己创建的会话
|
|
@@ -108,15 +145,21 @@ export class SessionService {
|
|
|
patch(id: string, source: string, body: unknown): SessionRow {
|
|
patch(id: string, source: string, body: unknown): SessionRow {
|
|
|
const row = this.getRow(id, source)
|
|
const row = this.getRow(id, source)
|
|
|
const data = parse(patchSchema, 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
|
|
const changesRunConfig = data.model !== undefined || data.permissionMode !== undefined || data.systemPromptAppend !== undefined
|
|
|
|| data.provider !== undefined || data.profileId !== undefined
|
|
|| data.provider !== undefined || data.profileId !== undefined
|
|
|
if (changesRunConfig && this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请等待完成后再修改')
|
|
if (changesRunConfig && this.statusOf(id) !== 'idle') throw conflict('session_busy', '会话正在运行,请等待完成后再修改')
|
|
|
- if ((data.provider && data.provider !== row.provider) || (data.profileId && data.profileId !== row.profileId)) {
|
|
|
|
|
- if (row.providerSessionId) throw badRequest('会话已经开始对话,不能再更换引擎或 Profile')
|
|
|
|
|
- }
|
|
|
|
|
- if (data.profileId) this.profiles.get(data.profileId)
|
|
|
|
|
- if (data.provider && !this.settings.get().providers[data.provider].enabled) throw badRequest(`引擎 ${data.provider} 未启用`)
|
|
|
|
|
- this.db.update(sessions).set({ ...data, updatedAt: nowIso() }).where(eq(sessions.id, id)).run()
|
|
|
|
|
|
|
+ const switching = (data.provider && data.provider !== row.provider) || (data.profileId && data.profileId !== row.profileId)
|
|
|
|
|
+ if (switching && 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)
|
|
|
|
|
+ if (!this.settings.get().providers[provider].enabled) throw badRequest(`引擎 ${provider} 未启用`)
|
|
|
|
|
+ // 更换 Profile 且没有指定模型时,改用新 Profile 的默认模型
|
|
|
|
|
+ const model = data.model ?? (data.profileId ? profile.model : row.model)
|
|
|
|
|
+ if (data.model !== undefined || data.profileId) this.checkModel(provider, model, profile)
|
|
|
|
|
+
|
|
|
|
|
+ this.db.update(sessions).set({ ...data, provider, model, updatedAt: nowIso() }).where(eq(sessions.id, id)).run()
|
|
|
return this.getRow(id)
|
|
return this.getRow(id)
|
|
|
}
|
|
}
|
|
|
|
|
|