Commit 2fe9c68a authored by ligaowei's avatar ligaowei

Merge branch 'feature/chat-form' into 'main'

1

See merge request !1
parents 8bcd41e5 8a629996
......@@ -202,6 +202,9 @@ frontend/.env.development.local
frontend/.env.test.local
frontend/.env.production.local
# Kiro IDE files
.kiro/
# OS generated files
Thumbs.db
.DS_Store
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -19,6 +19,7 @@
"highlight.js": "^11.9.0",
"marked": "^17.0.1",
"pako": "^2.1.0",
"pangea-ui": "^0.14.2-beta.9",
"pinia": "^2.1.7",
"snabbdom": "^3.6.3",
"vue": "^3.4.0",
......@@ -26,11 +27,13 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@arco-design/web-vue": "^2.55.3",
"@types/dompurify": "^3.0.5",
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^5.0.0",
"eslint": "^8.55.0",
"eslint-plugin-vue": "^9.19.0",
"less": "^4.2.0",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"vite": "^5.0.0",
......
......@@ -2,13 +2,27 @@
<div class="chat-area">
<!-- 顶部Agent选择和操作栏 -->
<div class="chat-header">
<el-select v-model="selectedAgent" @change="handleAgentChange" placeholder="选择智能体" class="agent-select">
<el-option v-for="agent in agents" :key="agent.id" :label="agent.name" :value="agent.id">
<el-select
v-model="selectedAgent"
@change="handleAgentChange"
placeholder="选择智能体"
class="agent-select"
>
<el-option
v-for="agent in agents"
:key="agent.id"
:label="agent.name"
:value="agent.id"
>
<span>{{ agent.name }} (ID: {{ agent.id }})</span>
</el-option>
</el-select>
<el-tooltip content="清空对话">
<el-button @click="clearMessages" :disabled="messages.length === 0" circle>
<el-button
@click="clearMessages"
:disabled="messages.length === 0"
circle
>
<span>🗑️</span>
</el-button>
</el-tooltip>
......@@ -70,271 +84,300 @@
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, defineExpose } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MessageItem from './MessageItem.vue'
import request from '@/utils/request'
import { useRoute } from 'vue-router'
import { ref, nextTick, onMounted, defineExpose } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import MessageItem from "./MessageItem.vue";
import request from "@/utils/request";
import { useRoute } from "vue-router";
interface Message {
content: string
isUser: boolean
agentId?: string
timestamp: number
isStreaming: boolean
hasError?: boolean
originalMessage?: string
content: string;
isUser: boolean;
agentId?: string;
timestamp: number;
isStreaming: boolean;
hasError?: boolean;
originalMessage?: string;
}
interface Agent {
id: string
name: string
[key: string]: any
id: string;
name: string;
[key: string]: any;
}
const selectedAgent = ref<string>('')
const agents = ref<Agent[]>([])
const messages = ref<Message[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement>()
const selectedAgent = ref<string>("");
const agents = ref<Agent[]>([]);
const messages = ref<Message[]>([]);
const inputMessage = ref("");
const isLoading = ref(false);
const messagesContainer = ref<HTMLElement>();
// 获取当前路由
const route = useRoute()
const route = useRoute();
// 全局维护SSE流超时计时器引用,确保能够正确清除
let streamTimeoutTimer: ReturnType<typeof setTimeout> | null = null
let streamTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
// 获取Agent列表
const loadAgents = async () => {
try {
const res = await request.get('/agent')
const res = await request.get("/agent");
// 检查响应结构并正确提取数据
if (res && res.data && res.data.code === 200) {
agents.value = Array.isArray(res.data.data) ? res.data.data : []
agents.value = Array.isArray(res.data.data) ? res.data.data : [];
} else {
agents.value = []
agents.value = [];
}
console.log('[Agent列表加载] 获取到的Agent列表:', agents.value)
console.log("[Agent列表加载] 获取到的Agent列表:", agents.value);
if (agents.value.length > 0 && !selectedAgent.value) {
selectedAgent.value = agents.value[0].id
selectedAgent.value = agents.value[0].id;
}
} catch (error) {
console.error('获取Agent列表失败:', error)
agents.value = []
ElMessage.error('获取Agent列表失败')
console.error("获取Agent列表失败:", error);
agents.value = [];
ElMessage.error("获取Agent列表失败");
}
}
};
// 获取Agent名称
const getAgentName = (agentId?: string): string => {
if (!agentId) return 'Assistant'
const agent = agents.value.find(a => a.id === agentId)
return agent?.name || 'Assistant'
}
if (!agentId) return "Assistant";
const agent = agents.value.find((a) => a.id === agentId);
return agent?.name || "Assistant";
};
// 处理Agent切换
const handleAgentChange = () => {
clearMessages()
}
clearMessages();
};
// 清空消息
const clearMessages = () => {
// 清除可能存在的超时计时器
clearStreamTimeout()
ElMessageBox.confirm('确认清空所有对话吗?此操作无法撤销。', '提示', {
confirmButtonText: '清空',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
messages.value = []
ElMessage.success('对话已清空')
}).catch(() => {
// 用户取消
clearStreamTimeout();
ElMessageBox.confirm("确认清空所有对话吗?此操作无法撤销。", "提示", {
confirmButtonText: "清空",
cancelButtonText: "取消",
type: "warning",
})
}
.then(() => {
messages.value = [];
ElMessage.success("对话已清空");
})
.catch(() => {
// 用户取消
});
};
// 合并重复的历史消息加载逻辑
const loadHistoryMessagesInternal = async (agentId: string) => {
try {
// 添加更严格的类型和存在性检查
if (!agentId || typeof agentId !== 'string' || agentId.trim() === '') {
console.log('[历史消息加载] 没有指定有效的Agent ID,跳过加载历史记录')
messages.value = []
return
if (!agentId || typeof agentId !== "string" || agentId.trim() === "") {
console.log("[历史消息加载] 没有指定有效的Agent ID,跳过加载历史记录");
messages.value = [];
return;
}
// 验证agentId是否存在于可用agents列表中
const isValidAgent = agents.value.some(agent => agent.id === agentId.trim())
const isValidAgent = agents.value.some(
(agent) => agent.id === agentId.trim()
);
// 如果agents列表为空,我们仍然尝试加载历史消息,因为可能是初始化时agents还未加载完成
if (agents.value.length > 0 && !isValidAgent) {
console.log(`[历史消息加载] 指定的Agent ID '${agentId}' 不存在于可用agents列表中`)
console.log(
`[历史消息加载] 指定的Agent ID '${agentId}' 不存在于可用agents列表中`
);
// 不立即返回,而是继续尝试加载历史消息
}
// 设置选中的Agent
selectedAgent.value = agentId.trim()
selectedAgent.value = agentId.trim();
// 调用后端API获取历史对话记录
console.log(`[历史消息加载] 正在请求API: /memory/dialogue/agent/${agentId.trim()}`)
const res = await request.get(`/memory/dialogue/agent/${encodeURIComponent(agentId.trim())}`)
console.log(
`[历史消息加载] 正在请求API: /memory/dialogue/agent/${agentId.trim()}`
);
const res = await request.get(
`/memory/dialogue/agent/${encodeURIComponent(agentId.trim())}`
);
console.log('[历史消息加载] API响应:', res)
console.log("[历史消息加载] API响应:", res);
// 验证响应结构:包括code、data和messages数组
if (!res || !res.data) {
console.error('[历史消息加载] 响应对象为空或无效')
messages.value = []
return
console.error("[历史消息加载] 响应对象为空或无效");
messages.value = [];
return;
}
const { code, data } = res.data
console.log(`[历史消息加载] 响应code: ${code}, data:`, data)
const { code, data } = res.data;
console.log(`[历史消息加载] 响应code: ${code}, data:`, data);
if (code === 200 && data && typeof data === 'object') {
const messagesArray = data.messages
if (code === 200 && data && typeof data === "object") {
const messagesArray = data.messages;
// 检查messages是否是数组
if (!Array.isArray(messagesArray)) {
console.warn(`[历史消息加载] messages字段不是数组,类型为: ${typeof messagesArray},值为:`, messagesArray)
messages.value = []
return
console.warn(
`[历史消息加载] messages字段不是数组,类型为: ${typeof messagesArray},值为:`,
messagesArray
);
messages.value = [];
return;
}
// 检查是否有消息
if (messagesArray.length === 0) {
console.log('[历史消息加载] 没有找到历史消息,API返回空数组')
messages.value = []
return
console.log("[历史消息加载] 没有找到历史消息,API返回空数组");
messages.value = [];
return;
}
// 转换消息格式
const historyMessages = messagesArray.map((msg: any) => {
const historyMessages = messagesArray
.map((msg: any) => {
// 验证消息对象的必要字段
if (!msg || typeof msg !== 'object') {
console.warn('[历史消息加载] 发现无效的消息对象:', msg)
return null
if (!msg || typeof msg !== "object") {
console.warn("[历史消息加载] 发现无效的消息对象:", msg);
return null;
}
const sender = msg.sender
const content = msg.content
const time = msg.time
const sender = msg.sender;
const content = msg.content;
const time = msg.time;
if (!content) {
console.warn('[历史消息加载] 消息内容为空:', msg)
return null
console.warn("[历史消息加载] 消息内容为空:", msg);
return null;
}
return {
content: content,
isUser: sender === 'user',
isUser: sender === "user",
agentId: agentId.trim(),
timestamp: time ? (typeof time === 'string' ? parseInt(time) : new Date(time).getTime()) : Date.now(),
isStreaming: false
}
}).filter((msg: any) => msg !== null) // 过滤掉无效消息
timestamp: time
? typeof time === "string"
? parseInt(time)
: new Date(time).getTime()
: Date.now(),
isStreaming: false,
};
})
.filter((msg: any) => msg !== null); // 过滤掉无效消息
// 修复:确保消息按时间顺序排列(最新的消息在最后)
historyMessages.sort((a: any, b: any) => a.timestamp - b.timestamp)
historyMessages.sort((a: any, b: any) => a.timestamp - b.timestamp);
messages.value = historyMessages
console.log(`[历史消息加载] 成功加载${historyMessages.length}条历史消息`)
messages.value = historyMessages;
console.log(`[历史消息加载] 成功加载${historyMessages.length}条历史消息`);
// 滚动到底部
await nextTick()
await nextTick();
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
messagesContainer.value.scrollTop =
messagesContainer.value.scrollHeight;
}
} else {
console.error(`[历史消息加载] 响应格式错误或错误状态。code: ${code}, data:`, data)
messages.value = []
console.error(
`[历史消息加载] 响应格式错误或错误状态。code: ${code}, data:`,
data
);
messages.value = [];
}
} catch (error: any) {
console.error('[历史消息加载] 加载历史对话记录失败', error)
messages.value = [] // 出错时清空消息列表
console.error("[历史消息加载] 加载历史对话记录失败", error);
messages.value = []; // 出错时清空消息列表
// 记录详细的错误信息便于调试
if (error.response) {
console.error('[历史消息加载] HTTP响应错误 - 状态码:', error.response.status)
console.error('[历史消息加载] HTTP响应错误 - 数据:', error.response.data)
console.error('[历史消息加载] HTTP响应错误 - 请求URL:', error.config?.url)
console.error(
"[历史消息加载] HTTP响应错误 - 状态码:",
error.response.status
);
console.error("[历史消息加载] HTTP响应错误 - 数据:", error.response.data);
console.error(
"[历史消息加载] HTTP响应错误 - 请求URL:",
error.config?.url
);
} else if (error.request) {
console.error('[历史消息加载] 网络请求错误 - 没有收到响应')
console.error('[历史消息加载] 请求配置:', error.request)
console.error("[历史消息加载] 网络请求错误 - 没有收到响应");
console.error("[历史消息加载] 请求配置:", error.request);
} else {
console.error('[历史消息加载] 错误信息:', error.message)
console.error('[历史消息加载] 错误堆栈:', error.stack)
console.error("[历史消息加载] 错误信息:", error.message);
console.error("[历史消息加载] 错误堆栈:", error.stack);
}
}
}
};
// 加载历史对话记录
const loadHistoryMessages = async () => {
// 首先尝试从路由参数获取agentId
let agentId = route.query.agentId as string
let agentId = route.query.agentId as string;
// 如果路由参数中没有agentId,则使用下拉框中选中的值
if (!agentId || typeof agentId !== 'string' || agentId.trim() === '') {
agentId = selectedAgent.value
if (!agentId || typeof agentId !== "string" || agentId.trim() === "") {
agentId = selectedAgent.value;
}
// 如果仍然没有有效的agentId,则不加载历史消息
if (!agentId || typeof agentId !== 'string' || agentId.trim() === '') {
console.log('[历史消息加载] 没有指定有效的Agent ID,跳过加载历史记录')
return
if (!agentId || typeof agentId !== "string" || agentId.trim() === "") {
console.log("[历史消息加载] 没有指定有效的Agent ID,跳过加载历史记录");
return;
}
await loadHistoryMessagesInternal(agentId);
}
};
// 通过指定agentId加载历史对话记录(供父组件调用)
const loadHistoryByAgentId = async (agentId: string) => {
await loadHistoryMessagesInternal(agentId);
}
};
// 处理重试
const handleRetry = async (index: number) => {
// 清除可能存在的超时计时器
clearStreamTimeout()
const msg = messages.value[index]
if (!msg.hasError || msg.isUser) return
clearStreamTimeout();
const msg = messages.value[index];
if (!msg.hasError || msg.isUser) return;
// 移除错误消息
messages.value.splice(index, 1)
messages.value.splice(index, 1);
// 重新发送原始消息
inputMessage.value = msg.originalMessage || ''
await sendMessage()
}
inputMessage.value = msg.originalMessage || "";
await sendMessage();
};
// 统一的超时计时器清理函数
const clearStreamTimeout = () => {
if (streamTimeoutTimer) {
clearTimeout(streamTimeoutTimer)
streamTimeoutTimer = null
clearTimeout(streamTimeoutTimer);
streamTimeoutTimer = null;
}
}
};
// 防抖函数
const debounce = (func: Function, wait: number) => {
let timeout: ReturnType<typeof setTimeout>
let timeout: ReturnType<typeof setTimeout>;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
// 自动滚动到底部(使用防抖优化性能)
const scrollToBottom = debounce(async () => {
await nextTick()
await nextTick();
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
}, 100)
}, 100);
// 【修复工具函数】处理转义序列,将\n、\t等转义形式还原为实际字符
const unescapeString = (str: string): string => {
......@@ -342,32 +385,40 @@ const unescapeString = (str: string): string => {
try {
// 处理转义序列:将转义形式还原为实际字符
let unescaped = str
.replace(/\\n/g, '\n') // \n 转换为换行符
.replace(/\\r/g, '\r') // \r 转换为回车符
.replace(/\\t/g, '\t') // \t 转换为制表符
.replace(/\\b/g, '\b') // \b 转换为退格符
.replace(/\\f/g, '\f') // \f 转换为换页符
.replace(/\\\\/g, '\\') // \\ 转换为单个反斜杠
.replace(/\\n/g, "\n") // \n 转换为换行符
.replace(/\\r/g, "\r") // \r 转换为回车符
.replace(/\\t/g, "\t") // \t 转换为制表符
.replace(/\\b/g, "\b") // \b 转换为退格符
.replace(/\\f/g, "\f") // \f 转换为换页符
.replace(/\\\\/g, "\\"); // \\ 转换为单个反斜杠
return unescaped;
} catch (e) {
console.warn('转义序列处理失败:', e);
console.warn("转义序列处理失败:", e);
return str;
}
};
// 处理SSE数据行的通用函数
const processSSELine = async (line: string, accumulatedContentRef: { value: string }, hasFinalAnswerRef: { value: boolean }, currentEventRef: { value: string }, aiMessageIndex: number, resetStreamTimeout: () => void) => {
if (!line.trim()) return false
if (line.startsWith('event:')) {
currentEventRef.value = line.slice(6).trim()
return false
} else if (line.startsWith('data:')) {
const processSSELine = async (
line: string,
accumulatedContentRef: { value: string },
hasFinalAnswerRef: { value: boolean },
currentEventRef: { value: string },
aiMessageIndex: number,
resetStreamTimeout: () => void
) => {
if (!line.trim()) return false;
console.log("line", line);
if (line.startsWith("event:")) {
currentEventRef.value = line.slice(6).trim();
return false;
} else if (line.startsWith("data:")) {
try {
const dataStr = line.startsWith('data: ') ? line.slice(6) : line.slice(5)
const dataStr = line.startsWith("data: ") ? line.slice(6) : line.slice(5);
// 修复:检查dataStr是否为空或只包含空白字符
if (!dataStr.trim()) {
return false
return false;
}
// 尝试解析为JSON,如果失败则作为纯文本处理
let data;
......@@ -376,62 +427,73 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
} catch (e) {
// 不是JSON格式,将其作为token事件处理(用于兼容旧协议)
data = {
type: 'token',
token: dataStr
type: "token",
token: dataStr,
};
}
// 修复:检查是否是JSON格式的API密钥错误消息
if (typeof data === 'object' && data !== null) {
if (typeof data === "object" && data !== null) {
// 检查是否包含API密钥错误信息
const errorMessage = data.message || data.error || data.token || data.content || '';
if (errorMessage.includes('请配置API密钥')) {
const errorMessage =
data.message || data.error || data.token || data.content || "";
if (errorMessage.includes("请配置API密钥")) {
// 处理API密钥错误
clearStreamTimeout();
messages.value[aiMessageIndex].isStreaming = false;
messages.value[aiMessageIndex].content = '[错误] 请配置API密钥';
messages.value[aiMessageIndex].content = "[错误] 请配置API密钥";
messages.value[aiMessageIndex].hasError = true;
isLoading.value = false;
console.error('[SSE解析错误] 接收到API密钥错误消息:', dataStr);
console.error("[SSE解析错误] 接收到API密钥错误消息:", dataStr);
return true; // 返回true表示流已完成
}
}
// 特殊处理流结束标记
if (dataStr.trim() === '[DONE]') {
if (dataStr.trim() === "[DONE]") {
clearStreamTimeout();
messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false;
console.log('[SSE流结束] 收到[DONE]标记');
console.log("[SSE流结束] 收到[DONE]标记");
return true; // 返回true表示流已完成
}
// 检查是否是纯文本错误消息
// 修复:正确处理API密钥错误,检查是否为JSON格式的错误消息
if ((dataStr.startsWith('[错误]') || dataStr.includes('请配置API密钥')) && !dataStr.trim().startsWith('{')) {
if (
(dataStr.startsWith("[错误]") || dataStr.includes("请配置API密钥")) &&
!dataStr.trim().startsWith("{")
) {
// 处理纯文本错误消息,包括API密钥错误
clearStreamTimeout();
messages.value[aiMessageIndex].isStreaming = false;
messages.value[aiMessageIndex].content = dataStr.startsWith('[错误]') ? dataStr : '[错误] ' + dataStr;
messages.value[aiMessageIndex].content = dataStr.startsWith("[错误]")
? dataStr
: "[错误] " + dataStr;
messages.value[aiMessageIndex].hasError = true;
isLoading.value = false;
console.warn('[SSE解析错误] 接收到错误消息:', dataStr);
console.warn("[SSE解析错误] 接收到错误消息:", dataStr);
return true; // 返回true表示流已完成
}
// 新增:处理JSON格式但包含API密钥错误的情况
if (typeof data === 'object' && data !== null) {
if (typeof data === "object" && data !== null) {
const jsonData = data;
// 检查各种可能包含错误信息的字段
const errorMsg = jsonData.message || jsonData.error || jsonData.token || jsonData.content || '';
if (errorMsg.includes('请配置API密钥')) {
const errorMsg =
jsonData.message ||
jsonData.error ||
jsonData.token ||
jsonData.content ||
"";
if (errorMsg.includes("请配置API密钥")) {
// 处理API密钥错误
clearStreamTimeout();
messages.value[aiMessageIndex].isStreaming = false;
messages.value[aiMessageIndex].content = '[错误] 请配置API密钥';
messages.value[aiMessageIndex].content = "[错误] 请配置API密钥";
messages.value[aiMessageIndex].hasError = true;
isLoading.value = false;
console.error('[SSE解析错误] 接收到API密钥错误消息:', dataStr);
console.error("[SSE解析错误] 接收到API密钥错误消息:", dataStr);
return true; // 返回true表示流已完成
}
}
......@@ -440,20 +502,20 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
// 根据事件类型处理数据
switch (eventType) {
case 'token':
case "token":
// 重置超时计时器,接收到token说明连接还活跃
resetStreamTimeout();
// 修复:对接收到的token进行反转义处理,䮵保换行符和格式化符号正常显示
const processedToken = unescapeString(data.token || '');
const processedToken = unescapeString(data.token || "");
accumulatedContentRef.value += processedToken;
messages.value[aiMessageIndex].content = accumulatedContentRef.value;
await scrollToBottom();
break;
case 'complete':
case "complete":
// 收到完成事件,清除超时计时器
clearStreamTimeout();
console.log('[SSE完成事件]', data);
console.log("[SSE完成事件]", data);
// 如果有完整文本内容,更新消息内容
if (data.fullText) {
messages.value[aiMessageIndex].content = data.fullText;
......@@ -463,43 +525,52 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
isLoading.value = false;
return true; // 返回true表示流已完成
case 'error':
case "error":
// 收到错误事件,清除超时计时器
clearStreamTimeout();
messages.value[aiMessageIndex].isStreaming = false;
// 检查是否是API密钥错误的特殊提示
const errorMsg = data.message || data.error || data.token || data.content || '';
if (errorMsg.includes('请配置API密钥')) {
messages.value[aiMessageIndex].content = '[错误] 请配置API密钥';
const errorMsg =
data.message || data.error || data.token || data.content || "";
if (errorMsg.includes("请配置API密钥")) {
messages.value[aiMessageIndex].content = "[错误] 请配置API密钥";
} else {
messages.value[aiMessageIndex].content = `[错误] ${errorMsg || '未知错误'}`;
messages.value[aiMessageIndex].content = `[错误] ${
errorMsg || "未知错误"
}`;
}
messages.value[aiMessageIndex].hasError = true;
isLoading.value = false;
// 记录错误日志便于调试
console.error('[SSE错误事件]', data);
console.error("[SSE错误事件]", data);
return true; // 返回true表示流已完成
case 'thinking':
case "thinking":
// 处理思考事件,将其发送到时间轴面板
const event = {
type: 'thought',
title: data.thinkingType === 'final_answer' ? '最终答案' : '思考过程',
type: "thought",
title:
data.thinkingType === "final_answer" ? "最终答案" : "思考过程",
content: data.content,
timestamp: data.timestamp
timestamp: data.timestamp,
};
// 通过事件总线将事件发送到时间轴
window.dispatchEvent(new CustomEvent('timeline-event', { detail: event }));
window.dispatchEvent(
new CustomEvent("timeline-event", { detail: event })
);
// 如果是最终答案,也应该显示在主要对话框中
// 修复:确保最终答案只添加一次,避免重复显示
if (data.thinkingType === 'final_answer' && !hasFinalAnswerRef.value) {
if (
data.thinkingType === "final_answer" &&
!hasFinalAnswerRef.value
) {
hasFinalAnswerRef.value = true;
// 修复:这里不应该再添加到accumulatedContentRef.value,因为这会在流结束后再次设置导致重复
// accumulatedContentRef.value += data.content || '';
// 直接设置消息内容为最终答案
messages.value[aiMessageIndex].content = data.content || '';
messages.value[aiMessageIndex].content = data.content || "";
messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false;
await scrollToBottom();
......@@ -507,29 +578,35 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
// 对于非最终答案的思考过程,不添加到主对话框中
break;
case 'tool_call':
case 'embed':
case "tool_call":
case "embed":
// 处理工具调用和嵌入事件,将其发送到时间轴面板
// 构建事件标题
let title = data.title || '事件';
if (eventType === 'tool_call' && data.toolName) {
let title = data.title || "事件";
if (eventType === "tool_call" && data.toolName) {
title = `调用工具: ${data.toolName}`;
} else if (eventType === 'embed' && data.embedTitle) {
} else if (eventType === "embed" && data.embedTitle) {
title = data.embedTitle;
}
// 构建元数据
const metadata: Record<string, any> = data.metadata || {};
if (eventType === 'tool_call') {
if (data.toolName) metadata['工具'] = data.toolName;
if (data.toolAction) metadata['操作'] = data.toolAction;
if (data.toolInput) metadata['输入'] = JSON.stringify(data.toolInput).substring(0, 100);
if (data.toolOutput) metadata['输出'] = String(data.toolOutput).substring(0, 100);
if (data.toolStatus) metadata['状态'] = data.toolStatus;
if (data.executionTime) metadata['耗时'] = `${data.executionTime}ms`;
} else if (eventType === 'embed') {
if (data.embedUrl) metadata['URL'] = data.embedUrl;
if (data.embedType) metadata['类型'] = data.embedType;
if (eventType === "tool_call") {
if (data.toolName) metadata["工具"] = data.toolName;
if (data.toolAction) metadata["操作"] = data.toolAction;
if (data.toolInput)
metadata["输入"] = JSON.stringify(data.toolInput).substring(
0,
100
);
if (data.toolOutput)
metadata["输出"] = String(data.toolOutput).substring(0, 100);
if (data.toolStatus) metadata["状态"] = data.toolStatus;
if (data.executionTime)
metadata["耗时"] = `${data.executionTime}ms`;
} else if (eventType === "embed") {
if (data.embedUrl) metadata["URL"] = data.embedUrl;
if (data.embedType) metadata["类型"] = data.embedType;
}
const timelineEvent = {
......@@ -547,52 +624,61 @@ const processSSELine = async (line: string, accumulatedContentRef: { value: stri
embedType: data.embedType,
embedTitle: data.embedTitle,
embedHtmlContent: data.embedHtmlContent,
timestamp: data.timestamp || Date.now()
timestamp: data.timestamp || Date.now(),
};
// 通过事件总线将事件发送到时间轴
console.log('[ChatArea] 发送timeline-event事件:', timelineEvent);
window.dispatchEvent(new CustomEvent('timeline-event', { detail: timelineEvent }));
console.log("[ChatArea] 发送timeline-event事件:", timelineEvent);
window.dispatchEvent(
new CustomEvent("timeline-event", { detail: timelineEvent })
);
// 对于embed事件,还需要触发embed-event事件
if (eventType === 'embed' && data.embedUrl) {
window.dispatchEvent(new CustomEvent('embed-event', {
if (eventType === "embed" && data.embedUrl) {
window.dispatchEvent(
new CustomEvent("embed-event", {
detail: {
url: data.embedUrl,
type: data.embedType,
title: data.embedTitle,
htmlContent: data.embedHtmlContent
}
}));
htmlContent: data.embedHtmlContent,
},
})
);
}
break;
}
// 重置当前事件类型
currentEventRef.value = '';
currentEventRef.value = "";
} catch (err) {
console.error('[SSE解析错误] 解析SSE数据失败,重置超时计时器:', err, '原始行:', line)
console.error(
"[SSE解析错误] 解析SSE数据失败,重置超时计时器:",
err,
"原始行:",
line
);
// 收到任何消息,都要重置超时计时器
resetStreamTimeout()
resetStreamTimeout();
// 根据错误类型,决定是否继续处理或中断流
if (line.includes('"type":"error"') || line.includes('"error"')) {
// 错误消息,继续处理
return false
return false;
}
}
return false
return false;
}
return false
}
return false;
};
// 发送消息
const sendMessage = async () => {
if (!selectedAgent.value || !inputMessage.value.trim()) {
return
return;
}
const userMessage = inputMessage.value.trim()
inputMessage.value = ''
const userMessage = inputMessage.value.trim();
inputMessage.value = "";
// 添加用户消息
messages.value.push({
......@@ -600,121 +686,142 @@ const sendMessage = async () => {
isUser: true,
agentId: selectedAgent.value,
timestamp: Date.now(),
isStreaming: false
})
isStreaming: false,
});
await scrollToBottom()
await scrollToBottom();
// 添加AI消息容器(流式接收)
const aiMessageIndex = messages.value.length
const aiMessageIndex = messages.value.length;
messages.value.push({
content: '',
content: "",
isUser: false,
agentId: selectedAgent.value,
timestamp: Date.now(),
isStreaming: true,
hasError: false,
originalMessage: userMessage
})
originalMessage: userMessage,
});
isLoading.value = true
let hasReceivedFirstToken = false // 标记是否接收到第一个token
isLoading.value = true;
let hasReceivedFirstToken = false; // 标记是否接收到第一个token
try {
// 使用自定义的流式处理
const token = localStorage.getItem('token')
const abortController = new AbortController()
const token = localStorage.getItem("token");
const abortController = new AbortController();
// 调用后端的流式API
const response = await fetch(
`/api/v1/agent/chat-stream?agentId=${selectedAgent.value}`,
{
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token || ''}`
"Content-Type": "application/json",
Authorization: `Bearer ${token || ""}`,
},
body: JSON.stringify({ message: userMessage }),
signal: abortController.signal
signal: abortController.signal,
}
)
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader()
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法读取响应体')
throw new Error("无法读取响应体");
}
const accumulatedContentRef = { value: '' }
const hasFinalAnswerRef = { value: false }
const currentEventRef = { value: '' }
const decoder = new TextDecoder()
let buffer = ''
let isStreamComplete = false // 标记流是否已完成
const STREAM_TIMEOUT = 60000 // 60秒无流式消息则为超时
const accumulatedContentRef = { value: "" };
const hasFinalAnswerRef = { value: false };
const currentEventRef = { value: "" };
const decoder = new TextDecoder();
let buffer = "";
let isStreamComplete = false; // 标记流是否已完成
const STREAM_TIMEOUT = 60000; // 60秒无流式消息则为超时
// 设置超时检查
const resetStreamTimeout = () => {
clearStreamTimeout()
clearStreamTimeout();
streamTimeoutTimer = setTimeout(() => {
if (!isStreamComplete) {
isStreamComplete = true
reader.cancel()
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
isStreamComplete = true;
reader.cancel();
messages.value[aiMessageIndex].isStreaming = false;
isLoading.value = false;
// 提示用户是否要重试
ElMessage.warning('流式输出超时,您可以点击重试按钮重新发送消息')
ElMessage.warning("流式输出超时,您可以点击重试按钮重新发送消息");
// 显示重试按钮
messages.value[aiMessageIndex].content = '[错误] 流式输出超时,请重试'
messages.value[aiMessageIndex].hasError = true
messages.value[aiMessageIndex].content =
"[错误] 流式输出超时,请重试";
messages.value[aiMessageIndex].hasError = true;
}
}, STREAM_TIMEOUT);
}
};
resetStreamTimeout()
resetStreamTimeout();
while (true) {
if (isStreamComplete) break // 如果已收到complete事件,停止读取
if (isStreamComplete) break; // 如果已收到complete事件,停止读取
const { done, value } = await reader.read()
if (done) break
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const isComplete = await processSSELine(line, accumulatedContentRef, hasFinalAnswerRef, currentEventRef, aiMessageIndex, resetStreamTimeout)
const isComplete = await processSSELine(
line,
accumulatedContentRef,
hasFinalAnswerRef,
currentEventRef,
aiMessageIndex,
resetStreamTimeout
);
if (isComplete) {
isStreamComplete = true
isStreamComplete = true;
}
// 在接收到第一个token时,立即隐藏Skeleton加载动画
if (!hasReceivedFirstToken && (line.includes('"type":"token"') || currentEventRef.value === 'token')) {
isLoading.value = false
hasReceivedFirstToken = true
if (
!hasReceivedFirstToken &&
(line.includes('"type":"token"') || currentEventRef.value === "token")
) {
isLoading.value = false;
hasReceivedFirstToken = true;
}
}
}
// 处理残余的数据
if (buffer && !isStreamComplete) {
const lines = buffer.split('\n')
const lines = buffer.split("\n");
for (const line of lines) {
const isComplete = await processSSELine(line, accumulatedContentRef, hasFinalAnswerRef, currentEventRef, aiMessageIndex, resetStreamTimeout)
const isComplete = await processSSELine(
line,
accumulatedContentRef,
hasFinalAnswerRef,
currentEventRef,
aiMessageIndex,
resetStreamTimeout
);
if (isComplete) {
isStreamComplete = true
isStreamComplete = true;
}
// 在接收到第一个token时,立即隐藏Skeleton加载动画
if (!hasReceivedFirstToken && (line.includes('"type":"token"') || currentEventRef.value === 'token')) {
isLoading.value = false
hasReceivedFirstToken = true
if (
!hasReceivedFirstToken &&
(line.includes('"type":"token"') || currentEventRef.value === "token")
) {
isLoading.value = false;
hasReceivedFirstToken = true;
}
}
}
......@@ -722,58 +829,62 @@ const sendMessage = async () => {
// 修复:仅有在没有最终答案且没有错误的情况下才更新消息内容
// 如果已经有最终答案或错误信息,就不需要再更新消息内容了,避免重复显示
if (!hasFinalAnswerRef.value && !messages.value[aiMessageIndex].hasError) {
messages.value[aiMessageIndex].content = accumulatedContentRef.value
messages.value[aiMessageIndex].content = accumulatedContentRef.value;
}
// 确保最终状态正确
messages.value[aiMessageIndex].isStreaming = false
messages.value[aiMessageIndex].isStreaming = false;
// 设置isLoading为false,结束加载状态
isLoading.value = false
isLoading.value = false;
// 清除超时计时器
clearStreamTimeout()
clearStreamTimeout();
// 确保滚动到底部
await scrollToBottom()
await scrollToBottom();
} catch (error: any) {
// 清除超时计时器
clearStreamTimeout()
clearStreamTimeout();
// 判断是否是网络错误
let errorMessage = '[错误] '
let errorMessage = "[错误] ";
// 检查是否是API密钥错误的特殊提示
if (error.message && (error.message.includes('请配置API密钥') || error.message.includes('[错误] 请配置API密钥'))) {
errorMessage = '[错误] 请配置API密钥'
if (
error.message &&
(error.message.includes("请配置API密钥") ||
error.message.includes("[错误] 请配置API密钥"))
) {
errorMessage = "[错误] 请配置API密钥";
} else if (error instanceof TypeError) {
errorMessage += '网络获取失败,请检查你的网络连接'
} else if (error.name === 'AbortError') {
errorMessage += '请求已取消'
} else if (error.message && error.message.includes('处理超时')) {
errorMessage = '[错误] 服务器处理超时,请稍后重试'
errorMessage += "网络获取失败,请检查你的网络连接";
} else if (error.name === "AbortError") {
errorMessage += "请求已取消";
} else if (error.message && error.message.includes("处理超时")) {
errorMessage = "[错误] 服务器处理超时,请稍后重试";
} else if (error.message) {
errorMessage += error.message
errorMessage += error.message;
} else {
errorMessage += '一个未知错误发生'
errorMessage += "一个未知错误发生";
}
messages.value[aiMessageIndex].content = errorMessage
messages.value[aiMessageIndex].isStreaming = false
messages.value[aiMessageIndex].hasError = true
isLoading.value = false
messages.value[aiMessageIndex].content = errorMessage;
messages.value[aiMessageIndex].isStreaming = false;
messages.value[aiMessageIndex].hasError = true;
isLoading.value = false;
}
}
};
onMounted(async () => {
await loadAgents()
await loadAgents();
// 等待下一个tick确保agents加载完成后再加载历史消息
await nextTick()
loadHistoryMessages()
})
await nextTick();
loadHistoryMessages();
});
// 暴露方法给父组件使用
defineExpose({
loadHistoryByAgentId
})
loadHistoryByAgentId,
});
</script>
<style scoped>
......
<template>
<hi-page-template
ref="templateRef"
:json="json"
:open-intl="false"
></hi-page-template>
<div class="button-wrap">
<a-button type="primary" @click="submit">提交</a-button>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import HiPageTemplate from "pangea-ui/hi-page-template";
const templateRef = ref();
const submit = () => {
templateRef.value?.ctx.validate(1, (res, data) => {
console.log(res, data);
});
};
const json = {
pages: [
{
key: 0,
type: "default",
name: "默认页",
code: "",
display: "",
props: {
margin: "16px",
padding: "12px",
backgroundColor: "white",
display: {},
},
bindProps: {},
coms: [
{
key: 1,
type: "node",
name: "表单容器",
code: "HiFormContainer",
display: "",
props: {
status: "default",
backgroundColor: "transparent",
layout: "horizontal",
size: "medium",
labelAlign: "right",
display: {},
borderRadius: {},
boxShadow: {},
loop: {
data: [],
},
},
bindProps: {},
coms: [
{
key: 1766473421208,
name: "输入框",
code: "HiInput",
props: {
title: "输入框",
status: "default",
placeholder: "请输入",
name: "INPUT_6CP8HIBK",
},
bindProps: {},
coms: [],
},
{
key: 1766476676439,
name: "日期",
code: "HiDatePicker",
props: {
title: "日期",
type: "date",
format: "YYYY-MM-DD",
status: "default",
name: "DATE_PA9TUPQQ",
},
bindProps: {},
},
],
},
],
},
],
params: [],
apis: [],
funcs: [],
pageTemplate: {},
};
</script>
<style scoped>
.button-wrap {
display: flex;
justify-content: center;
}
</style>
<template>
<div class="work-area">
<el-tabs v-model="activeTab" class="work-tabs">
<el-tab-pane label="表单" name="form">
<form-render ref="formRender" />
</el-tab-pane>
<el-tab-pane label="📋 时间轴" name="timeline">
<timeline-container ref="timelineContainerRef" />
</el-tab-pane>
......@@ -12,108 +15,121 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import TimelineContainer from './TimelineContainer.vue'
import WebpageBrowser from './WebpageBrowser.vue'
import { TimelineService } from '../services/TimelineService'
const activeTab = ref('timeline')
const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>(null)
const webBrowser = ref()
let timelineService: TimelineService | null = null
import { ref, onMounted, onUnmounted } from "vue";
import FormRender from "./FormRender.vue";
import TimelineContainer from "./TimelineContainer.vue";
import WebpageBrowser from "./WebpageBrowser.vue";
import { TimelineService } from "../services/TimelineService";
const activeTab = ref("form");
const formRender = ref();
const timelineContainerRef = ref<InstanceType<typeof TimelineContainer> | null>(
null
);
const webBrowser = ref();
let timelineService: TimelineService | null = null;
// 添加事件到时间轴
const addEvent = (event: any): void => {
timelineContainerRef.value?.addEvent(event)
}
timelineContainerRef.value?.addEvent(event);
};
// 初始化Timeline服务
const initTimelineService = () => {
if (timelineContainerRef.value) {
timelineService = new TimelineService((event: any) => {
addEvent(event)
})
timelineService.connectSSE()
addEvent(event);
});
timelineService.connectSSE();
}
}
};
// 清除时间轴
const clearTimeline = (): void => {
timelineContainerRef.value?.clearTimeline()
}
timelineContainerRef.value?.clearTimeline();
};
// 定义embed事件的详细信息类型
interface EmbedEventDetail {
url: string
type: string
title: string
htmlContent?: string
url: string;
type: string;
title: string;
htmlContent?: string;
}
// 监听embed事件
const handleEmbedEvent = (e: Event) => {
const customEvent = e as CustomEvent<EmbedEventDetail>
const { url, type, title, htmlContent } = customEvent.detail
const customEvent = e as CustomEvent<EmbedEventDetail>;
const { url, type, title, htmlContent } = customEvent.detail;
// 验证URL有效性
if (!url || typeof url !== 'string' || url.trim() === '') {
console.error('[WorkArea] embed事件URL验证失败:', {
if (!url || typeof url !== "string" || url.trim() === "") {
console.error("[WorkArea] embed事件URL验证失败:", {
url: url,
type: typeof url,
isEmpty: url?.trim() === '',
detail: customEvent.detail
})
return
isEmpty: url?.trim() === "",
detail: customEvent.detail,
});
return;
}
// 自动切换到浏览器标签页
activeTab.value = 'browser'
activeTab.value = "browser";
// 调用WebpageBrowser的导航方法,传递完整信息
if (webBrowser.value && typeof webBrowser.value.navigateToUrl === 'function') {
if (
webBrowser.value &&
typeof webBrowser.value.navigateToUrl === "function"
) {
webBrowser.value.navigateToUrl(url, {
htmlContent: htmlContent,
embedType: type,
embedTitle: title
})
embedTitle: title,
});
} else {
console.error('[WorkArea] webBrowser引用无效或navigateToUrl方法不存在', {
console.error("[WorkArea] webBrowser引用无效或navigateToUrl方法不存在", {
hasWebBrowser: !!webBrowser.value,
hasFn: webBrowser.value ? typeof webBrowser.value.navigateToUrl : 'undefined'
})
hasFn: webBrowser.value
? typeof webBrowser.value.navigateToUrl
: "undefined",
});
}
}
};
onMounted(() => {
// 监听embed事件
window.addEventListener('embed-event', handleEmbedEvent as EventListener)
window.addEventListener("embed-event", handleEmbedEvent as EventListener);
// 初始化Timeline服务
initTimelineService()
})
initTimelineService();
});
onUnmounted(() => {
// 移除事件监听
window.removeEventListener('embed-event', handleEmbedEvent as EventListener)
window.removeEventListener("embed-event", handleEmbedEvent as EventListener);
// 清理Timeline服务
if (timelineService) {
timelineService.cleanup()
timelineService.cleanup();
}
})// 暴露方法供父组件调用
}); // 暴露方法供父组件调用
defineExpose({
formRender,
timelineContainerRef,
webBrowser,
activeTab,
// 提供切换tab的方法
switchToForm: () => {
activeTab.value = "form";
},
switchToTimeline: () => {
activeTab.value = 'timeline'
activeTab.value = "timeline";
},
switchToBrowser: () => {
activeTab.value = 'browser'
activeTab.value = "browser";
},
// 提供时间轴操作方法
addEvent,
clearTimeline
})
clearTimeline,
});
</script>
<style scoped>
......
......@@ -4,6 +4,9 @@ import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import ArcoVue from "@arco-design/web-vue";
import ArcoVueIcon from "@arco-design/web-vue/es/icon";
import '@arco-design/web-vue/dist/arco.less';
import 'highlight.js/styles/atom-one-dark.css'
import './styles/variables.css'
import './styles/global.css'
......@@ -17,6 +20,8 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.use(ArcoVue)
app.use(ArcoVueIcon)
// 全局注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment