feat: AI聊天交互优化

This commit is contained in:
digua
2025-12-03 01:29:13 +08:00
parent bb902cacb2
commit 8b7d95c8df
7 changed files with 178 additions and 170 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+3
View File
@@ -16,6 +16,9 @@ declare module 'vue' {
UApp: 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/App.vue')['default']
UBadge: 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/Badge.vue')['default']
UButton: 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/Button.vue')['default']
UCard: 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/Card.vue')['default']
UChatPrompt: 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/ChatPrompt.vue')['default']
UChatPromptSubmit: 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/ChatPromptSubmit.vue')['default']
UContextMenu: 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/ContextMenu.vue')['default']
UIcon: 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/vue/components/Icon.vue')['default']
UInput: 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/Input.vue')['default']
+105 -85
View File
@@ -152,111 +152,131 @@ watch(
</script>
<template>
<div class="flex h-full gap-4 overflow-hidden">
<div class="relative flex h-full overflow-hidden px-4">
<!-- 左侧对话记录列表 -->
<ConversationList
ref="conversationListRef"
:session-id="sessionId"
:active-id="currentConversationId"
@select="handleSelectConversation"
@create="handleCreateConversation"
@delete="handleDeleteConversation"
/>
<div class="absolute left-0 top-0 h-full w-64 p-4">
<ConversationList
ref="conversationListRef"
:session-id="sessionId"
:active-id="currentConversationId"
@select="handleSelectConversation"
@create="handleCreateConversation"
@delete="handleDeleteConversation"
class="h-full"
/>
</div>
<!-- 中间对话区域 -->
<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 h-full flex-1 justify-center pl-64 pr-80">
<div
class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800"
class="flex min-w-0 flex-1 max-w-3xl flex-col overflow-hidden rounded-xl bg-white shadow-sm dark:bg-gray-900"
>
<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 class="shrink-0 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>
<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 items-center gap-2">
<!-- 配置状态指示 -->
<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"
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',
]"
>
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-white" />
<span class="h-2 w-2 rounded-full" :class="[hasLLMConfig ? 'bg-green-500' : 'bg-amber-500']" />
{{ hasLLMConfig ? '已配置' : '未配置' }}
</div>
<div
class="rounded-2xl rounded-tl-sm bg-gray-100 px-4 py-3 dark:bg-gray-800"
<UButton
icon="i-heroicons-cog-6-tooth"
color="gray"
variant="ghost"
size="sm"
@click="showConfigModal = true"
>
<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>
配置
</UButton>
</div>
</div>
<!-- 消息列表 -->
<div ref="messagesContainer" class="min-h-0 flex-1 overflow-y-auto p-4">
<div class="mx-auto 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>
</div>
<!-- 输入框 -->
<ChatInput :disabled="isAIThinking" @send="handleSend" />
<!-- 输入框 -->
<ChatInput
:disabled="isAIThinking"
:status="isAIThinking ? (isLoadingSource ? 'submitted' : 'streaming') : 'ready'"
@send="handleSend"
/>
</div>
</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>
<Transition name="slide-fade">
<div v-if="sourceMessages.length > 0 && !isSourcePanelCollapsed" class="absolute right-0 top-0 h-full w-80 p-4">
<DataSourcePanel
:messages="sourceMessages"
:keywords="currentKeywords"
:is-loading="isLoadingSource"
:is-collapsed="isSourcePanelCollapsed"
class="h-full"
@toggle="toggleSourcePanel"
@load-more="handleLoadMore"
/>
</div>
</Transition>
<!-- AI 配置弹窗 -->
<AIConfigModal v-model:open="showConfigModal" @saved="handleConfigSaved" />
</div>
</template>
<style scoped>
/* Transition styles for slide-fade */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease-out;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>
+27 -66
View File
@@ -5,96 +5,57 @@ import { ref, computed } from 'vue'
const props = defineProps<{
disabled?: boolean
placeholder?: string
status?: 'ready' | 'submitted' | 'streaming' | 'error'
}>()
// Emits
const emit = defineEmits<{
send: [content: string]
stop: []
}>()
// 输入内容
const inputValue = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
// 是否可以发送
const canSend = computed(() => inputValue.value.trim() && !props.disabled)
// 计算 status
const chatStatus = computed(() => {
if (props.disabled) {
return props.status || 'submitted'
}
return 'ready'
})
// 发送消息
function handleSend() {
if (!canSend.value) return
function handleSubmit() {
if (!inputValue.value.trim() || props.disabled) 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'
// 停止生成
function handleStop() {
emit('stop')
}
</script>
<template>
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
<div class="shrink-0 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' : '']"
<UChatPrompt
v-model="inputValue"
:placeholder="placeholder || '输入你的问题...'"
:disabled="disabled"
variant="subtle"
@submit="handleSubmit"
>
<!-- 输入框 -->
<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"
<UChatPromptSubmit
:status="chatStatus"
class="rounded-full"
color="primary"
@stop="handleStop"
/>
<!-- 发送按钮 -->
<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>
</UChatPrompt>
</div>
</div>
</template>
+7 -10
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import dayjs from 'dayjs'
import MarkdownIt from 'markdown-it'
import userAvatar from '@/assets/images/momo.png'
// Props
const props = defineProps<{
@@ -37,18 +38,14 @@ const renderedContent = computed(() => {
<template>
<div class="flex items-start gap-3" :class="[isUser ? 'flex-row-reverse' : '']">
<!-- 头像 -->
<div v-if="isUser" class="h-8 w-8 shrink-0 overflow-hidden rounded-full">
<img :src="userAvatar" alt="用户头像" class="h-full w-full object-cover" />
</div>
<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',
]"
v-else
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="isUser ? 'i-heroicons-user' : 'i-heroicons-sparkles'"
class="h-4 w-4 text-white"
/>
<UIcon name="i-heroicons-sparkles" class="h-4 w-4 text-white" />
</div>
<!-- 消息内容 -->
@@ -113,7 +113,7 @@ defineExpose({
</script>
<template>
<div class="flex h-full w-48 flex-col rounded-xl bg-white shadow-sm dark:bg-gray-900">
<div class="flex h-full w-64 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">
+35 -8
View File
@@ -333,7 +333,10 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
// 5. 保存对话到数据库
console.log('[AI] === Step 5: 保存对话 ===')
await saveConversation(userMessage, aiMessage)
// 使用数组中的最新消息(流式更新后的内容)
const finalAiMessage = messages.value[aiMessageIndex]
console.log('[AI] 保存 AI 消息内容长度:', finalAiMessage.content.length)
await saveConversation(userMessage, finalAiMessage)
console.log('[AI] 对话已保存')
console.log('[AI] ====== 处理完成 ======')
} catch (error) {
@@ -362,26 +365,40 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
* 保存对话到数据库
*/
async function saveConversation(userMsg: ChatMessage, aiMsg: ChatMessage): Promise<void> {
console.log('[AI] saveConversation 调用')
console.log('[AI] 用户消息内容长度:', userMsg.content?.length || 0)
console.log('[AI] AI消息内容长度:', aiMsg.content?.length || 0)
console.log('[AI] AI消息内容预览:', aiMsg.content?.slice(0, 100))
try {
// 如果没有当前对话,创建新对话
// 如果没有当前对话,创建新对话(使用用户第一次提问作为标题)
if (!currentConversationId.value) {
const conversation = await window.aiApi.createConversation(sessionId)
// 截取前 50 个字符作为标题
const title = userMsg.content.slice(0, 50) + (userMsg.content.length > 50 ? '...' : '')
const conversation = await window.aiApi.createConversation(sessionId, title)
currentConversationId.value = conversation.id
console.log('[AI] 创建了新对话:', conversation.id)
}
// 保存用户消息
console.log('[AI] 保存用户消息...')
await window.aiApi.addMessage(currentConversationId.value, 'user', userMsg.content)
// 保存 AI 消息
// 保存 AI 消息(需要将 Proxy 对象转为普通对象以便 IPC 序列化)
console.log('[AI] 保存 AI 消息...')
const keywords = aiMsg.dataSource?.keywords ? [...aiMsg.dataSource.keywords] : undefined
const messageCount = aiMsg.dataSource?.messageCount
await window.aiApi.addMessage(
currentConversationId.value,
'assistant',
aiMsg.content,
aiMsg.dataSource?.keywords,
aiMsg.dataSource?.messageCount
keywords,
messageCount
)
console.log('[AI] 消息保存完成')
} catch (error) {
console.error('保存对话失败:', error)
console.error('[AI] 保存对话失败:', error)
}
}
@@ -389,8 +406,17 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
* 加载对话历史
*/
async function loadConversation(conversationId: string): Promise<void> {
console.log('[AI] 加载对话历史,conversationId:', conversationId)
try {
const history = await window.aiApi.getMessages(conversationId)
console.log('[AI] 获取到的历史消息数量:', history.length)
console.log('[AI] 历史消息详情:', history.map(m => ({
id: m.id,
role: m.role,
contentLength: m.content?.length || 0,
content: m.content?.slice(0, 50) + '...'
})))
currentConversationId.value = conversationId
messages.value = history.map((msg) => ({
@@ -405,8 +431,9 @@ export function useAIChat(sessionId: string, timeFilter?: { startTs: number; end
}
: undefined,
}))
console.log('[AI] 加载完成,messages.value 数量:', messages.value.length)
} catch (error) {
console.error('加载对话历史失败:', error)
console.error('[AI] 加载对话历史失败:', error)
}
}