feat: 完成意心ai agent

This commit is contained in:
ccnetcore
2026-01-07 22:25:54 +08:00
parent 00a9bd00e5
commit 40234343ff
19 changed files with 1469 additions and 33 deletions

View File

@@ -1,26 +1,939 @@
<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 YMarkdown from '@/vue-element-plus-y/components/XMarkdown/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 = [];
latest.toolCalls.push({
name: data.content as string,
status: 'calling',
});
// 清空待处理的用量
pendingToolUsage = null;
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="image-generation-page">
<el-empty description="智能体功能开发中,敬请期待">
<template #image>
<el-icon style="font-size: 80px; color: var(--el-color-primary);">
<i-ep-picture />
<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>
</template>
</el-empty>
<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 }">
<YMarkdown
v-if="item.content && (item.role === 'assistant' || item.role === 'system')"
class="markdown-body"
:markdown="item.content"
:themes="{ light: 'github-light', dark: 'github-dark' }"
default-theme-mode="dark"
/>
<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">
.image-generation-page {
.agent-page {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
width: 100%;
height: 100%;
background-color: var(--el-bg-color);
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;
}
.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>