Browse Source

Add Agent management tabs and refresh model catalogs

LSamien 2 months ago
parent
commit
fc3b1444b4

+ 48 - 0
hermes-plugins/yaoyao/dashboard/tests/test_voice_settings_contract.py

@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+DASHBOARD_DIR = Path(__file__).resolve().parents[1]
+if str(DASHBOARD_DIR) not in sys.path:
+    sys.path.insert(0, str(DASHBOARD_DIR))
+
+import store  # noqa: E402
+
+
+class DuplexVoiceSettingsContractTests(unittest.TestCase):
+    def test_public_settings_mask_the_key_and_removed_current_voice_falls_back(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            settings_path = root / "duplex_voice.json"
+            with (
+                patch.object(store, "DEFAULT_DATA_ROOT", root),
+                patch.object(store, "_DUPLEX_VOICE_FILE", settings_path),
+            ):
+                store.save_duplex_voice({
+                    "apiKey": "top-secret-key",
+                    "voices": [
+                        {"id": "voice-a", "name": "音色 A"},
+                        {"id": "voice-b", "name": "音色 B"},
+                    ],
+                    "currentVoiceId": "voice-b",
+                })
+                public = store.public_duplex_voice()
+                self.assertTrue(public["hasApiKey"])
+                self.assertNotIn("apiKey", public)
+                self.assertNotIn("top-secret-key", str(public))
+                self.assertEqual(store.runtime_duplex_voice()["apiKey"], "top-secret-key")
+
+                updated = store.save_duplex_voice({
+                    "voices": [{"id": "voice-a", "name": "音色 A"}],
+                })
+                self.assertEqual(updated["currentVoiceId"], "voice-a")
+                self.assertEqual(updated["apiKey"], "top-secret-key")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 139 - 0
src/client/api/agentManagement.ts

@@ -0,0 +1,139 @@
+import type { JsonValue } from '@shared/types'
+import { apiRequest } from './client'
+
+export interface CustomModelService {
+  id: string
+  name: string
+  base_url: string
+  model: string
+  models: string[]
+  context_length?: number
+  discover_models: boolean
+  has_api_key: boolean
+  api_key_preview?: string
+  is_current: boolean
+  source?: string
+}
+
+export interface CustomModelServicesResponse {
+  endpoints: CustomModelService[]
+  current: { provider: string; model: string; base_url: string }
+  ok?: boolean
+  id?: string
+}
+
+export interface CustomModelServiceInput {
+  id?: string
+  name: string
+  base_url: string
+  model: string
+  api_key?: string
+  context_length?: number
+  discover_models: boolean
+  make_default: boolean
+  models?: string[]
+}
+
+export interface ModelServiceValidation {
+  ok: boolean
+  reachable: boolean
+  message: string
+  models: string[]
+}
+
+export interface ModelCatalogProvider {
+  slug: string
+  name: string
+  models: string[]
+  isCurrent: boolean
+}
+
+export interface LegacyModelService extends CustomModelService {
+  source: 'legacy'
+  can_edit_api_key: boolean
+}
+
+export interface DuplexVoice {
+  id: string
+  name: string
+}
+
+export interface DuplexVoiceSettings {
+  hasApiKey: boolean
+  voices: DuplexVoice[]
+  currentVoiceId: string
+  updatedAt: number
+}
+
+function profileQuery(profile: string): string {
+  return `?profile=${encodeURIComponent(profile)}`
+}
+
+export function listModelServices(profile: string): Promise<CustomModelServicesResponse> {
+  return apiRequest(`/api/app/admin/model-services${profileQuery(profile)}`)
+}
+
+export async function listModelCatalog(profile: string): Promise<ModelCatalogProvider[]> {
+  const payload = await apiRequest<{ providers?: unknown[] }>(`/api/app/models${profileQuery(profile)}`)
+  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 []
+    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,
+    }]
+  })
+}
+
+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 || []
+}
+
+export function saveLegacyModelService(profile: string, id: string, input: CustomModelServiceInput): Promise<void> {
+  return apiRequest(`/api/app/admin/legacy-model-services/${encodeURIComponent(id)}${profileQuery(profile)}`, {
+    method: 'PUT', body: input as unknown as JsonValue,
+  })
+}
+
+export function saveModelService(profile: string, input: CustomModelServiceInput): Promise<CustomModelServicesResponse> {
+  return apiRequest(`/api/app/admin/model-services${profileQuery(profile)}`, {
+    method: 'POST', body: input as unknown as JsonValue,
+  })
+}
+
+export function validateModelService(input: CustomModelServiceInput): Promise<ModelServiceValidation> {
+  return apiRequest('/api/app/admin/model-services/validate', {
+    method: 'POST', body: input as unknown as JsonValue, timeoutMs: 15_000,
+  })
+}
+
+export function activateModelService(profile: string, id: string): Promise<void> {
+  return apiRequest(`/api/app/admin/model-services/${encodeURIComponent(id)}/activate${profileQuery(profile)}`, {
+    method: 'POST', body: {},
+  })
+}
+
+export function deleteModelService(profile: string, id: string): Promise<void> {
+  return apiRequest(`/api/app/admin/model-services/${encodeURIComponent(id)}${profileQuery(profile)}`, {
+    method: 'DELETE', body: {},
+  })
+}
+
+export function getDuplexVoiceSettings(): Promise<DuplexVoiceSettings> {
+  return apiRequest('/api/app/admin/duplex-voice')
+}
+
+export function saveDuplexVoiceSettings(input: {
+  apiKey?: string
+  voices: DuplexVoice[]
+  currentVoiceId: string
+}): Promise<DuplexVoiceSettings> {
+  return apiRequest('/api/app/admin/duplex-voice', {
+    method: 'PUT', body: input as unknown as JsonValue,
+  })
+}

File diff suppressed because it is too large
+ 19 - 16
src/client/components/app/AgentIdentityDialog.vue


File diff suppressed because it is too large
+ 101 - 0
src/client/components/app/DuplexVoicePanel.vue


File diff suppressed because it is too large
+ 247 - 0
src/client/components/app/ModelServicesPanel.vue


+ 1 - 1
src/client/components/app/SystemManagementDialog.vue

@@ -9,7 +9,7 @@ const emit = defineEmits<{ close: [] }>()
 const users = ref<ManagedUser[]>([])
 const username = ref('')
 const password = ref('')
-const upstreamUsername = ref('admin')
+const upstreamUsername = ref('')
 const upstreamPassword = ref('')
 const busy = ref(false)
 const error = ref('')

+ 2 - 2
src/client/components/app/WorkspaceShell.vue

@@ -298,7 +298,7 @@ onBeforeUnmount(() => {
                 <strong>{{ profileTitle(profile) }}</strong>
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
-              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
+              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>Agent 管理</strong></button>
               <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
               <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
               <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
@@ -411,7 +411,7 @@ onBeforeUnmount(() => {
                 <strong>{{ profileTitle(profile) }}</strong>
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
-              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
+              <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>Agent 管理</strong></button>
               <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
               <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
               <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true; mobileDrawerOpen = false"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>

+ 1 - 1
src/client/components/workspace/WorkspaceView.vue

@@ -87,5 +87,5 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
     <template #default><slot /></template>
     <template #inspector><slot name="inspector" /></template>
   </WorkspaceShell>
-  <AgentIdentityDialog :open="identityOpen" :profile="auth.activeProfile" :busy="identityBusy" :error="identityError" @close="identityOpen = false" @save="saveIdentity" />
+  <AgentIdentityDialog :open="identityOpen" :profile="auth.activeProfile" :busy="identityBusy" :error="identityError" :is-admin="auth.user?.role === 'admin'" @close="identityOpen = false" @save="saveIdentity" />
 </template>

+ 9 - 0
src/client/utils/modelCatalogEvents.ts

@@ -0,0 +1,9 @@
+export const MODEL_CATALOG_CHANGED_EVENT = 'hermes-yaoyao:model-catalog-changed'
+
+export function notifyModelCatalogChanged(profile: string): void {
+  window.dispatchEvent(new CustomEvent(MODEL_CATALOG_CHANGED_EVENT, { detail: { profile } }))
+}
+
+export function modelCatalogChangedProfile(event: Event): string {
+  return event instanceof CustomEvent && typeof event.detail?.profile === 'string' ? event.detail.profile : ''
+}

+ 8 - 0
src/client/views/ChatView.vue

@@ -23,6 +23,7 @@ import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/compone
 import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
+import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
 import { estimateConversationTokens } from '@/utils/contextUsage'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
@@ -51,6 +52,11 @@ const headerSessionMenuButton = ref<HTMLButtonElement | null>(null)
 const restoringRouteProfile = ref('')
 let modelSyncTimer: number | undefined
 
+function handleModelCatalogChanged(event: Event) {
+  const profile = modelCatalogChangedProfile(event)
+  if (profile && profile === auth.activeProfile?.name) void chat.loadModels(profile).catch(() => undefined)
+}
+
 const sessions = computed(() => [...chat.sessions]
   .filter(session => !['cron', 'ios_group'].includes(session.source)))
 const agentNames = computed(() => new Map(auth.profiles.map(profile => [
@@ -332,6 +338,7 @@ onMounted(async () => {
   restoreShowThinking()
   document.addEventListener('pointerdown', handleSessionActionPointer)
   document.addEventListener('keydown', handleSessionActionKey)
+  window.addEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
   await refreshSessions()
   await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
   void chat.refreshActiveSessionModel().catch(() => undefined)
@@ -346,6 +353,7 @@ onMounted(async () => {
 onBeforeUnmount(() => {
   document.removeEventListener('pointerdown', handleSessionActionPointer)
   document.removeEventListener('keydown', handleSessionActionKey)
+  window.removeEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
   if (modelSyncTimer !== undefined) window.clearInterval(modelSyncTimer)
 })
 

+ 11 - 0
src/client/views/GroupsView.vue

@@ -25,6 +25,7 @@ import type { SidebarItem } from '@/components/app/types'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
+import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
 import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
 import { getModels } from '@/api/profiles'
 import { getGroupPushSubscriptions, getPushCapabilities, setGroupPushSubscription } from '@/api/push'
@@ -63,6 +64,14 @@ const subscribedPushRooms = ref<Set<string>>(new Set())
 const pushSubscriptionBusy = ref(false)
 const pushSubscriptionError = ref('')
 
+function handleModelCatalogChanged(event: Event) {
+  const profile = modelCatalogChangedProfile(event)
+  if (!profile) return
+  const { [profile]: _removed, ...remaining } = modelOptionsByProfile.value
+  modelOptionsByProfile.value = remaining
+  if (groups.agents.some(agent => agent.profile === profile)) void loadAgentModels(profile)
+}
+
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 const roomSidebarItems = computed(() => activeRooms.value.map(room => {
   const currentMembers = room.id === groups.selectedRoomId && displayAgents.value.length
@@ -652,6 +661,7 @@ async function addMediaToComposer(media: PreviewMedia) {
 onMounted(async () => {
   document.addEventListener('pointerdown', handleRoomActionPointer)
   document.addEventListener('keydown', handleRoomActionKey)
+  window.addEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
   restoreShowThinking()
   void loadPushSubscriptions()
   try {
@@ -677,6 +687,7 @@ onMounted(async () => {
 onBeforeUnmount(() => {
   document.removeEventListener('pointerdown', handleRoomActionPointer)
   document.removeEventListener('keydown', handleRoomActionKey)
+  window.removeEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
   groups.stop()
 })
 

+ 183 - 0
src/server/routes.ts

@@ -623,6 +623,30 @@ async function proxy(
   })
 }
 
+async function proxyAdminFeature(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  path: string,
+  options: {
+    search?: URLSearchParams
+    method?: string
+    requestBody?: unknown
+  } = {},
+): Promise<void> {
+  dependencies.auth.requireAdmin(ctx)
+  await withJar(ctx, async (jar) => {
+    const response = await dependencies.upstream.request(path, jar, {
+      method: options.method,
+      search: options.search,
+      body: options.requestBody,
+    })
+    if (response.status === 404 || response.status === 405) {
+      throw new HttpError(501, '当前上游版本不支持此管理功能', 'upstream_feature_unsupported')
+    }
+    sendUpstreamResponse(ctx, response, jar)
+  })
+}
+
 async function proxyOptionalUnread(
   ctx: Koa.Context,
   upstream: UpstreamClient,
@@ -1497,6 +1521,165 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     dependencies.auth.setUpstreamCredentials(admin, { username, password })
     json(ctx, 200, { ok: true })
   })
+  router.get('/api/app/admin/model-services', async (ctx) => {
+    await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
+      search: searchFrom(ctx, ['profile']),
+    })
+  })
+  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),
+    })
+  })
+  router.post('/api/app/admin/model-services/validate', async (ctx) => {
+    await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints/validate', {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
+  router.post('/api/app/admin/model-services/:serviceID/activate', async (ctx) => {
+    const id = safeIdentifier(ctx.params.serviceID, 'model service ID')
+    await proxyAdminFeature(ctx, dependencies, `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, {
+      method: 'POST', search: searchFrom(ctx, ['profile']), requestBody: optionalBody(ctx) ?? {},
+    })
+  })
+  router.delete('/api/app/admin/model-services/:serviceID', async (ctx) => {
+    const id = safeIdentifier(ctx.params.serviceID, 'model service ID')
+    await proxyAdminFeature(ctx, dependencies, `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, {
+      method: 'DELETE', search: searchFrom(ctx, ['profile']),
+    })
+  })
+  router.get('/api/app/admin/legacy-model-services', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    await withJar(ctx, async (jar) => {
+      const search = searchFrom(ctx, ['profile'])
+      const [configResponse, optionsResponse] = await Promise.all([
+        dependencies.upstream.request('/api/config', jar, { search }),
+        dependencies.upstream.request('/api/model/options', jar, {
+          search: new URLSearchParams([...search, ['explicit_only', 'true']]),
+        }),
+      ])
+      if ([configResponse.status, optionsResponse.status].some(status => status === 404 || status === 405)) {
+        throw new HttpError(501, '当前上游版本不支持旧版模型服务管理', 'upstream_feature_unsupported')
+      }
+      const config = requireSuccess(configResponse)
+      const options = requireSuccess(optionsResponse)
+      const currentProvider = typeof options.provider === 'string' ? options.provider.toLocaleLowerCase() : ''
+      const entries = Array.isArray(config.custom_providers) ? config.custom_providers : []
+      const items = entries.flatMap(raw => {
+        if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
+        const entry = raw as JsonObject
+        const name = typeof entry.name === 'string' ? entry.name.trim() : ''
+        const baseURL = typeof entry.base_url === 'string' ? entry.base_url.trim() : ''
+        const model = typeof entry.model === 'string' ? entry.model.trim() : ''
+        if (!name || !baseURL || !model) return []
+        const rawModels = entry.models
+        const models = Array.isArray(rawModels)
+          ? rawModels.map(String).map(value => value.trim()).filter(Boolean)
+          : rawModels && typeof rawModels === 'object'
+            ? Object.keys(rawModels).filter(value => !value.startsWith('__'))
+            : [model]
+        const modelConfig = rawModels && typeof rawModels === 'object' && !Array.isArray(rawModels)
+          ? (rawModels as JsonObject)[model]
+          : undefined
+        const contextLength = typeof entry.context_length === 'number' ? entry.context_length
+          : modelConfig && typeof modelConfig === 'object' && !Array.isArray(modelConfig)
+            && typeof (modelConfig as JsonObject).context_length === 'number'
+            ? (modelConfig as JsonObject).context_length as number
+            : undefined
+        const id = `custom:${name}`
+        return [{
+          id, name, base_url: baseURL, model, models: [...new Set([...models, model])],
+          ...(contextLength ? { context_length: contextLength } : {}),
+          discover_models: entry.models_discovered === true,
+          has_api_key: Boolean(entry.key_env || entry.api_key),
+          can_edit_api_key: typeof entry.key_env === 'string' && Boolean(entry.key_env.trim()),
+          is_current: currentProvider === id.toLocaleLowerCase(),
+          source: 'legacy',
+        }]
+      })
+      json(ctx, 200, { items })
+    })
+  })
+  router.put('/api/app/admin/legacy-model-services/:serviceID', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const serviceID = safeIdentifier(ctx.params.serviceID, 'legacy model service ID')
+    const request = body(ctx)
+    const baseURL = typeof request.base_url === 'string' ? request.base_url.trim().replace(/\/$/, '') : ''
+    const model = typeof request.model === 'string' ? request.model.trim() : ''
+    const models = Array.isArray(request.models)
+      ? [...new Set(request.models.map(String).map(value => value.trim()).filter(Boolean))]
+      : []
+    if (!/^https?:\/\//i.test(baseURL)) throw new HttpError(400, 'Base URL 必须使用 http 或 https', 'invalid_model_service')
+    if (!model) throw new HttpError(400, '默认模型不能为空', 'invalid_model_service')
+    if (!models.includes(model)) models.push(model)
+    const contextLength = typeof request.context_length === 'number' && Number.isInteger(request.context_length) && request.context_length > 0
+      ? request.context_length : undefined
+    await withJar(ctx, async (jar) => {
+      const search = searchFrom(ctx, ['profile'])
+      const [configResponse, optionsResponse] = await Promise.all([
+        dependencies.upstream.request('/api/config', jar, { search }),
+        dependencies.upstream.request('/api/model/options', jar, {
+          search: new URLSearchParams([...search, ['explicit_only', 'true']]),
+        }),
+      ])
+      const config = requireSuccess(configResponse)
+      const options = requireSuccess(optionsResponse)
+      const providers = Array.isArray(config.custom_providers) ? [...config.custom_providers] : []
+      const index = providers.findIndex(raw => {
+        if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
+        const name = typeof (raw as JsonObject).name === 'string' ? String((raw as JsonObject).name) : ''
+        return `custom:${name}`.toLocaleLowerCase() === serviceID.toLocaleLowerCase()
+      })
+      if (index < 0) throw new HttpError(404, '旧版模型服务不存在', 'model_service_not_found')
+      const current = providers[index] as JsonObject
+      const existingModels = current.models && typeof current.models === 'object' && !Array.isArray(current.models)
+        ? current.models as JsonObject : {}
+      const nextModels = Object.fromEntries(models.map(id => {
+        const previous = existingModels[id]
+        const metadata: JsonObject = previous && typeof previous === 'object' && !Array.isArray(previous)
+          ? { ...previous as JsonObject } : {}
+        if (id === model && contextLength) metadata.context_length = contextLength
+        return [id, metadata]
+      }))
+      providers[index] = {
+        ...current,
+        base_url: baseURL,
+        model,
+        models: nextModels,
+        models_discovered: request.discover_models === true,
+        ...(contextLength ? { context_length: contextLength } : {}),
+      }
+      requireSuccess(await dependencies.upstream.request('/api/config', jar, {
+        method: 'PUT', search, body: { config: { custom_providers: providers }, profile: search.get('profile') || undefined },
+      }))
+      if (Object.prototype.hasOwnProperty.call(request, 'api_key')) {
+        const keyEnvironment = typeof current.key_env === 'string' ? current.key_env.trim() : ''
+        if (!keyEnvironment) throw new HttpError(409, '该旧版服务没有可管理的密钥变量', 'model_service_key_unmanaged')
+        const apiKey = typeof request.api_key === 'string' ? request.api_key.trim() : ''
+        requireSuccess(await dependencies.upstream.request('/api/env', jar, {
+          method: apiKey ? 'PUT' : 'DELETE', search,
+          body: apiKey ? { key: keyEnvironment, value: apiKey, profile: search.get('profile') || undefined }
+            : { key: keyEnvironment, profile: search.get('profile') || undefined },
+        }))
+      }
+      const currentProvider = typeof options.provider === 'string' ? options.provider.toLocaleLowerCase() : ''
+      if (currentProvider === serviceID.toLocaleLowerCase()) {
+        requireSuccess(await dependencies.upstream.request('/api/model/set', jar, {
+          method: 'POST', search,
+          body: { scope: 'main', provider: serviceID, model, base_url: baseURL, confirm_expensive_model: true, profile: search.get('profile') || undefined },
+        }))
+      }
+      json(ctx, 200, { ok: true })
+    })
+  })
+  router.get('/api/app/admin/duplex-voice', async (ctx) => {
+    await proxyAdminFeature(ctx, dependencies, '/api/plugins/yaoyao/voice/settings')
+  })
+  router.put('/api/app/admin/duplex-voice', async (ctx) => {
+    await proxyAdminFeature(ctx, dependencies, '/api/plugins/yaoyao/voice/settings', {
+      method: 'PUT', requestBody: body(ctx),
+    })
+  })
   router.get('/api/app/system/update/status', async (ctx) => {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)

+ 68 - 0
tests/client/agentManagement.test.ts

@@ -0,0 +1,68 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  activateModelService,
+  deleteModelService,
+  getDuplexVoiceSettings,
+  listLegacyModelServices,
+  listModelCatalog,
+  listModelServices,
+  saveDuplexVoiceSettings,
+  saveLegacyModelService,
+  saveModelService,
+  validateModelService,
+} from '@/api/agentManagement'
+import { setApiCsrfToken } from '@/api/client'
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  setApiCsrfToken('')
+})
+
+describe('Agent management client protocol', () => {
+  it('uses profile-scoped admin routes and preserves omitted model secrets', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      return Response.json({ endpoints: [], current: {}, ok: true, reachable: true, message: '', models: [] })
+    }))
+    setApiCsrfToken('csrf-agent-management')
+    const input = { name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'local-model', discover_models: true, make_default: false }
+
+    await listModelServices('worker / 中文')
+    await listModelCatalog('worker / 中文')
+    await listLegacyModelServices('worker / 中文')
+    await saveModelService('worker / 中文', input)
+    await saveLegacyModelService('worker / 中文', 'custom:tingly', input)
+    await validateModelService(input)
+    await activateModelService('worker / 中文', 'local/model')
+    await deleteModelService('worker / 中文', 'local/model')
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/admin/model-services?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
+      '/api/app/models?profile=worker%20%2F%20%E4%B8%AD%E6%96%87',
+      '/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/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(new Headers(calls[3]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-agent-management')
+    expect(calls[7]!.init?.method).toBe('DELETE')
+  })
+
+  it('never includes an unchanged duplex API key', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      return Response.json({ hasApiKey: true, voices: [{ id: 'voice-1', name: 'Voice' }], currentVoiceId: 'voice-1', updatedAt: 1 })
+    }))
+    setApiCsrfToken('csrf-voice')
+    await getDuplexVoiceSettings()
+    await saveDuplexVoiceSettings({ voices: [{ id: 'voice-1', name: 'Voice' }], currentVoiceId: 'voice-1' })
+
+    expect(calls.map(call => call.path)).toEqual(['/api/app/admin/duplex-voice', '/api/app/admin/duplex-voice'])
+    expect(JSON.parse(String(calls[1]!.init?.body))).toEqual({ voices: [{ id: 'voice-1', name: 'Voice' }], currentVoiceId: 'voice-1' })
+  })
+})

+ 114 - 0
tests/client/agentManagementDialog.test.ts

@@ -0,0 +1,114 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import AgentIdentityDialog from '@/components/app/AgentIdentityDialog.vue'
+import { deleteModelService, listModelServices, saveDuplexVoiceSettings, saveLegacyModelService, saveModelService, 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 },
+  ]),
+  saveLegacyModelService: vi.fn(async () => undefined),
+  saveModelService: vi.fn(async () => ({ endpoints: [], current: {} })),
+  validateModelService: vi.fn(async () => ({ ok: true, reachable: true, message: '', models: ['model-a', 'model-b'] })),
+  activateModelService: vi.fn(async () => undefined),
+  deleteModelService: vi.fn(async () => undefined),
+  getDuplexVoiceSettings: vi.fn(async () => ({ hasApiKey: true, voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a', updatedAt: 1 })),
+  saveDuplexVoiceSettings: vi.fn(async input => ({ hasApiKey: true, ...input, updatedAt: 2 })),
+}))
+
+const profile = { name: 'default', displayName: '丫头', isDefault: true }
+afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks(); vi.clearAllMocks() })
+
+describe('Agent management dialog', () => {
+  it('keeps non-admin users on identity management only', async () => {
+    const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: false } })
+    await nextTick()
+    expect(document.querySelector('[aria-label="Agent 管理"]')?.textContent).toContain('Bots 头像')
+    expect(document.querySelector('.management-tabs')).toBeNull()
+    expect(document.body.textContent).not.toContain('模型服务')
+    wrapper.unmount()
+  })
+
+  it('shows admin model and global duplex voice panels', async () => {
+    const wrapper = mount(AgentIdentityDialog, { attachTo: document.body, props: { open: true, profile, isAdmin: true } })
+    await nextTick()
+    const tabs = [...document.querySelectorAll<HTMLButtonElement>('.management-tabs button')]
+    expect(tabs.map(tab => tab.textContent)).toEqual(['身份', '模型服务', '双流语音'])
+
+    tabs[1]!.click(); await flushPromises()
+    expect(document.body.textContent).toContain('9119 Provider 与模型')
+    expect(document.body.textContent).toContain('OpenCode Free')
+    expect(document.body.textContent).toContain('custom:tingly')
+    expect(document.body.textContent).toContain('只读')
+
+    document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[2]!.click(); await nextTick(); await nextTick()
+    expect(document.body.textContent).toContain('整个 yaoyao 安装共享的全局设置')
+    expect(document.querySelector<HTMLInputElement>('input[type="password"]')?.placeholder).toContain('留空保持不变')
+    wrapper.unmount()
+  })
+
+  it('discovers and saves a model service without overwriting an unchanged secret', 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()
+    ;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.includes('新增服务'))!.click()
+    await nextTick()
+    const inputs = [...document.querySelectorAll<HTMLInputElement>('.service-editor input:not([type="checkbox"])')]
+    for (const [input, value] of [[inputs[0], 'Local'], [inputs[1], 'http://127.0.0.1:9000/v1'], [inputs[2], 'model-a']] as const) {
+      input!.value = value; input!.dispatchEvent(new Event('input'))
+    }
+    ;[...document.querySelectorAll<HTMLButtonElement>('.service-editor button')].find(button => button.textContent?.includes('测试连接'))!.click()
+    await nextTick(); await nextTick()
+    expect(validateModelService).toHaveBeenCalledOnce()
+    expect(document.querySelector<HTMLTextAreaElement>('.service-editor textarea')?.value).toContain('model-b')
+    ;[...document.querySelectorAll<HTMLButtonElement>('.service-editor button')].find(button => button.textContent?.includes('保存模型服务'))!.click()
+    await nextTick(); await nextTick()
+    expect(saveModelService).toHaveBeenCalledOnce()
+    expect(vi.mocked(saveModelService).mock.calls[0]?.[1]).not.toHaveProperty('api_key')
+    wrapper.unmount()
+  })
+
+  it('edits an existing legacy custom provider and replaces its visible model list', 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 legacyCard = [...document.querySelectorAll<HTMLElement>('.service-card')].find(card => card.textContent?.includes('custom:tingly'))!
+    legacyCard.querySelector<HTMLButtonElement>('button')!.click(); await nextTick()
+    const modelInput = document.querySelectorAll<HTMLInputElement>('.service-editor input:not([type="checkbox"])')[2]!
+    modelInput.value = 'omni-2'; modelInput.dispatchEvent(new Event('input'))
+    const models = document.querySelector<HTMLTextAreaElement>('.service-editor textarea')!
+    models.value = 'omni\nomni-2'; models.dispatchEvent(new Event('input'))
+    document.querySelector<HTMLFormElement>('.service-editor')!.dispatchEvent(new Event('submit'))
+    await nextTick(); await nextTick()
+    expect(saveLegacyModelService).toHaveBeenCalledWith('default', 'custom:tingly', expect.objectContaining({ model: 'omni-2', models: ['omni', 'omni-2'] }))
+    wrapper.unmount()
+  })
+
+  it('confirms model deletion and rejects duplicate duplex voice ids', async () => {
+    vi.mocked(listModelServices).mockResolvedValueOnce({ endpoints: [{ id: 'local', name: 'Local', base_url: 'http://127.0.0.1:9000/v1', model: 'model-a', models: ['model-a'], discover_models: true, has_api_key: true, is_current: false }], current: { provider: '', model: '', base_url: '' } })
+    vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true)
+    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 deleteButton = [...document.querySelectorAll<HTMLButtonElement>('.service-card button')].find(button => button.textContent === '删除')!
+    deleteButton.click(); await nextTick()
+    expect(deleteModelService).not.toHaveBeenCalled()
+    deleteButton.click(); await nextTick(); await nextTick()
+    expect(deleteModelService).toHaveBeenCalledWith('default', 'local')
+
+    vi.mocked(saveDuplexVoiceSettings).mockClear()
+    document.querySelectorAll<HTMLButtonElement>('.management-tabs button')[2]!.click(); await nextTick(); await nextTick()
+    ;[...document.querySelectorAll<HTMLButtonElement>('.voice-list-heading button')][0]!.click(); await nextTick()
+    const idInputs = [...document.querySelectorAll<HTMLInputElement>('.voice-row label:first-child input')]
+    idInputs[1]!.value = idInputs[0]!.value; idInputs[1]!.dispatchEvent(new Event('input'))
+    document.querySelector<HTMLFormElement>('.voice-panel form')!.dispatchEvent(new Event('submit'))
+    await nextTick()
+    expect(document.querySelector('[role="alert"]')?.textContent).toContain('音色 ID 重复')
+    expect(saveDuplexVoiceSettings).not.toHaveBeenCalled()
+    wrapper.unmount()
+  })
+})

+ 97 - 0
tests/server/agentManagementRoutes.test.ts

@@ -0,0 +1,97 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+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'
+
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+describe('Agent management admin routes', () => {
+  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)
+    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 calls: Array<{ url: URL; method: string; body?: unknown }> = []
+    let unsupported = false
+    const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      const method = init?.method || (input instanceof Request ? input.method : 'GET')
+      const requestBody = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
+      calls.push({ url, method, body: requestBody })
+      if (url.pathname === '/api/status') return Response.json({ auth_required: true }, { headers: { 'set-cookie': 'hermes_session_at=session; Path=/; HttpOnly' } })
+      if (url.pathname === '/api/auth/me') return Response.json({ user_id: 'service-admin' })
+      if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (url.pathname === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
+      if (url.pathname === '/api/config' && method === 'GET') return Response.json({ custom_providers: [{ name: 'tingly', base_url: 'http://tingly.test/v1', key_env: 'TINGLY_API_KEY', model: 'omni', models: { omni: {}, old: {} }, models_discovered: true }] })
+      if (url.pathname === '/api/config' && method === 'PUT') return Response.json({ ok: true })
+      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 (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 })
+      if (url.pathname.endsWith('/activate')) return Response.json({ ok: true, provider: 'local', model: 'model-a' })
+      if (url.pathname.startsWith('/api/providers/custom-endpoints/')) return Response.json({ ok: true, endpoints: [], current: {} })
+      if (url.pathname === '/api/plugins/yaoyao/voice/settings') return Response.json({ hasApiKey: true, voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a', updatedAt: 1 })
+      return Response.json({ ok: true })
+    }) as typeof fetch
+    const runtime = createAuthenticatedApplication({ config, fetchImpl })
+    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)
+    const headers = (value: request.Test) => value
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+
+    await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800').expect(200)
+    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.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 })
+    expect(JSON.stringify(legacy.body)).not.toContain('TINGLY_API_KEY')
+    await headers(agent.put('/api/app/admin/legacy-model-services/custom%3Atingly?profile=worker')).send({
+      name: 'tingly', base_url: 'http://tingly.test/v2', model: 'omni-2', models: ['omni', 'omni-2'],
+      discover_models: false, make_default: false, api_key: 'replacement-secret',
+    }).expect(200)
+    const voice = await agent.get('/api/app/admin/duplex-voice').set('Host', '127.0.0.1:8800').expect(200)
+    expect(voice.body).not.toHaveProperty('apiKey')
+    await headers(agent.put('/api/app/admin/duplex-voice')).send({ voices: [{ id: 'voice-a', name: '音色 A' }], currentVoiceId: 'voice-a' }).expect(200)
+
+    const featureCalls = calls.filter(call => call.url.pathname.includes('custom-endpoints') || call.url.pathname.includes('/voice/settings'))
+    expect(featureCalls.map(call => [call.method, call.url.pathname, call.url.search])).toEqual([
+      ['GET', '/api/providers/custom-endpoints', '?profile=worker'],
+      ['POST', '/api/providers/custom-endpoints', '?profile=worker'],
+      ['POST', '/api/providers/custom-endpoints/validate', ''],
+      ['POST', '/api/providers/custom-endpoints/local/activate', '?profile=worker'],
+      ['DELETE', '/api/providers/custom-endpoints/local', '?profile=worker'],
+      ['GET', '/api/plugins/yaoyao/voice/settings', ''],
+      ['PUT', '/api/plugins/yaoyao/voice/settings', ''],
+    ])
+    expect(featureCalls[1]!.body).toMatchObject({ name: 'Local', model: 'model-a' })
+    expect(featureCalls[6]!.body).not.toHaveProperty('apiKey')
+    const configWrite = calls.find(call => call.url.pathname === '/api/config' && call.method === 'PUT')!
+    expect(configWrite.body).toMatchObject({ config: { custom_providers: [{ name: 'tingly', base_url: 'http://tingly.test/v2', model: 'omni-2', models: { omni: {}, 'omni-2': {} }, models_discovered: false }] } })
+    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)
+
+    unsupported = true
+    await agent.get('/api/app/admin/model-services?profile=worker').set('Host', '127.0.0.1:8800')
+      .expect(501, /当前上游版本不支持此管理功能/)
+  })
+})

Some files were not shown because too many files changed in this diff