mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-26 16:40:17 +08:00
feat: 支持AI分析
This commit is contained in:
Vendored
+1
@@ -22,6 +22,7 @@ declare module 'vue' {
|
||||
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
|
||||
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
|
||||
UProgress: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Progress.vue')['default']
|
||||
USelect: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
|
||||
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
|
||||
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
|
||||
UTooltip: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Tooltip.vue')['default']
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ChatExplorer from './ai/ChatExplorer.vue'
|
||||
|
||||
// Props
|
||||
defineProps<{
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
timeFilter?: { startTs: number; endTs: number }
|
||||
}>()
|
||||
|
||||
// 子 Tab 配置
|
||||
const subTabs = [
|
||||
{ id: 'chat-explorer', label: '对话式探索', icon: 'i-heroicons-chat-bubble-left-ellipsis' },
|
||||
{ id: 'lab', label: '实验室', icon: 'i-heroicons-beaker' },
|
||||
{ id: 'manual', label: '手动分析', icon: 'i-heroicons-adjustments-horizontal' },
|
||||
]
|
||||
|
||||
const activeSubTab = ref('chat-explorer')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col -m-6">
|
||||
<!-- 子 Tab 导航 -->
|
||||
<div class="border-b border-gray-200 bg-white px-6 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in subTabs"
|
||||
:key="tab.id"
|
||||
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
activeSubTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300',
|
||||
]"
|
||||
@click="activeSubTab = tab.id"
|
||||
>
|
||||
<UIcon :name="tab.icon" class="h-4 w-4" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子 Tab 内容 -->
|
||||
<div class="flex-1 min-h-0 overflow-hidden p-6">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<!-- 对话式探索 -->
|
||||
<ChatExplorer
|
||||
v-if="activeSubTab === 'chat-explorer'"
|
||||
class="h-full"
|
||||
:session-id="sessionId"
|
||||
:session-name="sessionName"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
|
||||
<!-- 实验室 - 暂未实现 -->
|
||||
<div
|
||||
v-else-if="activeSubTab === 'lab'"
|
||||
class="flex h-full items-center justify-center rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UIcon name="i-heroicons-beaker" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p class="mt-3 text-sm font-medium text-gray-600 dark:text-gray-400">实验室功能开发中</p>
|
||||
<p class="mt-1 text-xs text-gray-400">敬请期待...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动分析 - 暂未实现 -->
|
||||
<div
|
||||
v-else-if="activeSubTab === 'manual'"
|
||||
class="flex h-full items-center justify-center rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UIcon name="i-heroicons-adjustments-horizontal" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p class="mt-3 text-sm font-medium text-gray-600 dark:text-gray-400">手动分析功能开发中</p>
|
||||
<p class="mt-1 text-xs text-gray-400">敬请期待...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -82,7 +82,7 @@ const memberRankData = computed<RankItem[]>(() => {
|
||||
>
|
||||
🏆 {{ seasonTitle }}
|
||||
</h1>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">各榜单第一名请@群主领取奖品 🎁</p>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">各榜单前三名请 @群主 领取奖品 🎁</p>
|
||||
</div>
|
||||
|
||||
<!-- 龙王排名 -->
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
const isLoading = ref(false)
|
||||
const isValidating = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const providers = ref<Array<{
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
models: Array<{ id: string; name: string; description?: string }>
|
||||
}>>([])
|
||||
|
||||
// 表单数据
|
||||
const selectedProvider = ref('')
|
||||
const apiKey = ref('')
|
||||
const selectedModel = ref('')
|
||||
const maxTokens = ref(50)
|
||||
|
||||
// 当前配置状态
|
||||
const hasExistingConfig = ref(false)
|
||||
const existingConfigDisplay = ref('')
|
||||
|
||||
// 验证结果
|
||||
const validationResult = ref<'idle' | 'valid' | 'invalid'>('idle')
|
||||
const validationMessage = ref('')
|
||||
|
||||
// 当前选中的提供商信息
|
||||
const currentProvider = computed(() => {
|
||||
return providers.value.find((p) => p.id === selectedProvider.value)
|
||||
})
|
||||
|
||||
// 模型选项
|
||||
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(() => {
|
||||
return selectedProvider.value && apiKey.value && validationResult.value === 'valid'
|
||||
})
|
||||
|
||||
// 加载提供商列表
|
||||
async function loadProviders() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
providers.value = await window.llmApi.getProviders()
|
||||
if (providers.value.length > 0 && !selectedProvider.value) {
|
||||
selectedProvider.value = providers.value[0].id
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载提供商列表失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载当前配置
|
||||
async function loadCurrentConfig() {
|
||||
try {
|
||||
const config = await window.llmApi.getConfig()
|
||||
if (config && config.apiKeySet) {
|
||||
hasExistingConfig.value = true
|
||||
existingConfigDisplay.value = config.apiKey
|
||||
selectedProvider.value = config.provider
|
||||
selectedModel.value = config.model || ''
|
||||
maxTokens.value = config.maxTokens || 50
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载当前配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
async function validateKey() {
|
||||
if (!selectedProvider.value || !apiKey.value) {
|
||||
validationResult.value = 'idle'
|
||||
validationMessage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
isValidating.value = true
|
||||
validationResult.value = 'idle'
|
||||
|
||||
try {
|
||||
const isValid = await window.llmApi.validateApiKey(selectedProvider.value, apiKey.value)
|
||||
validationResult.value = isValid ? 'valid' : 'invalid'
|
||||
validationMessage.value = isValid ? 'API Key 验证成功' : 'API Key 无效,请检查'
|
||||
} catch (error) {
|
||||
validationResult.value = 'invalid'
|
||||
validationMessage.value = '验证失败:' + String(error)
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function saveConfig() {
|
||||
if (!canSave.value) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await window.llmApi.saveConfig({
|
||||
provider: selectedProvider.value,
|
||||
apiKey: apiKey.value,
|
||||
model: selectedModel.value || undefined,
|
||||
maxTokens: maxTokens.value,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
emit('saved')
|
||||
closeModal()
|
||||
} else {
|
||||
console.error('保存配置失败:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
async function deleteConfig() {
|
||||
try {
|
||||
await window.llmApi.deleteConfig()
|
||||
hasExistingConfig.value = false
|
||||
existingConfigDisplay.value = ''
|
||||
apiKey.value = ''
|
||||
validationResult.value = 'idle'
|
||||
validationMessage.value = ''
|
||||
} catch (error) {
|
||||
console.error('删除配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function closeModal() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
// 监听提供商变化,自动选择默认模型
|
||||
watch(selectedProvider, (newProvider) => {
|
||||
const provider = providers.value.find((p) => p.id === newProvider)
|
||||
if (provider && provider.models.length > 0) {
|
||||
selectedModel.value = provider.models[0].id
|
||||
}
|
||||
// 重置验证状态
|
||||
validationResult.value = 'idle'
|
||||
validationMessage.value = ''
|
||||
})
|
||||
|
||||
// 监听 API Key 变化,重置验证状态
|
||||
watch(apiKey, () => {
|
||||
validationResult.value = 'idle'
|
||||
validationMessage.value = ''
|
||||
})
|
||||
|
||||
// 监听弹窗打开
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
loadProviders()
|
||||
loadCurrentConfig()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) {
|
||||
loadProviders()
|
||||
loadCurrentConfig()
|
||||
}
|
||||
})
|
||||
</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">AI 服务配置</h3>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<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="hasExistingConfig"
|
||||
class="rounded-lg bg-green-50 p-3 dark:bg-green-900/20"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-circle" class="h-5 w-5 text-green-500" />
|
||||
<span class="text-sm text-green-700 dark:text-green-400">
|
||||
已配置 API Key: {{ existingConfigDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
<UButton size="xs" color="error" variant="ghost" @click="deleteConfig">
|
||||
删除
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务商选择 -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
AI 服务商
|
||||
</label>
|
||||
<USelect
|
||||
v-model="selectedProvider"
|
||||
:items="providers.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="apiKey"
|
||||
type="password"
|
||||
:placeholder="hasExistingConfig ? '输入新的 API Key(留空保持原有)' : '输入你的 API Key'"
|
||||
class="flex-1"
|
||||
/>
|
||||
<UButton
|
||||
:loading="isValidating"
|
||||
:disabled="!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-red-600 dark:text-red-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-x-circle" 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="selectedModel"
|
||||
:items="modelOptions"
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 发送条数限制 -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
发送条数限制
|
||||
</label>
|
||||
<UInput v-model.number="maxTokens" type="number" min="10" max="200" />
|
||||
<p class="mt-1 text-xs text-gray-500">每次发送给 AI 的最大消息条数(10-200)</p>
|
||||
</div>
|
||||
|
||||
<!-- 获取 API Key 链接 -->
|
||||
<div class="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 gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
保存配置
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
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
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
timeFilter?: { startTs: number; endTs: number }
|
||||
}>()
|
||||
|
||||
// 使用 AI 对话 Composable
|
||||
const {
|
||||
messages,
|
||||
sourceMessages,
|
||||
currentKeywords,
|
||||
isLoadingSource,
|
||||
isAIThinking,
|
||||
currentConversationId,
|
||||
sendMessage,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
loadMoreSourceMessages,
|
||||
updateMaxMessages,
|
||||
} = useAIChat(props.sessionId, props.timeFilter)
|
||||
|
||||
// UI 状态
|
||||
const showConfigModal = ref(false)
|
||||
const isSourcePanelCollapsed = ref(false)
|
||||
const hasLLMConfig = ref(false)
|
||||
const isCheckingConfig = ref(true)
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
const conversationListRef = ref<InstanceType<typeof ConversationList> | null>(null)
|
||||
|
||||
// 检查 LLM 配置
|
||||
async function checkLLMConfig() {
|
||||
isCheckingConfig.value = true
|
||||
try {
|
||||
hasLLMConfig.value = await window.llmApi.hasConfig()
|
||||
} catch (error) {
|
||||
console.error('检查 LLM 配置失败:', error)
|
||||
hasLLMConfig.value = false
|
||||
} finally {
|
||||
isCheckingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 配置保存后的回调
|
||||
async function handleConfigSaved() {
|
||||
hasLLMConfig.value = true
|
||||
await updateMaxMessages()
|
||||
|
||||
// 更新欢迎消息
|
||||
const welcomeMsg = messages.value.find((m) => m.id.startsWith('welcome'))
|
||||
if (welcomeMsg) {
|
||||
welcomeMsg.content = generateWelcomeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// 生成欢迎消息
|
||||
function generateWelcomeMessage() {
|
||||
const configHint = hasLLMConfig.value
|
||||
? '✅ AI 服务已配置,可以开始对话了!'
|
||||
: '**注意**:使用前请先点击右上角「配置」按钮配置 AI 服务 👆'
|
||||
|
||||
return `👋 你好!我是 AI 助手,可以帮你探索「${props.sessionName}」的聊天记录。
|
||||
|
||||
你可以这样问我:
|
||||
- "帮我找一下群里讨论买房的记录"
|
||||
- "大家最近聊了什么有趣的话题"
|
||||
- "谁是群里最活跃的人"
|
||||
|
||||
${configHint}`
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
async function handleSend(content: string) {
|
||||
await sendMessage(content)
|
||||
// 滚动到底部
|
||||
scrollToBottom()
|
||||
// 刷新对话列表
|
||||
conversationListRef.value?.refresh()
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 切换数据源面板
|
||||
function toggleSourcePanel() {
|
||||
isSourcePanelCollapsed.value = !isSourcePanelCollapsed.value
|
||||
}
|
||||
|
||||
// 加载更多数据源
|
||||
async function handleLoadMore() {
|
||||
await loadMoreSourceMessages()
|
||||
}
|
||||
|
||||
// 选择对话
|
||||
async function handleSelectConversation(convId: string) {
|
||||
await loadConversation(convId)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 创建新对话
|
||||
function handleCreateConversation() {
|
||||
startNewConversation(generateWelcomeMessage())
|
||||
}
|
||||
|
||||
// 删除对话
|
||||
function handleDeleteConversation(convId: string) {
|
||||
// 如果删除的是当前对话,创建新对话
|
||||
if (currentConversationId.value === convId) {
|
||||
startNewConversation(generateWelcomeMessage())
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await checkLLMConfig()
|
||||
await updateMaxMessages()
|
||||
|
||||
// 初始化欢迎消息
|
||||
startNewConversation(generateWelcomeMessage())
|
||||
})
|
||||
|
||||
// 监听消息变化,自动滚动
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
|
||||
// 监听 AI 响应流式更新
|
||||
watch(
|
||||
() => messages.value[messages.value.length - 1]?.content,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full gap-4 overflow-hidden">
|
||||
<!-- 左侧:对话记录列表 -->
|
||||
<ConversationList
|
||||
ref="conversationListRef"
|
||||
:session-id="sessionId"
|
||||
:active-id="currentConversationId"
|
||||
@select="handleSelectConversation"
|
||||
@create="handleCreateConversation"
|
||||
@delete="handleDeleteConversation"
|
||||
/>
|
||||
|
||||
<!-- 中间:对话区域 -->
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-sm dark:bg-gray-900">
|
||||
<!-- 对话区域头部 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-sparkles" class="h-5 w-5 text-violet-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">AI 对话探索</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 配置状态指示 -->
|
||||
<div
|
||||
v-if="!isCheckingConfig"
|
||||
class="flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs"
|
||||
:class="[
|
||||
hasLLMConfig
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="[hasLLMConfig ? 'bg-green-500' : 'bg-amber-500']"
|
||||
/>
|
||||
{{ hasLLMConfig ? '已配置' : '未配置' }}
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-cog-6-tooth"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showConfigModal = true"
|
||||
>
|
||||
配置
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
<ChatMessage
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:role="msg.role"
|
||||
:content="msg.content"
|
||||
:timestamp="msg.timestamp"
|
||||
:is-streaming="msg.isStreaming"
|
||||
/>
|
||||
|
||||
<!-- AI 思考中指示器 -->
|
||||
<div v-if="isAIThinking && !messages[messages.length - 1]?.isStreaming" class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
>
|
||||
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div
|
||||
class="rounded-2xl rounded-tl-sm bg-gray-100 px-4 py-3 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ isLoadingSource ? '正在搜索相关记录...' : '正在生成回复...' }}
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
<span class="h-2 w-2 animate-bounce rounded-full bg-violet-500 [animation-delay:0ms]" />
|
||||
<span class="h-2 w-2 animate-bounce rounded-full bg-violet-500 [animation-delay:150ms]" />
|
||||
<span class="h-2 w-2 animate-bounce rounded-full bg-violet-500 [animation-delay:300ms]" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<ChatInput :disabled="isAIThinking" @send="handleSend" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:数据源面板(限制高度) -->
|
||||
<div class="flex w-80 flex-col gap-4">
|
||||
<DataSourcePanel
|
||||
:messages="sourceMessages"
|
||||
:keywords="currentKeywords"
|
||||
:is-loading="isLoadingSource"
|
||||
:is-collapsed="isSourcePanelCollapsed"
|
||||
class="max-h-[60vh]"
|
||||
@toggle="toggleSourcePanel"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- AI 配置弹窗 -->
|
||||
<AIConfigModal v-model:open="showConfigModal" @saved="handleConfigSaved" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
send: [content: string]
|
||||
}>()
|
||||
|
||||
// 输入内容
|
||||
const inputValue = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// 是否可以发送
|
||||
const canSend = computed(() => inputValue.value.trim() && !props.disabled)
|
||||
|
||||
// 发送消息
|
||||
function handleSend() {
|
||||
if (!canSend.value) return
|
||||
|
||||
emit('send', inputValue.value.trim())
|
||||
inputValue.value = ''
|
||||
|
||||
// 重置 textarea 高度
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理键盘事件
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Enter 发送(Shift+Enter 换行)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + 'px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div
|
||||
class="flex items-end gap-3 rounded-2xl bg-gray-100 px-4 py-3 dark:bg-gray-800"
|
||||
:class="[disabled ? 'opacity-60' : '']"
|
||||
>
|
||||
<!-- 输入框 -->
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder || '输入你的问题...'"
|
||||
:disabled="disabled"
|
||||
rows="1"
|
||||
class="max-h-[200px] min-h-[24px] flex-1 resize-none bg-transparent text-sm text-gray-900 placeholder-gray-500 outline-none dark:text-white dark:placeholder-gray-400"
|
||||
@keydown="handleKeydown"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<button
|
||||
:disabled="!canSend"
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
|
||||
:class="[
|
||||
canSend
|
||||
? 'bg-violet-500 text-white hover:bg-violet-600'
|
||||
: 'cursor-not-allowed bg-gray-300 text-gray-500 dark:bg-gray-700',
|
||||
]"
|
||||
@click="handleSend"
|
||||
>
|
||||
<UIcon name="i-heroicons-paper-airplane" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<div class="mt-2 flex items-center justify-center gap-2">
|
||||
<span class="text-xs text-gray-400">
|
||||
<kbd class="rounded bg-gray-200 px-1 py-0.5 text-[10px] dark:bg-gray-700">Enter</kbd>
|
||||
发送
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
<kbd class="rounded bg-gray-200 px-1 py-0.5 text-[10px] dark:bg-gray-700">Shift + Enter</kbd>
|
||||
换行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
isStreaming?: boolean
|
||||
}>()
|
||||
|
||||
// 格式化时间
|
||||
const formattedTime = computed(() => {
|
||||
return dayjs(props.timestamp).format('HH:mm')
|
||||
})
|
||||
|
||||
// 是否是用户消息
|
||||
const isUser = computed(() => props.role === 'user')
|
||||
|
||||
// 创建 markdown-it 实例
|
||||
const md = new MarkdownIt({
|
||||
html: false, // 禁用 HTML 标签
|
||||
breaks: true, // 将换行转为 <br>
|
||||
linkify: true, // 自动将 URL 转为链接
|
||||
typographer: true, // 启用排版优化
|
||||
})
|
||||
|
||||
// 渲染后的 HTML
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return md.render(props.content)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-3" :class="[isUser ? 'flex-row-reverse' : '']">
|
||||
<!-- 头像 -->
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
:class="[
|
||||
isUser
|
||||
? 'bg-gradient-to-br from-blue-500 to-cyan-500'
|
||||
: 'bg-gradient-to-br from-violet-500 to-purple-600',
|
||||
]"
|
||||
>
|
||||
<UIcon
|
||||
:name="isUser ? 'i-heroicons-user' : 'i-heroicons-sparkles'"
|
||||
class="h-4 w-4 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="max-w-[80%] min-w-0">
|
||||
<div
|
||||
class="rounded-2xl px-4 py-3"
|
||||
:class="[
|
||||
isUser
|
||||
? 'rounded-tr-sm bg-blue-500 text-white'
|
||||
: 'rounded-tl-sm bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100',
|
||||
]"
|
||||
>
|
||||
<!-- 消息内容 -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none leading-relaxed"
|
||||
:class="[isUser ? 'prose-invert' : 'dark:prose-invert']"
|
||||
v-html="renderedContent"
|
||||
/>
|
||||
|
||||
<!-- 流式输出光标 -->
|
||||
<span
|
||||
v-if="isStreaming"
|
||||
class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-violet-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div class="mt-1 px-1" :class="[isUser ? 'text-right' : '']">
|
||||
<span class="text-xs text-gray-400">{{ formattedTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Markdown 渲染样式 */
|
||||
.prose :deep(p) {
|
||||
margin: 0.5em 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose :deep(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.prose :deep(h1) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 1em 0 0.5em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :deep(h2) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0.875em 0 0.5em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.prose :deep(h3) {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.75em 0 0.375em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.prose :deep(h1:first-child),
|
||||
.prose :deep(h2:first-child),
|
||||
.prose :deep(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
.prose :deep(ul),
|
||||
.prose :deep(ol) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.prose :deep(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose :deep(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose :deep(li) {
|
||||
margin: 0.25em 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :deep(li > p) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
/* 嵌套列表 */
|
||||
.prose :deep(ul ul),
|
||||
.prose :deep(ol ol),
|
||||
.prose :deep(ul ol),
|
||||
.prose :deep(ol ul) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
/* 代码 */
|
||||
.prose :deep(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .prose :deep(code) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.prose :deep(pre) {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.875em 1em;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #1e293b;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose :deep(pre code) {
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 引用块 */
|
||||
.prose :deep(blockquote) {
|
||||
margin: 0.75em 0;
|
||||
padding: 0.5em 0 0.5em 1em;
|
||||
border-left: 3px solid #8b5cf6;
|
||||
background-color: rgba(139, 92, 246, 0.05);
|
||||
border-radius: 0 0.25rem 0.25rem 0;
|
||||
}
|
||||
|
||||
.prose :deep(blockquote p) {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dark .prose :deep(blockquote p) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 链接 */
|
||||
.prose :deep(a) {
|
||||
color: #8b5cf6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.prose :deep(a:hover) {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.prose :deep(hr) {
|
||||
margin: 1em 0;
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .prose :deep(hr) {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
/* 加粗和斜体 */
|
||||
.prose :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.prose :deep(table) {
|
||||
width: 100%;
|
||||
margin: 0.75em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.prose :deep(th),
|
||||
.prose :deep(td) {
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dark .prose :deep(th),
|
||||
.dark .prose :deep(td) {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.prose :deep(th) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .prose :deep(th) {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
/* 用户消息中的样式调整 */
|
||||
.prose-invert :deep(code) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.prose-invert :deep(a) {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.prose-invert :deep(a:hover) {
|
||||
color: #ddd6fe;
|
||||
}
|
||||
|
||||
.prose-invert :deep(blockquote) {
|
||||
border-left-color: rgba(255, 255, 255, 0.5);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.prose-invert :deep(blockquote p) {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
interface Conversation {
|
||||
id: string
|
||||
sessionId: string
|
||||
title: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
activeId: string | null
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
create: []
|
||||
delete: [id: string]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const conversations = ref<Conversation[]>([])
|
||||
const isLoading = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingTitle = ref('')
|
||||
|
||||
// 加载对话列表
|
||||
async function loadConversations() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
conversations.value = await window.aiApi.getConversations(props.sessionId)
|
||||
} catch (error) {
|
||||
console.error('加载对话列表失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timestamp: number): string {
|
||||
const now = dayjs()
|
||||
const date = dayjs(timestamp)
|
||||
|
||||
if (now.diff(date, 'day') === 0) {
|
||||
return date.format('HH:mm')
|
||||
} else if (now.diff(date, 'day') < 7) {
|
||||
return date.format('ddd HH:mm')
|
||||
} else {
|
||||
return date.format('MM-DD')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对话标题
|
||||
function getTitle(conv: Conversation): string {
|
||||
return conv.title || '新对话'
|
||||
}
|
||||
|
||||
// 开始编辑标题
|
||||
function startEditing(conv: Conversation) {
|
||||
editingId.value = conv.id
|
||||
editingTitle.value = conv.title || ''
|
||||
}
|
||||
|
||||
// 保存标题
|
||||
async function saveTitle(convId: string) {
|
||||
if (editingTitle.value.trim()) {
|
||||
try {
|
||||
await window.aiApi.updateConversationTitle(convId, editingTitle.value.trim())
|
||||
const conv = conversations.value.find((c) => c.id === convId)
|
||||
if (conv) {
|
||||
conv.title = editingTitle.value.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新标题失败:', error)
|
||||
}
|
||||
}
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
// 删除对话
|
||||
async function handleDelete(convId: string) {
|
||||
try {
|
||||
await window.aiApi.deleteConversation(convId)
|
||||
conversations.value = conversations.value.filter((c) => c.id !== convId)
|
||||
emit('delete', convId)
|
||||
} catch (error) {
|
||||
console.error('删除对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadConversations()
|
||||
})
|
||||
|
||||
// 监听 sessionId 变化
|
||||
watch(
|
||||
() => props.sessionId,
|
||||
() => {
|
||||
loadConversations()
|
||||
}
|
||||
)
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: loadConversations,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-48 flex-col rounded-xl bg-white shadow-sm dark:bg-gray-900">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-5 w-5 text-gray-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">对话记录</span>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-plus" color="primary" variant="soft" size="xs" @click="emit('create')">新对话</UButton>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="conversations.length === 0" class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" class="h-10 w-10 text-gray-300 dark:text-gray-600" />
|
||||
<p class="mt-2 text-sm text-gray-500">暂无对话</p>
|
||||
<p class="text-xs text-gray-400">点击"新对话"开始</p>
|
||||
</div>
|
||||
|
||||
<!-- 对话列表 -->
|
||||
<div v-else class="p-2 space-y-1">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="group relative rounded-lg px-3 py-2.5 transition-colors cursor-pointer"
|
||||
:class="[
|
||||
activeId === conv.id ? 'bg-violet-50 dark:bg-violet-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
|
||||
]"
|
||||
@click="emit('select', conv.id)"
|
||||
>
|
||||
<!-- 编辑模式 -->
|
||||
<template v-if="editingId === conv.id">
|
||||
<input
|
||||
v-model="editingTitle"
|
||||
class="w-full rounded border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800"
|
||||
placeholder="输入标题..."
|
||||
@blur="saveTitle(conv.id)"
|
||||
@keyup.enter="saveTitle(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 正常模式 -->
|
||||
<template v-else>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="truncate text-sm font-medium"
|
||||
:class="[
|
||||
activeId === conv.id ? 'text-violet-700 dark:text-violet-400' : 'text-gray-900 dark:text-white',
|
||||
]"
|
||||
>
|
||||
{{ getTitle(conv) }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400">
|
||||
{{ formatTime(conv.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<UButton
|
||||
icon="i-heroicons-pencil"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="2xs"
|
||||
@click="startEditing(conv)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-trash"
|
||||
color="red"
|
||||
variant="ghost"
|
||||
size="2xs"
|
||||
@click="handleDelete(conv.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
messages: Array<{
|
||||
id: number
|
||||
senderName: string
|
||||
content: string
|
||||
timestamp: number
|
||||
}>
|
||||
keywords: string[]
|
||||
isLoading: boolean
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timestamp: number): string {
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 高亮关键词
|
||||
function highlightKeywords(text: string): string {
|
||||
if (!props.keywords.length) return text
|
||||
const pattern = props.keywords.join('|')
|
||||
const regex = new RegExp(`(${pattern})`, 'gi')
|
||||
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800/50 px-0.5 rounded">$1</mark>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col rounded-xl bg-white shadow-sm transition-all duration-300 dark:bg-gray-900"
|
||||
:class="[isCollapsed ? 'w-12' : 'w-full']"
|
||||
>
|
||||
<!-- 折叠状态 -->
|
||||
<template v-if="isCollapsed">
|
||||
<button
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<UIcon name="i-heroicons-chevron-left" class="h-5 w-5" />
|
||||
<span class="writing-vertical text-xs">数据源</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<template v-else>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-magnifying-glass" class="h-5 w-5 text-gray-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">数据源</span>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-chevron-right"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click="emit('toggle')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前关键词 -->
|
||||
<div v-if="keywords.length > 0" class="border-b border-gray-200 px-4 py-2 dark:border-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-gray-500">关键词:</span>
|
||||
<span
|
||||
v-for="kw in keywords"
|
||||
:key="kw"
|
||||
class="rounded-full bg-violet-100 px-2 py-0.5 text-xs text-violet-700 dark:bg-violet-900/30 dark:text-violet-400"
|
||||
>
|
||||
{{ kw }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="messages.length === 0"
|
||||
class="flex flex-col items-center justify-center py-8 text-center"
|
||||
>
|
||||
<UIcon name="i-heroicons-inbox" class="h-10 w-10 text-gray-300 dark:text-gray-600" />
|
||||
<p class="mt-2 text-sm text-gray-500">暂无数据</p>
|
||||
<p class="text-xs text-gray-400">发送问题后,相关记录会显示在这里</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="px-4 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ msg.senderName }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ formatTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
<p
|
||||
class="line-clamp-3 text-sm text-gray-600 dark:text-gray-400"
|
||||
v-html="highlightKeywords(msg.content)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部统计 & 加载更多 -->
|
||||
<div
|
||||
v-if="messages.length > 0"
|
||||
class="border-t border-gray-200 px-4 py-2 dark:border-gray-800"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">共 {{ messages.length }} 条记录</span>
|
||||
<UButton size="xs" color="gray" variant="ghost" @click="emit('loadMore')">
|
||||
加载更多
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 半透明数据流背景效果 -->
|
||||
<div
|
||||
v-if="!isCollapsed"
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden rounded-xl opacity-[0.03]"
|
||||
>
|
||||
<div class="data-flow-bg absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.writing-vertical {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
||||
/* 数据流背景动画 */
|
||||
.data-flow-bg {
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 20px,
|
||||
currentColor 20px,
|
||||
currentColor 21px
|
||||
);
|
||||
animation: dataFlow 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dataFlow {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,7 @@ const formattedCount = computed(() => props.countTemplate.replace('{count}', Str
|
||||
|
||||
<!-- 完整列表弹窗 -->
|
||||
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-4xl' }">
|
||||
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" color="gray" variant="ghost">查看完整列表</UButton>
|
||||
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" color="gray" variant="ghost">查看完整排行</UButton>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
export { useAsyncData, useMultipleAsyncData } from './useAsyncData'
|
||||
export { usePageAnchors, type AnchorItem } from './usePageAnchors'
|
||||
export { useAIChat, type ChatMessage, type SourceMessage } from './useAIChat'
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* AI 对话 Composable
|
||||
* 封装 AI 对话的核心逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 消息类型
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
dataSource?: {
|
||||
keywords: string[]
|
||||
messageCount: number
|
||||
}
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
// 搜索结果消息类型
|
||||
export interface SourceMessage {
|
||||
id: number
|
||||
senderName: string
|
||||
senderPlatformId: string
|
||||
content: string
|
||||
timestamp: number
|
||||
type: number
|
||||
}
|
||||
|
||||
// LLM 聊天消息类型
|
||||
interface LLMMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词提取 Prompt
|
||||
*/
|
||||
const KEYWORD_EXTRACTION_PROMPT = `你是一个关键词提取助手。用户会提出关于群聊记录的问题,你需要从问题中提取用于搜索的关键词。
|
||||
|
||||
规则:
|
||||
1. 提取 1-5 个最相关的关键词
|
||||
2. 关键词应该是可能出现在聊天记录中的词语
|
||||
3. 优先提取名词和动词,避免提取太泛化的词
|
||||
4. 如果问题涉及人名,也提取人名
|
||||
5. 只返回关键词,用逗号分隔,不要有其他内容
|
||||
|
||||
示例:
|
||||
问题:帮我找一下群里大家讨论买房的记录
|
||||
关键词:买房,房价,房子,购房
|
||||
|
||||
问题:谁最近在聊游戏
|
||||
关键词:游戏,玩游戏
|
||||
|
||||
问题:大家对新出的手机怎么看
|
||||
关键词:手机,新机,换机`
|
||||
|
||||
/**
|
||||
* RAG 总结 Prompt 模板
|
||||
*/
|
||||
function createRAGPrompt(question: string, messages: SourceMessage[]): string {
|
||||
const messageList = messages
|
||||
.map((m) => `[${m.senderName}] ${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
return `你是一个群聊记录分析助手。根据以下群聊记录回答用户的问题。
|
||||
|
||||
## 群聊记录
|
||||
${messageList}
|
||||
|
||||
## 用户问题
|
||||
${question}
|
||||
|
||||
## 回答要求
|
||||
1. 基于群聊记录内容回答,不要编造信息
|
||||
2. 如果记录中没有相关内容,请说明
|
||||
3. 总结时可以分析群友的态度、观点
|
||||
4. 回答要简洁明了,使用 Markdown 格式
|
||||
5. 可以引用具体的发言作为证据`
|
||||
}
|
||||
|
||||
export function useAIChat(sessionId: string, timeFilter?: { startTs: number; endTs: number }) {
|
||||
// 状态
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const sourceMessages = ref<SourceMessage[]>([])
|
||||
const currentKeywords = ref<string[]>([])
|
||||
const isLoadingSource = ref(false)
|
||||
const isAIThinking = ref(false)
|
||||
const currentConversationId = ref<string | null>(null)
|
||||
const maxMessagesToSend = ref(50) // 默认发送条数限制
|
||||
|
||||
// 生成消息 ID
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户问题中提取关键词
|
||||
*/
|
||||
async function extractKeywords(question: string): Promise<string[]> {
|
||||
console.log('[AI] 开始提取关键词,问题:', question)
|
||||
|
||||
try {
|
||||
const llmMessages: LLMMessage[] = [
|
||||
{ role: 'system', content: KEYWORD_EXTRACTION_PROMPT },
|
||||
{ role: 'user', content: question },
|
||||
]
|
||||
|
||||
console.log('[AI] 调用 LLM 提取关键词...')
|
||||
const result = await window.llmApi.chat(llmMessages, { temperature: 0.3, maxTokens: 100 })
|
||||
console.log('[AI] LLM 关键词提取结果:', result)
|
||||
|
||||
if (result.success && result.content) {
|
||||
// 解析关键词(逗号分隔)
|
||||
const keywords = result.content
|
||||
.split(/[,,]/)
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0 && k.length < 20) // 过滤掉空的和太长的
|
||||
|
||||
console.log('[AI] 解析后的关键词:', keywords)
|
||||
return keywords.slice(0, 5) // 最多 5 个关键词
|
||||
}
|
||||
|
||||
console.log('[AI] 关键词提取失败,使用简单分词')
|
||||
// 如果提取失败,简单分词
|
||||
return question
|
||||
.split(/[\s,,。!?!?]+/)
|
||||
.filter((w) => w.length >= 2)
|
||||
.slice(0, 3)
|
||||
} catch (error) {
|
||||
console.error('[AI] 提取关键词出错:', error)
|
||||
// 降级:简单分词
|
||||
return question
|
||||
.split(/[\s,,。!?!?]+/)
|
||||
.filter((w) => w.length >= 2)
|
||||
.slice(0, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索相关消息
|
||||
*/
|
||||
async function searchRelatedMessages(keywords: string[]): Promise<SourceMessage[]> {
|
||||
console.log('[AI] 开始搜索相关消息,关键词:', keywords)
|
||||
|
||||
if (keywords.length === 0) {
|
||||
console.log('[AI] 没有关键词,跳过搜索')
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[AI] 调用搜索 API...', {
|
||||
sessionId,
|
||||
keywords,
|
||||
timeFilter,
|
||||
limit: maxMessagesToSend.value,
|
||||
})
|
||||
|
||||
const result = await window.aiApi.searchMessages(
|
||||
sessionId,
|
||||
keywords,
|
||||
timeFilter,
|
||||
maxMessagesToSend.value,
|
||||
0
|
||||
)
|
||||
|
||||
console.log('[AI] 搜索结果:', {
|
||||
total: result.total,
|
||||
returned: result.messages.length,
|
||||
})
|
||||
|
||||
return result.messages
|
||||
} catch (error) {
|
||||
console.error('[AI] 搜索消息出错:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RAG 响应(流式)
|
||||
*/
|
||||
async function generateRAGResponse(
|
||||
question: string,
|
||||
searchResults: SourceMessage[],
|
||||
onChunk: (content: string) => void
|
||||
): Promise<void> {
|
||||
console.log('[AI] 开始生成 RAG 响应', {
|
||||
question,
|
||||
searchResultsCount: searchResults.length,
|
||||
})
|
||||
|
||||
const prompt = createRAGPrompt(question, searchResults)
|
||||
console.log('[AI] 构建的 RAG Prompt 长度:', prompt.length)
|
||||
|
||||
const llmMessages: LLMMessage[] = [{ role: 'user', content: prompt }]
|
||||
|
||||
console.log('[AI] 调用流式 LLM API...')
|
||||
let chunkCount = 0
|
||||
|
||||
try {
|
||||
await window.llmApi.chatStream(llmMessages, { temperature: 0.7, maxTokens: 2048 }, (chunk) => {
|
||||
chunkCount++
|
||||
if (chunk.content) {
|
||||
onChunk(chunk.content)
|
||||
}
|
||||
if (chunk.isFinished) {
|
||||
console.log('[AI] 流式响应完成', {
|
||||
chunkCount,
|
||||
finishReason: chunk.finishReason,
|
||||
})
|
||||
}
|
||||
})
|
||||
console.log('[AI] 流式 API 调用返回')
|
||||
} catch (error) {
|
||||
console.error('[AI] 流式 API 调用出错:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
async function sendMessage(content: string): Promise<void> {
|
||||
console.log('[AI] ====== 开始处理用户消息 ======')
|
||||
console.log('[AI] 用户输入:', content)
|
||||
|
||||
if (!content.trim() || isAIThinking.value) {
|
||||
console.log('[AI] 跳过:内容为空或正在思考')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已配置 LLM
|
||||
console.log('[AI] 检查 LLM 配置...')
|
||||
const hasConfig = await window.llmApi.hasConfig()
|
||||
console.log('[AI] LLM 配置状态:', hasConfig)
|
||||
|
||||
if (!hasConfig) {
|
||||
console.log('[AI] 未配置 LLM,显示提示')
|
||||
messages.value.push({
|
||||
id: generateId('error'),
|
||||
role: 'assistant',
|
||||
content: '⚠️ 请先配置 AI 服务。点击右上角「配置」按钮进行配置。',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId('user'),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
messages.value.push(userMessage)
|
||||
console.log('[AI] 已添加用户消息')
|
||||
|
||||
// 开始处理
|
||||
isAIThinking.value = true
|
||||
isLoadingSource.value = true
|
||||
console.log('[AI] 开始处理...')
|
||||
|
||||
try {
|
||||
// 1. 提取关键词
|
||||
console.log('[AI] === Step 1: 提取关键词 ===')
|
||||
const keywords = await extractKeywords(content)
|
||||
currentKeywords.value = keywords
|
||||
console.log('[AI] 关键词提取完成:', keywords)
|
||||
|
||||
// 2. 搜索相关消息
|
||||
console.log('[AI] === Step 2: 搜索相关消息 ===')
|
||||
const searchResults = await searchRelatedMessages(keywords)
|
||||
sourceMessages.value = searchResults
|
||||
isLoadingSource.value = false
|
||||
console.log('[AI] 搜索完成,找到', searchResults.length, '条消息')
|
||||
|
||||
// 3. 创建 AI 响应消息(流式)
|
||||
console.log('[AI] === Step 3: 创建 AI 响应消息 ===')
|
||||
const aiMessageId = generateId('ai')
|
||||
const aiMessage: ChatMessage = {
|
||||
id: aiMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
dataSource: {
|
||||
keywords,
|
||||
messageCount: searchResults.length,
|
||||
},
|
||||
isStreaming: true,
|
||||
}
|
||||
messages.value.push(aiMessage)
|
||||
const aiMessageIndex = messages.value.length - 1
|
||||
console.log('[AI] 已添加 AI 消息占位符,索引:', aiMessageIndex)
|
||||
|
||||
// 4. 生成 RAG 响应
|
||||
console.log('[AI] === Step 4: 生成 RAG 响应 ===')
|
||||
if (searchResults.length === 0) {
|
||||
// 没有搜索结果
|
||||
console.log('[AI] 没有搜索结果,显示提示')
|
||||
messages.value[aiMessageIndex] = {
|
||||
...messages.value[aiMessageIndex],
|
||||
content: `没有找到与「${keywords.join('」「')}」相关的聊天记录。
|
||||
|
||||
可能的原因:
|
||||
- 关键词不够准确
|
||||
- 该时间段内没有相关讨论
|
||||
|
||||
你可以尝试:
|
||||
- 换一种方式描述你想查找的内容
|
||||
- 使用更具体的关键词`,
|
||||
isStreaming: false,
|
||||
}
|
||||
} else {
|
||||
// 有搜索结果,生成总结
|
||||
console.log('[AI] 开始生成 RAG 总结...')
|
||||
let accumulatedContent = ''
|
||||
await generateRAGResponse(content, searchResults, (chunk) => {
|
||||
accumulatedContent += chunk
|
||||
// 使用索引更新以触发 Vue 响应式
|
||||
messages.value[aiMessageIndex] = {
|
||||
...messages.value[aiMessageIndex],
|
||||
content: accumulatedContent,
|
||||
}
|
||||
})
|
||||
console.log('[AI] RAG 总结生成完成')
|
||||
// 标记流式完成
|
||||
messages.value[aiMessageIndex] = {
|
||||
...messages.value[aiMessageIndex],
|
||||
isStreaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 保存对话到数据库
|
||||
console.log('[AI] === Step 5: 保存对话 ===')
|
||||
await saveConversation(userMessage, aiMessage)
|
||||
console.log('[AI] 对话已保存')
|
||||
console.log('[AI] ====== 处理完成 ======')
|
||||
} catch (error) {
|
||||
console.error('[AI] ====== 处理失败 ======')
|
||||
console.error('[AI] 错误:', error)
|
||||
|
||||
// 添加错误消息
|
||||
messages.value.push({
|
||||
id: generateId('error'),
|
||||
role: 'assistant',
|
||||
content: `❌ 处理失败:${error instanceof Error ? error.message : '未知错误'}
|
||||
|
||||
请检查:
|
||||
- 网络连接是否正常
|
||||
- API Key 是否有效
|
||||
- 配置是否正确`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
} finally {
|
||||
isAIThinking.value = false
|
||||
isLoadingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对话到数据库
|
||||
*/
|
||||
async function saveConversation(userMsg: ChatMessage, aiMsg: ChatMessage): Promise<void> {
|
||||
try {
|
||||
// 如果没有当前对话,创建新对话
|
||||
if (!currentConversationId.value) {
|
||||
const conversation = await window.aiApi.createConversation(sessionId)
|
||||
currentConversationId.value = conversation.id
|
||||
}
|
||||
|
||||
// 保存用户消息
|
||||
await window.aiApi.addMessage(currentConversationId.value, 'user', userMsg.content)
|
||||
|
||||
// 保存 AI 消息
|
||||
await window.aiApi.addMessage(
|
||||
currentConversationId.value,
|
||||
'assistant',
|
||||
aiMsg.content,
|
||||
aiMsg.dataSource?.keywords,
|
||||
aiMsg.dataSource?.messageCount
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('保存对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载对话历史
|
||||
*/
|
||||
async function loadConversation(conversationId: string): Promise<void> {
|
||||
try {
|
||||
const history = await window.aiApi.getMessages(conversationId)
|
||||
currentConversationId.value = conversationId
|
||||
|
||||
messages.value = history.map((msg) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp * 1000,
|
||||
dataSource: msg.dataKeywords
|
||||
? {
|
||||
keywords: msg.dataKeywords,
|
||||
messageCount: msg.dataMessageCount || 0,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载对话历史失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新对话
|
||||
*/
|
||||
function startNewConversation(welcomeMessage?: string): void {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
sourceMessages.value = []
|
||||
currentKeywords.value = []
|
||||
|
||||
if (welcomeMessage) {
|
||||
messages.value.push({
|
||||
id: generateId('welcome'),
|
||||
role: 'assistant',
|
||||
content: welcomeMessage,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多搜索结果
|
||||
*/
|
||||
async function loadMoreSourceMessages(): Promise<void> {
|
||||
if (currentKeywords.value.length === 0) return
|
||||
|
||||
try {
|
||||
const result = await window.aiApi.searchMessages(
|
||||
sessionId,
|
||||
currentKeywords.value,
|
||||
timeFilter,
|
||||
20,
|
||||
sourceMessages.value.length
|
||||
)
|
||||
|
||||
sourceMessages.value.push(...result.messages)
|
||||
} catch (error) {
|
||||
console.error('加载更多消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新发送条数限制
|
||||
*/
|
||||
async function updateMaxMessages(): Promise<void> {
|
||||
try {
|
||||
const config = await window.llmApi.getConfig()
|
||||
if (config && config.maxTokens) {
|
||||
maxMessagesToSend.value = config.maxTokens
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
messages,
|
||||
sourceMessages,
|
||||
currentKeywords,
|
||||
isLoadingSource,
|
||||
isAIThinking,
|
||||
currentConversationId,
|
||||
|
||||
// 方法
|
||||
sendMessage,
|
||||
loadConversation,
|
||||
startNewConversation,
|
||||
loadMoreSourceMessages,
|
||||
updateMaxMessages,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import RankingTab from '@/components/analysis/RankingTab.vue'
|
||||
import TimelineTab from '@/components/analysis/TimelineTab.vue'
|
||||
import QuotesTab from '@/components/analysis/QuotesTab.vue'
|
||||
import RelationshipsTab from '@/components/analysis/RelationshipsTab.vue'
|
||||
import AITab from '@/components/analysis/AITab.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -38,6 +39,7 @@ const tabs = [
|
||||
{ id: 'quotes', label: '群语录', icon: 'i-heroicons-chat-bubble-bottom-center-text' },
|
||||
{ id: 'relationships', label: '群关系', icon: 'i-heroicons-heart' },
|
||||
{ id: 'timeline', label: '群趋势', icon: 'i-heroicons-chart-bar' },
|
||||
{ id: 'ai', label: 'AI', icon: 'i-heroicons-sparkles' },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'overview')
|
||||
@@ -347,6 +349,13 @@ onMounted(() => {
|
||||
:time-range="timeRange"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
<AITab
|
||||
v-else-if="activeTab === 'ai'"
|
||||
:key="'ai-' + selectedYear"
|
||||
:session-id="currentSessionId!"
|
||||
:session-name="session.name"
|
||||
:time-filter="timeFilter"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user