refactor(智能体组件): 重构部分组件,优化部分样式

This commit is contained in:
Wenjie Zhang 2025-08-25 13:57:47 +08:00
parent eb04d1f2c4
commit 6ae61f6652
17 changed files with 1559 additions and 1253 deletions

View File

@ -28,6 +28,7 @@
- [ ] 使用其他的聊天记录管理方法(现在是基于 LangGraph 的 Memory 实现的)
- [ ] 添加测试脚本,覆盖最常见的功能
- [ ] 添加数据库迁移的脚本
- [ ] 封装现有工具为 mcpstdio调用
- [ ] 支持额外 mcp 配置(代码端)
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息

View File

@ -181,7 +181,7 @@ export function apiSuperAdminPut(url, data = {}, options = {}) {
* @param {boolean} requiresAuth - 是否需要认证
* @returns {Promise} - 请求结果
*/
export function apiDelete(url, options = {}, requiresAuth = false) {
export function apiDelete(url, options = {}, requiresAuth = true) {
return apiRequest(url, { method: 'DELETE', ...options }, requiresAuth)
}

View File

@ -41,6 +41,8 @@
--main-color: #016179;
/* 鲜艳一点的 */
--main-bright: #0188a6;
--secondry-color: #4e616d;
--error-color: #ba1a1a;

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
<div class="sidebar-header">
<div class="sidebar-title">
<SettingOutlined class="title-icon" />
<span>智能体配置</span>
<span>{{ selectedAgent.name || '未选择智能体' }} 配置</span>
</div>
<a-button
type="text"
@ -20,13 +20,12 @@
<div class="sidebar-content">
<div class="agent-info" v-if="selectedAgent">
<div class="agent-basic-info">
<h4>{{ selectedAgent.name || '未选择智能体' }}</h4>
<p class="agent-description">{{ selectedAgent.description }}</p>
<div class="debug-toggle" v-if="selectedAgent.name">
<!-- <div class="debug-toggle" v-if="selectedAgent.name">
<span class="debug-text">调试信息</span>
<a-switch v-model:checked="debugMode" size="small" />
</div>
<pre v-if="debugMode && selectedAgent.name" class="debug-info">{{ selectedAgent }}</pre>
<pre v-if="debugMode && selectedAgent.name" class="debug-info">{{ selectedAgent }}</pre> -->
</div>
<a-divider />
@ -72,7 +71,7 @@
v-else-if="key === 'system_prompt'"
:value="agentConfig[key]"
@update:value="(val) => agentStore.updateAgentConfig({ [key]: val })"
:rows="3"
:rows="10"
:placeholder="getPlaceholder(key, value)"
class="system-prompt-input"
/>
@ -209,9 +208,10 @@
<a-button type="primary" @click="saveConfig" class="save-btn">
保存配置
</a-button>
<a-button @click="resetConfig" class="reset-btn">
<!-- TODOBUG 目前有 bug 暂时不展示 -->
<!-- <a-button @click="resetConfig" class="reset-btn">
重置
</a-button>
</a-button> -->
</div>
</div>
</div>
@ -581,14 +581,6 @@ watch(() => props.isOpen, (newVal) => {
.agent-info {
.agent-basic-info {
margin-bottom: 16px;
h4 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
.agent-description {
margin: 0 0 12px 0;
@ -688,6 +680,13 @@ watch(() => props.isOpen, (newVal) => {
.system-prompt-input {
resize: vertical;
background: var(--gray-50);
border: 1px solid var(--gray-200);
padding: 8px 12px;
&:focus {
outline: none;
}
}
.config-select,

View File

@ -5,28 +5,28 @@
<!-- 助手消息 -->
<div v-else-if="message.type === 'ai'" class="assistant-message">
<div v-if="parsedMessage.reasoning_content" class="reasoning-box">
<div v-if="parsedData.reasoning_content" class="reasoning-box">
<a-collapse v-model:activeKey="reasoningActiveKey" :bordered="false">
<template #expandIcon="{ isActive }">
<caret-right-outlined :rotate="isActive ? 90 : 0" />
</template>
<a-collapse-panel key="show" :header="message.status=='reasoning' ? '正在思考...' : '推理过程'" class="reasoning-header">
<p class="reasoning-content">{{ parsedMessage.reasoning_content.trim() }}</p>
<p class="reasoning-content">{{ parsedData.reasoning_content }}</p>
</a-collapse-panel>
</a-collapse>
</div>
<!-- 消息内容 -->
<!-- <div v-else-if="message.content" v-html="renderMarkdown(message)" class="message-md"></div> -->
<MdPreview v-if="parsedMessage.content" ref="editorRef"
<MdPreview v-if="parsedData.content" ref="editorRef"
editorId="preview-only"
previewTheme="github"
:showCodeRowNumber="false"
:modelValue="parsedMessage.content"
:modelValue="parsedData.content"
:key="message.id"
class="message-md"/>
<div v-else-if="parsedMessage.reasoning_content" class="empty-block"></div>
<div v-else-if="parsedData.reasoning_content" class="empty-block"></div>
<div v-if="message.tool_calls && Object.keys(message.tool_calls).length > 0" class="tool-calls-container">
<div v-for="(toolCall, index) in message.tool_calls || {}" :key="index" class="tool-call-container">
@ -132,14 +132,6 @@ const emit = defineEmits(['retry', 'retryStoppedMessage', 'openRefs']);
const reasoningActiveKey = ref(['show']);
const expandedToolCalls = ref(new Set()); //
//
const isEmptyAndLoading = computed(() => {
const isEmpty = !props.message.content || props.message.content.length === 0;
const isLoading = props.message.status === 'init' && props.isProcessing
return isEmpty && isLoading;
});
// store
const agentStore = useAgentStore();
const { availableTools } = storeToRefs(agentStore);
@ -152,25 +144,26 @@ const getToolNameByToolCall = (toolCall) => {
return tool ? tool.name : toolId;
};
const parsedMessage = computed(() => {
const message = props.message;
if (message.content) {
// <think>...</think>
const thinkContent = message.content.match(/<think>(.*?)<\/think>/s);
if (thinkContent) {
message.reasoning_content = thinkContent[1];
message.content = message.content.replace(/<think>(.*?)<\/think>/s, '');
} else {
// <think>
const incompleteThinkContent = message.content.match(/<think>(.*?)$/s);
if (incompleteThinkContent) {
message.reasoning_content = incompleteThinkContent[1];
message.content = message.content.replace(/<think>(.*?)$/s, '');
}
}
const parsedData = computed(() => {
// Start with default values from the prop to avoid mutation.
let content = props.message.content || '';
let reasoning_content = props.message.additional_kwargs?.reasoning_content || '';
// Regex to find <think>...</think> or an unclosed <think>... at the end of the string.
const thinkRegex = /<think>(.*?)<\/think>|<think>(.*?)$/s;
const thinkMatch = content.match(thinkRegex);
if (thinkMatch) {
// The captured reasoning is in either group 1 (closed tag) or 2 (unclosed tag).
reasoning_content = (thinkMatch[1] || thinkMatch[2] || '').trim();
// Remove the entire matched <think> block from the original content.
content = content.replace(thinkMatch[0], '').trim();
}
message.reasoning_content = message.reasoning_content || message.additional_kwargs?.reasoning_content || '';
return message;
return {
content,
reasoning_content,
};
});
const toggleToolCall = (toolCallId) => {
@ -503,12 +496,12 @@ const toggleToolCall = (toolCallId) => {
}
</style>
<style lang="less">
.message-md {
<style lang="less" scoped>
:deep(.message-md) {
margin: 8px 0;
}
.message-md .md-editor-preview-wrapper {
:deep(.message-md .md-editor-preview-wrapper) {
max-width: 100%;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Noto Sans SC', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', 'Hiragino Sans GB', 'Source Han Sans CN', 'Courier New', monospace;
@ -516,7 +509,7 @@ const toggleToolCall = (toolCallId) => {
#preview-only-preview {
font-size: 1rem;
line-height: 1.75;
color: var(--gray-2000);
color: var(--gray-1000);
}
@ -533,15 +526,19 @@ const toggleToolCall = (toolCallId) => {
}
strong {
font-weight: 600;
font-weight: 500;
}
li > p, ol > p, ul > p {
margin: 0.25rem 0;
}
ol, ul {
padding-left: 1.2rem;
ol li::marker {
color: var(--main-color);
}
ul li::marker {
color: var(--main-bright);
}
cite {
@ -572,7 +569,7 @@ const toggleToolCall = (toolCallId) => {
}
}
.chat-box.font-smaller #preview-only-preview {
:deep(.chat-box.font-smaller #preview-only-preview) {
font-size: 14px;
h1, h2 {
@ -584,7 +581,7 @@ const toggleToolCall = (toolCallId) => {
}
}
.chat-box.font-larger #preview-only-preview {
:deep(.chat-box.font-larger #preview-only-preview) {
font-size: 16px;
h1, h2 {

View File

@ -284,7 +284,7 @@ const openAgentModal = () => {
gap: 8px;
&:hover {
background-color: var(--main-100);
background-color: var(--gray-100);
}
}
}
@ -415,11 +415,11 @@ const openAgentModal = () => {
justify-content: center;
align-items: center;
border-radius: 8px;
color: var(--gray-900);
padding: 0.5rem;
color: var(--gray-500);
// padding: 0.5rem;
transition: background-color 0.3s;
&:hover {
background-color: var(--gray-100);
color: var(--main-color);
}
}

View File

@ -5,9 +5,7 @@
class="database-info-header"
>
<template #left>
<a-button @click="backToDatabase">
<LeftOutlined />
</a-button>
<a-button @click="backToDatabase" shape="circle" :icon="h(LeftOutlined)" type="text"></a-button>
</template>
<template #behind-title>
<a-button type="link" @click="showEditModal" :style="{ padding: '0px', color: 'inherit' }">
@ -64,6 +62,7 @@ import {
DeleteOutlined,
} from '@ant-design/icons-vue';
import HeaderComponent from '@/components/HeaderComponent.vue';
import { h } from 'vue';
const router = useRouter();
const store = useDatabaseStore();
@ -111,7 +110,7 @@ const deleteDatabase = () => {
<style scoped>
.database-info-header {
padding: 8px 16px 6px 16px;
padding: 8px;
height: 50px;
}

View File

@ -310,6 +310,15 @@ import { getFileIcon, getFileIconColor, formatRelativeTime } from '@/utils/file_
.search-container {
display: flex;
align-items: center;
button {
padding: 0 8px;
}
button:hover {
background-color: var(--gray-50);
color: var(--main-color);
}
}
.panel-actions {

View File

@ -269,7 +269,7 @@ onBeforeUnmount(() => {
display: grid;
width: 100%;
margin: 0 auto;
border: 2px solid var(--gray-200);
border: 1px solid var(--gray-200);
border-radius: 0.8rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
@ -290,11 +290,11 @@ onBeforeUnmount(() => {
justify-self: end;
}
&:focus-within {
border-color: var(--main-500);
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
// &:focus-within {
// border-color: var(--main-500);
// background: white;
// box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
// }
&.single-line {
padding: 0.75rem 0.75rem;

View File

@ -1,5 +1,7 @@
import { defineStore } from 'pinia';
import { agentApi, threadApi } from '@/apis/agent';
import { MessageProcessor } from '@/utils/messageProcessor';
import { handleChatError } from '@/utils/errorHandler';
export const useAgentStore = defineStore('agent', {
state: () => ({
@ -20,6 +22,10 @@ export const useAgentStore = defineStore('agent', {
currentThreadId: null, // 当前选中的线程ID
threadMessages: {}, // 以线程ID为键的消息列表
// 对话状态
onGoingConv: { msgChunks: {} }, // 正在进行的对话(流式)
isStreaming: false, // 是否正在接收流式响应
// 加载状态
isLoadingAgents: false,
isLoadingConfig: false,
@ -35,75 +41,61 @@ export const useAgentStore = defineStore('agent', {
}),
getters: {
// 获取当前选中的智能体
selectedAgent() {
return this.selectedAgentId ? this.agents[this.selectedAgentId] : null;
},
// 获取默认智能体,默认取第一个
defaultAgent() {
return this.defaultAgentId ? this.agents[this.defaultAgentId] : this.agents[Object.keys(this.agents)[0]];
},
// 获取智能体列表(数组形式)
agentsList() {
return Object.values(this.agents);
},
// 判断当前智能体是否为默认智能体
isDefaultAgent() {
return this.selectedAgentId === this.defaultAgentId;
},
// 获取当前智能体的配置schema
configSchema() {
const agent = this.selectedAgentId ? this.agents[this.selectedAgentId] : null;
// --- 智能体相关 Getters ---
selectedAgent: (state) => state.selectedAgentId ? state.agents[state.selectedAgentId] : null,
defaultAgent: (state) => state.defaultAgentId ? state.agents[state.defaultAgentId] : state.agents[Object.keys(state.agents)[0]],
agentsList: (state) => Object.values(state.agents),
isDefaultAgent: (state) => state.selectedAgentId === state.defaultAgentId,
configSchema: (state) => {
const agent = state.selectedAgentId ? state.agents[state.selectedAgentId] : null;
return agent?.config_schema || {};
},
// 获取可配置项处理x_oap_ui_config
configurableItems() {
const schema = this.configSchema || {};
configurableItems: (state) => {
const schema = state.configSchema || {};
if (!schema || !schema.configurable_items) return {};
const items = { ...schema.configurable_items };
// 处理x_oap_ui_config将其提升到上一层
Object.keys(items).forEach(key => {
const item = items[key];
if (item && item.x_oap_ui_config) {
items[key] = {
...item,
...item.x_oap_ui_config
};
items[key] = { ...item, ...item.x_oap_ui_config };
delete items[key].x_oap_ui_config;
}
});
return items;
},
hasConfigChanges: (state) => JSON.stringify(state.agentConfig) !== JSON.stringify(state.originalAgentConfig),
// 检查配置是否有变更
hasConfigChanges() {
return JSON.stringify(this.agentConfig) !== JSON.stringify(this.originalAgentConfig);
// --- 线程与消息相关 Getters ---
currentAgentThreads: (state) => state.selectedAgentId ? (state.threads[state.selectedAgentId] || []) : [],
currentThread: (state) => {
if (!state.currentThreadId || !state.selectedAgentId) return null;
const agentThreads = state.threads[state.selectedAgentId] || [];
return agentThreads.find(thread => thread.id === state.currentThreadId);
},
currentThreadMessages: (state) => state.currentThreadId ? (state.threadMessages[state.currentThreadId] || []) : [],
// 获取当前智能体的线程列表
currentAgentThreads() {
return this.selectedAgentId ? (this.threads[this.selectedAgentId] || []) : [];
// --- 对话UI Getters ---
onGoingConvMessages: (state) => {
const msgs = Object.values(state.onGoingConv.msgChunks).map(MessageProcessor.mergeMessageChunk);
return msgs.length > 0
? MessageProcessor.convertToolResultToMessages(msgs).filter(msg => msg.type !== 'tool')
: [];
},
conversations: (state) => {
const historyConvs = MessageProcessor.convertServerHistoryToMessages(state.currentThreadMessages);
// Access onGoingConvMessages getter through state
const onGoingMessages = state.onGoingConvMessages;
// 获取当前线程
currentThread() {
if (!this.currentThreadId || !this.selectedAgentId) return null;
const agentThreads = this.threads[this.selectedAgentId] || [];
return agentThreads.find(thread => thread.id === this.currentThreadId);
if (onGoingMessages.length > 0) {
// Create a new conversation object for the ongoing messages
const onGoingConv = {
messages: onGoingMessages,
status: 'streaming'
};
return [...historyConvs, onGoingConv];
}
return historyConvs;
},
// 获取当前线程的消息
currentThreadMessages() {
return this.currentThreadId ? (this.threadMessages[this.currentThreadId] || []) : [];
}
},
actions: {
@ -355,6 +347,7 @@ export const useAgentStore = defineStore('agent', {
// 选择线程
selectThread(threadId) {
this.currentThreadId = threadId;
this.resetOnGoingConv();
// 如果没有该线程的消息,初始化空数组
if (threadId && !this.threadMessages[threadId]) {
@ -368,30 +361,110 @@ export const useAgentStore = defineStore('agent', {
this.isLoadingMessages = true;
this.error = null;
this.resetOnGoingConv();
try {
const response = await agentApi.getAgentHistory(this.selectedAgentId, threadId);
this.threadMessages[threadId] = response.history || [];
} catch (error) {
console.error('Failed to fetch thread messages:', error);
this.error = error.message;
handleChatError(error, 'load');
throw error;
} finally {
this.isLoadingMessages = false;
}
},
// 发送消息
async sendMessage(agentId, requestData) {
if (!agentId) return null;
// --- 流式对话 Actions ---
resetOnGoingConv() {
this.onGoingConv = { msgChunks: {} };
},
_processStreamChunk(chunk) {
const { status, msg, request_id, message } = chunk;
switch (status) {
case 'init':
this.onGoingConv.msgChunks[request_id] = [msg];
break;
case 'loading':
if (msg.id) {
if (!this.onGoingConv.msgChunks[msg.id]) {
this.onGoingConv.msgChunks[msg.id] = [];
}
this.onGoingConv.msgChunks[msg.id].push(msg);
}
break;
case 'error':
handleChatError({ message }, 'stream');
break;
case 'finished':
this.fetchThreadMessages(this.currentThreadId);
break;
}
},
// 发送消息并处理流式响应
async sendMessage(text) {
if (!this.selectedAgentId || !this.currentThreadId || !text) {
handleChatError({ message: "Missing agent, thread, or message text" }, 'send');
return;
}
this.isStreaming = true;
this.resetOnGoingConv();
// 如果是新对话,用消息内容作为标题
if (this.currentThreadMessages.length === 0) {
this.updateThread(this.currentThreadId, text);
}
const requestData = {
query: text,
config: {
thread_id: this.currentThreadId,
},
};
try {
const response = await agentApi.sendAgentMessage(agentId, requestData);
return response;
const response = await agentApi.sendAgentMessage(this.selectedAgentId, requestData);
const reader = response.body.getReader();
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.trim()) {
try {
const chunk = JSON.parse(line.trim());
this._processStreamChunk(chunk);
} catch (e) {
console.warn('Failed to parse stream chunk JSON:', e);
}
}
}
}
// Process any remaining data in the buffer
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer.trim());
this._processStreamChunk(chunk);
} catch (e) {
console.warn('Failed to parse final stream chunk JSON:', e);
}
}
} catch (error) {
console.error('Failed to send message:', error);
this.error = error.message;
throw error;
handleChatError(error, 'send');
} finally {
this.isStreaming = false;
}
},

View File

@ -0,0 +1,126 @@
/**
* AgentID验证工具类
* 统一处理AgentID相关的验证逻辑
*/
export class AgentValidator {
/**
* 验证AgentID是否存在
* @param {string} agentId - 要验证的AgentID
* @param {string} operation - 操作名称用于错误提示
* @returns {boolean} 验证是否通过
*/
static validateAgentId(agentId, operation = '操作') {
if (!agentId) {
console.warn(`未指定AgentID无法${operation}`);
return false;
}
return true;
}
/**
* 验证AgentID并显示错误提示
* @param {string} agentId - 要验证的AgentID
* @param {string} operation - 操作名称
* @param {Function} errorHandler - 错误处理函数
* @returns {boolean} 验证是否通过
*/
static validateAgentIdWithError(agentId, operation, errorHandler) {
if (!agentId) {
const message = `未指定AgentID无法${operation}`;
if (errorHandler) {
errorHandler(message);
}
return false;
}
return true;
}
/**
* 验证对话相关操作的前置条件
* @param {string} agentId - AgentID
* @param {string} chatId - 对话ID可选
* @param {string} operation - 操作名称
* @param {Function} errorHandler - 错误处理函数
* @returns {boolean} 验证是否通过
*/
static validateChatOperation(agentId, chatId, operation, errorHandler) {
// 验证AgentID
if (!this.validateAgentIdWithError(agentId, operation, errorHandler)) {
return false;
}
// 如果需要验证chatId
if (chatId !== undefined && !chatId) {
const message = `请先选择对话`;
if (errorHandler) {
errorHandler(message);
}
return false;
}
return true;
}
/**
* 验证重命名操作的参数
* @param {string} chatId - 对话ID
* @param {string} title - 新标题
* @param {string} agentId - AgentID
* @param {Function} errorHandler - 错误处理函数
* @returns {boolean} 验证是否通过
*/
static validateRenameOperation(chatId, title, agentId, errorHandler) {
// 验证基本参数
if (!chatId || !title) {
const message = '未指定对话ID或标题无法重命名对话';
if (errorHandler) {
errorHandler(message);
}
return false;
}
// 验证标题不为空
if (!title.trim()) {
const message = '标题不能为空';
if (errorHandler) {
errorHandler(message);
}
return false;
}
// 验证AgentID
return this.validateAgentIdWithError(agentId, '重命名对话', errorHandler);
}
/**
* 验证分享操作的前置条件
* @param {string} chatId - 对话ID
* @param {Object} agent - 当前智能体对象
* @param {Function} errorHandler - 错误处理函数
* @returns {boolean} 验证是否通过
*/
static validateShareOperation(chatId, agent, errorHandler) {
if (!chatId || !agent) {
const message = '请先选择对话';
if (errorHandler) {
errorHandler(message);
}
return false;
}
return true;
}
/**
* 验证加载操作的前置条件
* @param {string} agentId - AgentID
* @param {string} operation - 操作名称
* @returns {boolean} 验证是否通过
*/
static validateLoadOperation(agentId, operation = '加载状态') {
if (!agentId) {
console.warn(`未指定AgentID无法${operation}`);
return false;
}
return true;
}
}

View File

@ -0,0 +1,522 @@
/**
* 聊天对话HTML导出工具类
* 用于将聊天对话导出为HTML文件
*/
export class ChatExporter {
/**
* 导出聊天对话为HTML文件
* @param {Object} options 导出选项
* @param {string} options.chatTitle 对话标题
* @param {string} options.agentName 智能体名称
* @param {string} options.agentDescription 智能体描述
* @param {Array} options.messages 消息列表
* @param {Array} options.onGoingMessages 进行中的消息列表
*/
static async exportToHTML(options) {
const {
chatTitle = '新对话',
agentName = '智能助手',
agentDescription = '',
messages = [],
onGoingMessages = []
} = options;
try {
// 生成HTML内容
const htmlContent = this.generateHTML({
chatTitle,
agentName,
agentDescription,
messages,
onGoingMessages
});
// 创建下载链接
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
// 生成文件名
const timestamp = new Date().toLocaleString('zh-CN').replace(/[:/\s]/g, '-');
const filename = `${chatTitle}-${timestamp}.html`;
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理URL对象
URL.revokeObjectURL(url);
return { success: true, filename };
} catch (error) {
console.error('导出对话失败:', error);
throw new Error(`导出失败: ${error.message}`);
}
}
/**
* 生成HTML内容
* @param {Object} options 生成选项
* @returns {string} HTML内容
*/
static generateHTML(options) {
const {
chatTitle,
agentName,
agentDescription,
messages,
onGoingMessages
} = options;
const exportTime = new Date().toLocaleString('zh-CN');
// 合并所有消息
const allMessages = [];
// 添加历史对话消息
messages.forEach(conv => {
if (conv.messages) {
conv.messages.forEach(msg => {
allMessages.push(msg);
});
}
});
// 添加当前进行中的对话消息
onGoingMessages.forEach(msg => {
allMessages.push(msg);
});
if (allMessages.length === 0) {
throw new Error('没有可导出的对话内容');
}
// 生成消息HTML
const messagesHTML = this.generateMessagesHTML(allMessages, agentName);
// 返回完整的HTML文档
return this.generateHTMLTemplate({
chatTitle,
agentName,
agentDescription,
exportTime,
messagesHTML
});
}
/**
* 生成消息HTML
* @param {Array} messages 消息列表
* @param {string} agentName 智能体名称
* @returns {string} 消息HTML
*/
static generateMessagesHTML(messages, agentName) {
return messages.map((msg, index) => {
const isUser = msg.type === 'human';
const avatar = isUser ? '👤' : '🤖';
const senderName = isUser ? '用户' : agentName;
const messageClass = isUser ? 'user-message' : 'ai-message';
// 处理消息内容
let content = (msg.content || '').trim();
content = content.replace(/\n/g, '<br>');
// 处理思考过程
const reasoningHTML = this.generateReasoningHTML(msg, isUser);
// 处理工具调用
const toolCallsHTML = this.generateToolCallsHTML(msg);
return `
<div class="message ${messageClass}">
<div class="message-header">
<span class="avatar">${avatar}</span>
<span class="sender">${senderName}</span>
<span class="time">${new Date().toLocaleString('zh-CN')}</span>
</div>
<div class="message-content">
${reasoningHTML}
${content}
${toolCallsHTML}
</div>
</div>
`;
}).join('');
}
/**
* 生成思考过程HTML
* @param {Object} msg 消息对象
* @param {boolean} isUser 是否为用户消息
* @returns {string} 思考过程HTML
*/
static generateReasoningHTML(msg, isUser) {
if (isUser) return '';
const reasoningContent = msg.additional_kwargs?.reasoning_content || msg.reasoning_content;
if (!reasoningContent) return '';
const content = reasoningContent.trim().replace(/\n/g, '<br>');
return `
<div class="reasoning-section">
<div class="reasoning-header">💭 思考过程</div>
<div class="reasoning-content">${content}</div>
</div>
`;
}
/**
* 生成工具调用HTML
* @param {Object} msg 消息对象
* @returns {string} 工具调用HTML
*/
static generateToolCallsHTML(msg) {
if (!msg.tool_calls || msg.tool_calls.length === 0) return '';
let toolCallsHTML = '<div class="tool-calls">';
msg.tool_calls.forEach(toolCall => {
const args = toolCall.function?.arguments
? JSON.parse(toolCall.function.arguments)
: toolCall?.args || '{}';
toolCallsHTML += `
<div class="tool-call">
<div class="tool-call-header">
<strong>🔧 ${toolCall.function?.name || '工具调用'}</strong>
</div>
<div class="tool-call-args">
<pre>${JSON.stringify(args, null, 2)}</pre>
</div>
${toolCall.tool_call_result ? `
<div class="tool-call-result">
<div class="tool-result-header">执行结果:</div>
<div class="tool-result-content">${toolCall.tool_call_result.content || ''}</div>
</div>
` : ''}
</div>
`;
});
toolCallsHTML += '</div>';
return toolCallsHTML;
}
/**
* 生成完整的HTML模板
* @param {Object} options 模板选项
* @returns {string} 完整的HTML文档
*/
static generateHTMLTemplate(options) {
const { chatTitle, agentName, agentDescription, exportTime, messagesHTML } = options;
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${chatTitle} - 对话导出</title>
<style>
${this.getCSS()}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>${chatTitle}</h1>
<div class="agent-info">
<strong>智能体:</strong> ${agentName}
${agentDescription ? `<br><strong>描述:</strong> ${agentDescription}` : ''}
</div>
<div class="export-info">
导出时间: ${exportTime}
</div>
</div>
<div class="messages">
${messagesHTML}
</div>
<div class="footer">
<p>此对话由 <a href="#" target="_blank">智能助手平台</a> </p>
</div>
</div>
</body>
</html>`;
}
/**
* 获取CSS样式
* @returns {string} CSS样式字符串
*/
static getCSS() {
return `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: white;
margin: 0;
padding: 0;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
min-height: 100vh;
}
.header {
background: #f8f9fa;
border-bottom: 2px solid #e9ecef;
padding: 24px;
text-align: center;
}
.header h1 {
font-size: 24px;
margin-bottom: 8px;
color: #212529;
font-weight: 600;
}
.header .agent-info {
font-size: 14px;
color: #6c757d;
margin-bottom: 12px;
}
.header .export-info {
font-size: 12px;
color: #868e96;
padding-top: 12px;
border-top: 1px solid #dee2e6;
}
.messages {
padding: 32px 48px;
max-width: 100%;
}
.message {
margin-bottom: 32px;
max-width: 100%;
}
.message:last-child {
margin-bottom: 0;
}
.message-header {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: #666;
}
.avatar {
font-size: 16px;
margin-right: 8px;
}
.sender {
font-weight: 600;
margin-right: 12px;
}
.time {
font-size: 12px;
color: #999;
}
.message-content {
padding: 16px 20px;
border-radius: 8px;
width: 100%;
max-width: 100%;
}
.user-message .message-content {
color: white;
background: #1C6586;
border: 1px solid #1C6586;
width: fit-content;
}
.ai-message .message-content {
background: white;
border: 1px solid #e9ecef;
}
.reasoning-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.reasoning-header {
font-size: 13px;
font-weight: 600;
color: #495057;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.reasoning-content {
font-size: 14px;
color: #6c757d;
font-style: italic;
line-height: 1.5;
}
.tool-calls {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e9ecef;
}
.tool-call {
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.tool-call:last-child {
margin-bottom: 0;
}
.tool-call-header {
font-size: 14px;
color: #f57f17;
margin-bottom: 8px;
font-weight: 600;
}
.tool-call-args {
background: rgba(0,0,0,0.04);
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
}
.tool-call-args pre {
font-size: 12px;
color: #666;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.tool-call-result {
background: #e8f5e8;
border: 1px solid #c8e6c9;
border-radius: 4px;
padding: 8px;
word-break: break-all;
}
.tool-result-header {
font-size: 12px;
color: #2e7d32;
font-weight: 600;
margin-bottom: 4px;
}
.tool-result-content {
font-size: 13px;
color: #388e3c;
}
.footer {
background: #f8f9fa;
text-align: center;
padding: 16px;
font-size: 12px;
color: #666;
border-top: 1px solid #e9ecef;
}
.footer a {
color: #007bff;
text-decoration: none;
}
@media (max-width: 768px) {
.messages {
padding: 24px 16px;
}
.header {
padding: 16px;
}
.user-message .message-content {
margin-left: 10%;
}
.ai-message .message-content {
margin-right: 10%;
}
}
@media (max-width: 480px) {
.user-message .message-content,
.ai-message .message-content {
margin-left: 0;
margin-right: 0;
}
}
@media print {
body {
background: white;
margin: 0;
padding: 0;
}
.container {
box-shadow: none;
border-radius: 0;
max-width: 100%;
}
.header {
background: #f8f9fa !important;
-webkit-print-color-adjust: exact;
}
.messages {
padding: 20px;
}
.user-message .message-content {
background: #e3f2fd !important;
-webkit-print-color-adjust: exact;
}
.reasoning-section {
background: #f8f9fa !important;
-webkit-print-color-adjust: exact;
}
}
`;
}
}
export default ChatExporter;

View File

@ -0,0 +1,148 @@
import { message } from 'ant-design-vue';
/**
* 统一错误处理工具类
*/
export class ErrorHandler {
/**
* 处理通用错误
* @param {Error} error - 错误对象
* @param {string} context - 错误上下文
* @param {Object} options - 配置选项
*/
static handleError(error, context = '操作', options = {}) {
const {
showMessage = true,
logToConsole = true,
customMessage = null,
severity = 'error'
} = options;
// 控制台日志
if (logToConsole) {
console.error(`${context}失败:`, error);
}
// 用户提示
if (showMessage) {
const displayMessage = customMessage || this.getErrorMessage(error, context);
switch (severity) {
case 'warning':
message.warning(displayMessage);
break;
case 'info':
message.info(displayMessage);
break;
case 'error':
default:
message.error(displayMessage);
break;
}
}
return error;
}
/**
* 获取错误消息
* @param {Error} error - 错误对象
* @param {string} context - 错误上下文
* @returns {string} 错误消息
*/
static getErrorMessage(error, context) {
if (error?.message) {
return `${context}失败: ${error.message}`;
}
return `${context}失败`;
}
/**
* 处理网络请求错误
* @param {Error} error - 错误对象
* @param {string} context - 错误上下文
*/
static handleNetworkError(error, context = '网络请求') {
let customMessage = null;
if (error?.code === 'NETWORK_ERROR') {
customMessage = '网络连接失败,请检查网络设置';
} else if (error?.status === 401) {
customMessage = '认证失败,请重新登录';
} else if (error?.status === 403) {
customMessage = '权限不足,无法执行此操作';
} else if (error?.status === 404) {
customMessage = '请求的资源不存在';
} else if (error?.status >= 500) {
customMessage = '服务器错误,请稍后重试';
}
return this.handleError(error, context, { customMessage });
}
/**
* 处理聊天相关错误
* @param {Error} error - 错误对象
* @param {string} operation - 操作类型
*/
static handleChatError(error, operation) {
const contextMap = {
'send': '发送消息',
'create': '创建对话',
'delete': '删除对话',
'rename': '重命名对话',
'load': '加载对话',
'export': '导出对话',
'stream': '流式处理'
};
const context = contextMap[operation] || operation;
return this.handleError(error, context);
}
/**
* 处理验证错误
* @param {string} message - 验证错误消息
*/
static handleValidationError(message) {
return this.handleError(
new Error(message),
'输入验证',
{ severity: 'warning', customMessage: message }
);
}
/**
* 处理异步操作错误
* @param {Function} asyncFn - 异步函数
* @param {string} context - 错误上下文
* @param {Object} options - 配置选项
*/
static async handleAsync(asyncFn, context, options = {}) {
try {
return await asyncFn();
} catch (error) {
this.handleError(error, context, options);
throw error;
}
}
/**
* 创建错误处理装饰器
* @param {string} context - 错误上下文
* @param {Object} options - 配置选项
*/
static createHandler(context, options = {}) {
return (error) => this.handleError(error, context, options);
}
}
/**
* 快捷方法
*/
export const handleChatError = ErrorHandler.handleChatError.bind(ErrorHandler);
export const handleNetworkError = ErrorHandler.handleNetworkError.bind(ErrorHandler);
export const handleValidationError = ErrorHandler.handleValidationError.bind(ErrorHandler);
export const handleAsync = ErrorHandler.handleAsync.bind(ErrorHandler);
export default ErrorHandler;

View File

@ -0,0 +1,248 @@
/**
* 消息处理工具类
*/
export class MessageProcessor {
/**
* 将工具结果与消息合并
* @param {Array} msgs - 消息数组
* @returns {Array} 合并后的消息数组
*/
static convertToolResultToMessages(msgs) {
const toolResponseMap = new Map();
// 构建工具响应映射
for (const item of msgs) {
if (item.type === 'tool' && item.tool_call_id) {
toolResponseMap.set(item.tool_call_id, item);
}
}
// 合并工具调用和响应
const convertedMsgs = msgs.map(item => {
if (item.type === 'ai' && item.tool_calls && item.tool_calls.length > 0) {
return {
...item,
tool_calls: item.tool_calls.map(toolCall => {
const toolResponse = toolResponseMap.get(toolCall.id);
return {
...toolCall,
tool_call_result: toolResponse || null
};
})
};
}
return item;
});
return convertedMsgs;
}
/**
* 将服务器历史记录转换为对话格式
* @param {Array} serverHistory - 服务器历史记录
* @returns {Array} 对话数组
*/
static convertServerHistoryToMessages(serverHistory) {
// 第一步将所有tool消息与对应的tool call合并
const mergedHistory = this.convertToolResultToMessages(serverHistory);
// 第二步:按照对话分组
const conversations = [];
let currentConv = null;
for (const item of mergedHistory) {
if (item.type === 'human') {
currentConv = {
messages: [item],
status: 'loading'
};
conversations.push(currentConv);
} else if (item.type === 'ai' && currentConv) {
currentConv.messages.push(item);
if (item.response_metadata?.finish_reason === 'stop') {
item.isLast = true;
currentConv.status = 'finished';
currentConv = null;
}
}
}
return conversations;
}
/**
* 合并消息块
* @param {Array} chunks - 消息块数组
* @returns {Object|null} 合并后的消息
*/
static mergeMessageChunk(chunks) {
if (chunks.length === 0) return null;
// 深拷贝第一个chunk作为结果
const result = JSON.parse(JSON.stringify(chunks[0]));
result.content = result.content || '';
// 合并后续chunks
for (let i = 1; i < chunks.length; i++) {
const chunk = chunks[i];
// 合并内容
if (chunk.content) {
result.content += chunk.content;
}
// 合并reasoning_content
if (chunk.reasoning_content) {
if (!result.reasoning_content) {
result.reasoning_content = '';
}
result.reasoning_content += chunk.reasoning_content;
}
// 合并additional_kwargs中的reasoning_content
if (chunk.additional_kwargs?.reasoning_content) {
if (!result.additional_kwargs) result.additional_kwargs = {};
if (!result.additional_kwargs.reasoning_content) {
result.additional_kwargs.reasoning_content = '';
}
result.additional_kwargs.reasoning_content += chunk.additional_kwargs.reasoning_content;
}
// 合并tool_calls
MessageProcessor._mergeToolCalls(result, chunk);
}
// 处理AIMessageChunk类型
if (result.type === 'AIMessageChunk') {
result.type = 'ai';
if (result.additional_kwargs?.tool_calls) {
result.tool_calls = result.additional_kwargs.tool_calls;
}
}
return result;
}
/**
* 合并工具调用
* @private
* @param {Object} result - 结果对象
* @param {Object} chunk - 当前块
*/
static _mergeToolCalls(result, chunk) {
if (chunk.additional_kwargs?.tool_calls) {
if (!result.additional_kwargs) result.additional_kwargs = {};
if (!result.additional_kwargs.tool_calls) result.additional_kwargs.tool_calls = [];
for (const toolCall of chunk.additional_kwargs.tool_calls) {
const existingToolCall = result.additional_kwargs.tool_calls.find(
t => (t.id === toolCall.id || t.index === toolCall.index)
);
if (existingToolCall) {
// 合并相同ID的tool call
if (existingToolCall.function && toolCall.function) {
existingToolCall.function.arguments += toolCall.function.arguments;
}
} else {
// 添加新的tool call
result.additional_kwargs.tool_calls.push(JSON.parse(JSON.stringify(toolCall)));
}
}
}
}
/**
* 处理流式响应数据块
* @param {Object} data - 响应数据
* @param {Object} onGoingConv - 进行中的对话对象
* @param {Object} state - 状态对象
* @param {Function} getAgentHistory - 获取历史记录函数
* @param {Function} handleError - 错误处理函数
*/
static async processResponseChunk(data, onGoingConv, state, getAgentHistory, handleError) {
try {
switch (data.status) {
case 'init':
// 代表服务端收到请求并返回第一个响应
state.waitingServerResponse = false;
onGoingConv.msgChunks[data.request_id] = [data.msg];
break;
case 'loading':
if (data.msg.id) {
if (!onGoingConv.msgChunks[data.msg.id]) {
onGoingConv.msgChunks[data.msg.id] = [];
}
onGoingConv.msgChunks[data.msg.id].push(data.msg);
}
break;
case 'error':
console.error("流式处理出错:", data.message);
handleError(new Error(data.message), 'stream');
break;
case 'finished':
await getAgentHistory();
break;
default:
console.warn('未知的响应状态:', data.status);
}
} catch (error) {
handleError(error, 'stream');
}
}
/**
* 处理流式响应
* @param {Response} response - 响应对象
* @param {Function} processChunk - 处理块的函数
* @param {Function} scrollToBottom - 滚动到底部函数
* @param {Function} handleError - 错误处理函数
*/
static async handleStreamResponse(response, processChunk, scrollToBottom, handleError) {
try {
const reader = response.body.getReader();
let buffer = '';
const decoder = new TextDecoder();
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.trim()) {
try {
const data = JSON.parse(line.trim());
await processChunk(data);
} catch (e) {
console.debug('解析JSON出错:', e.message);
}
}
}
await scrollToBottom();
}
// 处理缓冲区中可能剩余的内容
if (buffer.trim()) {
try {
const data = JSON.parse(buffer.trim());
await processChunk(data);
} catch (e) {
console.warn('最终缓冲区内容无法解析:', buffer);
}
}
} catch (error) {
handleError(error, 'stream');
}
}
}
export default MessageProcessor;

View File

@ -0,0 +1,152 @@
import { nextTick } from 'vue';
/**
* 滚动控制工具类
*/
export class ScrollController {
constructor(containerSelector = '.chat', options = {}) {
this.containerSelector = containerSelector;
this.options = {
threshold: 100,
scrollDelay: 150,
retryDelays: [50, 150, 300],
...options
};
this.scrollTimer = null;
this.isUserScrolling = false;
this.shouldAutoScroll = true;
// Bind the context of 'this' for the event handler
this.handleScroll = this.handleScroll.bind(this);
}
/**
* 获取滚动容器
* @returns {Element|null}
*/
getContainer() {
return document.querySelector(this.containerSelector);
}
/**
* 检查是否在底部
* @returns {boolean}
*/
isAtBottom() {
const container = this.getContainer();
if (!container) return false;
const { threshold } = this.options;
return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
}
/**
* 处理滚动事件
*/
handleScroll() {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
// 标记用户正在滚动
this.isUserScrolling = true;
// 检查是否在底部
const atBottom = this.isAtBottom();
this.shouldAutoScroll = atBottom;
// 滚动结束后一段时间重置用户滚动状态
this.scrollTimer = setTimeout(() => {
this.isUserScrolling = false;
}, this.options.scrollDelay);
}
/**
* 智能滚动到底部
* @param {boolean} force - 是否强制滚动
*/
async scrollToBottom(force = false) {
await nextTick();
// 只有在应该自动滚动时才执行(除非强制)
if (!force && !this.shouldAutoScroll) return;
const container = this.getContainer();
if (!container) return;
const scrollOptions = {
top: container.scrollHeight,
behavior: 'smooth'
};
// 立即滚动
container.scrollTo(scrollOptions);
// 多次重试确保滚动成功
this.options.retryDelays.forEach((delay, index) => {
setTimeout(() => {
if (force || this.shouldAutoScroll) {
const behavior = index === this.options.retryDelays.length - 1 ? 'auto' : 'smooth';
container.scrollTo({
top: container.scrollHeight,
behavior
});
}
}, delay);
});
}
/**
* 启用自动滚动
*/
enableAutoScroll() {
this.shouldAutoScroll = true;
}
/**
* 禁用自动滚动
*/
disableAutoScroll() {
this.shouldAutoScroll = false;
}
/**
* 获取滚动状态
*/
getScrollState() {
return {
isUserScrolling: this.isUserScrolling,
shouldAutoScroll: this.shouldAutoScroll,
isAtBottom: this.isAtBottom()
};
}
/**
* 清理定时器
*/
cleanup() {
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
this.scrollTimer = null;
}
}
/**
* 重置滚动状态
*/
reset() {
this.cleanup();
this.isUserScrolling = false;
this.shouldAutoScroll = true;
}
}
/**
* 创建默认的滚动控制器实例
*/
export const createScrollController = (containerSelector, options) => {
return new ScrollController(containerSelector, options);
};
export default ScrollController;

View File

@ -63,6 +63,9 @@
ok-text="添加到图数据库" cancel-text="取消"
:confirm-loading="state.processing">
<div class="upload">
<div class="note">
<p>上传的文件内容参考 test/data/A_Dream_of_Red_Mansions_tiny.jsonl 中的格式</p>
</div>
<a-upload-dragger
class="upload-dragger"
v-model:fileList="fileList"