feat(chat): switch models from the chat panel
The chat model chip is a dropdown for built-in providers but a static label for OpenAI-compatible and custom-model setups, so anyone using a custom endpoint had to open Settings to change models -- even with several models already configured. Add a profile switcher that reassigns the design role from the chat panel. It lists saved profiles that can use tools, shows the profile name rather than the raw model ID, and badges the vision-capable ones. It writes the same assignments.design field the Settings dropdown does, so the two stay in sync. Only renders when more than one tools-capable profile exists; a single profile keeps the existing label. Built-in providers keep ProviderModelSelect unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9be70db9f9
commit
4bdaeb6d6e
|
|
@ -2,11 +2,13 @@
|
|||
import { TooltipProvider } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
|
||||
import ProviderModelSelect from '@/components/chat/ProviderModelSelect.vue'
|
||||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { useButtonUI } from '@/components/ui/button'
|
||||
import { useAIChat } from '@/app/ai/chat/use'
|
||||
import { aiModelSettings, designModelProfile } from '@/app/ai/models'
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
|
|
@ -58,6 +60,15 @@ const selectedModelName = computed(() => {
|
|||
return providerDef.value.models.find((m) => m.id === modelID.value)?.name ?? modelID.value
|
||||
})
|
||||
|
||||
// Switching between saved profiles only makes sense once more than one can drive the design agent.
|
||||
const switchableProfiles = computed(() =>
|
||||
aiModelSettings.value.models.filter((profile) => profile.capabilities.includes('tools'))
|
||||
)
|
||||
const canSwitchProfile = computed(() => switchableProfiles.value.length > 1)
|
||||
const selectedProfileName = computed(
|
||||
() => designModelProfile.value?.name ?? selectedModelName.value
|
||||
)
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const text = input.value.trim()
|
||||
|
|
@ -78,6 +89,9 @@ function handleSubmit(e: Event) {
|
|||
{{ acpAgentName }}
|
||||
</div>
|
||||
</template>
|
||||
<ChatProfileSelect v-else-if="canSwitchProfile">
|
||||
<template #value>{{ selectedProfileName }}</template>
|
||||
</ChatProfileSelect>
|
||||
<template v-else-if="isCustomProvider || usesCustomModel">
|
||||
<div
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-muted"
|
||||
|
|
|
|||
68
src/components/chat/ChatProfileSelect.vue
Normal file
68
src/components/chat/ChatProfileSelect.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import { aiModelSettings, setModelRoleAssignment } from '@/app/ai/models'
|
||||
import type { AIModelProfileId } from '@/app/ai/models'
|
||||
import AppBadge from '@/components/ui/AppBadge.vue'
|
||||
import { useSelectUI } from '@/components/ui/select'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
|
||||
// Only profiles that can drive the design agent — setModelRoleAssignment rejects the rest.
|
||||
const profiles = computed(() =>
|
||||
aiModelSettings.value.models.filter((profile) => profile.capabilities.includes('tools'))
|
||||
)
|
||||
|
||||
const selectedProfileId = computed({
|
||||
get: () => aiModelSettings.value.assignments.design,
|
||||
set: (profileId: AIModelProfileId) => setModelRoleAssignment('design', profileId)
|
||||
})
|
||||
|
||||
const selectCls = useSelectUI({
|
||||
trigger: 'gap-1 rounded border-none bg-transparent px-1.5 py-0.5 text-[10px] text-muted',
|
||||
content: 'max-h-60 overflow-y-auto',
|
||||
item: 'gap-2 rounded px-2 py-1.5 text-[11px]'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-model="selectedProfileId">
|
||||
<SelectTrigger
|
||||
data-test-id="chat-profile-selector"
|
||||
:aria-label="dialogs.models"
|
||||
:class="selectCls.trigger"
|
||||
>
|
||||
<icon-lucide-bot class="size-3" />
|
||||
<slot name="value" />
|
||||
<icon-lucide-chevron-down class="size-2.5" />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent position="popper" side="top" :side-offset="4" :class="selectCls.content">
|
||||
<SelectViewport>
|
||||
<SelectItem
|
||||
v-for="profile in profiles"
|
||||
:key="profile.id"
|
||||
:value="profile.id"
|
||||
:class="selectCls.item"
|
||||
>
|
||||
<SelectItemText class="flex-1">{{ profile.name }}</SelectItemText>
|
||||
<AppBadge v-if="profile.capabilities.includes('vision')">
|
||||
{{ dialogs.modelCapabilityVisionShort }}
|
||||
</AppBadge>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
aiModelSettings,
|
||||
createAIModelRuntime,
|
||||
createModelProfileDraft,
|
||||
modelSettingsSnapshot,
|
||||
|
|
@ -99,6 +100,36 @@ describe('AI model profiles and role assignments', () => {
|
|||
expect(resolveAIModelRole('vision')).toBeNull()
|
||||
})
|
||||
|
||||
test('switches the design agent between saved profiles', () => {
|
||||
// Backs the chat model switcher: it lists tools-capable profiles and reassigns the design role.
|
||||
const switchable = aiModelSettings.value.models.filter((profile) =>
|
||||
profile.capabilities.includes('tools')
|
||||
)
|
||||
expect(switchable.map((profile) => profile.id)).toEqual(['model-design', 'model-fast'])
|
||||
|
||||
setModelRoleAssignment('design', 'model-fast')
|
||||
expect(resolveAIModelRole('design')?.profile.id).toBe('model-fast')
|
||||
expect(resolveAIModelRole('design')?.connection.id).toBe('connection-google')
|
||||
|
||||
setModelRoleAssignment('design', 'model-design')
|
||||
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
|
||||
})
|
||||
|
||||
test('refuses to assign a profile that cannot use tools as the design agent', () => {
|
||||
aiModelSettings.value.models.push({
|
||||
id: 'model-textonly',
|
||||
name: 'Text only',
|
||||
connectionId: 'connection-google',
|
||||
modelID: 'text-only',
|
||||
customModelID: '',
|
||||
maxOutputTokens: 4096,
|
||||
capabilities: []
|
||||
})
|
||||
|
||||
setModelRoleAssignment('design', 'model-textonly')
|
||||
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
|
||||
})
|
||||
|
||||
test('reuses matching provider connections when adding models', () => {
|
||||
const draft = createModelProfileDraft()
|
||||
draft.name = 'Review model'
|
||||
|
|
|
|||
Loading…
Reference in a new issue