ModelServicesPanel.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. <script setup lang="ts">
  2. import { computed, onMounted, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import {
  5. activateModelService,
  6. deleteModelService,
  7. listLegacyModelServices,
  8. listModelCatalog,
  9. listModelServices,
  10. saveLegacyModelService,
  11. saveModelService,
  12. saveProfileDefaultModel,
  13. validateModelService,
  14. type CustomModelService,
  15. type CustomModelServiceInput,
  16. type LegacyModelService,
  17. type ModelCatalogProvider,
  18. } from '@/api/agentManagement'
  19. import { notifyModelCatalogChanged } from '@/utils/modelCatalogEvents'
  20. const props = defineProps<{ profile: string }>()
  21. const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
  22. interface Draft {
  23. source: 'managed' | 'legacy'
  24. id: string
  25. name: string
  26. baseUrl: string
  27. apiKey: string
  28. apiKeyTouched: boolean
  29. clearApiKey: boolean
  30. model: string
  31. modelsText: string
  32. contextLength: string
  33. discoverModels: boolean
  34. makeDefault: boolean
  35. hasApiKey: boolean
  36. canEditApiKey: boolean
  37. }
  38. const services = ref<CustomModelService[]>([])
  39. const legacyServices = ref<LegacyModelService[]>([])
  40. const catalog = ref<ModelCatalogProvider[]>([])
  41. const loading = ref(false)
  42. const busy = ref(false)
  43. const error = ref('')
  44. const notice = ref('')
  45. const draft = ref<Draft | null>(null)
  46. const draftBaseline = ref('')
  47. const query = ref('')
  48. const defaultModelKey = ref('')
  49. const defaultModelBaseline = ref('')
  50. function modelKey(provider: string, model: string): string {
  51. return JSON.stringify([provider, model])
  52. }
  53. function modelSelection(key: string): { provider: string; model: string } | undefined {
  54. try {
  55. const value = JSON.parse(key)
  56. if (Array.isArray(value) && value.length === 2 && value.every(item => typeof item === 'string' && item.trim())) {
  57. return { provider: value[0], model: value[1] }
  58. }
  59. } catch { /* Invalid selections are rejected before saving. */ }
  60. return undefined
  61. }
  62. function emptyDraft(): Draft {
  63. return { source: 'managed', id: '', name: '', baseUrl: '', apiKey: '', apiKeyTouched: false, clearApiKey: false, model: '', modelsText: '', contextLength: '', discoverModels: true, makeDefault: false, hasApiKey: false, canEditApiKey: true }
  64. }
  65. function draftSnapshot(value: Draft): string {
  66. return JSON.stringify(value)
  67. }
  68. const draftDirty = computed(() => Boolean(draft.value) && draftSnapshot(draft.value!) !== draftBaseline.value)
  69. const defaultModelDirty = computed(() => Boolean(defaultModelKey.value) && defaultModelKey.value !== defaultModelBaseline.value)
  70. const panelDirty = computed(() => draftDirty.value || defaultModelDirty.value)
  71. function replaceDraft(next: Draft): boolean {
  72. if (draftDirty.value && !window.confirm('放弃当前模型服务未保存的更改?')) return false
  73. draft.value = next
  74. draftBaseline.value = draftSnapshot(next)
  75. return true
  76. }
  77. function beginCreate() {
  78. replaceDraft(emptyDraft())
  79. }
  80. function closeEditor() {
  81. if (draftDirty.value && !window.confirm('放弃当前模型服务未保存的更改?')) return
  82. draft.value = null
  83. draftBaseline.value = ''
  84. }
  85. function edit(service: CustomModelService | LegacyModelService) {
  86. const next: Draft = {
  87. source: service.source === 'legacy' ? 'legacy' : 'managed',
  88. id: service.id,
  89. name: service.name,
  90. baseUrl: service.base_url,
  91. apiKey: '',
  92. apiKeyTouched: false,
  93. clearApiKey: false,
  94. model: service.model,
  95. modelsText: service.models.join('\n'),
  96. contextLength: service.context_length ? String(service.context_length) : '',
  97. discoverModels: service.discover_models,
  98. makeDefault: service.is_current,
  99. hasApiKey: service.has_api_key,
  100. canEditApiKey: !('can_edit_api_key' in service) || service.can_edit_api_key,
  101. }
  102. if (!replaceDraft(next)) return
  103. error.value = ''
  104. notice.value = ''
  105. }
  106. async function load(preserveDefaultDraft = false) {
  107. const selectedDefault = defaultModelKey.value
  108. const preserveSelection = preserveDefaultDraft && defaultModelDirty.value
  109. loading.value = true
  110. error.value = ''
  111. try {
  112. const [managed, legacy, available] = await Promise.all([
  113. listModelServices(props.profile),
  114. listLegacyModelServices(props.profile),
  115. listModelCatalog(props.profile),
  116. ])
  117. services.value = managed.endpoints || []
  118. legacyServices.value = legacy
  119. catalog.value = available
  120. const current = available.find(provider => provider.isCurrent && provider.currentModel)
  121. const key = current?.currentModel ? modelKey(current.slug, current.currentModel) : ''
  122. defaultModelBaseline.value = key
  123. defaultModelKey.value = preserveSelection ? selectedDefault : key
  124. } catch (cause) {
  125. error.value = cause instanceof Error ? cause.message : '模型服务加载失败'
  126. } finally {
  127. loading.value = false
  128. }
  129. }
  130. async function saveDefaultModel() {
  131. const selection = modelSelection(defaultModelKey.value)
  132. if (!selection) { error.value = '请选择默认 Provider 与模型'; return }
  133. busy.value = true; error.value = ''; notice.value = ''
  134. try {
  135. await saveProfileDefaultModel(props.profile, selection.provider, selection.model)
  136. await load()
  137. notifyModelCatalogChanged(props.profile)
  138. notice.value = `默认全局模型已更新为 ${selection.provider} / ${selection.model},仅影响新会话`
  139. } catch (cause) {
  140. error.value = cause instanceof Error ? cause.message : '默认全局模型保存失败'
  141. } finally {
  142. busy.value = false
  143. }
  144. }
  145. const providerGroups = computed(() => {
  146. const editable = new Map<string, CustomModelService | LegacyModelService>()
  147. for (const service of [...services.value, ...legacyServices.value]) editable.set(service.id.toLocaleLowerCase(), service)
  148. const groups = catalog.value.map(provider => {
  149. const service = editable.get(provider.slug.toLocaleLowerCase())
  150. if (service) editable.delete(provider.slug.toLocaleLowerCase())
  151. return { provider, service, models: provider.models.length ? provider.models : service?.models || [] }
  152. })
  153. for (const service of editable.values()) {
  154. groups.push({
  155. provider: { slug: service.id, name: service.name, models: service.models, isCurrent: service.is_current },
  156. service,
  157. models: service.models,
  158. })
  159. }
  160. return groups
  161. })
  162. const filteredProviderGroups = computed(() => {
  163. const needle = query.value.trim().toLocaleLowerCase()
  164. if (!needle) return providerGroups.value
  165. return providerGroups.value.filter(group => [
  166. group.provider.name,
  167. group.provider.slug,
  168. group.service?.base_url || '',
  169. ...group.models,
  170. ].some(value => value.toLocaleLowerCase().includes(needle)))
  171. })
  172. function inputFor(current: Draft): CustomModelServiceInput {
  173. const models = [...new Set(current.modelsText.split(/\r?\n|,/).map(value => value.trim()).filter(Boolean))]
  174. const contextLength = Number(current.contextLength)
  175. const input: CustomModelServiceInput = {
  176. ...(current.id ? { id: current.id } : {}),
  177. name: current.name.trim(),
  178. base_url: current.baseUrl.trim().replace(/\/$/, ''),
  179. model: current.model.trim(),
  180. models: [...new Set([...models, current.model.trim()].filter(Boolean))],
  181. discover_models: current.discoverModels,
  182. make_default: current.makeDefault,
  183. ...(Number.isInteger(contextLength) && contextLength > 0 ? { context_length: contextLength } : {}),
  184. }
  185. if (current.clearApiKey) input.api_key = ''
  186. else if (current.apiKeyTouched && current.apiKey.trim()) input.api_key = current.apiKey.trim()
  187. return input
  188. }
  189. function validateDraft(): string {
  190. const value = draft.value
  191. if (!value?.name.trim()) return '请输入服务名称'
  192. if (!/^https?:\/\//i.test(value.baseUrl.trim())) return 'Base URL 必须以 http:// 或 https:// 开头'
  193. if (!value.model.trim()) return '请输入默认模型'
  194. if (value.contextLength && (!Number.isInteger(Number(value.contextLength)) || Number(value.contextLength) <= 0)) return '上下文长度必须是正整数'
  195. return ''
  196. }
  197. async function probe() {
  198. const value = draft.value
  199. if (!value) return
  200. if (value.hasApiKey && !value.apiKeyTouched && !value.clearApiKey) {
  201. error.value = '9119 不会向浏览器回传已保存密钥;请重新输入 API Key 后再测试'
  202. return
  203. }
  204. const validationError = validateDraft()
  205. if (validationError) { error.value = validationError; return }
  206. busy.value = true; error.value = ''; notice.value = ''
  207. try {
  208. const result = await validateModelService(inputFor(value))
  209. if (!result.ok) throw new Error(result.message || '连接测试失败')
  210. const models = [...new Set(result.models.map(item => item.trim()).filter(Boolean))]
  211. if (models.length) {
  212. value.modelsText = models.join('\n')
  213. if (!models.includes(value.model)) value.model = models[0]!
  214. }
  215. notice.value = models.length ? `连接正常,发现 ${models.length} 个模型` : '连接正常,未返回模型列表'
  216. } catch (cause) {
  217. error.value = cause instanceof Error ? cause.message : '连接测试失败'
  218. } finally {
  219. busy.value = false
  220. }
  221. }
  222. async function save() {
  223. const value = draft.value
  224. if (!value) return
  225. const validationError = validateDraft()
  226. if (validationError) { error.value = validationError; return }
  227. busy.value = true; error.value = ''; notice.value = ''
  228. try {
  229. if (value.source === 'legacy') await saveLegacyModelService(props.profile, value.id, inputFor(value))
  230. else await saveModelService(props.profile, inputFor(value))
  231. draft.value = null
  232. draftBaseline.value = ''
  233. await load(true)
  234. notifyModelCatalogChanged(props.profile)
  235. notice.value = 'Provider 与模型已保存并刷新目录'
  236. } catch (cause) {
  237. error.value = cause instanceof Error ? cause.message : '模型服务保存失败'
  238. } finally {
  239. busy.value = false
  240. }
  241. }
  242. async function activate(service: CustomModelService) {
  243. busy.value = true; error.value = ''; notice.value = ''
  244. try {
  245. await activateModelService(props.profile, service.id)
  246. await load(true)
  247. notifyModelCatalogChanged(props.profile)
  248. notice.value = `“${service.name}”已设为当前 Agent 的默认模型服务`
  249. } catch (cause) {
  250. error.value = cause instanceof Error ? cause.message : '设为默认失败'
  251. } finally {
  252. busy.value = false
  253. }
  254. }
  255. async function remove(service: CustomModelService) {
  256. const suffix = service.is_current ? ',当前默认绑定也会被解除' : ''
  257. if (!window.confirm(`删除模型服务“${service.name}”及其模型${suffix}?`)) return
  258. busy.value = true; error.value = ''; notice.value = ''
  259. try {
  260. await deleteModelService(props.profile, service.id)
  261. if (draft.value?.id === service.id) draft.value = null
  262. if (!draft.value) draftBaseline.value = ''
  263. await load(true)
  264. notifyModelCatalogChanged(props.profile)
  265. notice.value = `“${service.name}”已删除`
  266. } catch (cause) {
  267. error.value = cause instanceof Error ? cause.message : '删除模型服务失败'
  268. } finally {
  269. busy.value = false
  270. }
  271. }
  272. watch(() => props.profile, () => { draft.value = null; draftBaseline.value = ''; defaultModelKey.value = ''; defaultModelBaseline.value = ''; void load() })
  273. watch(panelDirty, value => emit('dirty-change', value), { immediate: true })
  274. onMounted(() => { void load() })
  275. </script>
  276. <template>
  277. <section class="management-panel" aria-label="模型服务">
  278. <div class="panel-context"><strong>9119 Provider 与模型</strong><span>全部来自当前 Agent:<b>{{ profile }}</b></span></div>
  279. <form class="default-model-card" @submit.prevent="saveDefaultModel">
  280. <label><strong>默认全局模型</strong><small>仅用于之后新建的会话,已有会话保持原模型。</small></label>
  281. <select v-model="defaultModelKey" aria-label="默认全局模型" :disabled="busy || loading">
  282. <option value="" disabled>请选择 Provider 与模型</option>
  283. <optgroup v-for="provider in catalog" :key="provider.slug" :label="provider.name">
  284. <option v-for="model in provider.models" :key="modelKey(provider.slug, model)" :value="modelKey(provider.slug, model)">{{ provider.slug }} / {{ model }}</option>
  285. </optgroup>
  286. </select>
  287. <button class="primary-button" type="submit" :disabled="busy || loading || !defaultModelDirty">保存默认模型</button>
  288. </form>
  289. <div class="panel-heading"><label class="provider-search"><AppIcon name="search" :size="18" /><input v-model="query" type="search" placeholder="搜索 Provider 或模型" aria-label="搜索 Provider 或模型" /></label><button class="primary-button" type="button" :disabled="busy || loading" @click="beginCreate"><AppIcon name="plus" :size="15" />新增服务</button></div>
  290. <p v-if="error" class="panel-error" role="alert">{{ error }}</p>
  291. <p v-else-if="notice" class="panel-notice" role="status">{{ notice }}</p>
  292. <p v-if="loading" class="panel-empty">正在读取 9119 Provider 与模型…</p>
  293. <div v-else-if="filteredProviderGroups.length" class="service-list">
  294. <article v-for="group in filteredProviderGroups" :key="group.provider.slug" class="service-card">
  295. <div class="service-main"><div class="service-title"><strong>{{ group.provider.name }}</strong><em v-if="group.provider.isCurrent || group.service?.is_current">当前默认</em><em>{{ group.service ? '可编辑' : '只读' }}</em></div><small>{{ group.provider.slug }}<template v-if="group.service?.base_url"> · {{ group.service.base_url }}</template></small><div class="model-chips"><span v-for="model in group.models" :key="model" :class="{ current: group.provider.isCurrent && model === group.provider.currentModel }">{{ model }}</span></div></div>
  296. <div v-if="group.service" class="card-actions"><button type="button" :disabled="busy" @click="edit(group.service)">编辑</button><button v-if="group.service.source !== 'legacy' && !group.service.is_current" type="button" :disabled="busy" @click="activate(group.service)">设为默认</button><button v-if="group.service.source !== 'legacy'" class="danger" type="button" :disabled="busy" @click="remove(group.service)">删除</button></div>
  297. </article>
  298. </div>
  299. <p v-else-if="!loading" class="panel-empty">{{ query.trim() ? '没有匹配的 Provider 或模型。' : '9119 当前没有返回可用 Provider 或模型。' }}</p>
  300. <p v-if="!loading" class="catalog-note">完整目录来自 Hermes 9119;仅支持的自定义服务可编辑。</p>
  301. <form v-if="draft" class="service-editor" :aria-busy="busy" @submit.prevent="save">
  302. <header><strong>{{ draft.id ? `编辑 ${draft.id}` : '新增模型服务' }}</strong><button type="button" aria-label="关闭编辑" :disabled="busy" @click="closeEditor"><AppIcon name="close" :size="15" /></button></header>
  303. <div class="form-grid"><label>名称<input v-model="draft.name" :disabled="busy || draft.source === 'legacy'" maxlength="100" autocomplete="off" /></label><label>Base URL<input v-model="draft.baseUrl" :disabled="busy" placeholder="https://example.com/v1" autocomplete="url" /></label></div>
  304. <div class="form-grid"><label>默认模型<input v-model="draft.model" :disabled="busy" placeholder="model-id" autocomplete="off" /></label><label>上下文长度(可选)<input v-model="draft.contextLength" :disabled="busy" inputmode="numeric" placeholder="例如 131072" /></label></div>
  305. <label v-if="draft.canEditApiKey">API Key<input v-model="draft.apiKey" type="password" :disabled="busy" :placeholder="draft.hasApiKey && !draft.clearApiKey ? '已保存;留空保持不变' : '可选'" autocomplete="new-password" @input="draft.apiKeyTouched = Boolean(draft.apiKey.trim()); draft.clearApiKey = false" /></label>
  306. <p v-else class="editor-hint">该 Provider 的密钥不由 9119 环境变量管理,因此这里只编辑 URL 和模型。</p>
  307. <button v-if="draft.canEditApiKey && draft.hasApiKey && !draft.clearApiKey" class="text-button danger" type="button" :disabled="busy" @click="draft.apiKey = ''; draft.apiKeyTouched = false; draft.clearApiKey = true">清除已保存密钥</button><button v-else-if="draft.canEditApiKey && draft.clearApiKey" class="text-button" type="button" :disabled="busy" @click="draft.clearApiKey = false">取消清除密钥</button>
  308. <label>模型列表(每行一个)<textarea v-model="draft.modelsText" rows="5" :disabled="busy" placeholder="连接测试后自动填充,也可手动输入" /></label>
  309. <label class="check-row"><input v-model="draft.discoverModels" type="checkbox" :disabled="busy" />允许 9119 自动发现模型</label><label v-if="draft.source === 'managed'" class="check-row"><input v-model="draft.makeDefault" type="checkbox" :disabled="busy" />保存后设为当前 Agent 默认服务</label>
  310. <small class="editor-hint">{{ draft.source === 'legacy' ? '保存后模型列表按当前内容更新;当前默认 Provider 会同步新的 URL 与默认模型。' : '9119 会保留服务中已有的模型条目;删除整项服务会一并移除它们。' }}</small>
  311. <footer><button class="quiet-button" type="button" :disabled="busy" @click="probe">测试连接</button><button class="primary-button" type="submit" :disabled="busy">{{ busy ? '处理中…' : '保存模型服务' }}</button></footer>
  312. </form>
  313. </section>
  314. </template>
  315. <style scoped>
  316. .management-panel{display:grid;gap:16px}.panel-context{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--text-secondary)}.panel-context strong{font-size:14px}.panel-context span{color:var(--text-muted);font-size:12px}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:40px}.provider-search{display:flex;min-width:0;min-height:44px;flex:1;align-items:center;gap:9px;padding:0 12px;border:1px solid var(--line);border-radius:9px;color:var(--text-muted);background:var(--surface-raised)}.provider-search:focus-within{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}.provider-search input{min-width:0;flex:1;border:0;outline:0;background:transparent;color:var(--text-primary);font:14px var(--font-ui)}.primary-button,.quiet-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}.primary-button{background:var(--accent);color:var(--text-on-solid)}.quiet-button{border:1px solid var(--line);background:var(--surface-raised);color:var(--text-secondary)}button:disabled{cursor:wait;opacity:.5}.panel-error,.panel-notice,.panel-empty{margin:0;padding:11px 12px;border-radius:9px;font-size:13px}.panel-error{background:color-mix(in srgb,var(--danger) 8%,transparent);color:var(--danger)}.panel-notice{background:color-mix(in srgb,var(--success,#21845b) 9%,transparent);color:var(--success,#21845b)}.panel-empty{color:var(--text-muted);background:var(--surface-soft)}.service-list{display:grid;border-top:1px solid var(--line)}.service-card{display:flex;align-items:center;gap:14px;padding:18px 12px;border:0;border-bottom:1px solid var(--line)}.service-main{display:grid;min-width:0;flex:1;gap:8px}.service-title{display:flex;flex-wrap:wrap;align-items:center;gap:7px}.service-card strong{font-size:16px}.service-card small{color:var(--text-muted);font:12px var(--font-code);overflow-wrap:anywhere}.service-card em{padding:3px 7px;border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--text-muted);font:normal 11px var(--font-ui)}.model-chips{display:flex;flex-wrap:wrap;gap:8px}.model-chips span{max-width:100%;padding:6px 9px;overflow:hidden;border-radius:7px;background:var(--surface-soft);color:var(--text-secondary);font:11px var(--font-code);text-overflow:ellipsis;white-space:nowrap}.model-chips span.current{background:color-mix(in srgb,var(--accent) 9%,var(--surface-soft));color:var(--accent)}.card-actions{display:flex;flex:0 0 auto;gap:7px}.card-actions button,.service-editor header button,.text-button{min-height:32px;padding:0 7px;border:0;border-radius:7px;background:transparent;color:var(--text-secondary);cursor:pointer;font-size:12px}.card-actions button:hover,.text-button:hover{background:var(--surface-soft)}.card-actions .danger,.text-button.danger{color:var(--danger)}.catalog-note{margin:0;color:var(--text-muted);font-size:12px;line-height:1.6}.service-editor{display:grid;gap:14px;padding:18px;border:1px solid var(--line-strong);border-radius:12px;background:var(--surface-soft)}.service-editor header{display:flex;align-items:center;justify-content:space-between}.service-editor header strong{font-size:15px}.service-editor header button{display:grid;width:36px;height:36px;place-items:center}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.service-editor label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:650}.service-editor input:not([type=checkbox]),.service-editor textarea{width:100%;box-sizing:border-box;padding:10px 11px;border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface-raised);color:var(--text-primary);font:13px var(--font-ui)}.service-editor input:not([type=checkbox]){height:42px}.service-editor input:focus,.service-editor textarea:focus{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}.service-editor textarea{resize:vertical;font-family:var(--font-code);line-height:1.5}.check-row{display:flex!important;align-items:center;gap:8px!important;font-weight:500!important}.text-button{justify-self:start;padding-inline:0}.editor-hint{margin:0;color:var(--text-muted);font-size:11px}.service-editor footer{display:flex;justify-content:flex-end;gap:9px}@media(max-width:600px){.panel-heading{align-items:stretch;flex-direction:column;gap:10px}.panel-heading .primary-button{width:100%}.panel-context{align-items:flex-start;flex-direction:column}.service-card{align-items:flex-start;flex-direction:column}.card-actions{width:100%;justify-content:flex-end}.form-grid{grid-template-columns:1fr}}
  317. .default-model-card{display:grid;grid-template-columns:minmax(180px,1fr) minmax(240px,2fr) auto;align-items:end;gap:12px;padding:14px;border:1px solid var(--line);border-radius:11px;background:var(--surface-soft)}
  318. .default-model-card label{display:grid;gap:4px;color:var(--text-secondary)}
  319. .default-model-card label small{color:var(--text-muted);font-size:11px;font-weight:400}
  320. .default-model-card select{min-width:0;height:40px;padding:0 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-raised);color:var(--text-primary);font:12px var(--font-code)}
  321. @media(max-width:600px){.default-model-card{grid-template-columns:1fr}.default-model-card .primary-button{width:100%}}
  322. </style>