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,8 +1,15 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
public class SessionCreateAndUpdateInput
{
public string SessionTitle { get; set; }
public string SessionContent { get; set; }
public string? Remark { get; set; }
/// <summary>
/// 会话类型
/// </summary>
public SessionTypeEnum SessionType { get; set; } = SessionTypeEnum.Chat;
}

View File

@@ -1,4 +1,5 @@
using Volo.Abp.Application.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
@@ -7,4 +8,9 @@ public class SessionDto : FullAuditedEntityDto<Guid>
public string SessionTitle { get; set; }
public string SessionContent { get; set; }
public string Remark { get; set; }
/// <summary>
/// 会话类型
/// </summary>
public SessionTypeEnum SessionType { get; set; }
}

View File

@@ -1,8 +1,14 @@
using Yi.Framework.Ddd.Application.Contracts;
using Yi.Framework.AiHub.Domain.Shared.Enums;
using Yi.Framework.Ddd.Application.Contracts;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
public class SessionGetListInput:PagedAllResultRequestDto
public class SessionGetListInput : PagedAllResultRequestDto
{
public string? SessionTitle { get; set; }
/// <summary>
/// 会话类型
/// </summary>
public SessionTypeEnum? SessionType { get; set; }
}

View File

@@ -84,7 +84,8 @@ public class SessionService : CrudAppService<SessionAggregateRoot, SessionDto, G
RefAsync<int> total = 0;
var userId = CurrentUser.GetId();
var entities = await _repository._DbQueryable
.Where(x=>x.UserId == userId)
.Where(x => x.UserId == userId)
.WhereIF(input.SessionType.HasValue, x => x.SessionType == input.SessionType!.Value)
.OrderByDescending(x => x.Id)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
return new PagedResultDto<SessionDto>(total, entities.Adapt<List<SessionDto>>());

View File

@@ -0,0 +1,17 @@
namespace Yi.Framework.AiHub.Domain.Shared.Enums;
/// <summary>
/// 会话类型枚举
/// </summary>
public enum SessionTypeEnum
{
/// <summary>
/// 普通聊天
/// </summary>
Chat = 0,
/// <summary>
/// Agent智能体
/// </summary>
Agent = 1
}

View File

@@ -75,10 +75,7 @@ public class AnthropicChatCompletionsService(
{
Guid errorId = Guid.NewGuid();
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("Anthropic非流式对话异常 请求地址:{Address},ErrorId{errorId}, StatusCode: {StatusCode.GetHashCode()} Response: {Response}",
options.Endpoint,
errorId,
response.StatusCode, error);
logger.LogError($"Anthropic非流式对话异常 请求地址:{options.Endpoint},ErrorId{errorId}, StatusCode: {response.StatusCode.GetHashCode()}, Response: {error}");
throw new Exception( $"恭喜你运气爆棚遇到了错误尊享包对话异常StatusCode【{response.StatusCode.GetHashCode()}】ErrorId【{errorId}】");
}
@@ -125,10 +122,7 @@ public class AnthropicChatCompletionsService(
{
Guid errorId = Guid.NewGuid();
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError("Anthropic流式对话异常 请求地址:{Address},ErrorId{errorId}, StatusCode: {StatusCode.GetHashCode()} Response: {Response}",
options.Endpoint,
errorId,
response.StatusCode, error);
logger.LogError($"Anthropic流式对话异常 请求地址:{options.Endpoint},ErrorId{errorId}, StatusCode: {response.StatusCode.GetHashCode()}, Response: {error}");
throw new Exception( $"恭喜你运气爆棚遇到了错误尊享包对话异常StatusCode【{response.StatusCode.GetHashCode()}】ErrorId【{errorId}】");
}

View File

@@ -1,5 +1,6 @@
using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing;
using Yi.Framework.AiHub.Domain.Shared.Enums;
namespace Yi.Framework.AiHub.Domain.Entities.Chat;
@@ -9,8 +10,13 @@ public class SessionAggregateRoot : FullAuditedAggregateRoot<Guid>
{
public Guid UserId { get; set; }
public string SessionTitle { get; set; }
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString)]
public string SessionContent { get; set; }
public string? Remark { get; set; }
/// <summary>
/// 会话类型0-普通聊天1-Agent智能体
/// </summary>
public SessionTypeEnum SessionType { get; set; } = SessionTypeEnum.Chat;
}

View File

@@ -1,16 +1,199 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Volo.Abp.DependencyInjection;
using Yi.Framework.AiHub.Domain.Shared.Attributes;
namespace Yi.Framework.AiHub.Domain.Mcp;
[YiAgentTool]
public class OnlineSearchTool:ISingletonDependency
public class OnlineSearchTool : ISingletonDependency
{
[YiAgentTool("联网搜索"),DisplayName("OnlineSearch"), Description("进行在线搜索")]
public string OnlineSearch(string keyword)
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<OnlineSearchTool> _logger;
private readonly string _baiduApiKey;
private const string BaiduSearchUrl = "https://qianfan.baidubce.com/v2/ai_search/web_search";
public OnlineSearchTool(
IHttpClientFactory httpClientFactory,
ILogger<OnlineSearchTool> logger,
IConfiguration configuration)
{
return "奥德赛第一中学学生会会长是:郭老板";
_httpClientFactory = httpClientFactory;
_logger = logger;
_baiduApiKey = configuration["BaiduSearch:ApiKey"] ?? "";
}
}
[YiAgentTool("联网搜索"), DisplayName("OnlineSearch"), Description("进行在线搜索,获取最新的网络信息")]
public async Task<string> OnlineSearch(string keyword)
{
if (string.IsNullOrWhiteSpace(keyword))
{
return "搜索关键词不能为空";
}
try
{
var client = _httpClientFactory.CreateClient();
// 构建请求体
var requestBody = new BaiduSearchRequest
{
Messages = new List<BaiduSearchMessage>
{
new() { Role = "user", Content = keyword }
}
};
var jsonContent = JsonSerializer.Serialize(requestBody, BaiduJsonContext.Default.BaiduSearchRequest);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
// 设置请求头
var request = new HttpRequestMessage(HttpMethod.Post, BaiduSearchUrl)
{
Content = content
};
request.Headers.Add("Authorization", $"Bearer {_baiduApiKey}");
// 发送请求
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("百度搜索接口调用失败: {StatusCode}, {Error}", response.StatusCode, errorContent);
return $"搜索失败: {response.StatusCode}";
}
var responseJson = await response.Content.ReadAsStringAsync();
var searchResult = JsonSerializer.Deserialize(responseJson, BaiduJsonContext.Default.BaiduSearchResponse);
if (searchResult?.References == null || searchResult.References.Count == 0)
{
return "未找到相关搜索结果";
}
// 格式化搜索结果返回给AI
return FormatSearchResults(searchResult.References);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "百度搜索网络请求异常");
return "搜索服务暂时不可用,请稍后重试";
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, "百度搜索请求超时");
return "搜索请求超时,请稍后重试";
}
catch (JsonException ex)
{
_logger.LogError(ex, "百度搜索结果解析失败");
return "搜索结果解析失败";
}
catch (Exception ex)
{
_logger.LogError(ex, "百度搜索发生未知异常");
return "搜索发生异常,请稍后重试";
}
}
/// <summary>
/// 格式化搜索结果
/// </summary>
private string FormatSearchResults(List<BaiduSearchReference> references)
{
var sb = new StringBuilder();
sb.AppendLine("搜索结果:");
sb.AppendLine();
var count = 0;
foreach (var item in references.Take(10)) // 最多返回10条
{
count++;
sb.AppendLine($"【{count}】{item.Title}");
sb.AppendLine($"来源:{item.Website} | 时间:{item.Date}");
sb.AppendLine($"摘要:{item.Snippet}");
sb.AppendLine($"链接:{item.Url}");
sb.AppendLine();
}
return sb.ToString();
}
}
#region DTO
/// <summary>
/// 百度搜索请求
/// </summary>
public class BaiduSearchRequest
{
[JsonPropertyName("messages")]
public List<BaiduSearchMessage> Messages { get; set; } = new();
}
/// <summary>
/// 百度搜索消息
/// </summary>
public class BaiduSearchMessage
{
[JsonPropertyName("role")]
public string Role { get; set; } = "user";
[JsonPropertyName("content")]
public string Content { get; set; } = "";
}
/// <summary>
/// 百度搜索响应
/// </summary>
public class BaiduSearchResponse
{
[JsonPropertyName("request_id")]
public string? RequestId { get; set; }
[JsonPropertyName("references")]
public List<BaiduSearchReference>? References { get; set; }
}
/// <summary>
/// 百度搜索结果项
/// </summary>
public class BaiduSearchReference
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("date")]
public string? Date { get; set; }
[JsonPropertyName("snippet")]
public string? Snippet { get; set; }
[JsonPropertyName("website")]
public string? Website { get; set; }
}
#endregion
#region JSON
[JsonSerializable(typeof(BaiduSearchRequest))]
[JsonSerializable(typeof(BaiduSearchResponse))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class BaiduJsonContext : JsonSerializerContext
{
}
#endregion

View File

@@ -360,7 +360,7 @@ namespace Yi.Abp.Web
var app = context.GetApplicationBuilder();
app.UseRouting();
//app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<AiModelEntity>();
//app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<SessionAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<ActivationCodeRecordAggregateRoot>();
// app.ApplicationServices.GetRequiredService<ISqlSugarDbContext>().SqlSugarClient.CodeFirst.InitTables<UsageStatisticsAggregateRoot>();

View File

@@ -0,0 +1,25 @@
import type { AgentSendInput, AgentToolOutput } from './types';
import { get, post } from '@/utils/request';
/**
* Agent 发送消息
*/
export function agentSend(data: AgentSendInput) {
return post('/ai-chat/agent/send', data);
}
/**
* 获取 Agent 工具列表
*/
export function getAgentTools() {
return post<AgentToolOutput[]>('/ai-chat/agent/tool').json();
}
/**
* 获取 Agent 上下文
*/
export function getAgentContext(sessionId: string) {
return post<string>(`/ai-chat/agent/context/${sessionId}`).json();
}
export * from './types';

View File

@@ -0,0 +1,51 @@
/**
* Agent 发送消息输入
*/
export interface AgentSendInput {
/** 会话id */
sessionId: string;
/** 用户内容 */
content: string;
/** api密钥Id */
tokenId: string;
/** 模型id */
modelId: string;
/** 已选择工具 */
tools: string[];
}
/**
* Agent 工具输出
*/
export interface AgentToolOutput {
/** 工具代码 */
code: string;
/** 工具名称 */
name: string;
}
/**
* Agent 结果类型
*/
export type AgentResultType = 'text' | 'toolCalling' | 'toolCalled' | 'usage' | 'toolCallUsage';
/**
* Agent 流式结果输出
*/
export interface AgentResultOutput {
/** 类型 */
type: AgentResultType;
/** 内容载体 */
content: any;
}
/**
* Agent 用量信息
*/
export interface AgentUsage {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
prompt_tokens?: number;
completion_tokens?: number;
}

View File

@@ -1,4 +1,5 @@
export * from './announcement'
export * from './agent';
export * from './auth';
export * from './chat';
export * from './file';

View File

@@ -1,5 +1,15 @@
import type { Component } from 'vue';
/**
* 会话类型枚举
*/
export enum SessionTypeEnum {
/** 普通聊天 */
Chat = 0,
/** Agent智能体 */
Agent = 1,
}
export interface GetSessionListParams {
/**
* 创建者
@@ -61,6 +71,10 @@ export interface GetSessionListParams {
* 用户id
*/
userId: number;
/**
* 会话类型
*/
sessionType?: SessionTypeEnum;
}
/**
@@ -96,6 +110,10 @@ export interface ChatSessionVo {
* 自定义的消息前缀图标字段
*/
prefixIcon?: Component;
/**
* 会话类型
*/
sessionType?: SessionTypeEnum;
}
/**
@@ -147,6 +165,10 @@ export interface CreateSessionDTO {
* 用户id
*/
userId: number;
/**
* 会话类型
*/
sessionType?: SessionTypeEnum;
}
// export interface CreateSessionVO {

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

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>

View File

@@ -0,0 +1,202 @@
import type { ChatSessionVo, CreateSessionDTO, GetSessionListParams } from '@/api/session/types';
import { SessionTypeEnum } from '@/api/session/types';
import { Monitor } from '@element-plus/icons-vue';
import { defineStore } from 'pinia';
import { markRaw, ref } from 'vue';
import {
create_session,
delete_session,
get_session,
get_session_list,
update_session,
} from '@/api';
import { useUserStore } from './user';
export const useAgentSessionStore = defineStore('agentSession', () => {
const userStore = useUserStore();
// 当前选中的会话信息
const currentSession = ref<ChatSessionVo | null>(null);
// 设置当前会话
const setCurrentSession = (session: ChatSessionVo | null) => {
currentSession.value = session;
};
// 会话列表核心状态
const sessionList = ref<ChatSessionVo[]>([]);
const currentPage = ref(1);
const pageSize = ref(25);
const hasMore = ref(true);
const isLoading = ref(false);
const isLoadingMore = ref(false);
// 获取会话列表
const requestSessionList = async (page: number = currentPage.value, force: boolean = false) => {
if (!userStore.token) {
sessionList.value = [];
return;
}
if (!force && ((page > 1 && !hasMore.value) || isLoading.value || isLoadingMore.value))
return;
isLoading.value = page === 1;
isLoadingMore.value = page > 1;
try {
const params: GetSessionListParams = {
userId: userStore.userInfo?.userId as number,
skipCount: page,
maxResultCount: pageSize.value,
isAsc: 'desc',
orderByColumn: 'createTime',
sessionType: SessionTypeEnum.Agent, // 只查询Agent类型
};
const resArr = await get_session_list(params);
const res = processSessions(resArr.data.items);
const allSessions = new Map(sessionList.value.map(item => [item.id, item]));
res.forEach(item => allSessions.set(item.id, { ...item }));
if (page === 1) {
sessionList.value = [
...res,
...Array.from(allSessions.values()).filter(item => !res.some(r => r.id === item.id)),
];
}
else {
sessionList.value = [
...sessionList.value.filter(item => !res.some(r => r.id === item.id)),
...res,
];
}
if (!force)
hasMore.value = (res?.length || 0) === pageSize.value;
if (!force)
currentPage.value = page;
}
catch (error) {
console.error('requestSessionList错误:', error);
}
finally {
isLoading.value = false;
isLoadingMore.value = false;
}
};
// 创建新会话
const createSession = async (data: Omit<CreateSessionDTO, 'id' | 'sessionType'>) => {
if (!userStore.token) {
return null;
}
try {
const res = await create_session({
...data,
sessionType: SessionTypeEnum.Agent,
});
await requestSessionList(1, true);
const newSessionRes = await get_session(`${res.data.id}`);
setCurrentSession(newSessionRes.data);
return newSessionRes.data;
}
catch (error) {
console.error('createSession错误:', error);
return null;
}
};
// 加载更多会话
const loadMoreSessions = async () => {
if (hasMore.value)
await requestSessionList(currentPage.value + 1);
};
// 更新会话
const updateSession = async (item: ChatSessionVo) => {
try {
await update_session(item);
const targetIndex = sessionList.value.findIndex(session => session.id === item.id);
const targetPage = targetIndex >= 0
? Math.floor(targetIndex / pageSize.value) + 1
: 1;
await requestSessionList(targetPage, true);
}
catch (error) {
console.error('updateSession错误:', error);
}
};
// 删除会话
const deleteSession = async (ids: string[]) => {
try {
await delete_session(ids);
const targetIndex = sessionList.value.findIndex(session => session.id === ids[0]);
const targetPage = targetIndex >= 0
? Math.floor(targetIndex / pageSize.value) + 1
: 1;
await requestSessionList(targetPage, true);
// 如果删除的是当前会话,清空当前会话
if (currentSession.value && ids.includes(currentSession.value.id!)) {
setCurrentSession(null);
}
}
catch (error) {
console.error('deleteSession错误:', error);
}
};
// 预处理会话
function processSessions(sessions: ChatSessionVo[]) {
const currentDate = new Date();
return sessions.map((session) => {
const createDate = new Date(session.creationTime!);
const diffDays = Math.floor(
(currentDate.getTime() - createDate.getTime()) / (1000 * 60 * 60 * 24),
);
let group: string;
if (diffDays < 7) {
group = '7 天内';
}
else if (diffDays < 30) {
group = '30 天内';
}
else {
const year = createDate.getFullYear();
const month = String(createDate.getMonth() + 1).padStart(2, '0');
group = `${year}-${month}`;
}
return {
...session,
group,
prefixIcon: markRaw(Monitor),
};
});
}
return {
currentSession,
setCurrentSession,
sessionList,
currentPage,
pageSize,
hasMore,
isLoading,
isLoadingMore,
createSession,
requestSessionList,
loadMoreSessions,
updateSession,
deleteSession,
};
});

View File

@@ -1,4 +1,5 @@
import type { ChatSessionVo, CreateSessionDTO, GetSessionListParams } from '@/api/session/types';
import { SessionTypeEnum } from '@/api/session/types';
import { ChatLineRound } from '@element-plus/icons-vue';
import { defineStore } from 'pinia';
import { markRaw } from 'vue';
@@ -64,6 +65,7 @@ export const useSessionStore = defineStore('session', () => {
maxResultCount: pageSize.value,
isAsc: 'desc',
orderByColumn: 'createTime',
sessionType: SessionTypeEnum.Chat,
};
const resArr = await get_session_list(params);

View File

@@ -23,6 +23,7 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']

View File

@@ -7,7 +7,6 @@ interface ImportMetaEnv {
readonly VITE_WEB_BASE_API: string;
readonly VITE_API_URL: string;
readonly VITE_FILE_UPLOAD_API: string;
readonly VITE_BUILD_COMPRESS: string;
readonly VITE_SSO_SEVER_URL: string;
readonly VITE_APP_VERSION: string;
}