feat(ai): improve model selection with searchable dropdown
* fix(ui): toast 消息支持换行显示 - 添加 max-w-3xl 限制最大宽度 - 使用 whitespace-pre-wrap 允许自动换行 - 添加 break-words 防止长单词溢出 * fix(ai): 优化模型选择下拉列表 --------- Co-authored-by: t8y2 <1156263951@qq.com> Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
parent
7e5fd88e63
commit
74df246d7e
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -88,14 +89,6 @@ const modelOptions = ref<AiModelInfo[]>([]);
|
|||
const modelLoading = ref(false);
|
||||
let modelRequestToken = 0;
|
||||
|
||||
const currentModelLabel = computed(() => {
|
||||
const currentModel = settings.aiConfig.model;
|
||||
const found = modelOptions.value.find((m) => m.id === currentModel);
|
||||
const raw = found?.displayName || currentModel;
|
||||
if (!raw) return t("ai.model");
|
||||
return raw.length > 20 ? raw.slice(0, 18) + "…" : raw;
|
||||
});
|
||||
|
||||
function normalizeModelOptions(models: AiModelInfo[]): AiModelInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: AiModelInfo[] = [];
|
||||
|
|
@ -129,19 +122,19 @@ function handleModelSelect(modelId: string) {
|
|||
settings.updateAiConfig({ model: modelId });
|
||||
}
|
||||
|
||||
const modelDropdownItems = computed(() => {
|
||||
const modelOptionIds = computed(() => {
|
||||
const currentModel = settings.aiConfig.model;
|
||||
const items = modelOptions.value.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.displayName || m.id,
|
||||
title: m.id,
|
||||
}));
|
||||
if (currentModel && !items.some((item) => item.value === currentModel)) {
|
||||
items.unshift({ value: currentModel, label: currentModel, title: currentModel });
|
||||
const ids = modelOptions.value.map((model) => model.id);
|
||||
if (currentModel && !ids.includes(currentModel)) {
|
||||
return [currentModel, ...ids];
|
||||
}
|
||||
return items;
|
||||
return ids;
|
||||
});
|
||||
|
||||
function displayModelName(modelId: string) {
|
||||
return modelOptions.value.find((model) => model.id === modelId)?.displayName || modelId;
|
||||
}
|
||||
|
||||
/** Deferred context compaction info; applied after stream ends to avoid shifting assistantIdx. */
|
||||
const pendingCompaction = ref<{ summary: string; compactedMessages: number } | null>(null);
|
||||
|
||||
|
|
@ -1240,20 +1233,32 @@ const messageRenderer = computed(() => {
|
|||
<LightDropdown v-model="assistantMode" :items="assistantModeItems" :aria-label="activeModeHint" item-class="text-xs px-2" />
|
||||
<LightDropdown :model-value="activeAction" :items="actionMenuItems" content-class="w-max min-w-0" item-class="text-xs px-2" @update:model-value="(value) => selectAction(value as AiAction)" />
|
||||
<span class="flex-1" />
|
||||
<LightDropdown
|
||||
<SearchableSelect
|
||||
v-if="settings.isConfigured()"
|
||||
:model-value="settings.aiConfig.model"
|
||||
:items="modelDropdownItems"
|
||||
:trigger-title="settings.aiConfig.model"
|
||||
:aria-label="t('ai.model')"
|
||||
:trigger-label="currentModelLabel"
|
||||
trigger-class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground truncate max-w-[130px]"
|
||||
:options="modelOptionIds"
|
||||
:placeholder="t('ai.browseModels')"
|
||||
:search-placeholder="t('ai.searchModels')"
|
||||
:empty-text="t('ai.modelListHint')"
|
||||
:loading-text="t('ai.loadingModels')"
|
||||
:loading="modelLoading"
|
||||
:display-name="displayModelName"
|
||||
trigger-class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground truncate max-w-[130px]"
|
||||
content-class="w-72"
|
||||
item-class="text-xs px-2"
|
||||
:match-trigger-width="false"
|
||||
content-class="w-max min-w-36 max-w-64"
|
||||
check-position="right"
|
||||
@update:model-value="handleModelSelect"
|
||||
/>
|
||||
@update:open="(open: boolean) => open && fetchModelOptions()"
|
||||
>
|
||||
<template #trigger-label="{ label, loading }">
|
||||
<span class="truncate">{{ loading ? t("ai.loadingModels") : label }}</span>
|
||||
</template>
|
||||
<template #option-label="{ option, label }">
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<span v-if="label !== option" class="truncate text-[11px] text-muted-foreground">{{ option }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
<button v-if="isGenerating" class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
|
||||
<Square class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const props = withDefaults(
|
|||
allowCustom?: boolean;
|
||||
triggerClass?: HTMLAttributes["class"];
|
||||
contentClass?: HTMLAttributes["class"];
|
||||
itemClass?: HTMLAttributes["class"];
|
||||
displayName?: (option: string) => string;
|
||||
normalizeCustom?: (value: string) => string;
|
||||
}>(),
|
||||
|
|
@ -51,6 +52,11 @@ const filteredOptions = computed(() => filterDatabaseOptions(props.options, sear
|
|||
const customOptionValue = computed(() => props.normalizeCustom(searchText.value.trim()));
|
||||
const canSelectCustom = computed(() => props.allowCustom && !!customOptionValue.value && !props.options.includes(customOptionValue.value));
|
||||
|
||||
function highlightSelectedOption() {
|
||||
const selectedIndex = filteredOptions.value.findIndex((option) => option === props.modelValue);
|
||||
highlightIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
}
|
||||
|
||||
watch(open, async (value) => {
|
||||
emit("update:open", value);
|
||||
if (!value) {
|
||||
|
|
@ -61,12 +67,22 @@ watch(open, async (value) => {
|
|||
await nextTick();
|
||||
const input = searchInput.value?.$el as HTMLInputElement | undefined;
|
||||
input?.focus();
|
||||
highlightSelectedOption();
|
||||
});
|
||||
|
||||
watch(searchText, () => {
|
||||
highlightIndex.value = 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.options],
|
||||
() => {
|
||||
if (!open.value || searchText.value) return;
|
||||
highlightSelectedOption();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(highlightIndex, async () => {
|
||||
await nextTick();
|
||||
const container = listContainer.value;
|
||||
|
|
@ -86,6 +102,11 @@ function selectCustomOption() {
|
|||
selectOption(customOptionValue.value);
|
||||
}
|
||||
|
||||
function optionTitle(option: string) {
|
||||
const label = props.displayName(option);
|
||||
return label === option ? option : `${label}\n${option}`;
|
||||
}
|
||||
|
||||
function optionCount() {
|
||||
return filteredOptions.value.length + (canSelectCustom.value ? 1 : 0);
|
||||
}
|
||||
|
|
@ -118,7 +139,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="ghost" :class="cn('h-6 w-auto max-w-56 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0', triggerClass)">
|
||||
<Button type="button" variant="ghost" :title="selectedLabel" :class="cn('h-6 w-auto max-w-56 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0', triggerClass)">
|
||||
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
|
||||
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
|
||||
</slot>
|
||||
|
|
@ -139,7 +160,14 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
v-for="(option, index) in filteredOptions"
|
||||
:key="option"
|
||||
type="button"
|
||||
:class="cn('flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none', index === highlightIndex && 'bg-accent text-accent-foreground')"
|
||||
:title="optionTitle(option)"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
index === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||
|
|
@ -150,9 +178,11 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
<button
|
||||
v-if="canSelectCustom"
|
||||
type="button"
|
||||
:title="customOptionValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
filteredOptions.length === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
|
|
@ -167,7 +197,14 @@ function handleKeydown(event: KeyboardEvent) {
|
|||
<button
|
||||
v-else-if="canSelectCustom"
|
||||
type="button"
|
||||
:class="cn('flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none', 0 === highlightIndex && 'bg-accent text-accent-foreground')"
|
||||
:title="customOptionValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
|
||||
props.itemClass,
|
||||
0 === highlightIndex && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="selectCustomOption"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue