feat: 优化AI配置

This commit is contained in:
digua
2025-12-03 23:05:10 +08:00
parent 63ed7765aa
commit 6b2f4e6db6
20 changed files with 2460 additions and 478 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ onMounted(async () => {
</main>
</template>
</div>
<SettingModal v-model:open="chatStore.showSettingModal" />
<SettingModal v-model:open="chatStore.showSettingModal" @ai-config-saved="chatStore.notifyAIConfigChanged" />
</UApp>
</template>
+14
View File
@@ -19,6 +19,19 @@ const subTabs = [
]
const activeSubTab = ref('chat-explorer')
// ChatExplorer 组件引用
const chatExplorerRef = ref<InstanceType<typeof ChatExplorer> | null>(null)
// 刷新 AI 配置(供父组件调用)
function refreshAIConfig() {
chatExplorerRef.value?.refreshConfig()
}
// 暴露方法供父组件调用
defineExpose({
refreshAIConfig,
})
</script>
<template>
@@ -32,6 +45,7 @@ const activeSubTab = ref('chat-explorer')
<!-- 对话式探索 -->
<ChatExplorer
v-if="activeSubTab === 'chat-explorer'"
ref="chatExplorerRef"
class="h-full"
:session-id="sessionId"
:session-name="sessionName"
+25 -32
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import ConversationList from './ConversationList.vue'
import DataSourcePanel from './DataSourcePanel.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import AIConfigModal from './AIConfigModal.vue'
import { useAIChat } from '@/composables/useAIChat'
// Props
@@ -32,8 +32,10 @@ const {
stopGeneration,
} = useAIChat(props.sessionId, props.timeFilter)
// Store
const chatStore = useChatStore()
// UI 状态
const showConfigModal = ref(false)
const isSourcePanelCollapsed = ref(false)
const hasLLMConfig = ref(false)
const isCheckingConfig = ref(true)
@@ -53,11 +55,12 @@ async function checkLLMConfig() {
}
}
// 配置保存后的回调
async function handleConfigSaved() {
hasLLMConfig.value = true
await updateMaxMessages()
// 刷新配置状态(供外部调用)
async function refreshConfig() {
await checkLLMConfig()
if (hasLLMConfig.value) {
await updateMaxMessages()
}
// 更新欢迎消息
const welcomeMsg = messages.value.find((m) => m.id.startsWith('welcome'))
if (welcomeMsg) {
@@ -65,11 +68,16 @@ async function handleConfigSaved() {
}
}
// 暴露方法供父组件调用
defineExpose({
refreshConfig,
})
// 生成欢迎消息
function generateWelcomeMessage() {
const configHint = hasLLMConfig.value
? '✅ AI 服务已配置,可以开始对话了!'
: '**注意**:使用前请先点击右上角「配置」按钮配置 AI 服务 👆'
: '**注意**:使用前请先在侧边栏底部的「设置」中配置 AI 服务 ⚙️'
return `👋 你好!我是 AI 助手,可以帮你探索「${props.sessionName}」的聊天记录。
@@ -128,18 +136,6 @@ function handleDeleteConversation(convId: string) {
}
}
// 工具名称映射
const TOOL_DISPLAY_NAMES: Record<string, string> = {
search_messages: '搜索聊天记录',
get_recent_messages: '获取最近消息',
get_member_stats: '获取成员统计',
get_time_stats: '获取时间分布',
}
function getToolDisplayName(toolName: string): string {
return TOOL_DISPLAY_NAMES[toolName] || toolName
}
// 格式化时间戳
function formatTimestamp(ts: number): string {
const date = new Date(ts * 1000)
@@ -180,6 +176,14 @@ watch(
scrollToBottom()
}
)
// 监听全局 AI 配置变化(从设置弹窗保存时触发)
watch(
() => (chatStore as unknown as { aiConfigVersion: number }).aiConfigVersion,
async () => {
await refreshConfig()
}
)
</script>
<template>
@@ -425,16 +429,8 @@ watch(
:class="[hasLLMConfig ? 'text-gray-400' : 'text-amber-500 font-medium']"
>
<span class="h-1.5 w-1.5 rounded-full" :class="[hasLLMConfig ? 'bg-green-500' : 'bg-amber-500']" />
{{ hasLLMConfig ? '服务已连接' : '配置 AI 服务' }}
{{ hasLLMConfig ? '服务已连接' : '请在全局设置中配置 AI 服务' }}
</div>
<button
class="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
@click="showConfigModal = true"
>
<UIcon name="i-heroicons-cog-6-tooth" class="h-3.5 w-3.5" />
<span>配置</span>
</button>
</div>
</div>
</div>
@@ -459,9 +455,6 @@ watch(
/>
</div>
</Transition>
<!-- AI 配置弹窗 -->
<AIConfigModal v-model:open="showConfigModal" @saved="handleConfigSaved" />
</div>
</template>
+1 -6
View File
@@ -49,12 +49,7 @@ function handleStop() {
variant="subtle"
@submit="handleSubmit"
>
<UChatPromptSubmit
:status="chatStatus"
class="rounded-full"
color="primary"
@stop="handleStop"
/>
<UChatPromptSubmit :status="chatStatus" class="rounded-full" color="primary" @stop="handleStop" />
</UChatPrompt>
</div>
</div>
+1 -5
View File
@@ -66,10 +66,7 @@ const renderedContent = computed(() => {
/>
<!-- 流式输出光标 -->
<span
v-if="isStreaming"
class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-violet-500"
/>
<span v-if="isStreaming" class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-violet-500" />
</div>
<!-- 时间戳 -->
@@ -285,4 +282,3 @@ const renderedContent = computed(() => {
color: rgba(255, 255, 255, 0.8);
}
</style>
+163 -172
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import AIConfigTab from './settings/AIConfigTab.vue'
import AIChatConfigTab from './settings/AIChatConfigTab.vue'
// Props
const props = defineProps<{
@@ -9,42 +11,52 @@ const props = defineProps<{
// Emits
const emit = defineEmits<{
'update:open': [value: boolean]
'ai-config-saved': []
}>()
// Tab 配置
const tabs = [
{ id: 'ai-model', label: 'AI 模型', icon: 'i-heroicons-sparkles' },
{ id: 'ai-chat', label: 'AI 聊天', icon: 'i-heroicons-chat-bubble-left-right' },
{ id: 'settings', label: '设置', icon: 'i-heroicons-cog-6-tooth' },
{ id: 'help', label: '帮助', icon: 'i-heroicons-question-mark-circle' },
]
const activeTab = ref('settings')
const activeTab = ref('ai-model')
const aiConfigRef = ref<InstanceType<typeof AIConfigTab> | null>(null)
// AI 配置变更回调
function handleAIConfigChanged() {
emit('ai-config-saved')
}
// 关闭弹窗
function closeModal() {
emit('update:open', false)
}
// 监听打开状态,重置 Tab
// 监听打开状态
watch(
() => props.open,
(newVal) => {
if (newVal) {
activeTab.value = 'settings'
activeTab.value = 'ai-model'
// 刷新 AI 配置
aiConfigRef.value?.refresh()
}
}
)
</script>
<template>
<UModal :open="open" @update:open="emit('update:open', $event)">
<UModal :open="open" @update:open="emit('update:open', $event)" :ui="{ content: 'md:w-full max-w-2xl' }">
<template #content>
<UCard class="w-full">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">全局设置</h2>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" size="sm" @click="closeModal" />
</div>
</template>
<div class="p-6">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">全局设置</h2>
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" size="sm" @click="closeModal" />
</div>
<!-- Tab 导航 -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
@@ -67,187 +79,166 @@ watch(
</div>
<!-- Tab 内容 -->
<div class="min-h-[300px]">
<Transition name="fade" mode="out-in">
<!-- 设置 Tab -->
<div v-if="activeTab === 'settings'" key="settings" class="space-y-6">
<!-- AI 服务配置 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-violet-500" />
AI 服务配置
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="text-sm text-gray-600 dark:text-gray-400">
AI 配置已移至 AI 对话探索页面请在该页面右上角点击配置按钮进行设置
</p>
<div class="mt-3 flex items-center gap-2 text-xs text-gray-400">
<UIcon name="i-heroicons-arrow-right" class="h-3 w-3" />
<span>群聊分析 AI 对话式探索 配置</span>
<div class="min-h-[400px]">
<!-- AI 模型配置 Tab -->
<div v-show="activeTab === 'ai-model'">
<AIConfigTab ref="aiConfigRef" @config-changed="handleAIConfigChanged" />
</div>
<!-- AI 聊天配置 Tab -->
<div v-show="activeTab === 'ai-chat'">
<AIChatConfigTab @config-changed="handleAIConfigChanged" />
</div>
<!-- 设置 Tab -->
<div v-show="activeTab === 'settings'" class="space-y-6">
<!-- 外观设置 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-paint-brush" class="h-4 w-4 text-blue-500" />
外观设置
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">深色模式</p>
<p class="text-xs text-gray-500 dark:text-gray-400">跟随系统自动切换</p>
</div>
<UBadge color="gray" variant="soft">自动</UBadge>
</div>
</div>
</div>
<!-- 数据设置 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-circle-stack" class="h-4 w-4 text-green-500" />
数据设置
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="text-sm text-gray-500 dark:text-gray-400">更多数据相关设置即将推出...</p>
</div>
</div>
</div>
<!-- 帮助 Tab -->
<div v-show="activeTab === 'help'" class="space-y-6">
<!-- 关于 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-blue-500" />
关于 ChatLab
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-violet-500 to-purple-600"
>
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6 text-white" />
</div>
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-white">ChatLab</p>
<p class="text-xs text-gray-500 dark:text-gray-400">聊天记录分析工具</p>
<p class="mt-1 text-xs text-gray-400">版本 0.1.0</p>
</div>
</div>
</div>
</div>
<!-- 外观设置 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-paint-brush" class="h-4 w-4 text-blue-500" />
外观设置
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<!-- 快捷键 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-command-line" class="h-4 w-4 text-green-500" />
快捷键
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">深色模式</p>
<p class="text-xs text-gray-500 dark:text-gray-400">跟随系统自动切换</p>
<span class="text-gray-600 dark:text-gray-400">发送消息</span>
<div class="flex gap-1">
<kbd class="rounded bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">Enter</kbd>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">换行</span>
<div class="flex gap-1">
<kbd class="rounded bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">Shift</kbd>
<span class="text-gray-400">+</span>
<kbd class="rounded bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">Enter</kbd>
</div>
<UBadge color="gray" variant="soft">自动</UBadge>
</div>
</div>
</div>
<!-- 数据设置 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-circle-stack" class="h-4 w-4 text-green-500" />
数据设置
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="text-sm text-gray-500 dark:text-gray-400">更多数据相关设置即将推出...</p>
</div>
</div>
</div>
<!-- 帮助 Tab -->
<div v-else-if="activeTab === 'help'" key="help" class="space-y-6">
<!-- 关于 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-information-circle" class="h-4 w-4 text-blue-500" />
关于 ChatLab
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-600"
>
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-6 w-6 text-white" />
</div>
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-white">ChatLab</p>
<p class="text-xs text-gray-500 dark:text-gray-400">聊天记录分析工具</p>
<p class="mt-1 text-xs text-gray-400">版本 0.1.0</p>
</div>
<!-- 常见问题 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" class="h-4 w-4 text-amber-500" />
常见问题
</h3>
<div class="space-y-2">
<details
class="group rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<summary
class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"
>
如何导入聊天记录
<UIcon
name="i-heroicons-chevron-down"
class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180"
/>
</summary>
<div
class="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
实用工具页面选择导入聊天记录支持 QQ 导出的 txt 格式文件
</div>
</div>
</div>
</details>
<!-- 快捷键 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-command-line" class="h-4 w-4 text-green-500" />
快捷键
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">发送消息</span>
<div class="flex gap-1">
<kbd class="rounded bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">Enter</kbd>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">换行</span>
<div class="flex gap-1">
<kbd class="rounded bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">Shift</kbd>
<span class="text-gray-400">+</span>
<kbd class="rounded bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">Enter</kbd>
</div>
</div>
<details
class="group rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<summary
class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"
>
如何使用 AI 功能
<UIcon
name="i-heroicons-chevron-down"
class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180"
/>
</summary>
<div
class="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
首先在 AI 配置 Tab 中添加服务配置支持 DeepSeekQwen本地 Ollama 然后即可开始使用
</div>
</div>
</div>
</details>
<!-- 常见问题 -->
<div>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" class="h-4 w-4 text-amber-500" />
常见问题
</h3>
<div class="space-y-2">
<details
class="group rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
<details
class="group rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<summary
class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"
>
<summary
class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"
>
如何导入聊天记录
<UIcon
name="i-heroicons-chevron-down"
class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180"
/>
</summary>
<div
class="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
实用工具页面选择导入聊天记录支持 QQ 导出的 txt 格式文件
</div>
</details>
<details
class="group rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
数据保存在哪里
<UIcon
name="i-heroicons-chevron-down"
class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180"
/>
</summary>
<div
class="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
<summary
class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"
>
如何使用 AI 功能
<UIcon
name="i-heroicons-chevron-down"
class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180"
/>
</summary>
<div
class="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
首先在 AI 对话探索页面配置 API Key支持 DeepSeekQwen 然后即可开始提问
</div>
</details>
<details
class="group rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
>
<summary
class="flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-gray-900 dark:text-white"
>
数据保存在哪里
<UIcon
name="i-heroicons-chevron-down"
class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180"
/>
</summary>
<div
class="border-t border-gray-200 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
所有数据保存在本地文档/ChatLab目录下包括聊天记录数据库和 AI 配置
</div>
</details>
</div>
所有数据保存在本地文档/ChatLab目录下包括聊天记录数据库和 AI 配置
</div>
</details>
</div>
</div>
</Transition>
</div>
</div>
</UCard>
</div>
</template>
</UModal>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
-8
View File
@@ -319,14 +319,6 @@ function getContextMenuItems(session: AnalysisSession) {
<!-- 版本号 & 社交链接 -->
<div v-if="!isCollapsed" class="flex items-center justify-center gap-2 py-1 text-xs text-gray-400">
<span>v{{ version }}</span>
<span>·</span>
<a
href="https://github.com/hellodigua/ChatLab"
target="_blank"
class="hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
Github
</a>
</div>
</div>
</div>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { storeToRefs } from 'pinia'
// Store
const chatStore = useChatStore()
const { aiGlobalSettings } = storeToRefs(chatStore)
// Emits
const emit = defineEmits<{
'config-changed': []
}>()
// 发送条数限制
const globalMaxMessages = computed({
get: () => aiGlobalSettings.value.maxMessagesPerRequest,
set: (val: number) => {
const clampedVal = Math.max(10, Math.min(5000, val || 200))
chatStore.updateAIGlobalSettings({ maxMessagesPerRequest: clampedVal })
emit('config-changed')
},
})
</script>
<template>
<div class="space-y-6">
<!-- 发送条数限制 -->
<div>
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-4 w-4 text-violet-500" />
对话设置
</h4>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<p class="text-sm font-medium text-gray-900 dark:text-white">发送条数限制</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
每次发送给 AI 的最大消息条数用于控制上下文长度10-5000
</p>
</div>
<UInput v-model.number="globalMaxMessages" type="number" min="10" max="5000" class="w-24" />
</div>
</div>
</div>
<!-- 更多设置占位 -->
<div>
<h4 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-adjustments-horizontal" class="h-4 w-4 text-blue-500" />
更多设置
</h4>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="text-sm text-gray-500 dark:text-gray-400">更多聊天相关设置即将推出...</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,644 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// ============ 类型定义 ============
interface AIServiceConfig {
id: string
name: string
provider: string
apiKey: string
apiKeySet: boolean
model?: string
baseUrl?: string
disableThinking?: boolean
createdAt: number
updatedAt: number
}
interface Provider {
id: string
name: string
description: string
defaultBaseUrl: string
models: Array<{ id: string; name: string; description?: string }>
}
// 三种配置类型
type ConfigType = 'preset' | 'local' | 'openai-compatible'
// ============ Props & Emits ============
const props = defineProps<{
open: boolean
mode: 'add' | 'edit'
config: AIServiceConfig | null
providers: Provider[]
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
saved: []
}>()
// ============ 状态 ============
const configType = ref<ConfigType>('preset')
const isValidating = ref(false)
const isSaving = ref(false)
const showAdvanced = ref(false)
const formData = ref({
name: '',
provider: '',
apiKey: '',
model: '',
baseUrl: '',
disableThinking: true, // 默认禁用思考模式
})
const validationResult = ref<'idle' | 'valid' | 'invalid'>('idle')
const validationMessage = ref('')
// ============ 计算属性 ============
// 预设服务商(排除 openai-compatible
const presetProviders = computed(() => {
return props.providers.filter((p) => p.id !== 'openai-compatible')
})
const currentProvider = computed(() => {
return props.providers.find((p) => p.id === formData.value.provider)
})
const modelOptions = computed(() => {
if (!currentProvider.value) return []
return currentProvider.value.models.map((m) => ({
label: m.name,
value: m.id,
description: m.description,
}))
})
const canSave = computed(() => {
const { name, provider, apiKey, baseUrl, model } = formData.value
if (props.mode === 'add') {
switch (configType.value) {
case 'preset':
// 预设服务:需要名称、提供商、API Key
return name.trim() && provider && apiKey.trim()
case 'local':
// 本地服务:需要名称、端点、模型名
return name.trim() && baseUrl.trim() && model.trim()
case 'openai-compatible':
// OpenAI 兼容:需要名称、端点、API Key、模型名
return name.trim() && baseUrl.trim() && apiKey.trim() && model.trim()
}
}
// 编辑模式
if (formData.value.provider === 'openai-compatible') {
if (configType.value === 'local') {
return name.trim() && baseUrl.trim() && model.trim()
}
return name.trim() && baseUrl.trim() && model.trim()
}
return name.trim() && provider
})
const modalTitle = computed(() => (props.mode === 'add' ? '添加新配置' : '编辑配置'))
// ============ 方法 ============
function resetForm() {
configType.value = 'preset'
showAdvanced.value = false
formData.value = {
name: '',
provider: presetProviders.value[0]?.id || '',
apiKey: '',
model: presetProviders.value[0]?.models[0]?.id || '',
baseUrl: '',
disableThinking: true, // 默认禁用思考模式
}
validationResult.value = 'idle'
validationMessage.value = ''
}
function initFromConfig(config: AIServiceConfig) {
// 判断配置类型
if (config.provider === 'openai-compatible') {
// 根据是否有 API Key 和 baseUrl 判断是本地还是 OpenAI 兼容
const isLocal = !config.apiKeySet || (config.baseUrl?.includes('localhost') ?? false)
configType.value = isLocal ? 'local' : 'openai-compatible'
showAdvanced.value = isLocal && !!config.apiKeySet
} else {
configType.value = 'preset'
showAdvanced.value = false
}
formData.value = {
name: config.name,
provider: config.provider,
apiKey: '',
model: config.model || '',
baseUrl: config.baseUrl || '',
disableThinking: config.disableThinking ?? true, // 默认禁用
}
validationResult.value = 'idle'
validationMessage.value = ''
}
function switchConfigType(type: ConfigType) {
configType.value = type
validationResult.value = 'idle'
validationMessage.value = ''
showAdvanced.value = false
switch (type) {
case 'preset':
formData.value.provider = presetProviders.value[0]?.id || ''
formData.value.model = presetProviders.value[0]?.models[0]?.id || ''
formData.value.baseUrl = ''
formData.value.apiKey = ''
break
case 'local':
formData.value.provider = 'openai-compatible'
formData.value.model = ''
formData.value.baseUrl = 'http://localhost:11434/v1'
formData.value.apiKey = ''
break
case 'openai-compatible':
formData.value.provider = 'openai-compatible'
formData.value.model = ''
formData.value.baseUrl = ''
formData.value.apiKey = ''
break
}
}
async function validateKey() {
const { provider, apiKey, baseUrl } = formData.value
// 本地服务可以不需要 API Key
if (configType.value === 'local') {
if (!baseUrl) return
} else {
if (!provider || !apiKey) {
validationResult.value = 'idle'
validationMessage.value = ''
return
}
}
isValidating.value = true
validationResult.value = 'idle'
try {
const testApiKey = apiKey || 'sk-no-key-required'
const isValid = await window.llmApi.validateApiKey(
provider || 'openai-compatible',
testApiKey,
baseUrl || undefined
)
validationResult.value = isValid ? 'valid' : 'invalid'
validationMessage.value = isValid ? '连接验证成功' : '连接验证失败,但仍可保存'
} catch (error) {
validationResult.value = 'invalid'
validationMessage.value = '验证失败:' + String(error)
} finally {
isValidating.value = false
}
}
async function saveConfig() {
if (!canSave.value) return
isSaving.value = true
try {
// 确定最终的 provider
const finalProvider = configType.value === 'preset' ? formData.value.provider : 'openai-compatible'
// 确定 API Key
let finalApiKey = formData.value.apiKey.trim()
if (!finalApiKey && configType.value === 'local') {
finalApiKey = 'sk-no-key-required'
}
if (props.mode === 'add') {
const result = await window.llmApi.addConfig({
name: formData.value.name.trim(),
provider: finalProvider,
apiKey: finalApiKey,
model: formData.value.model.trim() || undefined,
baseUrl: formData.value.baseUrl.trim() || undefined,
// 仅本地服务才传递 disableThinking
disableThinking: configType.value === 'local' ? formData.value.disableThinking : undefined,
})
if (result.success) {
emit('update:open', false)
emit('saved')
} else {
console.error('添加配置失败:', result.error)
}
} else {
const updates: Record<string, unknown> = {
name: formData.value.name.trim(),
provider: finalProvider,
model: formData.value.model.trim() || undefined,
baseUrl: formData.value.baseUrl.trim() || undefined,
// 仅本地服务才传递 disableThinking
disableThinking: configType.value === 'local' ? formData.value.disableThinking : undefined,
}
if (formData.value.apiKey.trim()) {
updates.apiKey = formData.value.apiKey.trim()
}
const result = await window.llmApi.updateConfig(props.config!.id, updates)
if (result.success) {
emit('update:open', false)
emit('saved')
} else {
console.error('更新配置失败:', result.error)
}
}
} catch (error) {
console.error('保存配置失败:', error)
} finally {
isSaving.value = false
}
}
function closeModal() {
emit('update:open', false)
}
// ============ 监听器 ============
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
if (props.mode === 'edit' && props.config) {
initFromConfig(props.config)
} else {
resetForm()
}
}
}
)
watch(
() => formData.value.provider,
(newProvider) => {
const provider = props.providers.find((p) => p.id === newProvider)
if (provider && provider.models.length > 0 && configType.value === 'preset') {
formData.value.model = provider.models[0].id
}
validationResult.value = 'idle'
validationMessage.value = ''
}
)
watch(
() => formData.value.apiKey,
() => {
validationResult.value = 'idle'
validationMessage.value = ''
}
)
</script>
<template>
<UModal :open="open" @update:open="emit('update:open', $event)">
<template #content>
<div class="p-6">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ modalTitle }}</h3>
<!-- 配置类型选择仅新增时显示-->
<div v-if="mode === 'add'" class="mb-6">
<div class="grid grid-cols-3 gap-2">
<!-- 预设服务商 -->
<button
class="flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-colors"
:class="[
configType === 'preset'
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
]"
@click="switchConfigType('preset')"
>
<UIcon
name="i-heroicons-cloud"
class="h-5 w-5"
:class="[configType === 'preset' ? 'text-primary-500' : 'text-gray-400']"
/>
<div class="text-center">
<p
class="text-xs font-medium"
:class="[
configType === 'preset'
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300',
]"
>
云端服务
</p>
<p class="mt-0.5 text-[10px] text-gray-500">DeepSeekQwen</p>
</div>
</button>
<!-- 本地服务 -->
<button
class="flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-colors"
:class="[
configType === 'local'
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
]"
@click="switchConfigType('local')"
>
<UIcon
name="i-heroicons-server"
class="h-5 w-5"
:class="[configType === 'local' ? 'text-primary-500' : 'text-gray-400']"
/>
<div class="text-center">
<p
class="text-xs font-medium"
:class="[
configType === 'local'
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300',
]"
>
本地服务
</p>
<p class="mt-0.5 text-[10px] text-gray-500">Ollama </p>
</div>
</button>
<!-- OpenAI 兼容 -->
<button
class="flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-colors"
:class="[
configType === 'openai-compatible'
? 'border-primary-500 bg-primary-50 dark:border-primary-400 dark:bg-primary-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600',
]"
@click="switchConfigType('openai-compatible')"
>
<UIcon
name="i-heroicons-globe-alt"
class="h-5 w-5"
:class="[configType === 'openai-compatible' ? 'text-primary-500' : 'text-gray-400']"
/>
<div class="text-center">
<p
class="text-xs font-medium"
:class="[
configType === 'openai-compatible'
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300',
]"
>
OpenAI 兼容
</p>
<p class="mt-0.5 text-[10px] text-gray-500">自定义端点</p>
</div>
</button>
</div>
</div>
<div class="space-y-4">
<!-- 配置名称 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">配置名称</label>
<UInput
v-model="formData.name"
:placeholder="
configType === 'preset'
? '如:DeepSeek 主力'
: configType === 'local'
? '如:本地 Ollama'
: '如:自建 API'
"
/>
</div>
<!-- ========== 预设服务商配置 ========== -->
<template v-if="configType === 'preset'">
<!-- 服务商选择 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">AI 服务商</label>
<USelect
v-model="formData.provider"
:items="presetProviders.map((p) => ({ label: p.name, value: p.id }))"
placeholder="选择服务商"
/>
<p v-if="currentProvider" class="mt-1 text-xs text-gray-500">
{{ currentProvider.description }}
</p>
</div>
<!-- API Key -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
<div class="flex gap-2">
<UInput
v-model="formData.apiKey"
type="password"
:placeholder="mode === 'edit' ? '输入新的 API Key(留空保持原有)' : '输入你的 API Key'"
class="flex-1"
/>
<UButton
:loading="isValidating"
:disabled="!formData.apiKey"
color="gray"
variant="soft"
@click="validateKey"
>
验证
</UButton>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="mt-2">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
</div>
<!-- 模型选择 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">模型</label>
<USelect v-model="formData.model" :items="modelOptions" placeholder="选择模型" />
</div>
</template>
<!-- ========== 本地服务配置 ========== -->
<template v-else-if="configType === 'local'">
<!-- API 端点 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API 端点</label>
<div class="flex gap-2">
<UInput v-model="formData.baseUrl" placeholder="http://localhost:11434/v1" class="flex-1" />
<UButton
:loading="isValidating"
:disabled="!formData.baseUrl"
color="gray"
variant="soft"
@click="validateKey"
>
测试
</UButton>
</div>
<p class="mt-1 text-xs text-gray-500">Ollama 默认http://localhost:11434/v1</p>
</div>
<!-- 模型名称 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">模型名称</label>
<UInput v-model="formData.model" placeholder="如 llama3.2、qwen2.5、deepseek-r1" />
<p class="mt-1 text-xs text-gray-500">输入本地部署的模型名称</p>
</div>
<!-- 禁用思考模式 -->
<div class="flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">禁用思考模式</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
针对 Qwen3DeepSeek-R1 等模型禁用后使用标准工具调用格式
</p>
</div>
<USwitch v-model="formData.disableThinking" />
</div>
<!-- 验证结果 -->
<div v-if="validationMessage">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
<!-- 高级选项API Key -->
<div>
<button
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
@click="showAdvanced = !showAdvanced"
>
<UIcon
name="i-heroicons-chevron-right"
class="h-4 w-4 transition-transform"
:class="{ 'rotate-90': showAdvanced }"
/>
高级选项
</button>
<div v-if="showAdvanced" class="mt-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
API Key
<span class="font-normal text-gray-400">可选</span>
</label>
<UInput v-model="formData.apiKey" type="password" placeholder="本地服务通常不需要" />
<p class="mt-1 text-xs text-gray-500">如果服务设置了认证在此输入</p>
</div>
</div>
</div>
</template>
<!-- ========== OpenAI 兼容配置 ========== -->
<template v-else>
<!-- API 端点 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API 端点</label>
<UInput v-model="formData.baseUrl" placeholder="https://api.example.com/v1" />
<p class="mt-1 text-xs text-gray-500">兼容 OpenAI 格式的 API 端点</p>
</div>
<!-- API Key -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
<div class="flex gap-2">
<UInput
v-model="formData.apiKey"
type="password"
:placeholder="mode === 'edit' ? '输入新的 API Key(留空保持原有)' : '输入你的 API Key'"
class="flex-1"
/>
<UButton
:loading="isValidating"
:disabled="!formData.apiKey || !formData.baseUrl"
color="gray"
variant="soft"
@click="validateKey"
>
验证
</UButton>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="mt-2">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
</div>
<!-- 模型名称 -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">模型名称</label>
<UInput v-model="formData.model" placeholder="如 gpt-4、claude-3" />
<p class="mt-1 text-xs text-gray-500">输入 API 支持的模型名称</p>
</div>
</template>
</div>
<!-- 底部按钮 -->
<div class="mt-6 flex justify-end gap-2">
<UButton color="gray" variant="soft" @click="closeModal">取消</UButton>
<UButton color="primary" :disabled="!canSave" :loading="isSaving" @click="saveConfig">
{{ mode === 'add' ? '添加' : '保存' }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import AIConfigEditModal from './AIConfigEditModal.vue'
// Emits../common/settings/AIConfigEditModal.vue../../settings/AIConfigEditModal.vue
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 isLoading = ref(false)
const providers = ref<Provider[]>([])
const configs = ref<AIServiceConfig[]>([])
const activeConfigId = ref<string | null>(null)
// 弹窗状态
const showEditModal = ref(false)
const editMode = ref<'add' | 'edit'>('add')
const editingConfig = ref<AIServiceConfig | null>(null)
// ============ 计算属性 ============
const isMaxConfigs = computed(() => configs.value.length >= 10)
// ============ 方法 ============
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) {
editMode.value = 'edit'
editingConfig.value = config
showEditModal.value = true
}
async function handleModalSaved() {
await loadData()
emit('config-changed')
}
async function deleteConfig(id: string) {
try {
const result = await window.llmApi.deleteConfig(id)
if (result.success) {
await loadData()
emit('config-changed')
} else {
console.error('删除配置失败:', result.error)
}
} catch (error) {
console.error('删除配置失败:', error)
}
}
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)
}
}
function getProviderName(providerId: string): string {
return providers.value.find((p) => p.id === providerId)?.name || providerId
}
// ============ 暴露方法 ============
function refresh() {
loadData()
}
defineExpose({ refresh })
onMounted(() => {
loadData()
})
</script>
<template>
<!-- 加载中 -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-gray-400" />
</div>
<!-- 配置列表视图 -->
<div v-else class="space-y-4">
<!-- 配置列表 -->
<div v-if="configs.length > 0" class="space-y-2">
<div
v-for="config in configs"
:key="config.id"
class="group flex items-center justify-between rounded-lg border p-3 transition-colors"
:class="[
config.id === activeConfigId
? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20'
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800',
]"
>
<!-- 配置信息 -->
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
:class="[
config.id === activeConfigId
? 'bg-primary-500 text-white'
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
]"
>
<UIcon
:name="config.id === activeConfigId ? 'i-heroicons-check' : 'i-heroicons-sparkles'"
class="h-4 w-4"
/>
</div>
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">{{ config.name }}</span>
<UBadge v-if="config.id === activeConfigId" color="primary" variant="soft" size="xs">使用中</UBadge>
</div>
<div class="mt-0.5 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{{ getProviderName(config.provider) }}</span>
<span>·</span>
<span>{{ config.model || '默认模型' }}</span>
<span v-if="config.baseUrl">·</span>
<span v-if="config.baseUrl" class="text-violet-500">
{{ config.provider === 'openai-compatible' ? '本地服务' : '自定义端点' }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<UButton
v-if="config.id !== activeConfigId"
size="xs"
color="primary"
variant="ghost"
@click="setActive(config.id)"
>
激活
</UButton>
<UButton size="xs" color="gray" variant="ghost" @click="openEditModal(config)">编辑</UButton>
<UButton size="xs" color="error" variant="ghost" @click="deleteConfig(config.id)">删除</UButton>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-200 py-12 dark:border-gray-700"
>
<UIcon name="i-heroicons-sparkles" class="h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">还没有配置 AI 服务</p>
<p class="text-xs text-gray-400 dark:text-gray-500">添加一个配置开始使用 AI 功能</p>
</div>
<!-- 添加按钮 -->
<UButton block color="gray" variant="soft" :disabled="isMaxConfigs" class="mt-4" @click="openAddModal">
<UIcon name="i-heroicons-plus" class="mr-2 h-4 w-4" />
{{ isMaxConfigs ? '已达最大配置数量(10个)' : '添加新配置' }}
</UButton>
<!-- 获取 API Key 链接仅在没有配置时显示 -->
<div v-if="configs.length === 0" class="mt-4 rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<p class="text-sm text-gray-600 dark:text-gray-400">还没有 API Key前往获取</p>
<div class="mt-2 flex flex-wrap gap-3">
<a
href="https://platform.deepseek.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
DeepSeek
</a>
<a
href="https://dashscope.console.aliyun.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
通义千问
</a>
<a
href="https://ollama.com/"
target="_blank"
class="text-sm text-violet-600 hover:underline dark:text-violet-400"
>
Ollama (本地)
</a>
</div>
</div>
</div>
<!-- 编辑/添加弹窗 -->
<AIConfigEditModal
v-model:open="showEditModal"
:mode="editMode"
:config="editingConfig"
:providers="providers"
@saved="handleModalSaved"
/>
</template>
+3
View File
@@ -0,0 +1,3 @@
export { default as AIConfigTab } from './AIConfigTab.vue'
export { default as AIChatConfigTab } from './AIChatConfigTab.vue'
export { default as AIConfigEditModal } from './AIConfigEditModal.vue'
+3 -2
View File
@@ -221,7 +221,9 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
// 更新用户消息中的工具调用状态
const userMsg = messages.value[userMessageIndex]
if (userMsg.toolCalls) {
const toolIndex = userMsg.toolCalls.findIndex((t) => t.name === chunk.toolName && t.status === 'running')
const toolIndex = userMsg.toolCalls.findIndex(
(t) => t.name === chunk.toolName && t.status === 'running'
)
if (toolIndex >= 0) {
userMsg.toolCalls[toolIndex] = {
...userMsg.toolCalls[toolIndex],
@@ -432,4 +434,3 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
stopGeneration,
}
}
+28 -2
View File
@@ -227,6 +227,28 @@ export const useChatStore = defineStore(
// 设置弹窗状态
const showSettingModal = ref(false)
// AI 配置更新计数器(用于触发其他组件刷新)
const aiConfigVersion = ref(0)
function notifyAIConfigChanged() {
aiConfigVersion.value++
}
// AI 全局设置
interface AIGlobalSettings {
/** 每次发送给 AI 的最大消息条数 */
maxMessagesPerRequest: number
}
const aiGlobalSettings = ref<AIGlobalSettings>({
maxMessagesPerRequest: 200,
})
function updateAIGlobalSettings(settings: Partial<AIGlobalSettings>) {
aiGlobalSettings.value = { ...aiGlobalSettings.value, ...settings }
notifyAIConfigChanged()
}
// ==================== 自定义关键词模板 ====================
const customKeywordTemplates = ref<KeywordTemplate[]>([])
@@ -269,6 +291,8 @@ export const useChatStore = defineStore(
isInitialized,
isSidebarCollapsed,
showSettingModal,
aiConfigVersion,
aiGlobalSettings,
customKeywordTemplates,
deletedPresetTemplateIds,
// Computed
@@ -282,6 +306,8 @@ export const useChatStore = defineStore(
renameSession,
clearSelection,
toggleSidebar,
notifyAIConfigChanged,
updateAIGlobalSettings,
addCustomKeywordTemplate,
updateCustomKeywordTemplate,
removeCustomKeywordTemplate,
@@ -296,8 +322,8 @@ export const useChatStore = defineStore(
storage: sessionStorage,
},
{
// 自定义模板:localStorage(持久保存)
pick: ['customKeywordTemplates', 'deletedPresetTemplateIds'],
// 自定义模板和 AI 全局设置localStorage(持久保存)
pick: ['customKeywordTemplates', 'deletedPresetTemplateIds', 'aiGlobalSettings'],
storage: localStorage,
},
],