Files
Yi.Admin/Yi.Ai.Vue3/src/pages/chat/agent/index.vue
2026-01-26 21:08:21 +08:00

959 lines
24 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 type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { Loading, Tools, Check, Plus, Delete } from '@element-plus/icons-vue';
import { ElIcon, ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, ref, watch } from 'vue';
import { Sender } from 'vue-element-plus-x';
import { agentSend, getAgentTools, getAgentContext } from '@/api/agent';
import type { AgentToolOutput, AgentResultOutput, AgentUsage } from '@/api/agent/types';
import { getSelectableTokenInfo } from '@/api';
import { useUserStore } from '@/stores/modules/user';
import { useAgentSessionStore } from '@/stores/modules/agentSession';
import { getUserProfilePicture } from '@/utils/user.ts';
import MarkedMarkdown from '@/components/MarkedMarkdown/index.vue';
import agentAvatar from '@/assets/images/czld.png';
import '@/styles/github-markdown.css';
import '@/styles/yixin-markdown.scss';
// 消息类型定义
type MessageItem = BubbleProps & {
key: number;
role: 'ai' | 'user' | 'assistant';
avatar: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
reasoning_content?: string;
toolCalls?: { name: string; status: 'calling' | 'called'; result?: any; usage?: { prompt: number; completion: number; total: number } }[];
tokenUsage?: { prompt: number; completion: number; total: number };
};
const userStore = useUserStore();
const agentSessionStore = useAgentSessionStore();
// 响应式数据
const inputValue = ref('');
const bubbleItems = ref<MessageItem[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null);
const isSending = ref(false);
const sessionId = ref('');
// 会话列表相关
const showSessionList = ref(true);
// 工具相关
const availableTools = ref<AgentToolOutput[]>([]);
const selectedTools = ref<string[]>([]);
const showToolsPanel = ref(false);
// 配置相关
const tokenId = ref('');
const tokenOptions = ref<any[]>([]);
const tokenLoading = ref(false);
const modelId = ref('gpt-5.2-chat');
// 加载Token列表
async function loadTokens() {
tokenLoading.value = true;
try {
const res = await getSelectableTokenInfo();
const data = Array.isArray(res) ? res : (res as any).data || [];
tokenOptions.value = data;
// 默认选中第一个可用的token
if (tokenOptions.value.length > 0 && !tokenId.value) {
const firstAvailable = tokenOptions.value.find(t => !t.isDisabled);
if (firstAvailable) {
tokenId.value = firstAvailable.tokenId;
}
}
} catch (error) {
console.error('加载Token列表失败:', error);
} finally {
tokenLoading.value = false;
}
}
// 加载工具列表
async function loadTools() {
try {
const res = await getAgentTools();
availableTools.value = res.data || [];
// 默认选中所有工具
selectedTools.value = availableTools.value.map(t => t.code);
} catch (error) {
console.error('加载工具列表失败:', error);
}
}
onMounted(() => {
loadTokens();
loadTools();
// 加载会话列表
if (userStore.token) {
agentSessionStore.requestSessionList(1);
}
});
// 创建新会话
async function createNewSession() {
sessionId.value = '';
bubbleItems.value = [];
agentSessionStore.setCurrentSession(null);
}
// 选择会话
async function selectSession(session: any) {
sessionId.value = session.id;
agentSessionStore.setCurrentSession(session);
bubbleItems.value = [];
// 加载历史上下文
await loadSessionContext(session.id);
}
// 加载会话历史上下文
async function loadSessionContext(sid: string) {
try {
const res = await getAgentContext(sid);
// 获取实际数据,可能是 res.data 或直接是 res
let rawData = res?.data ?? res;
if (!rawData) return;
// 如果是字符串则解析JSON
let contextData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
// 获取消息列表
const messages = contextData?.storeState?.messages || [];
if (messages.length === 0) return;
// 转换为消息列表
messages.forEach((msg: any, index: number) => {
const isUser = msg.role === 'user';
const content = msg.contents
?.filter((c: any) => c.$type === 'text')
?.map((c: any) => c.text)
?.join('') || '';
if (content) {
const obj: MessageItem = {
key: index,
avatar: isUser ? getUserProfilePicture() : agentAvatar,
avatarSize: '32px',
role: isUser ? 'user' : 'assistant',
placement: isUser ? 'end' : 'start',
isMarkdown: !isUser,
loading: false,
content,
reasoning_content: '',
thinkingStatus: 'end',
thinlCollapse: false,
noStyle: !isUser,
toolCalls: [],
};
bubbleItems.value.push(obj);
}
});
// 滚动到底部
setTimeout(() => {
bubbleListRef.value?.scrollToBottom();
}, 100);
} catch (error) {
console.error('加载会话上下文失败:', error);
}
}
// 删除会话
async function handleDeleteSession(session: any, event: Event) {
event.stopPropagation();
try {
await ElMessageBox.confirm('确定要删除该会话吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await agentSessionStore.deleteSession([session.id]);
if (sessionId.value === session.id) {
createNewSession();
}
ElMessage.success('删除成功');
} catch {
// 取消删除
}
}
// 切换工具选择
function toggleTool(code: string) {
const index = selectedTools.value.indexOf(code);
if (index > -1) {
selectedTools.value.splice(index, 1);
} else {
selectedTools.value.push(code);
}
}
// 添加消息
function addMessage(message: string, isUser: boolean) {
const i = bubbleItems.value.length;
const obj: MessageItem = {
key: i,
avatar: isUser ? getUserProfilePicture() : agentAvatar,
avatarSize: '32px',
role: isUser ? 'user' : 'assistant',
placement: isUser ? 'end' : 'start',
isMarkdown: !isUser,
loading: !isUser,
content: message || '',
reasoning_content: '',
thinkingStatus: 'start',
thinlCollapse: false,
noStyle: !isUser,
toolCalls: [],
};
bubbleItems.value.push(obj);
}
// AbortController 用于取消请求
let abortController: AbortController | null = null;
// 临时存储工具调用用量
let pendingToolUsage: { prompt: number; completion: number; total: number } | null = null;
// 处理Agent流式数据
function handleAgentChunk(data: AgentResultOutput) {
const latest = bubbleItems.value[bubbleItems.value.length - 1];
if (!latest) return;
switch (data.type) {
case 'text':
latest.content += data.content || '';
latest.loading = false;
break;
case 'toolCalling':
// 工具调用中
if (!latest.toolCalls) latest.toolCalls = [];
const newToolCall: { name: string; status: 'calling' | 'called'; result?: any; usage?: { prompt: number; completion: number; total: number } } = {
name: data.content as string,
status: 'calling',
};
// 如果有待处理的用量toolCallUsage 先于 toolCalling 到达),设置到这个工具调用
if (pendingToolUsage) {
newToolCall.usage = pendingToolUsage;
pendingToolUsage = null;
}
latest.toolCalls.push(newToolCall);
break;
case 'toolCallUsage':
// 工具调用用量统计 - 先保存,等 toolCalled 时再设置
const toolUsage = data.content as AgentUsage;
pendingToolUsage = {
prompt: toolUsage.input_tokens || toolUsage.prompt_tokens || 0,
completion: toolUsage.output_tokens || toolUsage.completion_tokens || 0,
total: toolUsage.total_tokens || 0,
};
// 同时尝试设置到最后一个工具调用
if (latest.toolCalls && latest.toolCalls.length > 0) {
const lastTool = latest.toolCalls[latest.toolCalls.length - 1];
if (lastTool) {
lastTool.usage = pendingToolUsage;
}
}
break;
case 'toolCalled':
// 工具调用完成
if (latest.toolCalls && latest.toolCalls.length > 0) {
const lastTool = latest.toolCalls[latest.toolCalls.length - 1];
if (lastTool) {
lastTool.status = 'called';
lastTool.result = data.content;
// 如果有待处理的用量,设置到这个工具调用
if (pendingToolUsage && !lastTool.usage) {
lastTool.usage = pendingToolUsage;
}
}
}
pendingToolUsage = null;
break;
case 'usage':
// 对话用量统计
const chatUsage = data.content as AgentUsage;
latest.tokenUsage = {
prompt: chatUsage.input_tokens || chatUsage.prompt_tokens || 0,
completion: chatUsage.output_tokens || chatUsage.completion_tokens || 0,
total: chatUsage.total_tokens || 0,
};
break;
}
}
// 发送消息
async function startSSE(chatContent: string) {
if (isSending.value) return;
if (!chatContent.trim()) {
ElMessage.warning('请输入消息内容');
return;
}
if (!tokenId.value) {
ElMessage.warning('请先选择 API 密钥');
showToolsPanel.value = true;
return;
}
isSending.value = true;
inputValue.value = '';
// 如果没有会话ID先创建会话
let currentSessionId = sessionId.value;
if (!currentSessionId && userStore.token) {
const newSession = await agentSessionStore.createSession({
sessionTitle: chatContent.slice(0, 20),
sessionContent: chatContent,
userId: userStore.userInfo?.userId as number,
});
if (newSession) {
currentSessionId = newSession.id!;
sessionId.value = currentSessionId;
} else {
currentSessionId = crypto.randomUUID();
sessionId.value = currentSessionId;
}
} else if (!currentSessionId) {
currentSessionId = crypto.randomUUID();
sessionId.value = currentSessionId;
}
// 添加用户消息和AI消息占位
addMessage(chatContent, true);
addMessage('', false);
// 滚动到底部
setTimeout(() => {
bubbleListRef.value?.scrollToBottom();
}, 100);
abortController = new AbortController();
try {
const response = await fetch(`${import.meta.env.VITE_WEB_BASE_API}/ai-chat/agent/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userStore.token}`,
},
body: JSON.stringify({
sessionId: currentSessionId,
content: chatContent,
tokenId: tokenId.value,
modelId: modelId.value,
tools: selectedTools.value,
}),
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No reader available');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') {
break;
}
try {
const parsed = JSON.parse(data) as AgentResultOutput;
handleAgentChunk(parsed);
} catch (e) {
console.error('解析数据失败:', e);
}
}
}
}
} catch (err: any) {
if (err.name === 'AbortError') {
console.log('请求已取消');
} else {
console.error('请求失败:', err);
ElMessage.error(err.message || '请求失败');
}
} finally {
isSending.value = false;
abortController = null;
// 停止加载状态
if (bubbleItems.value.length) {
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.loading = false;
}
}
}
// 取消请求
function cancelSSE() {
if (abortController) {
abortController.abort();
isSending.value = false;
}
}
</script>
<template>
<div class="agent-page">
<!-- 左侧会话列表 -->
<div class="session-sidebar" :class="{ collapsed: !showSessionList }">
<div class="sidebar-header">
<span class="sidebar-title">会话列表</span>
<el-button type="primary" :icon="Plus" circle size="small" @click="createNewSession" />
</div>
<div class="session-list">
<div
v-for="session in agentSessionStore.sessionList"
:key="session.id"
class="session-item"
:class="{ active: sessionId === session.id }"
@click="selectSession(session)"
>
<span class="session-title">{{ session.sessionTitle }}</span>
<el-icon class="delete-icon" @click="handleDeleteSession(session, $event)">
<Delete />
</el-icon>
</div>
<div v-if="agentSessionStore.sessionList.length === 0" class="empty-tip">
暂无会话记录
</div>
</div>
</div>
<!-- 右侧主内容区 -->
<div class="main-content">
<!-- 头部 -->
<div class="agent-header">
<div class="header-left">
<el-icon :size="24" color="var(--el-color-primary)">
<Tools />
</el-icon>
<span class="header-title">AI 智能体</span>
</div>
<div class="header-right">
<el-button type="primary" plain size="small" @click="showToolsPanel = !showToolsPanel">
<el-icon><Tools /></el-icon>
<span>配置</span>
</el-button>
</div>
</div>
<!-- 工具配置面板 -->
<el-collapse-transition>
<div v-show="showToolsPanel" class="tools-panel">
<div class="tools-header">
<span>可用工具</span>
<el-button link type="primary" size="small" @click="selectedTools = availableTools.map(t => t.code)">
全选
</el-button>
<el-button link type="info" size="small" @click="selectedTools = []">
清空
</el-button>
</div>
<div class="tools-list">
<div
v-for="tool in availableTools"
:key="tool.code"
class="tool-item"
:class="{ active: selectedTools.includes(tool.code) }"
@click="toggleTool(tool.code)"
>
<el-icon v-if="selectedTools.includes(tool.code)" class="check-icon">
<Check />
</el-icon>
<span>{{ tool.name }}</span>
</div>
</div>
<div class="tools-config">
<el-select
v-model="tokenId"
placeholder="请选择"
size="small"
style="width: 300px;"
:loading="tokenLoading"
clearable
>
<template #prefix>
<span style="color: var(--el-text-color-regular);">API密钥可选</span>
</template>
<el-option
v-for="token in tokenOptions"
:key="token.tokenId"
:label="token.name"
:value="token.tokenId"
:disabled="token.isDisabled"
/>
</el-select>
<!-- 模型ID固定为 gpt-5.2-chat不允许用户修改 -->
<!-- <el-input v-model="modelId" placeholder="请输入模型ID" size="small" style="width: 300px;">
<template #prepend>模型</template>
</el-input> -->
</div>
</div>
</el-collapse-transition>
<!-- 聊天区域 -->
<div class="chat-area">
<!-- 消息为空时的欢迎界面 -->
<div v-if="bubbleItems.length === 0" class="welcome-container">
<div class="welcome-icon">
<img :src="agentAvatar" alt="Agent" class="welcome-avatar" />
</div>
<h2 class="welcome-title">意心Ai 智能体</h2>
<p class="welcome-desc">我叫橙子老弟啥都会</p>
<div class="welcome-tips">
<div class="tip-item">可选择 API 密钥后开始对话</div>
<div class="tip-item">选择需要的工具来增强我的能力</div>
</div>
</div>
<!-- 消息列表 -->
<BubbleList v-else ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 320px)">
<template #header="{ item }">
<!-- 工具调用状态 -->
<div v-if="item.toolCalls && item.toolCalls.length > 0" class="tool-calls-container">
<div v-for="(tc, idx) in item.toolCalls" :key="idx" class="tool-call-item">
<el-icon v-if="tc.status === 'calling'" class="is-loading">
<Loading />
</el-icon>
<el-icon v-else color="var(--el-color-success)">
<Check />
</el-icon>
<span class="tool-name">{{ tc.name }}</span>
<span class="tool-status">{{ tc.status === 'calling' ? '调用中...' : '已完成' }}</span>
<span v-if="tc.usage?.total" class="tool-usage">token: {{ tc.usage.total }}</span>
</div>
</div>
</template>
<template #content="{ item }">
<MarkedMarkdown
v-if="item.content && (item.role === 'assistant' || item.role === 'system')"
class="markdown-body"
:content="item.content"
/>
<div v-if="item.role === 'user'" class="user-content">
{{ item.content }}
</div>
</template>
<template #footer="{ item }">
<div v-if="item.tokenUsage?.total" class="footer-wrapper">
<span class="footer-token">token: {{ item.tokenUsage.total }}</span>
</div>
</template>
</BubbleList>
<!-- 输入区域 -->
<Sender
v-model="inputValue"
class="agent-sender"
:auto-size="{ maxRows: 6, minRows: 2 }"
variant="updown"
clearable
allow-speech
:loading="isSending"
:disabled="!tokenId"
@submit="startSSE"
@cancel="cancelSSE"
>
<template #suffix>
<ElIcon v-if="isSending" class="is-loading">
<Loading />
</ElIcon>
</template>
</Sender>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.agent-page {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.session-sidebar {
width: 240px;
height: 100%;
border-right: 1px solid var(--el-border-color-lighter);
display: flex;
flex-direction: column;
flex-shrink: 0;
background: var(--el-bg-color);
&.collapsed {
width: 0;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
.sidebar-title {
font-weight: 600;
font-size: 14px;
}
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color-lighter);
border-radius: 2px;
&:hover {
background: var(--el-border-color);
}
}
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
transition: all 0.2s;
&:hover {
background: var(--el-fill-color-light);
.delete-icon {
opacity: 1;
}
}
&.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.session-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.delete-icon {
opacity: 0;
color: var(--el-text-color-secondary);
transition: opacity 0.2s;
&:hover {
color: var(--el-color-danger);
}
}
}
.empty-tip {
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
padding: 20px;
}
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 0 20px;
overflow: hidden;
}
.agent-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
flex-shrink: 0;
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.tools-panel {
background: var(--el-bg-color-page);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
.tools-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-weight: 500;
}
.tools-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.tool-item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 16px;
border: 1px solid var(--el-border-color);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary);
}
&.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.check-icon {
font-size: 12px;
}
}
.tools-config {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
max-width: 1000px;
width: 100%;
margin: 0 auto;
overflow: hidden;
}
.welcome-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
.welcome-icon {
margin-bottom: 20px;
.welcome-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
}
.welcome-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px;
color: var(--el-text-color-primary);
}
.welcome-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0 0 24px;
}
.welcome-tips {
display: flex;
flex-direction: column;
gap: 8px;
.tip-item {
font-size: 13px;
color: var(--el-text-color-regular);
padding: 8px 16px;
background: var(--el-fill-color-light);
border-radius: 6px;
}
}
}
.tool-calls-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
border-left: 3px solid var(--el-color-primary);
}
.tool-call-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
.tool-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.tool-status {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.tool-usage {
font-size: 11px;
background: rgba(255, 153, 0, 0.6);
padding: 1px 6px;
border-radius: 4px;
color: #ffffff;
margin-left: auto;
}
}
.user-content {
white-space: pre-wrap;
word-break: break-word;
}
.footer-wrapper {
margin-top: 4px;
.footer-token {
font-size: 12px;
background: rgba(1, 183, 86, 0.53);
padding: 2px 6px;
border-radius: 4px;
color: #ffffff;
}
}
.agent-sender {
margin-top: auto;
margin-bottom: 20px;
}
.is-loading {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
:deep(.el-bubble-list) {
padding-top: 24px;
}
:deep(.el-bubble) {
padding: 0 12px;
padding-bottom: 24px;
}
:deep(.markdown-body) {
background-color: transparent;
}
/* 移动端适配 */
@media (max-width: 768px) {
.agent-page {
padding: 0 12px;
}
.agent-header {
height: 50px;
.header-title {
font-size: 16px;
}
}
.tools-panel {
padding: 12px;
.tools-config {
flex-direction: column;
:deep(.el-input) {
width: 100% !important;
margin-right: 0 !important;
}
}
}
.welcome-container {
padding: 20px 12px;
.welcome-title {
font-size: 20px;
}
}
}
</style>