2256 lines
53 KiB
Vue
2256 lines
53 KiB
Vue
<script setup lang="ts">
|
||
import { ref, nextTick, onMounted } from 'vue'
|
||
import { http } from '../services/api'
|
||
import { marked } from 'marked'
|
||
|
||
// 配置marked选项
|
||
onMounted(() => {
|
||
marked.setOptions({
|
||
breaks: true,
|
||
gfm: true, // GitHub风格Markdown,支持表格等扩展语法
|
||
})
|
||
|
||
// 初始化对话列表
|
||
fetchConversations()
|
||
})
|
||
|
||
// 消息类型定义
|
||
interface Message {
|
||
id: string
|
||
type: 'user' | 'assistant'
|
||
content: string
|
||
timestamp: Date
|
||
isStreaming?: boolean
|
||
}
|
||
|
||
// 对话类型定义
|
||
interface Conversation {
|
||
id: string
|
||
name: string
|
||
}
|
||
|
||
// 历史消息类型定义
|
||
interface HistoryMessage {
|
||
id: string
|
||
conversation_id: string
|
||
query: string
|
||
answer: string
|
||
created_at: number
|
||
}
|
||
|
||
// 状态管理
|
||
const messageInput = ref('')
|
||
const messages = ref<Message[]>([])
|
||
const isLoading = ref(false)
|
||
const chatContainer = ref<HTMLElement | null>(null)
|
||
|
||
// 对话相关状态
|
||
const conversations = ref<Conversation[]>([])
|
||
const selectedConversationId = ref<string>('')
|
||
const isLoadingConversations = ref(false)
|
||
const isLoadingHistory = ref(false)
|
||
const isDropdownOpen = ref(false)
|
||
|
||
// 自定义弹框状态
|
||
const showDeleteDialog = ref(false)
|
||
const showRenameDialog = ref(false)
|
||
const conversationToDelete = ref<Conversation | null>(null)
|
||
const conversationToRename = ref<Conversation | null>(null)
|
||
const newConversationName = ref('')
|
||
const renameInputRef = ref<HTMLInputElement | null>(null)
|
||
|
||
// 流式输出控制
|
||
const currentTaskId = ref<string>('')
|
||
const streamingAbortController = ref<AbortController | null>(null)
|
||
|
||
// 根据环境选择API基础URL
|
||
const apiBaseUrl =
|
||
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
|
||
|
||
// 获取对话列表
|
||
const fetchConversations = async () => {
|
||
try {
|
||
isLoadingConversations.value = true
|
||
const response = await http.get(`${apiBaseUrl}/analysis/conversations`)
|
||
if (response.ok) {
|
||
conversations.value = await response.json()
|
||
|
||
// 默认始终使用新对话,不自动加载历史会话
|
||
}
|
||
} catch (error) {
|
||
console.error('获取对话列表失败:', error)
|
||
} finally {
|
||
isLoadingConversations.value = false
|
||
}
|
||
}
|
||
|
||
// 获取对话历史消息
|
||
const fetchConversationMessages = async (conversationId: string) => {
|
||
try {
|
||
isLoadingHistory.value = true
|
||
const response = await http.get(
|
||
`${apiBaseUrl}/analysis/conversation_messages/${conversationId}`,
|
||
)
|
||
if (response.ok) {
|
||
const historyMessages: HistoryMessage[] = await response.json()
|
||
|
||
// 将历史消息转换为Message格式
|
||
const convertedMessages: Message[] = []
|
||
historyMessages.forEach((msg) => {
|
||
// 添加用户消息
|
||
convertedMessages.push({
|
||
id: `${msg.id}_query`,
|
||
type: 'user',
|
||
content: msg.query,
|
||
timestamp: new Date(msg.created_at * 1000),
|
||
})
|
||
|
||
// 添加助手回复
|
||
convertedMessages.push({
|
||
id: `${msg.id}_answer`,
|
||
type: 'assistant',
|
||
content: msg.answer,
|
||
timestamp: new Date(msg.created_at * 1000),
|
||
})
|
||
})
|
||
|
||
messages.value = convertedMessages
|
||
await scrollToBottom()
|
||
}
|
||
} catch (error) {
|
||
console.error('获取对话历史失败:', error)
|
||
} finally {
|
||
isLoadingHistory.value = false
|
||
}
|
||
}
|
||
|
||
// 处理对话选择变化
|
||
const selectConversationFromDropdown = async (conversationId: string) => {
|
||
selectedConversationId.value = conversationId
|
||
await fetchConversationMessages(conversationId)
|
||
closeConversationDropdown()
|
||
}
|
||
|
||
// 选择新对话
|
||
const selectNewConversation = () => {
|
||
selectedConversationId.value = ''
|
||
messages.value = []
|
||
closeConversationDropdown()
|
||
}
|
||
|
||
// 关闭下拉会话列表
|
||
const closeConversationDropdown = () => {
|
||
isDropdownOpen.value = false
|
||
}
|
||
|
||
// 获取当前对话名称
|
||
const getCurrentConversationName = () => {
|
||
if (!selectedConversationId.value) {
|
||
return '新对话'
|
||
}
|
||
const conversation = conversations.value.find((c) => c.id === selectedConversationId.value)
|
||
return conversation ? conversation.name : '未命名对话'
|
||
}
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = async () => {
|
||
await nextTick()
|
||
if (chatContainer.value) {
|
||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
|
||
}
|
||
}
|
||
|
||
// 生成唯一ID
|
||
const generateId = () => {
|
||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||
}
|
||
|
||
// 处理回车键发送消息
|
||
const handleKeydown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault()
|
||
sendMessage()
|
||
}
|
||
}
|
||
|
||
// 发送消息
|
||
const sendMessage = async () => {
|
||
const message = messageInput.value.trim()
|
||
if (!message || isLoading.value) return
|
||
|
||
// 添加用户消息
|
||
const userMessage: Message = {
|
||
id: generateId(),
|
||
type: 'user',
|
||
content: message,
|
||
timestamp: new Date(),
|
||
}
|
||
messages.value.push(userMessage)
|
||
messageInput.value = ''
|
||
|
||
// 创建助手消息占位符
|
||
const assistantMessage: Message = {
|
||
id: generateId(),
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(),
|
||
isStreaming: true,
|
||
}
|
||
messages.value.push(assistantMessage)
|
||
|
||
isLoading.value = true
|
||
await scrollToBottom()
|
||
|
||
// 创建AbortController用于取消请求
|
||
streamingAbortController.value = new AbortController()
|
||
|
||
try {
|
||
const response = await http.post(`${apiBaseUrl}/analysis/chat-messages`, {
|
||
message: message,
|
||
...(selectedConversationId.value && { conversation_id: selectedConversationId.value }),
|
||
})
|
||
|
||
console.log('响应状态:', response.status, response.ok) // 调试日志
|
||
console.log('响应头:', response.headers) // 调试日志
|
||
|
||
if (!response.ok) {
|
||
// 处理错误响应
|
||
try {
|
||
const errorData = await response.json()
|
||
assistantMessage.content = `错误: ${errorData.detail || '请求失败'}`
|
||
} catch {
|
||
assistantMessage.content = `错误: 请求失败,状态码: ${response.status}`
|
||
}
|
||
assistantMessage.isStreaming = false
|
||
return
|
||
}
|
||
|
||
const reader = response.body?.getReader()
|
||
if (!reader) {
|
||
throw new Error('无法获取响应流')
|
||
}
|
||
|
||
console.log('开始读取流式数据...') // 调试日志
|
||
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
|
||
while (true) {
|
||
// 检查是否被中止
|
||
if (streamingAbortController.value?.signal.aborted) {
|
||
break
|
||
}
|
||
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
const chunk = decoder.decode(value, { stream: true })
|
||
buffer += chunk
|
||
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop() || ''
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim() || !line.startsWith('data: ')) continue
|
||
|
||
try {
|
||
const jsonStr = line.slice(6)
|
||
console.log('接收到数据:', jsonStr) // 调试日志
|
||
const data = JSON.parse(jsonStr)
|
||
console.log('解析后的数据:', data) // 调试日志
|
||
|
||
switch (data.event) {
|
||
case 'workflow_started':
|
||
// 提取task_id
|
||
if (data.task_id) {
|
||
currentTaskId.value = data.task_id
|
||
console.log('获取到task_id:', data.task_id)
|
||
}
|
||
break
|
||
case 'node_started':
|
||
break
|
||
case 'node_finished':
|
||
break
|
||
case 'node_failed':
|
||
break
|
||
|
||
case 'text_chunk':
|
||
if (data.data && data.data.text) {
|
||
console.log('添加文本块:', data.data.text) // 调试日志
|
||
assistantMessage.content += data.data.text
|
||
// 强制触发响应式更新
|
||
messages.value = [...messages.value]
|
||
await nextTick()
|
||
await scrollToBottom()
|
||
}
|
||
break
|
||
|
||
case 'message':
|
||
// 处理新的消息格式
|
||
if (data.answer) {
|
||
console.log('添加消息内容:', data.answer) // 调试日志
|
||
assistantMessage.content += data.answer
|
||
// 强制触发响应式更新
|
||
messages.value = [...messages.value]
|
||
await nextTick()
|
||
await scrollToBottom()
|
||
}
|
||
break
|
||
|
||
case 'workflow_finished':
|
||
if (data.data && data.data.outputs && data.data.outputs.text) {
|
||
if (assistantMessage.content !== data.data.outputs.text) {
|
||
assistantMessage.content = data.data.outputs.text
|
||
await scrollToBottom()
|
||
}
|
||
}
|
||
assistantMessage.isStreaming = false
|
||
// 清理task_id
|
||
currentTaskId.value = ''
|
||
break
|
||
|
||
case 'error':
|
||
const errorMessage = data.error || '未知错误'
|
||
assistantMessage.content = `错误: ${errorMessage}`
|
||
assistantMessage.isStreaming = false
|
||
// 清理task_id
|
||
currentTaskId.value = ''
|
||
await scrollToBottom()
|
||
break
|
||
|
||
default:
|
||
console.log('未处理的事件类型:', data.event, data) // 调试日志
|
||
break
|
||
}
|
||
} catch (error) {
|
||
console.error('解析响应数据出错:', error, '原始数据:', line)
|
||
assistantMessage.content = '错误: 解析响应数据时出错,请稍后重试'
|
||
assistantMessage.isStreaming = false
|
||
currentTaskId.value = ''
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确保消息状态正确设置
|
||
if (assistantMessage.isStreaming) {
|
||
assistantMessage.isStreaming = false
|
||
}
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error)
|
||
assistantMessage.content = '错误: 发送消息失败,请稍后重试'
|
||
assistantMessage.isStreaming = false
|
||
} finally {
|
||
isLoading.value = false
|
||
currentTaskId.value = ''
|
||
streamingAbortController.value = null
|
||
await scrollToBottom()
|
||
}
|
||
}
|
||
|
||
// 复制消息内容
|
||
const copyMessage = async (content: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(content)
|
||
} catch (error) {
|
||
console.error('复制失败:', error)
|
||
}
|
||
}
|
||
|
||
// 解析markdown内容
|
||
const parseMarkdown = (content: string) => {
|
||
if (!content || content.startsWith('错误:')) {
|
||
return content
|
||
}
|
||
|
||
try {
|
||
// 对于可能不完整的内容,先进行一些预处理
|
||
const processedContent = content
|
||
|
||
// 如果内容以不完整的代码块开始,暂时不处理markdown
|
||
if (
|
||
processedContent.includes('```') &&
|
||
(processedContent.match(/```/g) || []).length % 2 !== 0
|
||
) {
|
||
// 代码块未闭合,显示原始文本但保留换行
|
||
return processedContent.replace(/\n/g, '<br>')
|
||
}
|
||
|
||
// 处理markdown内容
|
||
let html = marked(processedContent) as string
|
||
|
||
// 将表格包装在div中以提供更好的滚动支持
|
||
html = html.replace(/<table>/g, '<div class="table-container"><table>')
|
||
html = html.replace(/<\/table>/g, '</table></div>')
|
||
|
||
return html
|
||
} catch (error) {
|
||
// 如果markdown解析失败(比如内容不完整),返回原始文本
|
||
console.warn('Markdown解析失败,返回原始文本:', error)
|
||
return content.replace(/\n/g, '<br>')
|
||
}
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatTime = (date: Date) => {
|
||
return date.toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
// 发送示例消息
|
||
const sendExampleMessage = async (message: string) => {
|
||
messageInput.value = message
|
||
await sendMessage()
|
||
}
|
||
|
||
// 切换下拉会话列表
|
||
const toggleConversationDropdown = () => {
|
||
isDropdownOpen.value = !isDropdownOpen.value
|
||
}
|
||
|
||
// 重命名会话
|
||
const startRenaming = (conversation: Conversation) => {
|
||
conversationToRename.value = conversation
|
||
newConversationName.value = conversation.name
|
||
showRenameDialog.value = true
|
||
// 下一个tick后聚焦输入框
|
||
nextTick(() => {
|
||
renameInputRef.value?.focus()
|
||
renameInputRef.value?.select()
|
||
})
|
||
}
|
||
|
||
// 取消重命名
|
||
const cancelRename = () => {
|
||
showRenameDialog.value = false
|
||
conversationToRename.value = null
|
||
newConversationName.value = ''
|
||
}
|
||
|
||
// 确认重命名
|
||
const confirmRename = () => {
|
||
if (!conversationToRename.value || !newConversationName.value.trim()) return
|
||
|
||
const trimmedName = newConversationName.value.trim()
|
||
if (trimmedName === conversationToRename.value.name) {
|
||
cancelRename()
|
||
return
|
||
}
|
||
|
||
renameConversation(conversationToRename.value.id, trimmedName)
|
||
cancelRename()
|
||
}
|
||
|
||
// 重命名会话API调用
|
||
const renameConversation = async (conversationId: string, newName: string) => {
|
||
try {
|
||
const response = await http.post(
|
||
`${apiBaseUrl}/analysis/conversations/${conversationId}/name`,
|
||
{
|
||
name: newName,
|
||
},
|
||
)
|
||
if (response.ok) {
|
||
// 更新本地会话列表
|
||
const conversationIndex = conversations.value.findIndex((c) => c.id === conversationId)
|
||
if (conversationIndex !== -1) {
|
||
conversations.value[conversationIndex].name = newName
|
||
}
|
||
} else {
|
||
console.error('重命名会话失败')
|
||
alert('重命名失败,请稍后重试')
|
||
}
|
||
} catch (error) {
|
||
console.error('重命名会话失败:', error)
|
||
alert('重命名失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
// 删除会话
|
||
const deleteConversation = async (conversationId: string) => {
|
||
const conversation = conversations.value.find((c) => c.id === conversationId)
|
||
if (!conversation) return
|
||
|
||
conversationToDelete.value = conversation
|
||
showDeleteDialog.value = true
|
||
}
|
||
|
||
// 取消删除
|
||
const cancelDelete = () => {
|
||
showDeleteDialog.value = false
|
||
conversationToDelete.value = null
|
||
}
|
||
|
||
// 确认删除
|
||
const confirmDelete = async () => {
|
||
if (!conversationToDelete.value) return
|
||
|
||
const conversationId = conversationToDelete.value.id
|
||
|
||
try {
|
||
// 使用createAuthenticatedFetch发送DELETE请求
|
||
const { createAuthenticatedFetch } = await import('../services/api')
|
||
const authenticatedFetch = createAuthenticatedFetch()
|
||
const response = await authenticatedFetch(
|
||
`${apiBaseUrl}/analysis/conversations/${conversationId}`,
|
||
{
|
||
method: 'DELETE',
|
||
},
|
||
)
|
||
|
||
if (response.ok) {
|
||
// 从本地会话列表中移除
|
||
conversations.value = conversations.value.filter((c) => c.id !== conversationId)
|
||
|
||
// 如果删除的是当前选中的会话,切换到新对话或第一个会话
|
||
if (selectedConversationId.value === conversationId) {
|
||
if (conversations.value.length > 0) {
|
||
selectedConversationId.value = conversations.value[0].id
|
||
await fetchConversationMessages(conversations.value[0].id)
|
||
} else {
|
||
selectedConversationId.value = ''
|
||
messages.value = []
|
||
}
|
||
}
|
||
} else {
|
||
console.error('删除会话失败')
|
||
alert('删除失败,请稍后重试')
|
||
}
|
||
} catch (error) {
|
||
console.error('删除会话失败:', error)
|
||
alert('删除失败,请稍后重试')
|
||
}
|
||
|
||
cancelDelete()
|
||
}
|
||
|
||
// 停止流式输出
|
||
const stopStreaming = async () => {
|
||
if (!currentTaskId.value) return
|
||
|
||
try {
|
||
const response = await http.post(`${apiBaseUrl}/analysis/stop_streaming`, {
|
||
task_id: currentTaskId.value,
|
||
})
|
||
|
||
if (response.ok) {
|
||
console.log('停止流式输出成功')
|
||
} else {
|
||
console.error('停止流式输出失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('停止流式输出失败:', error)
|
||
}
|
||
|
||
// 无论是否成功,都清理状态
|
||
currentTaskId.value = ''
|
||
if (streamingAbortController.value) {
|
||
streamingAbortController.value.abort()
|
||
streamingAbortController.value = null
|
||
}
|
||
|
||
// 停止当前流式消息
|
||
const lastMessage = messages.value[messages.value.length - 1]
|
||
if (lastMessage && lastMessage.isStreaming) {
|
||
lastMessage.isStreaming = false
|
||
}
|
||
|
||
isLoading.value = false
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="chat-agent-view">
|
||
<div class="chat-container">
|
||
<!-- 会话标题和选择器 -->
|
||
<div class="conversation-header">
|
||
<div class="conversation-title-container" @click="toggleConversationDropdown">
|
||
<h2 class="conversation-title">
|
||
{{ getCurrentConversationName() }}
|
||
</h2>
|
||
<svg
|
||
class="dropdown-icon"
|
||
:class="{ active: isDropdownOpen }"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<polyline points="6,9 12,15 18,9"></polyline>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- 下拉会话列表 -->
|
||
<transition name="dropdown" appear>
|
||
<div v-if="isDropdownOpen" class="conversation-dropdown">
|
||
<div class="dropdown-content">
|
||
<!-- 新对话选项 -->
|
||
<button
|
||
class="dropdown-item new-conversation"
|
||
@click="selectNewConversation"
|
||
:disabled="isLoadingHistory"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
<span>新对话</span>
|
||
</button>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="isLoadingConversations" class="dropdown-loading">
|
||
<span>加载中...</span>
|
||
</div>
|
||
|
||
<!-- 会话列表 -->
|
||
<template v-else>
|
||
<div
|
||
v-for="conversation in conversations"
|
||
:key="conversation.id"
|
||
class="dropdown-item"
|
||
:class="{ active: selectedConversationId === conversation.id }"
|
||
>
|
||
<div
|
||
class="conversation-info"
|
||
@click="selectConversationFromDropdown(conversation.id)"
|
||
>
|
||
<span class="conversation-name">{{ conversation.name }}</span>
|
||
</div>
|
||
<div class="conversation-actions">
|
||
<button
|
||
class="action-btn rename-btn"
|
||
@click.stop="startRenaming(conversation)"
|
||
title="重命名"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="action-btn delete-btn"
|
||
@click.stop="deleteConversation(conversation.id)"
|
||
title="删除"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="3,6 5,6 21,6"></polyline>
|
||
<path
|
||
d="m19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"
|
||
></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 历史加载状态 -->
|
||
<div v-if="isLoadingHistory" class="dropdown-loading">
|
||
<span>加载对话历史中...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
|
||
<!-- 对话区域 -->
|
||
<div class="chat-messages" ref="chatContainer">
|
||
<!-- 欢迎消息 -->
|
||
<div v-if="messages.length === 0" class="welcome-message">
|
||
<div class="welcome-content">
|
||
<div class="welcome-icon">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48">
|
||
<path
|
||
d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h3>AI交易智能体</h3>
|
||
<p>为您提供市场分析、交易建议和投资策略</p>
|
||
|
||
<!-- 积分消耗提示 -->
|
||
<div class="points-notice">
|
||
<div class="points-notice-icon">💰</div>
|
||
<div class="points-notice-text">
|
||
<strong>积分消耗提示:</strong>每次分析消耗
|
||
<span class="points-amount">20 积分</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="example-questions">
|
||
<p class="example-title">快速点击分析:</p>
|
||
<div class="example-grid">
|
||
<button
|
||
class="example-question"
|
||
@click="sendExampleMessage('分析BTC行情')"
|
||
:disabled="isLoading"
|
||
>
|
||
<span class="example-icon">₿</span>
|
||
<span>分析BTC行情</span>
|
||
</button>
|
||
<button
|
||
class="example-question"
|
||
@click="sendExampleMessage('分析ETH行情')"
|
||
:disabled="isLoading"
|
||
>
|
||
<span class="example-icon">Ξ</span>
|
||
<span>分析ETH行情</span>
|
||
</button>
|
||
<button
|
||
class="example-question"
|
||
@click="sendExampleMessage('分析贵州茅台股票')"
|
||
:disabled="isLoading"
|
||
>
|
||
<span class="example-icon">📈</span>
|
||
<span>分析贵州茅台股票</span>
|
||
</button>
|
||
<button
|
||
class="example-question"
|
||
@click="sendExampleMessage('分析中国平安股票')"
|
||
:disabled="isLoading"
|
||
>
|
||
<span class="example-icon">🏦</span>
|
||
<span>分析中国平安股票</span>
|
||
</button>
|
||
<button
|
||
class="example-question"
|
||
@click="sendExampleMessage('分析苹果公司(AAPL)股票')"
|
||
:disabled="isLoading"
|
||
>
|
||
<span class="example-icon">🍎</span>
|
||
<span>分析苹果公司股票</span>
|
||
</button>
|
||
<button
|
||
class="example-question"
|
||
@click="sendExampleMessage('分析黄金价格走势')"
|
||
:disabled="isLoading"
|
||
>
|
||
<span class="example-icon">🪙</span>
|
||
<span>分析黄金价格走势</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 消息列表 -->
|
||
<div v-for="message in messages" :key="message.id" class="message-wrapper">
|
||
<div
|
||
class="message"
|
||
:class="{
|
||
'user-message': message.type === 'user',
|
||
'assistant-message': message.type === 'assistant',
|
||
}"
|
||
>
|
||
<div class="message-avatar">
|
||
<div v-if="message.type === 'user'" class="user-avatar">用</div>
|
||
<div v-else class="assistant-avatar">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||
<path
|
||
d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<div class="message-content">
|
||
<div class="message-header">
|
||
<span class="message-sender">{{
|
||
message.type === 'user' ? '您' : 'AI 交易智能体'
|
||
}}</span>
|
||
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
|
||
</div>
|
||
<div class="message-body">
|
||
<div
|
||
v-if="message.type === 'user' || message.content.startsWith('错误:')"
|
||
class="message-text"
|
||
:class="{ 'error-message': message.content.startsWith('错误:') }"
|
||
>
|
||
{{ message.content }}
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="message-text markdown-content"
|
||
v-html="parseMarkdown(message.content)"
|
||
></div>
|
||
<div v-if="message.isStreaming" class="streaming-indicator">
|
||
<div class="typing-dots">
|
||
<span></span>
|
||
<span></span>
|
||
<span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="message-actions">
|
||
<button
|
||
v-if="!message.isStreaming"
|
||
class="action-btn copy-btn"
|
||
@click="copyMessage(message.content)"
|
||
:disabled="!message.content"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输入区域 -->
|
||
<div class="chat-input">
|
||
<div class="input-container">
|
||
<textarea
|
||
v-model="messageInput"
|
||
class="message-input"
|
||
placeholder="输入您的问题,例如:分析BTC、分析苹果股票、分析黄金价格"
|
||
@keydown="handleKeydown"
|
||
:disabled="isLoading"
|
||
rows="1"
|
||
></textarea>
|
||
<button
|
||
v-if="!isLoading"
|
||
class="send-button"
|
||
@click="sendMessage"
|
||
:disabled="!messageInput.trim()"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||
<polygon points="22,2 15,22 11,13 2,9"></polygon>
|
||
</svg>
|
||
</button>
|
||
<button v-else class="stop-button" @click="stopStreaming" title="停止生成">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||
<rect x="6" y="6" width="12" height="12" rx="2" ry="2"></rect>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 删除确认弹框 -->
|
||
<div v-if="showDeleteDialog" class="dialog-overlay" @click.self="cancelDelete">
|
||
<div class="dialog-content">
|
||
<div class="dialog-header">
|
||
<h3>删除会话</h3>
|
||
<button class="dialog-close" @click="cancelDelete">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<p>确定要删除会话"{{ conversationToDelete?.name }}"吗?</p>
|
||
<p class="warning-text">删除后将无法恢复。</p>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button class="btn btn-secondary" @click="cancelDelete">取消</button>
|
||
<button class="btn btn-danger" @click="confirmDelete">删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重命名弹框 -->
|
||
<div v-if="showRenameDialog" class="dialog-overlay" @click.self="cancelRename">
|
||
<div class="dialog-content">
|
||
<div class="dialog-header">
|
||
<h3>重命名会话</h3>
|
||
<button class="dialog-close" @click="cancelRename">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<label class="input-label">新的会话名称</label>
|
||
<input
|
||
v-model="newConversationName"
|
||
type="text"
|
||
class="rename-input"
|
||
placeholder="请输入会话名称"
|
||
@keydown.enter="confirmRename"
|
||
@keydown.esc="cancelRename"
|
||
ref="renameInputRef"
|
||
/>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button class="btn btn-secondary" @click="cancelRename">取消</button>
|
||
<button
|
||
class="btn btn-primary"
|
||
@click="confirmRename"
|
||
:disabled="
|
||
!newConversationName.trim() ||
|
||
newConversationName.trim() === conversationToRename?.name
|
||
"
|
||
>
|
||
确定
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.chat-agent-view {
|
||
height: 100vh;
|
||
height: 100dvh; /* 动态视口高度,适配移动端 */
|
||
background-color: var(--color-bg-primary);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-container {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.conversation-header {
|
||
padding: 0.6rem 0.75rem;
|
||
height: 3rem;
|
||
background-color: var(--color-bg-primary);
|
||
position: relative;
|
||
}
|
||
|
||
.conversation-header::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 12px;
|
||
background: linear-gradient(to bottom, var(--color-bg-primary), transparent);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.conversation-title-container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.25rem;
|
||
cursor: pointer;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
|
||
.conversation-title {
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
text-align: center;
|
||
}
|
||
|
||
.dropdown-icon {
|
||
width: 14px;
|
||
height: 14px;
|
||
color: var(--color-text-secondary);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.dropdown-icon.active {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.conversation-dropdown {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
max-width: 500px;
|
||
margin: 0 auto;
|
||
background-color: var(--color-bg-primary);
|
||
border: 1px solid var(--color-border);
|
||
border-top: none;
|
||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
z-index: 120;
|
||
}
|
||
|
||
.dropdown-content {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.dropdown-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
border: 1px solid transparent;
|
||
border-radius: var(--border-radius);
|
||
background-color: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.dropdown-item:hover:not(:disabled) {
|
||
border-color: var(--color-accent);
|
||
background-color: rgba(51, 85, 255, 0.05);
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.dropdown-item:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.dropdown-item svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.conversation-info {
|
||
flex: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.conversation-name {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
display: block;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.dropdown-item.active .conversation-name {
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.conversation-actions {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
.dropdown-item:hover .conversation-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
.conversation-actions .action-btn {
|
||
padding: 0.25rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.conversation-actions .action-btn:hover {
|
||
background-color: rgba(0, 0, 0, 0.1);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.conversation-actions .action-btn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.conversation-actions .delete-btn:hover {
|
||
background-color: rgba(239, 68, 68, 0.1);
|
||
color: #ef4444;
|
||
}
|
||
|
||
.dropdown-loading {
|
||
text-align: center;
|
||
padding: 1rem;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
/* 下拉框动画 */
|
||
.dropdown-enter-active {
|
||
transition: all 0.3s ease-out;
|
||
}
|
||
|
||
.dropdown-leave-active {
|
||
transition: all 0.2s ease-in;
|
||
}
|
||
|
||
.dropdown-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
|
||
.dropdown-enter-to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.dropdown-leave-from {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.dropdown-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 2rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-bottom: 1rem; /* 为输入框留出空间 */
|
||
}
|
||
|
||
.welcome-message {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100%;
|
||
text-align: center;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.welcome-content {
|
||
max-width: 500px;
|
||
padding: 2rem 1rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.welcome-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 1.5rem;
|
||
color: var(--color-accent);
|
||
}
|
||
|
||
.welcome-content h3 {
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.welcome-content p {
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: 1.5rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 移动端积分提示样式 */
|
||
.points-notice {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 1rem 1.25rem;
|
||
background-color: rgba(255, 193, 7, 0.1);
|
||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||
border-radius: 12px;
|
||
margin-bottom: 2rem;
|
||
max-width: 100%;
|
||
box-shadow: 0 2px 8px rgba(255, 193, 7, 0.1);
|
||
}
|
||
|
||
.points-notice-icon {
|
||
font-size: 1.5rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.points-notice-text {
|
||
font-size: 0.9rem;
|
||
color: var(--color-text-primary);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.points-notice-text strong {
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.points-amount {
|
||
color: #f59e0b;
|
||
font-weight: 600;
|
||
font-size: 1em;
|
||
}
|
||
|
||
.example-questions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.example-title {
|
||
font-size: 0.9rem;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.example-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 0.75rem;
|
||
width: 100%;
|
||
max-width: 600px;
|
||
}
|
||
|
||
.example-question {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 1rem 1.5rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 12px;
|
||
background: var(--color-bg-secondary);
|
||
color: var(--color-text-primary);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-size: 0.9rem;
|
||
text-align: left;
|
||
min-height: 60px;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.example-question:hover:not(:disabled) {
|
||
border-color: var(--color-accent);
|
||
background: var(--color-bg-primary);
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.example-question:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
|
||
.example-icon {
|
||
font-size: 1.1rem;
|
||
flex-shrink: 0;
|
||
width: 24px;
|
||
text-align: center;
|
||
}
|
||
|
||
.message-wrapper {
|
||
width: 100%;
|
||
display: flex;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
gap: 1rem;
|
||
max-width: 80%;
|
||
width: fit-content;
|
||
}
|
||
|
||
.user-message {
|
||
flex-direction: row-reverse;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.assistant-message {
|
||
margin-right: auto;
|
||
}
|
||
|
||
.message-avatar {
|
||
flex-shrink: 0;
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.2rem;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.user-avatar {
|
||
background: linear-gradient(135deg, var(--color-accent), #667eea);
|
||
color: white;
|
||
font-weight: 600;
|
||
font-size: 1.1rem;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.3);
|
||
}
|
||
|
||
.assistant-avatar {
|
||
background: linear-gradient(135deg, var(--color-bg-secondary), #f8fafc);
|
||
color: var(--color-text-primary);
|
||
border: 2px solid var(--color-border);
|
||
font-weight: 600;
|
||
font-size: 1.1rem;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.message-content {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
max-width: none;
|
||
width: fit-content;
|
||
}
|
||
|
||
.user-message .message-content {
|
||
text-align: right;
|
||
}
|
||
|
||
.message-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.user-message .message-header {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.message-sender {
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.message-body {
|
||
background-color: var(--color-bg-secondary);
|
||
border-radius: 18px;
|
||
padding: 1rem 1.25rem;
|
||
position: relative;
|
||
width: fit-content;
|
||
min-width: 100px;
|
||
max-width: 600px;
|
||
word-wrap: break-word;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.user-message .message-body {
|
||
background: linear-gradient(135deg, var(--color-accent), #667eea);
|
||
color: white;
|
||
border: none;
|
||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||
}
|
||
|
||
.message-text {
|
||
line-height: 1.6;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.user-message .message-text {
|
||
color: white;
|
||
}
|
||
|
||
.error-message {
|
||
color: #ff3333 !important;
|
||
background-color: rgba(255, 51, 51, 0.1) !important;
|
||
border: 1px solid rgba(255, 51, 51, 0.2);
|
||
}
|
||
|
||
.streaming-indicator {
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.typing-dots {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.typing-dots span {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background-color: var(--color-text-secondary);
|
||
animation: typing 1.4s infinite ease-in-out;
|
||
}
|
||
|
||
.typing-dots span:nth-child(1) {
|
||
animation-delay: -0.32s;
|
||
}
|
||
|
||
.typing-dots span:nth-child(2) {
|
||
animation-delay: -0.16s;
|
||
}
|
||
|
||
@keyframes typing {
|
||
0%,
|
||
80%,
|
||
100% {
|
||
opacity: 0.3;
|
||
transform: scale(0.8);
|
||
}
|
||
40% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
.message-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 0.5rem;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.user-message .message-actions {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.action-btn {
|
||
padding: 0.25rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
border-radius: 4px;
|
||
transition: all 0.2s ease;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.action-btn:hover:not(:disabled) {
|
||
opacity: 1;
|
||
background-color: rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.action-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.action-btn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.user-message .action-btn {
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.user-message .action-btn:hover:not(:disabled) {
|
||
color: white;
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.chat-input {
|
||
padding: 1rem;
|
||
background-color: var(--color-bg-primary);
|
||
border-top: none;
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
bottom: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.input-container {
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
max-width: 100%;
|
||
background-color: var(--color-bg-primary);
|
||
border-radius: var(--border-radius);
|
||
padding: 0.5rem;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
.message-input {
|
||
flex: 1;
|
||
/* height: 44px; */
|
||
max-height: 120px;
|
||
padding: 0 1rem;
|
||
border: none;
|
||
border-radius: var(--border-radius);
|
||
background-color: transparent;
|
||
color: var(--color-text-primary);
|
||
font-size: 0.95rem;
|
||
line-height: 1.5;
|
||
resize: none;
|
||
outline: none;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.message-input:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.input-container:focus-within {
|
||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||
border-color: var(--color-accent);
|
||
}
|
||
|
||
.message-input:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.send-button {
|
||
width: 44px;
|
||
height: 44px;
|
||
border: none;
|
||
border-radius: var(--border-radius);
|
||
background-color: var(--color-accent);
|
||
color: white;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.send-button:hover:not(:disabled) {
|
||
background-color: var(--color-accent-hover);
|
||
}
|
||
|
||
.send-button:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.send-button svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.stop-button {
|
||
width: 44px;
|
||
height: 44px;
|
||
border: none;
|
||
border-radius: var(--border-radius);
|
||
background-color: rgba(239, 68, 68, 0.1);
|
||
color: #ef4444;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.stop-button:hover:not(:disabled) {
|
||
background-color: rgba(239, 68, 68, 0.2);
|
||
color: #dc2626;
|
||
}
|
||
|
||
.stop-button:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.stop-button svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.button-loader {
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||
border-top-color: white;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.chat-agent-view {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
min-height: -webkit-fill-available;
|
||
}
|
||
|
||
.chat-messages {
|
||
padding: 1rem;
|
||
padding-bottom: 100px; /* 为固定输入框留出空间 */
|
||
}
|
||
|
||
.welcome-message {
|
||
height: auto;
|
||
min-height: calc(100vh - 160px); /* 减去header和输入框的高度 */
|
||
align-items: flex-start;
|
||
padding-top: 2rem;
|
||
}
|
||
|
||
.welcome-content {
|
||
padding: 0 1rem 2rem 1rem;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.welcome-icon {
|
||
font-size: 2.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.welcome-content h3 {
|
||
font-size: 1.25rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.welcome-content p {
|
||
margin-bottom: 1.5rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
/* 移动端积分提示样式 */
|
||
.points-notice {
|
||
padding: 0.75rem 1rem;
|
||
margin-bottom: 1.5rem;
|
||
gap: 0.5rem;
|
||
margin-left: 0.5rem;
|
||
margin-right: 0.5rem;
|
||
}
|
||
|
||
.points-notice-icon {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.points-notice-text {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.example-questions {
|
||
gap: 1rem;
|
||
}
|
||
|
||
.example-title {
|
||
font-size: 0.85rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.example-grid {
|
||
grid-template-columns: 1fr;
|
||
max-width: 100%;
|
||
gap: 0.75rem;
|
||
padding: 0;
|
||
}
|
||
|
||
.example-question {
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.85rem;
|
||
min-height: 48px;
|
||
margin: 0 0.5rem;
|
||
text-align: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.example-icon {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.chat-input {
|
||
padding: 0.75rem 1rem;
|
||
background-color: var(--color-bg-primary);
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.input-container {
|
||
gap: 0.75rem;
|
||
padding: 0.375rem;
|
||
border-radius: 24px;
|
||
align-items: center;
|
||
display: flex;
|
||
flex-direction: row;
|
||
margin: 0 auto;
|
||
max-width: 800px;
|
||
}
|
||
|
||
.message-input {
|
||
height: 36px !important;
|
||
padding: 0 0.75rem;
|
||
font-size: 0.9rem;
|
||
border-radius: 20px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
resize: none;
|
||
overflow: hidden;
|
||
line-height: 36px;
|
||
}
|
||
|
||
.send-button {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.send-button svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.stop-button {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.stop-button svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.message {
|
||
gap: 0.75rem;
|
||
max-width: 90%;
|
||
}
|
||
|
||
.message-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.user-avatar {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.assistant-avatar {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.message-content {
|
||
min-width: 80px;
|
||
}
|
||
|
||
.message-body {
|
||
max-width: 400px;
|
||
min-width: 80px;
|
||
}
|
||
|
||
.title {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.description {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.welcome-icon {
|
||
font-size: 2.5rem;
|
||
}
|
||
|
||
.welcome-content h3 {
|
||
font-size: 1.25rem;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.chat-agent-view {
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
min-height: -webkit-fill-available;
|
||
}
|
||
|
||
.chat-messages {
|
||
padding: 1rem 0.75rem 100px 0.75rem; /* 为固定输入框留出空间 */
|
||
}
|
||
|
||
.welcome-message {
|
||
height: auto;
|
||
min-height: calc(100vh - 180px); /* 减去header和输入框的高度 */
|
||
align-items: flex-start;
|
||
padding-top: 1rem;
|
||
}
|
||
|
||
.welcome-content {
|
||
padding: 0 0.5rem 2rem 0.5rem;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.welcome-icon {
|
||
font-size: 2rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.welcome-content h3 {
|
||
font-size: 1.1rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.welcome-content p {
|
||
margin-bottom: 1.25rem;
|
||
font-size: 0.85rem;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* 小屏幕积分提示样式 */
|
||
.points-notice {
|
||
padding: 0.6rem 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
gap: 0.4rem;
|
||
margin-left: 0;
|
||
margin-right: 0;
|
||
}
|
||
|
||
.points-notice-icon {
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.points-notice-text {
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.example-questions {
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.example-title {
|
||
font-size: 0.8rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.example-grid {
|
||
grid-template-columns: 1fr;
|
||
max-width: 100%;
|
||
gap: 0.5rem;
|
||
padding: 0;
|
||
}
|
||
|
||
.example-question {
|
||
padding: 0.6rem 0.75rem;
|
||
font-size: 0.8rem;
|
||
min-height: 44px;
|
||
margin: 0;
|
||
text-align: center;
|
||
justify-content: center;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.example-icon {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.conversation-header {
|
||
padding: 0.6rem 0.75rem;
|
||
height: 3rem;
|
||
}
|
||
|
||
.conversation-header::after {
|
||
height: 12px;
|
||
}
|
||
|
||
.conversation-title {
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.dropdown-icon {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
/* 移动端让会话操作按钮始终可见 */
|
||
.conversation-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
.conversation-actions .action-btn {
|
||
padding: 0.5rem;
|
||
}
|
||
|
||
.conversation-actions .action-btn svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* Markdown 样式 - 继承自 UniversalAnalysisView */
|
||
.chat-agent-view .markdown-content {
|
||
white-space: normal !important;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content h1,
|
||
.chat-agent-view .markdown-content h2,
|
||
.chat-agent-view .markdown-content h3,
|
||
.chat-agent-view .markdown-content h4,
|
||
.chat-agent-view .markdown-content h5,
|
||
.chat-agent-view .markdown-content h6 {
|
||
margin-top: 1rem;
|
||
margin-bottom: 0.75rem;
|
||
font-weight: 600;
|
||
line-height: 1.25;
|
||
color: var(--color-text-primary);
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content h1 {
|
||
border-bottom: 1px solid var(--color-border);
|
||
padding-bottom: 0.3em;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content h2 {
|
||
border-bottom: 1px solid var(--color-border);
|
||
padding-bottom: 0.3em;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content p {
|
||
margin-top: 0;
|
||
margin-bottom: 1rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content ul,
|
||
.chat-agent-view .markdown-content ol {
|
||
margin-top: 0;
|
||
margin-bottom: 1rem;
|
||
padding-left: 1.5rem;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content li {
|
||
margin-bottom: 0.25rem;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content li p {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content blockquote {
|
||
padding: 0.5rem 1rem;
|
||
color: var(--color-text-secondary);
|
||
border-left: 0.25rem solid var(--color-border);
|
||
margin: 0 0 1rem;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content pre {
|
||
margin-top: 0;
|
||
margin-bottom: 1rem;
|
||
padding: 0.75rem;
|
||
overflow: auto;
|
||
font-size: 0.85rem;
|
||
line-height: 1.45;
|
||
background-color: rgba(0, 0, 0, 0.05);
|
||
border-radius: var(--border-radius);
|
||
}
|
||
|
||
.chat-agent-view .markdown-content code {
|
||
font-family:
|
||
SFMono-Regular,
|
||
Consolas,
|
||
Liberation Mono,
|
||
Menlo,
|
||
monospace;
|
||
font-size: 0.85rem;
|
||
padding: 0.2em 0.4em;
|
||
margin: 0;
|
||
background-color: rgba(0, 0, 0, 0.05);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content pre code {
|
||
padding: 0;
|
||
background-color: transparent;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content .table-container {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content table {
|
||
width: 100%;
|
||
margin-top: 0;
|
||
margin-bottom: 0;
|
||
border-spacing: 0;
|
||
border-collapse: collapse;
|
||
border: 1px solid var(--color-border);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content table th,
|
||
.chat-agent-view .markdown-content table td {
|
||
padding: 0.5rem 0.75rem;
|
||
border: 1px solid var(--color-border);
|
||
text-align: left;
|
||
vertical-align: top;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content table th {
|
||
font-weight: 600;
|
||
background-color: var(--color-bg-secondary);
|
||
}
|
||
|
||
.chat-agent-view .markdown-content table tr:nth-child(2n) {
|
||
background-color: rgba(0, 0, 0, 0.02);
|
||
}
|
||
|
||
.chat-agent-view .markdown-content a {
|
||
color: var(--color-accent);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
margin: 0.5rem auto;
|
||
}
|
||
|
||
.chat-agent-view .markdown-content hr {
|
||
height: 0.25em;
|
||
padding: 0;
|
||
margin: 1rem 0;
|
||
background-color: var(--color-border);
|
||
border: 0;
|
||
}
|
||
|
||
/* 用户消息中的markdown样式调整 */
|
||
.user-message .markdown-content h1,
|
||
.user-message .markdown-content h2,
|
||
.user-message .markdown-content h3,
|
||
.user-message .markdown-content h4,
|
||
.user-message .markdown-content h5,
|
||
.user-message .markdown-content h6 {
|
||
color: white;
|
||
}
|
||
|
||
.user-message .markdown-content a {
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.user-message .markdown-content code {
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
color: white;
|
||
}
|
||
|
||
.user-message .markdown-content pre {
|
||
background-color: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.user-message .markdown-content blockquote {
|
||
border-left-color: rgba(255, 255, 255, 0.3);
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
/* 自定义弹框样式 */
|
||
.dialog-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.dialog-content {
|
||
background-color: var(--color-bg-primary);
|
||
border-radius: 12px;
|
||
min-width: 400px;
|
||
max-width: 500px;
|
||
width: 90%;
|
||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||
border: 1px solid var(--color-border);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.dialog-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1.5rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
background-color: var(--color-bg-secondary);
|
||
}
|
||
|
||
.dialog-header h3 {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.dialog-close {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background-color: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.dialog-close:hover {
|
||
background-color: rgba(0, 0, 0, 0.1);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.dialog-close svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.dialog-body {
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.dialog-body p {
|
||
margin: 0 0 0.5rem 0;
|
||
color: var(--color-text-primary);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.warning-text {
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.input-label {
|
||
display: block;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.rename-input {
|
||
width: 100%;
|
||
padding: 0.75rem 1rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--border-radius);
|
||
background-color: var(--color-bg-secondary);
|
||
color: var(--color-text-primary);
|
||
font-size: 0.95rem;
|
||
transition: all 0.2s ease;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.rename-input:focus {
|
||
outline: none;
|
||
border-color: var(--color-accent);
|
||
background-color: var(--color-bg-primary);
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
padding: 1.5rem;
|
||
border-top: 1px solid var(--color-border);
|
||
background-color: var(--color-bg-secondary);
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.btn {
|
||
padding: 0.75rem 1.5rem;
|
||
border: none;
|
||
border-radius: var(--border-radius);
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
min-width: 80px;
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: var(--color-accent);
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
background-color: var(--color-accent-hover);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background-color: transparent;
|
||
color: var(--color-text-secondary);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
.btn-secondary:hover:not(:disabled) {
|
||
background-color: var(--color-bg-primary);
|
||
color: var(--color-text-primary);
|
||
border-color: var(--color-text-secondary);
|
||
}
|
||
|
||
.btn-danger {
|
||
background-color: #ef4444;
|
||
color: white;
|
||
}
|
||
|
||
.btn-danger:hover:not(:disabled) {
|
||
background-color: #dc2626;
|
||
}
|
||
|
||
/* 移动端弹框适配 */
|
||
@media (max-width: 480px) {
|
||
.dialog-content {
|
||
min-width: auto;
|
||
width: 95%;
|
||
margin: 1rem;
|
||
}
|
||
|
||
.dialog-header,
|
||
.dialog-body,
|
||
.dialog-footer {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.dialog-footer {
|
||
flex-direction: column-reverse;
|
||
}
|
||
|
||
.btn {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|