feat: 支持AI分析

This commit is contained in:
digua
2025-12-03 00:42:07 +08:00
parent 3d7cadc123
commit 5a2bab52be
29 changed files with 4022 additions and 4 deletions
+1
View File
@@ -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']
+94
View File
@@ -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>
+1 -1
View File
@@ -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>
+262
View File
@@ -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>
+100
View File
@@ -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>
+291
View File
@@ -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>
+1 -1
View File
@@ -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>
+1
View File
@@ -3,3 +3,4 @@
*/
export { useAsyncData, useMultipleAsyncData } from './useAsyncData'
export { usePageAnchors, type AnchorItem } from './usePageAnchors'
export { useAIChat, type ChatMessage, type SourceMessage } from './useAIChat'
+484
View File
@@ -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,
}
}
+9
View File
@@ -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>