Files
ai-agent-admin/web/apps/web-ele/src/components/ai-chat-panel/AiChatPanel.vue
T

936 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type {
AiChatPanelProps,
Attachment,
ChatMessage,
DesignPreviewData,
ReasoningStep,
WorkflowVariable,
} from './types';
/**
* AI Chat Panel - 统一聊天面板组件
*
* 支持三种模式:
* - workflow: 工作流编辑器调试模式(使用草稿版本)
* - agent-debug: 智能体编辑器调试模式
* - agent-chat: 智能体正式对话模式(支持历史、反馈)
*/
import { computed, onUnmounted, ref, watch } from 'vue';
import { Close, Setting } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { AiWorkingAnimation } from '#/components/ai-loading';
import { ChatBox } from '#/components/ChatBox';
import {
DesignEditorPanel,
SystemSummaryConfirmPanel,
} from '#/components/form-editor';
import { useChatApi } from './composables/useChatApi';
import { useEventHandler } from './composables/useEventHandler';
// ==================== Props ====================
const props = withDefaults(defineProps<AiChatPanelProps>(), {
mode: 'workflow',
useDraft: true,
enableVariablesForm: undefined,
enableHistory: undefined,
enableFeedback: false,
enableWelcome: undefined,
enableNodeEvents: undefined,
showClearButton: true,
showCloseButton: true,
showHeader: true,
showAvatar: true,
enableVoice: true,
enableAttachment: false,
enableImage: false,
layout: 'sidebar',
});
// ==================== Emits ====================
const emit = defineEmits<{
(e: 'node-start', data: any): void;
(e: 'node-complete', data: any): void;
(e: 'node-streaming', data: any): void;
(e: 'loop-iteration', data: any): void;
(e: 'clear-status'): void;
(e: 'conversation-created', conversationId: string): void;
(e: 'close'): void;
(e: 'message-sent', message: string): void;
(e: 'message-complete', conversationId: null | string): void;
}>();
// ==================== 计算默认值 ====================
const enableVariablesForm = computed(
() => props.enableVariablesForm ?? props.mode === 'workflow',
);
const enableHistory = computed(
() => props.enableHistory ?? props.mode === 'agent-chat',
);
const enableFeedback = computed(
() => props.enableFeedback ?? props.mode === 'agent-chat',
);
const enableWelcome = computed(
() => props.enableWelcome ?? props.mode.startsWith('agent'),
);
const enableNodeEvents = computed(
() => props.enableNodeEvents ?? props.mode === 'workflow',
);
// ==================== 计算 UI 配置 ====================
const panelTitle = computed(() => {
if (props.title) return props.title;
switch (props.mode) {
case 'agent-chat': {
return props.agent?.name || '对话';
}
case 'agent-debug': {
return '调试预览';
}
case 'workflow': {
return '运行预览';
}
default: {
return '对话';
}
}
});
const placeholderText = computed(() => {
if (props.placeholder) return props.placeholder;
if (props.disabled) return '请先发布后再进行对话';
switch (props.mode) {
case 'agent-chat': {
return '输入消息开始对话...';
}
case 'agent-debug': {
return '输入消息进行调试...';
}
case 'workflow': {
return '输入消息运行工作流...';
}
default: {
return '输入消息...';
}
}
});
const emptyText = computed(() => {
if (props.emptyText) return props.emptyText;
switch (props.mode) {
case 'agent-chat':
case 'agent-debug': {
return '开始对话吧';
}
case 'workflow': {
return '输入消息开始运行工作流';
}
default: {
return '开始对话吧';
}
}
});
const assistantName = computed(() => {
if (props.assistantName) return props.assistantName;
switch (props.mode) {
case 'agent-chat':
case 'agent-debug': {
return props.agent?.name || '助手';
}
case 'workflow': {
return '工作流';
}
default: {
return '助手';
}
}
});
// ==================== 欢迎配置 ====================
const welcomeConfig = computed(() => {
if (!enableWelcome.value || !props.agent) return undefined;
const isPublished = props.agent.status === 'published';
return {
title: props.agent.name,
description: isPublished
? props.agent.welcome_message || '发送消息开始对话'
: '请先发布智能体后再进行对话',
suggestions: isPublished ? props.agent.suggested_questions : [],
};
});
// ==================== 核心状态 ====================
const messages = ref<ChatMessage[]>([]);
const running = ref(false);
const streamingContent = ref('');
const currentSteps = ref<ReasoningStep[]>([]);
const currentRunId = ref('');
const currentAssistantMsgId = ref<null | string>(null);
const waitingForInput = ref(false);
const waitingConfig = ref<any>(null);
let cancelStream: (() => void) | null = null;
// ==================== 多变量输入对话框 ====================
const variablesDialogVisible = ref(false);
const variablesForm = ref<Record<string, any>>({});
const pendingMessage = ref('');
// ==================== 设计预览面板状态 ====================
const showDesignPanel = ref(false);
const currentDesign = ref<DesignPreviewData | undefined>(undefined);
const showSystemSummaryPanel = ref(false);
const currentSystemSummary = ref<DesignPreviewData | undefined>(undefined);
// AI 工作动画状态(用于 application 类型工作流)
const showLoadingAnimation = ref(false);
const loadingAnimationTitle = ref('AI 正在开发...');
// 是否有任何设计面板打开
const hasAnyDesignPanelOpen = computed(
() =>
showDesignPanel.value ||
showSystemSummaryPanel.value ||
showLoadingAnimation.value,
);
// ==================== 工作流变量 ====================
const startNode = computed(() => props.nodes?.find((n) => n.type === 'start'));
const variables = computed<WorkflowVariable[]>(
() => startNode.value?.data?.variables || [],
);
const hasMultipleVariables = computed(() => variables.value.length > 1);
// 初始化变量表单
watch(
variables,
(vars) => {
const newForm: Record<string, any> = {};
vars.forEach((v) => {
newForm[v.variable] =
variablesForm.value[v.variable] || v.default_value || '';
});
variablesForm.value = newForm;
},
{ immediate: true },
);
// ==================== API 适配器 ====================
const { adapter: chatApi, conversationId } = useChatApi(props);
// 创建一个类型安全的 conversationId 引用用于事件处理器
const safeConversationId = ref<null | string>(conversationId.value ?? null);
watch(conversationId, (newId) => {
safeConversationId.value = newId ?? null;
});
// 监听 running 状态变化,执行完成时关闭加载动画
watch(running, (isRunning) => {
if (!isRunning && showLoadingAnimation.value) {
showLoadingAnimation.value = false;
}
});
// ==================== 事件处理器 ====================
const generateId = () =>
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const handleDesignPreview = (data: DesignPreviewData) => {
// 关闭加载动画
showLoadingAnimation.value = false;
switch (data.type) {
case 'system_summary': {
currentSystemSummary.value = data;
showSystemSummaryPanel.value = true;
break;
}
default: {
currentDesign.value = data;
showDesignPanel.value = true;
break;
}
}
};
const { handleStreamEvent, updateAssistantMessage } = useEventHandler({
props: {
...props,
enableNodeEvents: enableNodeEvents.value,
},
emit: emit as any,
messages,
currentSteps,
currentRunId,
currentAssistantMsgId,
waitingForInput,
waitingConfig,
running,
conversationId: safeConversationId,
onDesignPreview: handleDesignPreview,
});
// ==================== 加载历史消息 ====================
watch(
() => props.conversationId,
async (newId) => {
if (enableHistory.value && newId && chatApi.loadHistory) {
try {
const history = await chatApi.loadHistory(newId);
messages.value = history;
} catch (error) {
console.error('加载历史消息失败:', error);
}
} else if (!newId) {
messages.value = [];
}
},
{ immediate: true },
);
// ==================== 发送消息 ====================
// 存储待发送的附件
const pendingAttachments = ref<Attachment[]>([]);
const handleSend = (text: string, attachments?: Attachment[]) => {
if (!text || running.value) return;
emit('message-sent', text);
// 如果正在等待用户输入,恢复执行
if (waitingForInput.value && currentRunId.value) {
const lastAssistantMsg = messages.value.findLast(
(m) => m.role === 'assistant',
);
if (lastAssistantMsg?.interaction) {
const msgIndex = messages.value.findIndex(
(m) => m.id === lastAssistantMsg.id,
);
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...lastAssistantMsg,
interaction: undefined,
};
}
}
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: text,
timestamp: new Date(),
};
messages.value.push(userMsg);
waitingForInput.value = false;
waitingConfig.value = null;
resumeExecution(text);
return;
}
// 工作流模式:检查多变量
if (
props.mode === 'workflow' &&
enableVariablesForm.value &&
hasMultipleVariables.value
) {
pendingMessage.value = text;
pendingAttachments.value = attachments || [];
const firstVar = variables.value[0];
if (firstVar) {
variablesForm.value[firstVar.variable] = text;
}
variablesDialogVisible.value = true;
return;
}
// 检查最后一条用户消息是否是语音消息且内容已更新
// 如果是,则跳过创建新消息,直接发送给 AI
const lastUserMsg = messages.value.findLast((m) => m.role === 'user');
const isVoiceMessageUpdate =
lastUserMsg?.voice && lastUserMsg.content === text;
if (isVoiceMessageUpdate) {
// 语音消息已存在,直接发送给 AI(不创建新消息)
const inputs =
props.mode === 'workflow'
? { user_input: text }
: props.initialInputs || undefined;
runMessageWithExistingUserMsg(lastUserMsg, inputs);
} else {
// 发送消息(创建新的用户消息)
const inputs =
props.mode === 'workflow'
? { user_input: text }
: props.initialInputs || undefined;
runMessage(text, inputs, attachments);
}
};
// 确认变量表单后运行
const handleVariablesConfirm = () => {
variablesDialogVisible.value = false;
runMessage(
pendingMessage.value,
variablesForm.value,
pendingAttachments.value,
);
pendingAttachments.value = [];
};
// 运行消息(使用已存在的用户消息,用于语音消息)
const runMessageWithExistingUserMsg = (
userMsg: ChatMessage,
inputs?: Record<string, any>,
) => {
// 如果是 application 类型工作流且是全屏布局,显示加载动画
if (
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
showLoadingAnimation.value = true;
loadingAnimationTitle.value = 'AI 正在开发...';
}
// 添加 AI 消息(pending 状态)
const assistantMsgId = generateId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
timestamp: new Date(),
status: 'pending',
reasoning_steps: [],
};
messages.value.push(assistantMsg);
currentAssistantMsgId.value = assistantMsgId;
currentSteps.value = [];
streamingContent.value = '';
running.value = true;
cancelStream = chatApi.sendMessage(
userMsg.content,
inputs,
(event) => handleStreamEvent(event, assistantMsgId),
(error) => {
console.error('Stream error:', error);
updateAssistantMessage(assistantMsgId, {
status: 'failed',
error_message: error.message || '执行失败',
});
running.value = false;
ElMessage.error(error.message || '执行失败');
},
() => {
cancelStream = null;
},
userMsg.attachments,
);
};
// 运行消息
const runMessage = (
userMessage: string,
inputs?: Record<string, any>,
attachments?: Attachment[],
) => {
// 添加用户消息(包含附件)
// 附件使用 file_id 关联文件管理系统
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: userMessage,
timestamp: new Date(),
attachments: attachments?.map((att) => ({
file_id: att.file_id || att.id,
id: att.file_id || att.id,
type: att.type as 'audio' | 'file' | 'image' | 'video',
name: att.name,
url: att.url,
size: att.size,
thumbnail: att.thumbnail,
})),
};
messages.value.push(userMsg);
// 如果是 application 类型工作流且是全屏布局,显示加载动画
if (
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
showLoadingAnimation.value = true;
loadingAnimationTitle.value = 'AI 正在开发...';
}
// 添加 AI 消息(pending 状态)
const assistantMsgId = generateId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
timestamp: new Date(),
status: 'pending',
reasoning_steps: [],
};
messages.value.push(assistantMsg);
currentAssistantMsgId.value = assistantMsgId;
currentSteps.value = [];
streamingContent.value = '';
running.value = true;
cancelStream = chatApi.sendMessage(
userMessage,
inputs,
(event) => handleStreamEvent(event, assistantMsgId),
(error) => {
console.error('Stream error:', error);
updateAssistantMessage(assistantMsgId, {
status: 'failed',
error_message: error.message || '执行失败',
});
running.value = false;
ElMessage.error(error.message || '执行失败');
},
() => {
cancelStream = null;
},
attachments,
);
};
// 恢复执行
const resumeExecution = (userInput: any) => {
// 工作流模式需要 runIdAgent 模式需要 conversationId
if (props.mode === 'workflow' && !currentRunId.value) return;
if (props.mode !== 'workflow' && !conversationId.value) return;
const assistantMsgId = generateId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
timestamp: new Date(),
status: 'pending',
};
messages.value.push(assistantMsg);
currentAssistantMsgId.value = assistantMsgId;
running.value = true;
cancelStream = chatApi.resumeExecution(
currentRunId.value || conversationId.value || '',
userInput,
(event) => handleStreamEvent(event, assistantMsgId),
(error) => {
console.error('Resume stream error:', error);
updateAssistantMessage(assistantMsgId, {
status: 'failed',
error_message: error.message || '恢复执行失败',
});
running.value = false;
// 关闭加载动画
showLoadingAnimation.value = false;
ElMessage.error(error.message || '恢复执行失败');
},
() => {
cancelStream = null;
},
);
};
// ==================== 交互处理 ====================
const handleInteractionSubmit = (messageId: string, value: any) => {
const msgIndex = messages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = messages.value[msgIndex];
if (msg) {
messages.value[msgIndex] = { ...msg, interaction: undefined };
}
}
// 全屏布局且是 application 类型工作流时,显示加载动画
if (
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
showLoadingAnimation.value = true;
loadingAnimationTitle.value = 'AI 正在继续开发...';
}
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: formatInteractionValue(value),
timestamp: new Date(),
};
messages.value.push(userMsg);
// 工作流模式需要 runIdAgent 模式需要 conversationId
if (
currentRunId.value ||
(props.mode !== 'workflow' && conversationId.value)
) {
resumeExecution(value);
}
waitingForInput.value = false;
waitingConfig.value = null;
};
const handleInteractionCancel = (messageId: string) => {
const msgIndex = messages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = messages.value[msgIndex];
if (msg) {
messages.value[msgIndex] = { ...msg, interaction: undefined };
}
}
// 全屏布局且是 application 类型工作流时,显示加载动画
if (
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
showLoadingAnimation.value = true;
loadingAnimationTitle.value = 'AI 正在继续开发...';
}
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: waitingConfig.value?.cancel_text || '取消',
timestamp: new Date(),
};
messages.value.push(userMsg);
// 工作流模式需要 runIdAgent 模式需要 conversationId
if (
currentRunId.value ||
(props.mode !== 'workflow' && conversationId.value)
) {
resumeExecution(false);
}
waitingForInput.value = false;
waitingConfig.value = null;
};
const formatInteractionValue = (value: any): string => {
if (value === true) {
return waitingConfig.value?.confirm_text || '确认';
}
if (Array.isArray(value)) {
const options = waitingConfig.value?.options || [];
const labels = value.map((v: string) => {
const opt = options.find((o: any) => o.value === v);
return opt?.label || v;
});
return labels.join(', ');
}
if (waitingConfig.value?.type === 'choice') {
const options = waitingConfig.value?.options || [];
const opt = options.find((o: any) => o.value === value);
return opt?.label || value;
}
return String(value);
};
// ==================== 其他操作 ====================
const handleStop = () => {
cancelStream?.();
cancelStream = null;
running.value = false;
if (currentAssistantMsgId.value) {
updateAssistantMessage(currentAssistantMsgId.value, {
status: 'failed',
error_message: '用户停止',
});
currentAssistantMsgId.value = null;
}
};
const handleClear = () => {
messages.value = [];
streamingContent.value = '';
currentSteps.value = [];
currentRunId.value = '';
waitingForInput.value = false;
waitingConfig.value = null;
emit('clear-status');
};
const handleFeedback = (messageId: string, feedback: 'dislike' | 'like') => {
if (chatApi.sendFeedback) {
chatApi.sendFeedback(messageId, feedback);
const msgIndex = messages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex]!,
feedback,
};
}
}
};
const handleAddMessage = (message: ChatMessage) => {
messages.value.push(message);
};
const handleUpdateMessage = (
messageId: string,
updates: Partial<ChatMessage>,
) => {
const idx = messages.value.findIndex((m) => m.id === messageId);
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx]!, ...updates };
}
};
// ==================== 设计预览处理 ====================
const createDesignConfirmHandler = (
showRef: ReturnType<typeof ref<boolean>>,
dataRef: ReturnType<typeof ref<DesignPreviewData | undefined>>,
confirmMessage: string,
) => {
return (data: Record<string, any>) => {
showRef.value = false;
dataRef.value = undefined;
// 全屏布局且是 application 类型工作流时,显示加载动画
if (
props.layout === 'fullscreen' &&
props.agent?.workflow_type === 'application'
) {
showLoadingAnimation.value = true;
loadingAnimationTitle.value = 'AI 正在继续开发...';
}
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: confirmMessage,
timestamp: new Date(),
};
messages.value.push(userMsg);
waitingForInput.value = false;
waitingConfig.value = null;
resumeExecution(JSON.stringify(data));
};
};
const createDesignCloseHandler = (
showRef: ReturnType<typeof ref<boolean>>,
dataRef: ReturnType<typeof ref<DesignPreviewData | undefined>>,
) => {
return () => {
showRef.value = false;
dataRef.value = undefined;
};
};
const handleDesignConfirm = createDesignConfirmHandler(
showDesignPanel,
currentDesign,
'确认设计',
);
const handleDesignClose = createDesignCloseHandler(
showDesignPanel,
currentDesign,
);
const handleSystemSummaryConfirm = createDesignConfirmHandler(
showSystemSummaryPanel,
currentSystemSummary,
'完成',
);
const handleSystemSummaryClose = createDesignCloseHandler(
showSystemSummaryPanel,
currentSystemSummary,
);
// ==================== 暴露方法 ====================
defineExpose({
clearChat: handleClear,
sendMessage: handleSend,
});
// ==================== 生命周期 ====================
onUnmounted(() => {
cancelStream?.();
});
</script>
<template>
<div class="flex h-full overflow-hidden">
<!-- 聊天面板 -->
<div
class="flex h-full flex-col overflow-hidden"
:class="[
layout === 'fullscreen'
? hasAnyDesignPanelOpen
? 'w-[400px] flex-shrink-0'
: 'flex-1'
: 'bg-card w-96 rounded-[8px] shadow-xl',
]"
>
<!-- 头部 -->
<div
v-if="showHeader"
class="flex items-center justify-between px-4 py-4"
>
<div class="text-foreground flex items-center gap-2 font-medium">
{{ panelTitle }}
</div>
<div class="flex items-center gap-1">
<!-- 变量设置按钮工作流模式且有多变量时显示 -->
<ElButton
v-if="
mode === 'workflow' && enableVariablesForm && hasMultipleVariables
"
link
:icon="Setting"
title="配置输入变量"
@click="variablesDialogVisible = true"
/>
<ElButton
v-if="showCloseButton"
link
:icon="Close"
@click="$emit('close')"
/>
</div>
</div>
<!-- ChatBox 组件 -->
<ChatBox
:messages="messages"
:loading="running"
:streaming-content="streamingContent"
:show-avatar="showAvatar"
:assistant-name="assistantName"
:placeholder="placeholderText"
:disabled="disabled"
:enable-attachment="enableAttachment"
:enable-image="enableImage"
:enable-voice="enableVoice"
:enable-feedback="enableFeedback"
:show-clear-button="showClearButton"
:empty-text="emptyText"
:welcome-config="welcomeConfig"
class="flex-1 rounded-none border-0"
@send="handleSend"
@stop="handleStop"
@clear="handleClear"
@feedback="handleFeedback"
@interaction-submit="handleInteractionSubmit"
@interaction-cancel="handleInteractionCancel"
@add-message="handleAddMessage"
@update-message="handleUpdateMessage"
/>
<!-- 多变量输入对话框 -->
<ElDialog
v-model="variablesDialogVisible"
title="配置输入变量"
width="400px"
:close-on-click-modal="false"
>
<ElForm label-position="top">
<ElFormItem
v-for="v in variables"
:key="v.variable"
:label="v.variable"
:required="v.required"
>
<ElInput
v-model="variablesForm[v.variable]"
:placeholder="`请输入 ${v.variable}`"
:type="v.type === 'text' ? 'textarea' : 'text'"
:rows="v.type === 'text' ? 3 : undefined"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="variablesDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleVariablesConfirm">
开始运行
</ElButton>
</template>
</ElDialog>
</div>
<!-- 设计面板区域(全屏布局时并排显示) -->
<div
v-if="layout === 'fullscreen' && hasAnyDesignPanelOpen"
class="flex flex-1 flex-col overflow-hidden"
>
<!-- AI 工作动画 -->
<AiWorkingAnimation
v-if="showLoadingAnimation"
:title="loadingAnimationTitle"
subtitle="正在分析需求并生成代码"
:show-background="false"
class="ml-3 h-full min-h-[400px] flex-1 rounded-lg"
/>
<!-- 设计编辑面板 -->
<DesignEditorPanel
v-if="showDesignPanel"
:visible="showDesignPanel"
:design="currentDesign as any"
@update:visible="showDesignPanel = $event"
@confirm="handleDesignConfirm"
@close="handleDesignClose"
/>
<!-- 系统总结面板 -->
<SystemSummaryConfirmPanel
v-if="showSystemSummaryPanel"
:visible="showSystemSummaryPanel"
:data="currentSystemSummary as any"
@update:visible="showSystemSummaryPanel = $event"
@confirm="handleSystemSummaryConfirm"
@close="handleSystemSummaryClose"
/>
</div>
<!-- 侧边栏布局时的设计面板(保持原有行为) -->
<template v-if="layout === 'sidebar'">
<DesignEditorPanel
:visible="showDesignPanel"
:design="currentDesign as any"
@update:visible="showDesignPanel = $event"
@confirm="handleDesignConfirm"
@close="handleDesignClose"
/>
<SystemSummaryConfirmPanel
:visible="showSystemSummaryPanel"
:data="currentSystemSummary as any"
@update:visible="showSystemSummaryPanel = $event"
@confirm="handleSystemSummaryConfirm"
@close="handleSystemSummaryClose"
/>
</template>
</div>
</template>