web/src/views/AIAgentView.vue
2025-05-09 12:09:00 +08:00

989 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, nextTick, computed, onMounted, watch, onUnmounted } from 'vue'
import { useUserStore } from '../stores/user'
import { marked } from 'marked'
// 配置 marked
marked.setOptions({
async: false,
})
// 渲染 Markdown
const renderMarkdown = (content: string) => {
return marked.parse(content)
}
// 定义Agent类型
interface Agent {
id: string
name: string
description: string
icon?: string
hello_prompt?: string
}
// 定义API返回的Agent类型
interface AgentResponse {
id: string
name: string
description: string
hello_prompt: string
}
// 获取用户状态
const userStore = useUserStore()
const isAuthenticated = computed(() => userStore.isAuthenticated)
// 显示访问限制提示
const showAccessDeniedAlert = ref(false)
// Agent列表
const agents = ref<Agent[]>([])
const isLoadingAgents = ref(false)
// 当前选中的Agent
const selectedAgent = ref<Agent | null>(null)
// 根据环境选择API基础URL
const apiBaseUrl =
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
// 获取Agent列表
const fetchAgents = async () => {
if (!isAuthenticated.value) return
isLoadingAgents.value = true
try {
const headers: Record<string, string> = {}
if (userStore.authHeader) {
headers['Authorization'] = userStore.authHeader
}
const response = await fetch(`${apiBaseUrl}/agent/list`, {
method: 'GET',
headers,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
agents.value = data.map((agent: AgentResponse) => ({
...agent,
icon: '📊', // 可以根据agent类型设置不同的图标
}))
// 如果有Agent默认选中第一个并显示问候语
if (agents.value.length > 0 && !selectedAgent.value) {
selectedAgent.value = agents.value[0]
addInitialGreeting(agents.value[0])
}
} catch (error) {
console.error('获取Agent列表失败:', error)
} finally {
isLoadingAgents.value = false
}
}
// 当用户登录状态改变时获取Agent列表
onMounted(() => {
if (isAuthenticated.value) {
fetchAgents()
}
})
interface ChatMessage {
role: 'user' | 'assistant' | 'thought'
content: string
files: Array<{
type: string
url: string
}>
thought?: {
thought: string
observation: string
tool: string
tool_input: string
}
tools?: string[]
}
const userInput = ref('')
const chatHistory = ref<ChatMessage[]>([])
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
// 当用户不是VIP时显示提示
onMounted(() => {
if (isAuthenticated.value) {
fetchAgents()
}
})
const closeAlert = () => {
showAccessDeniedAlert.value = false
}
const scrollToBottom = async () => {
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 添加初始问候语
const addInitialGreeting = (agent: Agent) => {
chatHistory.value = [
{
role: 'assistant',
content:
agent.hello_prompt ||
'你好我是AI Agent可以回答你的任何关于Web3的问题。请告诉我你想了解什么',
files: [],
},
]
}
// 监听选中的Agent变化
watch(selectedAgent, (newAgent) => {
if (newAgent) {
addInitialGreeting(newAgent)
}
})
const sendMessage = async () => {
if (!userInput.value.trim() || isLoading.value || !isAuthenticated.value || !selectedAgent.value)
return
// 添加用户消息到历史记录
chatHistory.value.push({
role: 'user',
content: userInput.value,
files: [],
})
// 滚动到底部以显示用户消息
await scrollToBottom()
const currentInput = userInput.value
userInput.value = ''
isLoading.value = true
// 添加临时助手消息用于流式显示
const tempMessageIndex = chatHistory.value.length
chatHistory.value.push({
role: 'assistant',
content: 'AI 正在思考...',
files: [],
tools: [],
})
await scrollToBottom()
try {
// 准备请求头,包含认证信息
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// 添加认证头
if (userStore.authHeader) {
headers['Authorization'] = userStore.authHeader
}
// 调用流式接口
const response = await fetch(`${apiBaseUrl}/agent/chat`, {
method: 'POST',
headers,
body: JSON.stringify({
user_prompt: currentInput,
agent_id: selectedAgent.value.id,
}),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法获取响应流')
}
const decoder = new TextDecoder()
let responseText = ''
// 处理流式响应
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
switch (data.event) {
case 'agent_thought':
// 记录使用的工具
if (data.tool) {
if (!chatHistory.value[tempMessageIndex].tools) {
chatHistory.value[tempMessageIndex].tools = []
}
chatHistory.value[tempMessageIndex].tools?.push(data.tool)
// 更新显示的工具列表
let toolsText = ''
chatHistory.value[tempMessageIndex].tools?.forEach((tool, index) => {
toolsText += `${index + 1}.调用 ${tool} 工具\n`
})
if (responseText) {
chatHistory.value[tempMessageIndex].content = toolsText + '\n' + responseText
} else {
chatHistory.value[tempMessageIndex].content = toolsText
}
await scrollToBottom()
}
break
case 'agent_message':
if (data.answer) {
responseText += data.answer
// 更新临时助手消息的内容,保留工具列表
let toolsText = ''
if (chatHistory.value[tempMessageIndex].tools?.length) {
chatHistory.value[tempMessageIndex].tools?.forEach((tool, index) => {
toolsText += `${index + 1}.调用 ${tool} 工具\n`
})
chatHistory.value[tempMessageIndex].content = toolsText + '\n' + responseText
} else {
chatHistory.value[tempMessageIndex].content = responseText
}
await scrollToBottom()
}
break
case 'message_file':
if (data.type === 'image') {
// 找到最后一条助手消息来添加文件
const lastIndex = chatHistory.value.length - 1
if (chatHistory.value[lastIndex].role === 'assistant') {
chatHistory.value[lastIndex].files.push({
type: 'image',
url: data.url,
})
}
}
break
case 'message_end':
break
case 'tts_message':
// 处理语音合成消息
break
}
} catch (e) {
console.error('解析响应数据出错:', e)
}
}
}
} catch (error) {
console.error('调用API出错:', error)
// 更新错误消息
chatHistory.value[tempMessageIndex].content = '抱歉,请求出错了。请稍后再试。'
await scrollToBottom()
} finally {
isLoading.value = false
}
}
// 添加下拉菜单状态控制
const showAgentMenu = ref(false)
// 切换下拉菜单
const toggleAgentMenu = () => {
showAgentMenu.value = !showAgentMenu.value
}
// 选择Agent
const selectAgent = (agent: Agent) => {
selectedAgent.value = agent
showAgentMenu.value = false
}
// 点击其他地方关闭菜单
const closeAgentMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.agent-selector')) {
showAgentMenu.value = false
}
}
onMounted(() => {
if (isAuthenticated.value) {
fetchAgents()
}
document.addEventListener('click', closeAgentMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeAgentMenu)
})
</script>
<template>
<div class="ai-agent-view">
<!-- 访问限制提示 -->
<div v-if="showAccessDeniedAlert" class="access-denied-alert">
<div class="alert-content">
<span>AI Agent功能仅对VIP及超级VIP用户开放</span>
<button class="close-alert" @click="closeAlert">×</button>
</div>
</div>
<!-- <h1 class="page-title">AI Agent</h1>
<p class="page-description">为您提供全面区块链信息查询和专业分析</p> -->
<!-- 未登录状态显示登录提示 -->
<div v-if="!isAuthenticated" class="login-prompt">
<div class="login-prompt-content">
<h2>需要登录</h2>
<p>请登录或注册账号以使用AI Agent功能</p>
<div class="login-prompt-actions">
<router-link to="/login" class="login-button">登录</router-link>
<router-link to="/register" class="register-button">注册</router-link>
</div>
</div>
</div>
<!-- 已登录用户显示聊天界面 -->
<div v-else class="main-container">
<!-- 右侧聊天界面 -->
<div class="chat-container">
<div class="chat-window">
<div class="messages-container" ref="messagesContainer">
<div v-for="(message, index) in chatHistory" :key="index" class="message-wrapper">
<div :class="['message', message.role === 'user' ? 'user-message' : 'ai-message']">
<div class="message-content">
<div class="message-text" v-html="renderMarkdown(message.content)"></div>
</div>
</div>
</div>
</div>
<div class="input-container">
<div class="input-wrapper">
<textarea
v-model="userInput"
class="message-input"
placeholder="请输入您的问题..."
@keydown.enter.prevent="sendMessage"
:disabled="isLoading || !isAuthenticated || !selectedAgent"
></textarea>
<button
class="send-button"
@click="sendMessage"
:disabled="isLoading || !isAuthenticated || !selectedAgent"
>
<span class="send-icon">➤</span>
</button>
</div>
</div>
</div>
</div>
<!-- 添加新的 Agent 选择器 -->
<div class="agent-selector">
<button class="agent-selector-button" @click="toggleAgentMenu">
<span class="agent-icon">{{ selectedAgent?.icon || '🤖' }}</span>
<span class="agent-name">{{ selectedAgent?.name || '选择 Agent' }}</span>
</button>
<div v-if="showAgentMenu" class="agent-selector-menu">
<div v-if="isLoadingAgents" class="agent-loading">加载中...</div>
<template v-else>
<div
v-for="agent in agents"
:key="agent.id"
class="agent-menu-item"
:class="{ active: selectedAgent?.id === agent.id }"
@click="selectAgent(agent)"
>
<div class="agent-icon">{{ agent.icon }}</div>
<div class="agent-info">
<div class="agent-name">{{ agent.name }}</div>
<div class="agent-description">{{ agent.description }}</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ai-agent-view {
width: 100%;
height: calc(100vh - var(--header-height));
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #343541;
}
/* 访问限制提示样式 */
.access-denied-alert {
position: fixed;
top: calc(var(--header-height) + 20px);
left: 50%;
transform: translateX(-50%);
background-color: rgba(255, 152, 0, 0.95);
color: #000;
padding: 12px 20px;
border-radius: var(--border-radius);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
max-width: 90%;
width: auto;
}
.alert-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-weight: 500;
}
.close-alert {
background: none;
border: none;
color: #000;
font-size: 1.2rem;
cursor: pointer;
padding: 0 4px;
}
@keyframes slideDown {
from {
transform: translate(-50%, -20px);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
.page-title {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.page-description {
color: var(--color-text-secondary);
margin-bottom: 2rem;
}
/* 登录提示样式 */
.login-prompt {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem 1rem;
width: 100%;
height: calc(80vh - 150px);
}
.login-prompt-content {
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
padding: 2rem;
text-align: center;
max-width: 500px;
width: 100%;
border: 1px solid var(--color-border);
}
.login-prompt-content h2 {
font-size: 1.8rem;
margin-bottom: 1rem;
}
.login-prompt-content p {
color: var(--color-text-secondary);
margin-bottom: 2rem;
}
.login-prompt-actions {
display: flex;
justify-content: center;
gap: 1rem;
}
.login-button,
.register-button {
padding: 0.8rem 2rem;
border-radius: var(--border-radius);
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
}
.login-button {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.login-button:hover {
background-color: var(--color-bg-elevated);
border-color: var(--color-border-hover);
}
.register-button {
background-color: #3355ff;
color: white;
border: 1px solid #3355ff;
}
.register-button:hover {
background-color: #2244ee;
}
/* 聊天界面样式 */
.main-container {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
background-color: #343541;
overflow: hidden;
position: relative;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background-color: #343541;
}
.chat-window {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg-primary);
max-width: 900px;
margin: 0 auto;
box-sizing: border-box;
width: 100%;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 2rem 0.5rem;
scroll-behavior: smooth;
display: flex;
flex-direction: column;
align-items: center;
}
.message-wrapper {
margin: 0.5rem 0;
display: flex;
width: 100%;
}
.user-message {
justify-content: flex-end;
width: 100%;
display: flex;
}
.ai-message {
justify-content: flex-start;
width: 100%;
display: flex;
}
.message {
display: flex;
padding: 0;
}
.message-content {
display: inline-block;
max-width: 800px;
min-width: 4em;
padding: 1rem;
border-radius: 0.75rem;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
background-clip: padding-box;
overflow: hidden;
}
.user-message .message-content {
background-color: #2563eb;
color: white;
border-radius: 1rem 1rem 0 1rem;
}
.ai-message .message-content {
background-color: #1f2937;
color: var(--color-text-primary);
border-radius: 1rem 1rem 1rem 0;
}
.message-text {
font-size: 1rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-text :deep(ul),
.message-text :deep(ol) {
margin: 0.5em 0;
padding-left: 0;
list-style-position: inside;
width: 100%;
box-sizing: border-box;
}
.message-text :deep(li) {
margin: 0.25em 0;
padding-left: 0;
text-indent: 0;
list-style-position: inside;
}
.message-text :deep(li > p) {
display: inline;
margin: 0;
}
.input-container {
padding: 1.5rem 2rem;
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-divider);
}
.input-wrapper {
/* max-width: 48rem; */
margin: 0 auto;
position: relative;
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
.message-input {
flex: 1;
height: 3.5rem;
min-height: 2.5rem;
max-height: 8rem;
padding: 0.75rem 1rem;
padding-right: 3rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
color: var(--color-text-primary);
font-size: 1rem;
line-height: 1rem;
resize: none;
outline: none;
transition: border-color 0.2s ease;
text-align: left;
overflow-y: auto;
}
.message-input::placeholder {
color: var(--color-text-secondary);
opacity: 1;
line-height: 1rem;
text-align: left;
}
.message-input:focus {
border-color: var(--color-accent);
}
.message-input:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.send-button {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-accent);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
transition: background-color 0.2s ease;
}
.send-button:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.send-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.send-icon {
font-size: 1rem;
transform: rotate(270deg);
}
:deep(.markdown-body) {
color: inherit;
font-size: 1rem;
line-height: 1.6;
width: 100%;
overflow-wrap: break-word;
word-break: break-word;
}
:deep(.markdown-body pre) {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
}
:deep(.markdown-body code) {
background-color: rgba(0, 0, 0, 0.2);
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.9em;
}
:deep(.markdown-body pre code) {
background-color: transparent;
padding: 0;
}
:deep(.markdown-body p) {
margin: 0.5em 0;
overflow-wrap: break-word;
word-break: break-word;
width: 100%;
box-sizing: border-box;
}
:deep(.markdown-body blockquote) {
border-left: 4px solid var(--color-accent);
margin: 0.5rem 0;
padding: 0.5rem 1rem;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 0 0.25rem 0.25rem 0;
}
:deep(.markdown-body table) {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
}
:deep(.markdown-body th),
:deep(.markdown-body td) {
border: 1px solid var(--color-border);
padding: 0.5rem;
text-align: left;
}
:deep(.markdown-body th) {
background-color: rgba(0, 0, 0, 0.2);
}
:deep(.markdown-body img) {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
:deep(.markdown-body a) {
color: var(--color-accent);
text-decoration: none;
}
:deep(.markdown-body a:hover) {
text-decoration: underline;
}
/* 添加新的 Agent 选择器样式 */
.agent-selector {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 10;
}
.agent-selector-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
color: var(--color-text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.agent-selector-button:hover {
background-color: var(--color-bg-secondary);
}
.agent-selector-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
min-width: 200px;
max-width: 300px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.agent-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--color-border);
}
.agent-menu-item:last-child {
border-bottom: none;
}
.agent-menu-item:hover {
background-color: var(--color-bg-secondary);
}
.agent-menu-item.active {
background-color: var(--color-bg-secondary);
}
.agent-menu-item .agent-icon {
font-size: 1.25rem;
}
.agent-menu-item .agent-info {
flex: 1;
min-width: 0;
}
.agent-menu-item .agent-name {
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 0.25rem;
}
.agent-menu-item .agent-description {
font-size: 0.75rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
/* 移动端适配 */
@media (max-width: 768px) {
.agent-selector {
top: 0.5rem;
right: 0.5rem;
}
.agent-selector-menu {
max-width: calc(100vw - 2rem);
}
}
@media (max-width: 480px) {
.agent-selector {
max-height: 50px;
}
.agent-menu-item {
padding: 0.4rem;
min-width: 100px;
}
.agent-name {
font-size: 0.8rem;
}
.message {
padding: 0.75rem;
}
.input-field {
font-size: 0.9rem;
padding: 0.75rem;
}
}
.message-content {
overflow: auto;
word-break: break-all;
}
.message-content pre,
.message-content table {
max-width: 100%;
overflow-x: auto;
word-break: break-all;
}
.message-content ul,
.message-content ol {
margin: 0.5em 0;
padding-left: 1.5em;
box-sizing: border-box;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
}
.message-content li {
margin: 0.25em 0;
word-break: break-word;
overflow-wrap: break-word;
}
</style>