refactor(智能体组件): 重构部分组件,优化部分样式
This commit is contained in:
parent
eb04d1f2c4
commit
6ae61f6652
@ -28,6 +28,7 @@
|
||||
|
||||
- [ ] 使用其他的聊天记录管理方法(现在是基于 LangGraph 的 Memory 实现的)
|
||||
- [ ] 添加测试脚本,覆盖最常见的功能
|
||||
- [ ] 添加数据库迁移的脚本
|
||||
- [ ] 封装现有工具为 mcp(stdio)调用
|
||||
- [ ] 支持额外 mcp 配置(代码端)
|
||||
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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">
|
||||
<!-- TODO:BUG 目前有 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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
126
web/src/utils/agentValidator.js
Normal file
126
web/src/utils/agentValidator.js
Normal 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;
|
||||
}
|
||||
}
|
||||
522
web/src/utils/chatExporter.js
Normal file
522
web/src/utils/chatExporter.js
Normal 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;
|
||||
148
web/src/utils/errorHandler.js
Normal file
148
web/src/utils/errorHandler.js
Normal 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;
|
||||
248
web/src/utils/messageProcessor.js
Normal file
248
web/src/utils/messageProcessor.js
Normal 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;
|
||||
152
web/src/utils/scrollController.js
Normal file
152
web/src/utils/scrollController.js
Normal 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;
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user