feat: 聊天对话底部支持快速选择对话模型

This commit is contained in:
digua
2026-01-22 22:37:20 +08:00
parent 2f123903f1
commit a5cf9dca29
5 changed files with 284 additions and 87 deletions

View File

@@ -12,6 +12,7 @@ import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { usePromptStore } from '@/stores/prompt'
import { useSettingsStore } from '@/stores/settings'
import { useLLMStore } from '@/stores/llm'
const { t } = useI18n()
@@ -19,6 +20,7 @@ const sessionStore = useSessionStore()
const layoutStore = useLayoutStore()
const promptStore = usePromptStore()
const settingsStore = useSettingsStore()
const llmStore = useLLMStore()
const { isInitialized } = storeToRefs(sessionStore)
const route = useRoute()
@@ -30,6 +32,8 @@ const tooltip = {
onMounted(async () => {
// 初始化语言设置(同步 i18n 和 dayjs
settingsStore.initLocale()
// 初始化 LLM 配置(预加载,避免首次使用时延迟)
llmStore.init()
// 从数据库加载会话列表
await sessionStore.loadSessions()
})

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
import { usePromptStore } from '@/stores/prompt'
import { useLayoutStore } from '@/stores/layout'
import { useLLMStore } from '@/stores/llm'
const { t } = useI18n()
const toast = useToast()
@@ -20,7 +21,9 @@ const props = defineProps<{
// Store
const promptStore = usePromptStore()
const layoutStore = useLayoutStore()
const llmStore = useLLMStore()
const { aiPromptSettings, activePreset, aiGlobalSettings } = storeToRefs(promptStore)
const { configs, activeConfig, isLoading: isLoadingLLM } = storeToRefs(llmStore)
// 当前类型对应的预设列表(根据 applicableTo 过滤)
const currentPresets = computed(() => promptStore.getPresetsForChatType(props.chatType))
@@ -34,8 +37,9 @@ const currentActivePreset = computed(() => {
return activeInList || activePreset.value
})
// 预设下拉菜单状态
// 下拉菜单状态
const isPresetPopoverOpen = ref(false)
const isModelPopoverOpen = ref(false)
const isOpeningLog = ref(false)
// 设置激活预设
@@ -55,6 +59,27 @@ function openChatSettings() {
layoutStore.openSettingAt('ai', 'chat')
}
// 切换 AI 模型配置
async function switchModelConfig(configId: string) {
const success = await llmStore.setActiveConfig(configId)
if (success) {
isModelPopoverOpen.value = false
} else {
toast.add({
title: t('model.switchFailed'),
icon: 'i-heroicons-x-circle',
color: 'error',
duration: 2000,
})
}
}
// 打开设置弹窗并跳转到模型配置
function openModelSettings() {
isModelPopoverOpen.value = false
layoutStore.openSettingAt('ai', 'model')
}
// 打开当前 AI 日志文件并定位到文件
async function openAiLogFile() {
if (isOpeningLog.value) return
@@ -87,8 +112,9 @@ async function openAiLogFile() {
<template>
<div class="flex items-center justify-between px-1">
<!-- 左侧预设选择器 -->
<UPopover v-model:open="isPresetPopoverOpen" :ui="{ content: 'p-0' }">
<!-- 左侧预设选择器 + 模型切换器 -->
<div class="flex items-center gap-1">
<UPopover v-model:open="isPresetPopoverOpen" :ui="{ content: 'p-0' }">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
@@ -125,18 +151,77 @@ async function openAiLogFile() {
<!-- 分隔线 -->
<div class="my-1 border-t border-gray-200 dark:border-gray-700" />
<!-- 新增预设按钮 -->
<!-- 管理预设按钮 -->
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="openPresetSettings"
>
<UIcon name="i-heroicons-plus" class="h-4 w-4 shrink-0" />
<span>{{ t('preset.new') }}</span>
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 shrink-0" />
<span>{{ t('preset.manage') }}</span>
</button>
</div>
</template>
</UPopover>
<!-- 模型切换器 -->
<UPopover v-model:open="isModelPopoverOpen" :ui="{ content: 'p-0' }">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:disabled="isLoadingLLM"
>
<UIcon name="i-heroicons-cpu-chip" class="h-3.5 w-3.5" />
<span class="max-w-[120px] truncate">{{ activeConfig?.name || t('model.notConfigured') }}</span>
<UIcon name="i-heroicons-chevron-down" class="h-3 w-3" />
</button>
<template #content>
<div class="w-48 py-1">
<div class="px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500">
{{ t('model.title') }}
</div>
<!-- 配置列表 -->
<template v-if="configs.length > 0">
<button
v-for="config in configs"
:key="config.id"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:class="[
config.id === activeConfig?.id
? 'text-pink-600 dark:text-pink-400'
: 'text-gray-700 dark:text-gray-300',
]"
@click="switchModelConfig(config.id)"
>
<UIcon
:name="config.id === activeConfig?.id ? 'i-heroicons-check-circle-solid' : 'i-heroicons-cpu-chip'"
class="h-4 w-4 shrink-0"
:class="[config.id === activeConfig?.id ? 'text-pink-500' : 'text-gray-400']"
/>
<span class="truncate">{{ config.name }}</span>
</button>
</template>
<!-- 空状态 -->
<div v-else class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">
{{ t('model.empty') }}
</div>
<!-- 分隔线 -->
<div class="my-1 border-t border-gray-200 dark:border-gray-700" />
<!-- 管理配置按钮 -->
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="openModelSettings"
>
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 shrink-0" />
<span>{{ t('model.manage') }}</span>
</button>
</div>
</template>
</UPopover>
</div>
<!-- 右侧配置状态指示 -->
<div class="flex items-center gap-1">
<!-- 消息条数限制点击跳转设置 -->
@@ -157,16 +242,6 @@ async function openAiLogFile() {
<UIcon name="i-heroicons-folder-open" class="h-3.5 w-3.5" />
<span>{{ t('log.label') }}</span>
</button>
<!-- Token 使用量 -->
<div
v-if="sessionTokenUsage.totalTokens > 0"
class="flex items-center gap-1.5 text-xs text-gray-400"
:title="t('tokenUsageTitle')"
>
<UIcon name="i-heroicons-chart-bar-square" class="h-3.5 w-3.5" />
<span>{{ sessionTokenUsage.totalTokens.toLocaleString() }} tokens</span>
</div>
<!-- 配置状态 -->
<div
v-if="!isCheckingConfig"
@@ -187,7 +262,14 @@ async function openAiLogFile() {
"default": "默认预设",
"groupTitle": "群聊提示词预设",
"privateTitle": "私聊提示词预设",
"new": "新增提示词"
"manage": "管理提示词"
},
"model": {
"title": "AI 模型配置",
"notConfigured": "未配置",
"empty": "暂无配置",
"manage": "管理配置",
"switchFailed": "切换模型失败"
},
"messageLimit": {
"label": "消息上限:",
@@ -210,7 +292,14 @@ async function openAiLogFile() {
"default": "Default Preset",
"groupTitle": "Group Chat Presets",
"privateTitle": "Private Chat Presets",
"new": "New Preset"
"manage": "Manage Presets"
},
"model": {
"title": "AI Model Configs",
"notConfigured": "Not Configured",
"empty": "No configs",
"manage": "Manage Configs",
"switchFailed": "Failed to switch model"
},
"messageLimit": {
"label": "Limit: ",

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useLLMStore, type AIServiceConfigDisplay } from '@/stores/llm'
import AIModelEditModal from './AIModelEditModal.vue'
import AlertTips from './AlertTips.vue'
@@ -11,80 +13,34 @@ const emit = defineEmits<{
'config-changed': []
}>()
// ============ 类型定义 ============
interface AIServiceConfig {
id: string
name: string
provider: string
apiKey: string
apiKeySet: boolean
model?: string
baseUrl?: string
createdAt: number
updatedAt: number
}
interface Provider {
id: string
name: string
description: string
defaultBaseUrl: string
models: Array<{ id: string; name: string; description?: string }>
}
const aiTips = JSON.parse(localStorage.getItem('chatlab_app_config') || '{}').aiTips || {}
// ============ 状态 ============
// ============ Store ============
const isLoading = ref(false)
const providers = ref<Provider[]>([])
const configs = ref<AIServiceConfig[]>([])
const activeConfigId = ref<string | null>(null)
const llmStore = useLLMStore()
const { configs, providers, activeConfigId, isLoading, isMaxConfigs } = storeToRefs(llmStore)
// 弹窗状态
const showEditModal = ref(false)
const editMode = ref<'add' | 'edit'>('add')
const editingConfig = ref<AIServiceConfig | null>(null)
// ============ 计算属性 ============
const isMaxConfigs = computed(() => configs.value.length >= 10)
const editingConfig = ref<AIServiceConfigDisplay | null>(null)
// ============ 方法 ============
async function loadData() {
isLoading.value = true
try {
const [providersData, configsData, activeId] = await Promise.all([
window.llmApi.getProviders(),
window.llmApi.getAllConfigs(),
window.llmApi.getActiveConfigId(),
])
providers.value = providersData
configs.value = configsData
activeConfigId.value = activeId
} catch (error) {
console.error('加载配置失败:', error)
} finally {
isLoading.value = false
}
}
function openAddModal() {
editMode.value = 'add'
editingConfig.value = null
showEditModal.value = true
}
function openEditModal(config: AIServiceConfig) {
function openEditModal(config: AIServiceConfigDisplay) {
editMode.value = 'edit'
editingConfig.value = config
showEditModal.value = true
}
async function handleModalSaved() {
await loadData()
await llmStore.refreshConfigs()
emit('config-changed')
}
@@ -92,7 +48,7 @@ async function deleteConfig(id: string) {
try {
const result = await window.llmApi.deleteConfig(id)
if (result.success) {
await loadData()
await llmStore.refreshConfigs()
emit('config-changed')
} else {
console.error('删除配置失败:', result.error)
@@ -103,16 +59,9 @@ async function deleteConfig(id: string) {
}
async function setActive(id: string) {
try {
const result = await window.llmApi.setActiveConfig(id)
if (result.success) {
activeConfigId.value = id
emit('config-changed')
} else {
console.error('设置激活配置失败:', result.error)
}
} catch (error) {
console.error('设置激活配置失败:', error)
const success = await llmStore.setActiveConfig(id)
if (success) {
emit('config-changed')
}
}
@@ -123,20 +72,25 @@ function getProviderName(providerId: string): string {
if (translated !== key) {
return translated
}
// Fallback to original name
return providers.value.find((p) => p.id === providerId)?.name || providerId
// Fallback to store method
return llmStore.getProviderName(providerId)
}
// ============ 暴露方法 ============
function refresh() {
loadData()
llmStore.refreshConfigs()
}
defineExpose({ refresh })
onMounted(() => {
loadData()
// 如果 Store 未初始化,则初始化;否则刷新
if (!llmStore.isInitialized) {
llmStore.init()
} else {
llmStore.refreshConfigs()
}
})
</script>

View File

@@ -198,7 +198,7 @@
},
"about": {
"title": "关于 ChatLab",
"description": "聊天记录分析工具",
"description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆。",
"version": "版本",
"checkUpdate": "检查更新",
"checking": "检查中...",

150
src/stores/llm.ts Normal file
View File

@@ -0,0 +1,150 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
/**
* LLM 服务配置(展示用,不含敏感信息)
*/
export interface AIServiceConfigDisplay {
id: string
name: string
provider: string
apiKeySet: boolean
model?: string
baseUrl?: string
createdAt: number
updatedAt: number
}
/**
* LLM 提供商信息
*/
export interface LLMProvider {
id: string
name: string
description: string
defaultBaseUrl: string
models: Array<{ id: string; name: string; description?: string }>
}
/**
* LLM 配置状态管理
* 集中管理 LLM 配置的获取、切换和刷新
*/
export const useLLMStore = defineStore('llm', () => {
// ============ 状态 ============
/** 所有配置列表 */
const configs = ref<AIServiceConfigDisplay[]>([])
/** 所有提供商列表 */
const providers = ref<LLMProvider[]>([])
/** 当前激活配置 ID */
const activeConfigId = ref<string | null>(null)
/** 是否正在加载 */
const isLoading = ref(false)
/** 是否已初始化 */
const isInitialized = ref(false)
// ============ 计算属性 ============
/** 当前激活的配置 */
const activeConfig = computed(() => configs.value.find((c) => c.id === activeConfigId.value) || null)
/** 是否有可用配置 */
const hasConfig = computed(() => !!activeConfigId.value)
/** 是否达到最大配置数量 */
const isMaxConfigs = computed(() => configs.value.length >= 10)
// ============ 方法 ============
/**
* 初始化加载配置(仅首次调用生效)
*/
async function init() {
if (isInitialized.value) return
await loadConfigs()
isInitialized.value = true
}
/**
* 加载所有配置和提供商
*/
async function loadConfigs() {
isLoading.value = true
try {
const [providersData, configsData, activeId] = await Promise.all([
window.llmApi.getProviders(),
window.llmApi.getAllConfigs(),
window.llmApi.getActiveConfigId(),
])
providers.value = providersData
configs.value = configsData
activeConfigId.value = activeId
} catch (error) {
console.error('[LLM Store] 加载配置失败:', error)
} finally {
isLoading.value = false
}
}
/**
* 切换激活配置
* @param id 配置 ID
* @returns 是否成功
*/
async function setActiveConfig(id: string): Promise<boolean> {
try {
const result = await window.llmApi.setActiveConfig(id)
if (result.success) {
activeConfigId.value = id
return true
}
console.error('[LLM Store] 设置激活配置失败:', result.error)
return false
} catch (error) {
console.error('[LLM Store] 设置激活配置失败:', error)
return false
}
}
/**
* 刷新配置列表
* 供外部(如设置页面修改后)调用
*/
async function refreshConfigs() {
await loadConfigs()
}
/**
* 获取提供商名称
* @param providerId 提供商 ID
* @returns 提供商名称
*/
function getProviderName(providerId: string): string {
return providers.value.find((p) => p.id === providerId)?.name || providerId
}
return {
// 状态
configs,
providers,
activeConfigId,
isLoading,
isInitialized,
// 计算属性
activeConfig,
hasConfig,
isMaxConfigs,
// 方法
init,
loadConfigs,
setActiveConfig,
refreshConfigs,
getProviderName,
}
})