Jelajahi Sumber

feat: 对齐夭夭模型选择弹窗

samien 2 bulan lalu
induk
melakukan
c0a22d0631

+ 150 - 0
src/client/components/composer/ModelChoiceDialog.vue

@@ -0,0 +1,150 @@
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import type { ModelOption } from '@shared/types'
+import AppIcon from '@/components/common/AppIcon.vue'
+
+const props = withDefaults(defineProps<{
+  open: boolean
+  options: ModelOption[]
+  selectedId?: string
+  busy?: boolean
+}>(), { selectedId: '', busy: false })
+
+const emit = defineEmits<{ close: []; select: [id: string] }>()
+const search = ref('')
+const searchInput = ref<HTMLInputElement | null>(null)
+const collapsedProviders = ref<Record<string, boolean>>({})
+
+const PROVIDER_LABELS: Record<string, string> = {
+  anthropic: 'Anthropic', google: 'Google', groq: 'Groq', minimax: 'MiniMax',
+  mistral: 'Mistral', ollama: 'Ollama', openai: 'OpenAI', openrouter: 'OpenRouter', xai: 'xAI',
+}
+
+function modelId(option: ModelOption): string { return `${option.provider}:${option.id}` }
+
+function providerLabel(provider: string): string {
+  const key = provider.toLocaleLowerCase()
+  return PROVIDER_LABELS[key] || provider.replace(/[-_]+/g, ' ').replace(/\b\w/g, value => value.toLocaleUpperCase())
+}
+
+const groups = computed(() => {
+  const query = search.value.trim().toLocaleLowerCase()
+  const grouped = new Map<string, ModelOption[]>()
+  for (const option of props.options) {
+    const haystack = `${option.name} ${option.id} ${option.provider}`.toLocaleLowerCase()
+    if (query && !haystack.includes(query)) continue
+    const items = grouped.get(option.provider) ?? []
+    items.push(option)
+    grouped.set(option.provider, items)
+  }
+  return [...grouped].map(([provider, models]) => ({ provider, label: providerLabel(provider), models }))
+})
+
+function close(): void { if (!props.busy) emit('close') }
+
+function toggleProvider(provider: string): void {
+  if (props.busy) return
+  collapsedProviders.value = { ...collapsedProviders.value, [provider]: !collapsedProviders.value[provider] }
+}
+
+function onKeydown(event: KeyboardEvent): void {
+  if (props.open && event.key === 'Escape') close()
+}
+
+watch(() => props.open, async open => {
+  if (!open) return
+  search.value = ''
+  collapsedProviders.value = {}
+  await nextTick()
+  searchInput.value?.focus()
+})
+
+onMounted(() => window.addEventListener('keydown', onKeydown))
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="model-dialog-fade">
+      <div v-if="open" class="model-dialog-layer" role="presentation" @mousedown.self="close">
+        <section class="model-dialog" role="dialog" aria-modal="true" aria-labelledby="model-dialog-title" :aria-busy="busy">
+          <header class="model-dialog__header">
+            <h2 id="model-dialog-title">选择模型</h2>
+            <button class="icon-button" type="button" aria-label="关闭模型选择" :disabled="busy" @click="close"><AppIcon name="close" :size="15" /></button>
+          </header>
+
+          <label class="model-dialog__search">
+            <AppIcon name="search" :size="14" />
+            <input ref="searchInput" v-model="search" type="search" placeholder="搜索模型名称或 ID" :disabled="busy" autocomplete="off" />
+          </label>
+
+          <div class="model-dialog__list">
+            <section v-for="group in groups" :key="group.provider" class="model-dialog__group">
+              <button class="model-dialog__group-header" type="button" :aria-expanded="!collapsedProviders[group.provider]" :disabled="busy" @click="toggleProvider(group.provider)">
+                <AppIcon class="model-dialog__chevron" :class="{ collapsed: collapsedProviders[group.provider] }" name="chevron-down" :size="13" />
+                <strong>{{ group.label }}</strong><span>{{ group.models.length }}</span>
+              </button>
+              <div v-show="!collapsedProviders[group.provider]" class="model-dialog__items">
+                <button v-for="model in group.models" :key="modelId(model)" class="model-dialog__item" :class="{ 'model-dialog__item--active': modelId(model) === selectedId }" type="button" :disabled="busy" @click="emit('select', modelId(model))">
+                  <span class="model-dialog__item-label">
+                    <strong>{{ model.name || model.id }}</strong>
+                    <small v-if="model.name && model.name !== model.id">{{ model.id }}</small>
+                  </span>
+                  <span v-if="model.isDefault" class="model-dialog__badge">默认</span>
+                  <span v-if="model.supportsReasoning" class="model-dialog__badge model-dialog__badge--quiet">推理</span>
+                  <AppIcon v-if="modelId(model) === selectedId" class="model-dialog__check" name="check" :size="15" />
+                </button>
+              </div>
+            </section>
+            <p v-if="!groups.length" class="model-dialog__empty">没有匹配的模型</p>
+          </div>
+          <p v-if="busy" class="model-dialog__status" role="status">正在切换模型…</p>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.model-dialog-layer { position: fixed; z-index: 230; inset: 0; display: grid; place-items: center; padding: 16px; background: var(--scrim); backdrop-filter: blur(4px); }
+.model-dialog { display: flex; width: min(480px, calc(100vw - 32px)); max-height: min(650px, calc(100dvh - 32px)); padding: 12px; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); box-shadow: var(--shadow-float); flex-direction: column; }
+.model-dialog__header { display: flex; min-height: 42px; align-items: center; justify-content: space-between; padding: 0 2px 7px 8px; }
+.model-dialog__header h2 { margin: 0; color: var(--text-primary); font-size: 14px; font-weight: 650; letter-spacing: -.02em; }
+.model-dialog__header .icon-button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); cursor: pointer; }
+.model-dialog__header .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
+.model-dialog__header .icon-button:disabled { cursor: wait; opacity: .45; }
+.model-dialog__search { display: flex; min-height: 38px; align-items: center; gap: 8px; margin: 0 4px 10px; padding: 0 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--text-muted); transition: border-color 120ms ease, box-shadow 120ms ease; }
+.model-dialog__search:focus-within { border-color: var(--input-border-focus-color); box-shadow: 0 0 0 3px var(--focus-ring); }
+.model-dialog__search input { min-width: 0; flex: 1; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: 12px var(--font-ui); }
+.model-dialog__search input::placeholder { color: var(--input-placeholder-color); }
+.model-dialog__list { min-height: 148px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
+.model-dialog__group { margin-bottom: 4px; }
+.model-dialog__group-header { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 6px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }
+.model-dialog__group-header:hover { background: var(--surface-soft); }
+.model-dialog__group-header:disabled { cursor: wait; }
+.model-dialog__group-header strong { min-width: 0; flex: 1; font-size: 11px; font-weight: 650; }
+.model-dialog__group-header span { color: var(--text-muted); font-size: 10px; font-weight: 450; }
+.model-dialog__chevron { flex: 0 0 auto; transition: transform 120ms ease; }
+.model-dialog__chevron.collapsed { transform: rotate(-90deg); }
+.model-dialog__items { padding-left: 8px; }
+.model-dialog__item { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 7px; padding: 6px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; transition: background-color 120ms ease, color 120ms ease; }
+.model-dialog__item:hover { background: color-mix(in srgb, #1677ff 7%, transparent); color: var(--text-primary); }
+.model-dialog__item:disabled { cursor: wait; opacity: .58; }
+.model-dialog__item--active { color: #1677ff; font-weight: 550; }
+.dark .model-dialog__item--active { color: #69a7ff; }
+.model-dialog__item-label { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
+.model-dialog__item-label strong, .model-dialog__item-label small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-code); }
+.model-dialog__item-label strong { font-size: 11px; font-weight: inherit; }
+.model-dialog__item-label small { color: var(--text-muted); font-size: 9px; font-weight: 400; }
+.model-dialog__badge { flex: 0 0 auto; padding: 2px 5px; border-radius: 4px; background: #1677ff; color: #fff; font-size: 8px; font-weight: 650; }
+.model-dialog__badge--quiet { border: 1px solid var(--line); background: transparent; color: var(--text-muted); }
+.model-dialog__check { flex: 0 0 auto; color: currentColor; }
+.model-dialog__empty { margin: 0; padding: 32px 10px; color: var(--text-muted); font-size: 11px; text-align: center; }
+.model-dialog__status { margin: 7px 8px 0; color: var(--text-muted); font-size: 9px; text-align: right; }
+.model-dialog-fade-enter-active, .model-dialog-fade-leave-active { transition: opacity 130ms ease; }
+.model-dialog-fade-enter-active .model-dialog, .model-dialog-fade-leave-active .model-dialog { transition: transform 160ms var(--ease-out); }
+.model-dialog-fade-enter-from, .model-dialog-fade-leave-to { opacity: 0; }
+.model-dialog-fade-enter-from .model-dialog, .model-dialog-fade-leave-to .model-dialog { transform: translateY(7px) scale(.99); }
+@media (max-width: 600px) { .model-dialog-layer { padding: 12px; }.model-dialog { width: min(480px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }.model-dialog__items { padding-left: 3px; } }
+@media (prefers-reduced-motion: reduce) { .model-dialog-fade-enter-active, .model-dialog-fade-leave-active, .model-dialog-fade-enter-active .model-dialog, .model-dialog-fade-leave-active .model-dialog, .model-dialog__chevron { transition: none; } }
+</style>

+ 15 - 5
src/client/views/ChatView.vue

@@ -3,9 +3,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
-import ChoiceDialog from '@/components/common/ChoiceDialog.vue'
 import type { ChoiceOption } from '@/components/common/types'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
+import ModelChoiceDialog from '@/components/composer/ModelChoiceDialog.vue'
 import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
 import PreviewInspector from '@/components/library/PreviewInspector.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
@@ -36,6 +36,7 @@ const filePreview = ref<UiLibraryItem | null>(null)
 const mediaPreviewIndex = ref<number | null>(null)
 const outlineOpen = ref(false)
 const modelDialog = ref(false)
+const modelSwitching = ref(false)
 const showThinking = ref(true)
 const queueMode = ref(false)
 const actionSessionId = ref('')
@@ -72,7 +73,6 @@ const forkSourceTitle = computed(() => {
   if (!parentId) return ''
   return chat.sessions.find(session => session.id === parentId && session.profile === activeSession.value?.profile)?.title || parentId
 })
-const modelOptions = computed<ChoiceOption[]>(() => chat.models.map(model => ({ id: `${model.provider}:${model.id}`, label: model.name || model.id, description: model.provider })))
 const selectedModelId = computed(() => chat.selectedModel ? `${chat.selectedModel.provider}:${chat.selectedModel.id}` : '')
 const reasoningOptions: ChoiceOption[] = [
   { id: '', label: '默认', description: '使用模型或 Profile 的默认强度' },
@@ -194,10 +194,20 @@ async function addMediaToComposer(media: PreviewMedia) {
 }
 
 async function selectModel(id: string) {
+  if (id === selectedModelId.value) {
+    modelDialog.value = false
+    return
+  }
   const [provider, ...rest] = id.split(':')
   const model = chat.models.find(item => item.provider === provider && item.id === rest.join(':'))
-  if (model) await chat.setModel(model)
-  modelDialog.value = false
+  if (!model || modelSwitching.value) return
+  modelSwitching.value = true
+  try {
+    await chat.setModel(model)
+    modelDialog.value = false
+  } finally {
+    modelSwitching.value = false
+  }
 }
 
 function selectReasoning(id: string) {
@@ -453,7 +463,7 @@ watch(() => chat.activeSessionId, async id => {
   <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" @add="addMediaToComposer" />
 
-  <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
+  <ModelChoiceDialog :open="modelDialog" :options="chat.models" :selected-id="selectedModelId" :busy="modelSwitching" @close="modelDialog = false" @select="selectModel" />
 
   <Teleport to="body">
     <Transition name="session-menu">

+ 41 - 0
tests/client/modelChoiceDialog.test.ts

@@ -0,0 +1,41 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import { nextTick } from 'vue'
+import ModelChoiceDialog from '@/components/composer/ModelChoiceDialog.vue'
+
+const options = [
+  { id: 'gpt-5.6-terra', name: 'gpt-5.6-terra', provider: 'openai', supportsReasoning: true, isDefault: true },
+  { id: 'gpt-5.5', name: 'GPT 5.5', provider: 'openai' },
+  { id: 'claude-sonnet', name: 'Claude Sonnet', provider: 'anthropic' },
+]
+
+afterEach(() => { document.body.innerHTML = '' })
+
+describe('ModelChoiceDialog', () => {
+  it('groups and filters models while preserving the selected state', async () => {
+    const wrapper = mount(ModelChoiceDialog, { attachTo: document.body, props: { open: true, options, selectedId: 'openai:gpt-5.6-terra' } })
+    await nextTick()
+    const dialog = document.querySelector('[role="dialog"][aria-labelledby="model-dialog-title"]')
+    expect(dialog?.textContent).toContain('OpenAI')
+    expect(dialog?.textContent).toContain('Anthropic')
+    expect(dialog?.querySelector('.model-dialog__item--active')?.textContent).toContain('gpt-5.6-terra')
+
+    const input = document.querySelector<HTMLInputElement>('input[placeholder="搜索模型名称或 ID"]')!
+    input.value = 'claude'
+    input.dispatchEvent(new Event('input'))
+    await nextTick()
+    expect(dialog?.textContent).toContain('Claude Sonnet')
+    expect(dialog?.textContent).not.toContain('gpt-5.5')
+
+    document.querySelector<HTMLButtonElement>('.model-dialog__item')!.click()
+    expect(wrapper.emitted('select')?.[0]).toEqual(['anthropic:claude-sonnet'])
+    wrapper.unmount()
+  })
+
+  it('closes with Escape when no model switch is running', () => {
+    const wrapper = mount(ModelChoiceDialog, { attachTo: document.body, props: { open: true, options } })
+    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+    expect(wrapper.emitted('close')).toHaveLength(1)
+    wrapper.unmount()
+  })
+})

+ 27 - 0
tests/e2e/workspace.spec.ts

@@ -10,6 +10,33 @@ test.beforeEach(async ({ page }) => {
   await expect(page.getByRole('navigation')).toBeVisible()
 })
 
+test('uses the yaoyao-webui grouped model picker without a mobile full-screen sheet', async ({ page }) => {
+  await page.goto('/chat/session-demo')
+  await page.locator('.composer-tool--model').click()
+
+  const dialog = page.getByRole('dialog', { name: '选择模型' })
+  await expect(dialog).toBeVisible()
+  await expect(dialog.getByPlaceholder('搜索模型名称或 ID')).toBeFocused()
+  await expect(dialog.locator('.model-dialog__group-header')).toContainText('OpenAI')
+  await expect(dialog.locator('.model-dialog__item--active')).toContainText('gpt-5.6')
+
+  const desktopBox = await dialog.boundingBox()
+  expect(desktopBox?.width).toBeGreaterThanOrEqual(450)
+  expect(desktopBox?.width).toBeLessThanOrEqual(480)
+
+  await dialog.getByPlaceholder('搜索模型名称或 ID').fill('gpt-5.5')
+  await expect(dialog.locator('.model-dialog__item')).toHaveCount(1)
+  await page.keyboard.press('Escape')
+  await expect(dialog).toBeHidden()
+
+  await page.setViewportSize({ width: 390, height: 844 })
+  await page.locator('.composer-tool--model').click()
+  const mobileBox = await dialog.boundingBox()
+  expect(mobileBox?.x).toBeGreaterThanOrEqual(12)
+  expect(mobileBox?.width).toBeLessThanOrEqual(366)
+  expect(mobileBox?.height).toBeLessThan(820)
+})
+
 test('navigates every 9119 workspace without blank transitions', async ({ page }) => {
   const consoleErrors: string[] = []
   page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) })