mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-01-24 09:23:07 +08:00
feat: 聊天对话底部支持快速选择对话模型
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
},
|
||||
"about": {
|
||||
"title": "关于 ChatLab",
|
||||
"description": "聊天记录分析工具",
|
||||
"description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆。",
|
||||
"version": "版本",
|
||||
"checkUpdate": "检查更新",
|
||||
"checking": "检查中...",
|
||||
|
||||
150
src/stores/llm.ts
Normal file
150
src/stores/llm.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user