mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-27 17:30:23 +08:00
feat: 优化AI配置
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 中添加服务配置(支持 DeepSeek、Qwen、本地 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(支持 DeepSeek、Qwen 等),然后即可开始提问。
|
||||
</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>
|
||||
|
||||
@@ -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">DeepSeek、Qwen</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">
|
||||
针对 Qwen3、DeepSeek-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>
|
||||
@@ -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'
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user