Преглед изворни кода

支持查看和保存默认全局模型

LSamien пре 2 месеци
родитељ
комит
bc1e6105d0

+ 27 - 3
src/client/api/agentManagement.ts

@@ -46,6 +46,7 @@ export interface ModelCatalogProvider {
   name: string
   models: string[]
   isCurrent: boolean
+  currentModel?: string
 }
 
 export interface LegacyModelService extends CustomModelService {
@@ -74,21 +75,44 @@ export function listModelServices(profile: string): Promise<CustomModelServicesR
 }
 
 export async function listModelCatalog(profile: string): Promise<ModelCatalogProvider[]> {
-  const payload = await apiRequest<{ providers?: unknown[] }>(`/api/app/models${profileQuery(profile)}`)
+  const payload = await apiRequest<{ provider?: unknown; model?: unknown; providers?: unknown[] }>(`/api/app/models${profileQuery(profile)}`)
+  const currentProvider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
+  const currentModel = typeof payload.model === 'string' ? payload.model.trim() : ''
   return (payload.providers || []).flatMap(raw => {
     if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
     const provider = raw as Record<string, unknown>
     const slug = typeof provider.slug === 'string' ? provider.slug.trim() : ''
     if (!slug) return []
+    const isCurrent = provider.is_current === true
+      || slug.toLocaleLowerCase() === currentProvider.toLocaleLowerCase()
+    const models = Array.isArray(provider.models) ? provider.models.flatMap(rawModel => {
+      if (typeof rawModel === 'string') return rawModel.trim() ? [rawModel.trim()] : []
+      if (!rawModel || typeof rawModel !== 'object' || Array.isArray(rawModel)) return []
+      const item = rawModel as Record<string, unknown>
+      const id = typeof item.id === 'string' ? item.id.trim()
+        : typeof item.model === 'string' ? item.model.trim() : ''
+      return id ? [id] : []
+    }) : []
     return [{
       slug,
       name: typeof provider.name === 'string' && provider.name.trim() ? provider.name.trim() : slug,
-      models: Array.isArray(provider.models) ? provider.models.map(String).map(item => item.trim()).filter(Boolean) : [],
-      isCurrent: provider.is_current === true,
+      models,
+      isCurrent,
+      ...(isCurrent && currentModel ? { currentModel } : {}),
     }]
   })
 }
 
+export function saveProfileDefaultModel(
+  profile: string,
+  provider: string,
+  model: string,
+): Promise<{ ok: boolean; provider: string; model: string }> {
+  return apiRequest(`/api/app/admin/profiles/${encodeURIComponent(profile)}/model`, {
+    method: 'PUT', body: { provider, model },
+  })
+}
+
 export async function listLegacyModelServices(profile: string): Promise<LegacyModelService[]> {
   const response = await apiRequest<{ items?: LegacyModelService[] }>(`/api/app/admin/legacy-model-services${profileQuery(profile)}`)
   return response.items || []

+ 25 - 0
src/client/components/app/AgentIdentityPanel.vue

@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import { listModelCatalog } from '@/api/agentManagement'
 
 const props = withDefaults(defineProps<{
   profile: Profile
@@ -11,12 +12,14 @@ const props = withDefaults(defineProps<{
   formId?: string
   showActions?: boolean
   resetVersion?: number
+  defaultModelLabel?: string
 }>(), {
   busy: false,
   error: '',
   formId: undefined,
   showActions: true,
   resetVersion: 0,
+  defaultModelLabel: '',
 })
 const emit = defineEmits<{
   save: [input: { title: string; avatarDataURL?: string | null }]
@@ -29,7 +32,9 @@ const avatarTouched = ref(false)
 const localError = ref('')
 const fileInput = ref<HTMLInputElement>()
 const baseline = ref<{ title: string; avatarDataURL: string | null }>({ title: '', avatarDataURL: null })
+const resolvedDefaultModelLabel = ref('')
 let profileGeneration = 0
+let modelLoadGeneration = 0
 
 function resetFromProfile() {
   profileGeneration += 1
@@ -44,6 +49,19 @@ function resetFromProfile() {
 }
 
 watch([() => props.profile.name, () => props.resetVersion], resetFromProfile, { immediate: true })
+watch([() => props.profile.name, () => props.defaultModelLabel], async () => {
+  const generation = ++modelLoadGeneration
+  const fallback = props.defaultModelLabel
+    || [props.profile.provider, props.profile.model].filter(Boolean).join(' / ')
+  resolvedDefaultModelLabel.value = fallback
+  try {
+    const current = (await listModelCatalog(props.profile.name))
+      .find(provider => provider.isCurrent && provider.currentModel)
+    if (generation === modelLoadGeneration && current?.currentModel) {
+      resolvedDefaultModelLabel.value = `${current.slug} / ${current.currentModel}`
+    }
+  } catch { /* Keep the Profile payload as a read-only fallback. */ }
+}, { immediate: true })
 
 const dirty = computed(() => title.value !== baseline.value.title || avatarDataURL.value !== baseline.value.avatarDataURL)
 watch(dirty, value => emit('dirty-change', value), { immediate: true })
@@ -118,6 +136,11 @@ function submit() {
       <input ref="fileInput" class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" @change="chooseAvatar" />
     </div>
     <label>名称<input v-model="title" :disabled="busy" maxlength="100" autocomplete="off" /></label>
+    <div class="identity-default-model">
+      <strong>默认全局模型</strong>
+      <span>{{ resolvedDefaultModelLabel || '服务器未返回默认模型' }}</span>
+      <small>仅影响之后新建的会话;已有会话保留自己的模型。</small>
+    </div>
     <p v-if="localError || error" class="identity-error" role="alert">{{ localError || error }}</p>
     <footer v-if="showActions" class="identity-actions">
       <slot name="actions">
@@ -130,4 +153,6 @@ function submit() {
 <style scoped>
 .identity-form{display:grid;max-width:720px}.identity-avatar{display:flex;align-items:flex-start;gap:24px;margin-bottom:32px;padding:0;background:transparent}.identity-avatar>div{display:grid;gap:7px;padding-top:4px}.identity-avatar strong{font-size:16px}.identity-avatar small{max-width:480px;color:var(--text-muted);font-size:13px;line-height:1.55}.identity-avatar p{display:flex;gap:8px;margin:6px 0 0}.identity-form label{display:grid;max-width:480px;gap:8px;color:var(--text-secondary);font-size:14px;font-weight:650}.identity-form input:not(.sr-only){width:100%;min-height:46px;box-sizing:border-box;padding:0 13px;border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface-raised);color:var(--text-primary);font:14px var(--font-ui);font-weight:400}.identity-form input:focus{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}.quiet-button,.primary-button,.identity-actions :slotted(.quiet-button),.identity-actions :slotted(.primary-button){display:inline-flex;min-height:40px;align-items:center;justify-content:center;gap:7px;padding:0 14px;border:0;border-radius:9px;cursor:pointer;font-size:13px;font-weight:600}.quiet-button,.identity-actions :slotted(.quiet-button){border:1px solid var(--line);background:var(--surface-raised);color:var(--text-secondary)}.primary-button,.identity-actions :slotted(.primary-button){min-width:118px;background:var(--accent);color:var(--text-on-solid)}.quiet-button:disabled,.primary-button:disabled,.identity-actions :slotted(.quiet-button:disabled),.identity-actions :slotted(.primary-button:disabled){cursor:wait;opacity:.5}.identity-error{margin:14px 0 0;color:var(--danger);font-size:13px}.identity-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:30px;padding-top:18px;border-top:1px solid var(--line)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
 @media(max-width:600px){.identity-avatar{align-items:flex-start;flex-direction:column}.identity-form label{max-width:none}}
+.identity-default-model{display:grid;max-width:480px;gap:5px;margin-top:20px;padding:13px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft)}
+.identity-default-model strong{color:var(--text-secondary);font-size:13px}.identity-default-model span{font:12px var(--font-code);overflow-wrap:anywhere}.identity-default-model small{color:var(--text-muted);font-size:11px}
 </style>

Разлика између датотеке није приказан због своје велике величине
+ 58 - 7
src/client/components/app/ModelServicesPanel.vue


+ 16 - 0
src/server/routes.ts

@@ -1588,6 +1588,22 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       search: searchFrom(ctx, ['profile']),
     })
   })
+  router.put('/api/app/admin/profiles/:profileName/model', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const profileName = safeIdentifier(ctx.params.profileName, 'profile name')
+    const request = body(ctx)
+    const provider = typeof request.provider === 'string' ? request.provider.trim() : ''
+    const model = typeof request.model === 'string' ? request.model.trim() : ''
+    if (!provider || !model) {
+      throw new HttpError(400, 'Provider 和模型不能为空', 'invalid_profile_model')
+    }
+    await proxyAdminFeature(
+      ctx,
+      dependencies,
+      `/api/profiles/${encodeURIComponent(profileName)}/model`,
+      { method: 'PUT', requestBody: { provider, model } },
+    )
+  })
   router.post('/api/app/admin/model-services', async (ctx) => {
     await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
       method: 'POST', search: searchFrom(ctx, ['profile']), requestBody: body(ctx),

+ 5 - 1
tests/client/agentManagement.test.ts

@@ -9,6 +9,7 @@ import {
   saveDuplexVoiceSettings,
   saveLegacyModelService,
   saveModelService,
+  saveProfileDefaultModel,
   validateModelService,
 } from '@/api/agentManagement'
 import { setApiCsrfToken } from '@/api/client'
@@ -33,6 +34,7 @@ describe('Agent management client protocol', () => {
     await listLegacyModelServices('worker / 中文')
     await saveModelService('worker / 中文', input)
     await saveLegacyModelService('worker / 中文', 'custom:tingly', input)
+    await saveProfileDefaultModel('worker / 中文', 'custom:tingly', 'omni')
     await validateModelService(input)
     await activateModelService('worker / 中文', 'local/model')
     await deleteModelService('worker / 中文', 'local/model')
@@ -43,13 +45,15 @@ describe('Agent management client protocol', () => {
       '/api/app/admin/legacy-model-services?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
       '/api/app/admin/model-services?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
       '/api/app/admin/legacy-model-services/custom%3Atingly?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
+      '/api/app/admin/profiles/worker%20%2F%20%E4%B8%AD%E6%96%87/model',
       '/api/app/admin/model-services/validate',
       '/api/app/admin/model-services/local%2Fmodel/activate?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
       '/api/app/admin/model-services/local%2Fmodel?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
     ])
     expect(JSON.parse(String(calls[3]!.init?.body))).not.toHaveProperty('api_key')
+    expect(JSON.parse(String(calls[5]!.init?.body))).toEqual({ provider: 'custom:tingly', model: 'omni' })
     expect(new Headers(calls[3]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-agent-management')
-    expect(calls[7]!.init?.method).toBe('DELETE')
+    expect(calls[8]!.init?.method).toBe('DELETE')
   })
 
   it('never includes an unchanged duplex API key', async () => {

+ 19 - 2
tests/client/agentManagementDialog.test.ts

@@ -4,17 +4,18 @@ import { nextTick } from 'vue'
 import AgentIdentityDialog from '@/components/app/AgentIdentityDialog.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
 import ModelServicesPanel from '@/components/app/ModelServicesPanel.vue'
-import { deleteModelService, listModelServices, saveDuplexVoiceSettings, saveLegacyModelService, saveModelService, validateModelService } from '@/api/agentManagement'
+import { deleteModelService, listModelServices, saveDuplexVoiceSettings, saveLegacyModelService, saveModelService, saveProfileDefaultModel, validateModelService } from '@/api/agentManagement'
 
 vi.mock('@/api/agentManagement', () => ({
   listModelServices: vi.fn(async () => ({ endpoints: [], current: {} })),
   listLegacyModelServices: vi.fn(async () => [{ id: 'custom:tingly', name: 'tingly', base_url: 'http://tingly.test/v1', model: 'omni', models: ['omni'], discover_models: true, has_api_key: true, can_edit_api_key: true, is_current: true, source: 'legacy' }]),
   listModelCatalog: vi.fn(async () => [
     { slug: 'opencode-free', name: 'OpenCode Free', models: ['free-a'], isCurrent: false },
-    { slug: 'custom:tingly', name: 'tingly', models: ['omni'], isCurrent: true },
+    { slug: 'custom:tingly', name: 'tingly', models: ['omni', 'omni-2'], isCurrent: true, currentModel: 'omni' },
   ]),
   saveLegacyModelService: vi.fn(async () => undefined),
   saveModelService: vi.fn(async () => ({ endpoints: [], current: {} })),
+  saveProfileDefaultModel: vi.fn(async (_profile, provider, model) => ({ ok: true, provider, model })),
   validateModelService: vi.fn(async () => ({ ok: true, reachable: true, message: '', models: ['model-a', 'model-b'] })),
   activateModelService: vi.fn(async () => undefined),
   deleteModelService: vi.fn(async () => undefined),
@@ -32,6 +33,22 @@ describe('Agent management dialog', () => {
     expect(document.querySelector('[aria-label="Agent 管理"]')?.textContent).toContain('Bots 头像')
     expect(document.querySelector('.management-tabs')).toBeNull()
     expect(document.body.textContent).not.toContain('模型服务')
+    expect(document.body.textContent).toContain('custom:tingly / omni')
+    wrapper.unmount()
+  })
+
+  it('sets any catalog model as the Agent default without switching a session', async () => {
+    const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
+    await nextTick()
+    document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[1]!.click(); await flushPromises()
+    const picker = document.querySelector<HTMLSelectElement>('select[aria-label="默认全局模型"]')!
+    picker.value = JSON.stringify(['opencode-free', 'free-a'])
+    picker.dispatchEvent(new Event('change'))
+    await nextTick()
+    document.querySelector<HTMLButtonElement>('.default-model-card button')!.click()
+    await flushPromises()
+    expect(saveProfileDefaultModel).toHaveBeenCalledWith('default', 'opencode-free', 'free-a')
+    expect(document.body.textContent).toContain('仅影响新会话')
     wrapper.unmount()
   })
 

+ 25 - 1
tests/server/agentManagementRoutes.test.ts

@@ -5,7 +5,7 @@ import request from 'supertest'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
-import { createAuthenticatedApplication } from './authenticatedApplication.js'
+import { createAuthenticatedApplication, createUserAuthenticatedApplication } from './authenticatedApplication.js'
 
 const roots: string[] = []
 const runtimes: ApplicationRuntime[] = []
@@ -15,6 +15,26 @@ afterEach(() => {
 })
 
 describe('Agent management admin routes', () => {
+  it('rejects default-model writes from a regular 8800 user', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-model-user-'))
+    roots.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const runtime = createUserAuthenticatedApplication({ config })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    await agent.put('/api/app/admin/profiles/default/model')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ provider: 'opencode-free', model: 'free-a' })
+      .expect(403, /需要管理员权限/)
+  })
+
   it('proxies profile-scoped model services and masked duplex voice settings through 9119', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-agent-management-'))
     roots.push(home)
@@ -39,6 +59,7 @@ describe('Agent management admin routes', () => {
       if (url.pathname === '/api/model/options') return Response.json({ provider: 'custom:tingly', model: 'omni', providers: [{ slug: 'custom:tingly', name: 'tingly', models: ['omni', 'old'], is_current: true }] })
       if (url.pathname === '/api/env') return Response.json({ ok: true })
       if (url.pathname === '/api/model/set') return Response.json({ ok: true, provider: 'custom:tingly', model: 'omni-2' })
+      if (url.pathname === '/api/profiles/worker/model' && method === 'PUT') return Response.json({ ok: true, provider: 'opencode-free', model: 'free-a' })
       if (unsupported && url.pathname === '/api/providers/custom-endpoints') return Response.json({ detail: 'missing' }, { status: 404 })
       if (url.pathname === '/api/providers/custom-endpoints/validate') return Response.json({ ok: true, reachable: true, message: '', models: ['model-a'] })
       if (url.pathname === '/api/providers/custom-endpoints') return Response.json({ endpoints: [], current: {}, ok: true })
@@ -60,6 +81,7 @@ describe('Agent management admin routes', () => {
     await headers(agent.post('/api/app/admin/model-services?profile=worker')).send({ name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', discover_models: true, make_default: false }).expect(200)
     await headers(agent.post('/api/app/admin/model-services/validate')).send({ name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', discover_models: true, make_default: false }).expect(200)
     await headers(agent.post('/api/app/admin/model-services/local/activate?profile=worker')).send({}).expect(200)
+    await headers(agent.put('/api/app/admin/profiles/worker/model')).send({ provider: 'opencode-free', model: 'free-a' }).expect(200)
     await headers(agent.delete('/api/app/admin/model-services/local?profile=worker')).send({}).expect(200)
     const legacy = await agent.get('/api/app/admin/legacy-model-services?profile=worker').set('Host', '127.0.0.1:8800').expect(200)
     expect(legacy.body.items[0]).toMatchObject({ id: 'custom:tingly', models: ['omni', 'old'], has_api_key: true, can_edit_api_key: true, is_current: true })
@@ -89,6 +111,8 @@ describe('Agent management admin routes', () => {
     const envWrite = calls.find(call => call.url.pathname === '/api/env' && call.method === 'PUT')!
     expect(envWrite.body).toMatchObject({ key: 'TINGLY_API_KEY', value: 'replacement-secret' })
     expect(calls.some(call => call.url.pathname === '/api/model/set' && call.method === 'POST')).toBe(true)
+    expect(calls.find(call => call.url.pathname === '/api/profiles/worker/model' && call.method === 'PUT')?.body)
+      .toEqual({ provider: 'opencode-free', model: 'free-a' })
 
     unsupported = true
     await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800')

+ 22 - 0
tests/server/authenticatedApplication.ts

@@ -17,12 +17,24 @@ const TEST_ADMIN: LocalUser = {
   updatedAt: 1,
 }
 
+const TEST_USER: LocalUser = {
+  ...TEST_ADMIN,
+  id: 'test-user',
+  username: 'test-user',
+  role: 'user',
+}
+
 class AuthenticatedTestStore extends LocalAuthStore {
   override current(_ctx: Koa.Context): LocalUser { return TEST_ADMIN }
   override require(_ctx: Koa.Context, _allowPasswordChange = false): LocalUser { return TEST_ADMIN }
   override requireAdmin(_ctx: Koa.Context): LocalUser { return TEST_ADMIN }
 }
 
+class UserAuthenticatedTestStore extends LocalAuthStore {
+  override current(_ctx: Koa.Context): LocalUser { return TEST_USER }
+  override require(_ctx: Koa.Context, _allowPasswordChange = false): LocalUser { return TEST_USER }
+}
+
 export function createAuthenticatedApplication(
   options: ApplicationOptions & { config: ServerConfig },
 ): ApplicationRuntime {
@@ -31,3 +43,13 @@ export function createAuthenticatedApplication(
     auth: new AuthenticatedTestStore(options.config.home, Boolean(options.config.tlsCert)),
   })
 }
+
+
+export function createUserAuthenticatedApplication(
+  options: ApplicationOptions & { config: ServerConfig },
+): ApplicationRuntime {
+  return createApplication({
+    ...options,
+    auth: new UserAuthenticatedTestStore(options.config.home, Boolean(options.config.tlsCert)),
+  })
+}

Неке датотеке нису приказане због велике количине промена