# Conflicts: # dist/copilot_web/App-C2CSnZyL.js # dist/copilot_web/App-DT2fOvHP.js # dist/copilot_web/App-DfYXBz6y.js # dist/copilot_web/DebugGuide-CqUJVeP7.js # dist/copilot_web/DebugGuide-nVo3jJ10.js # dist/copilot_web/DebugGuide-oR460wi1.js # dist/copilot_web/DebugResult-swqmG-gW.js # dist/copilot_web/WorkflowOption-BRKJ_AoR.js # dist/copilot_web/WorkflowOption-BXMoNmz0.js # dist/copilot_web/WorkflowOption-KaEJtkSh.js # dist/copilot_web/input.js # dist/copilot_web/message-components-DEIvJ4jZ.js # dist/copilot_web/workflowChat-CgW0f6JP.js
973 lines
36 KiB
TypeScript
973 lines
36 KiB
TypeScript
/*
|
|
* @Author: ai-business-hql ai.bussiness.hql@gmail.com
|
|
* @Date: 2025-03-20 15:15:20
|
|
* @LastEditors: ai-business-hql qingli.hql@alibaba-inc.com
|
|
* @LastEditTime: 2025-07-17 15:19:41
|
|
* @FilePath: /comfyui_copilot/ui/src/workflowChat/workflowChat.tsx
|
|
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
|
*/
|
|
// Copyright (C) 2025 AIDC-AI
|
|
// Licensed under the MIT License.
|
|
|
|
import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from "react";
|
|
import { Message } from "../types/types";
|
|
import { WorkflowChatAPI } from "../apis/workflowChatApi";
|
|
import { ChatHeader } from "../components/chat/ChatHeader";
|
|
import { ChatInput } from "../components/chat/ChatInput";
|
|
import { SelectedNodeInfo } from "../components/chat/SelectedNodeInfo";
|
|
import { MessageList } from "../components/chat/MessageList";
|
|
import { generateUUID } from "../utils/uuid";
|
|
import { getInstalledNodes } from "../apis/comfyApiCustom";
|
|
import { UploadedImage } from '../components/chat/ChatInput';
|
|
import React from "react";
|
|
import { debounce } from "lodash";
|
|
import { useChatContext } from '../context/ChatContext';
|
|
import { useMousePosition } from '../hooks/useMousePosition';
|
|
import { useNodeSelection } from '../hooks/useNodeSelection';
|
|
import { MemoizedReactMarkdown } from "../components/markdown";
|
|
import remarkGfm from 'remark-gfm';
|
|
import rehypeExternalLinks from 'rehype-external-links';
|
|
import { indexedDBManager } from '../utils/indexedDB';
|
|
|
|
// Define the Tab type - We should import this from context to ensure consistency
|
|
import type { TabType } from '../context/ChatContext';
|
|
import { ParameterDebugInterface } from "../components/debug/ParameterDebugInterfaceV2";
|
|
import { COPILOT_EVENTS } from "../constants/events";
|
|
import { app } from "../utils/comfyapp";
|
|
import { config } from "../config";
|
|
|
|
const BASE_URL = config.apiBaseUrl
|
|
|
|
interface WorkflowChatProps {
|
|
onClose?: () => void;
|
|
visible?: boolean;
|
|
triggerUsage?: boolean;
|
|
onUsageTriggered?: () => void;
|
|
}
|
|
|
|
const enum DispatchEventType {
|
|
NONE = 'None',
|
|
USAGE = 'Usage',
|
|
PARAMETERS = 'Parameters',
|
|
DOWNSTREAM_NODES = 'DownstreamNodes'
|
|
}
|
|
|
|
// 优化公告组件样式 - 更加美观和专业,支持Markdown
|
|
const Announcement = ({ message, onClose }: { message: string, onClose: () => void }) => {
|
|
if (!message) return null;
|
|
|
|
return (
|
|
<div
|
|
className="bg-gradient-to-r from-gray-50 to-gray-100
|
|
border border-gray-300 px-2 py-1 mt-2 ml-2 mr-2 relative shadow-sm rounded-sm"
|
|
>
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0 mr-1">
|
|
<svg className="w-3.5 h-3.5 text-gray-700" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd"></path>
|
|
</svg>
|
|
</div>
|
|
<div className="text-gray-700 font-medium leading-relaxed pr-6 markdown-content" style={{ fontSize: '11px' }}>
|
|
<MemoizedReactMarkdown
|
|
rehypePlugins={[
|
|
[rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }]
|
|
]}
|
|
remarkPlugins={[remarkGfm]}
|
|
className="prose !text-[11px] prose-a:text-gray-700 prose-a:underline prose-a:font-medium hover:prose-a:text-gray-800"
|
|
components={{
|
|
a: ({ node, ...props }) => (
|
|
<a {...props} className="text-gray-700 underline hover:text-gray-800 transition-colors" style={{ fontSize: '11px' }} />
|
|
),
|
|
p: ({ children }) => (
|
|
<span className="inline" style={{ fontSize: '11px' }}>{children}</span>
|
|
),
|
|
// Add explicit styling for all text elements
|
|
span: ({ children }) => (
|
|
<span style={{ fontSize: '11px' }}>{children}</span>
|
|
),
|
|
li: ({ children }) => (
|
|
<li style={{ fontSize: '11px' }}>{children}</li>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul style={{ fontSize: '11px' }}>{children}</ul>
|
|
),
|
|
ol: ({ children }) => (
|
|
<ol style={{ fontSize: '11px' }}>{children}</ol>
|
|
)
|
|
}}
|
|
>
|
|
{message}
|
|
</MemoizedReactMarkdown>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-700 hover:text-gray-800 transition-colors p-1 rounded-full hover:bg-gray-800"
|
|
aria-label="Close announcement"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Parameter Debug Tab Component
|
|
const ParameterDebugTab = () => {
|
|
const { state, dispatch } = useChatContext();
|
|
const { selectedNode, screenState } = state;
|
|
const selectedNodes = selectedNode ? selectedNode : [];
|
|
|
|
// const ParameterDebugInterface = React.lazy(() =>
|
|
// import("../components/debug/ParameterDebugInterfaceV2").then(module => ({
|
|
// default: module.ParameterDebugInterface
|
|
// }))
|
|
// );
|
|
|
|
const handleCloseParameterDebug = () => {
|
|
// Clear selected nodes and screen state
|
|
dispatch({ type: 'SET_SELECTED_NODE', payload: null });
|
|
dispatch({ type: 'SET_SCREEN_STATE', payload: null });
|
|
// 同时清除localStorage中保存的状态
|
|
localStorage.removeItem("screenState");
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
|
<ParameterDebugInterface
|
|
selectedNodes={selectedNodes}
|
|
visible={true}
|
|
onClose={handleCloseParameterDebug}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Tab component
|
|
const TabButton = ({
|
|
active,
|
|
onClick,
|
|
children
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode
|
|
}) => (
|
|
<button
|
|
onClick={onClick}
|
|
className={`px-4 py-2 font-medium text-xs transition-colors duration-200 border-b-2 ${
|
|
active
|
|
? "text-[#71A3F2] border-[#71A3F2]"
|
|
: "text-gray-600 border-transparent hover:!text-[#71A3F2] hover:!border-[#71A3F2]"
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
|
|
export default function WorkflowChat({ onClose, visible = true, triggerUsage = false, onUsageTriggered }: WorkflowChatProps) {
|
|
const { state, dispatch, isAutoScroll, showcasIng } = useChatContext();
|
|
const { messages, installedNodes, loading, sessionId, selectedNode, activeTab } = state;
|
|
const messageDivRef = useRef<HTMLDivElement>(null);
|
|
const [input, setInput] = useState<string>('');
|
|
const [latestInput, setLatestInput] = useState<string>('');
|
|
const [width, setWidth] = useState(window.innerWidth / 3);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
|
|
const [selectedModel, setSelectedModel] = useState<string>("gemini-2.5-flash");
|
|
const [height, setHeight] = useState<number>(window.innerHeight);
|
|
const [topPosition, setTopPosition] = useState<number>(0);
|
|
// 添加公告状态
|
|
const [announcement, setAnnouncement] = useState<string>('');
|
|
const [showAnnouncement, setShowAnnouncement] = useState<boolean>(false);
|
|
// 添加 AbortController 引用
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const currentSelectedNode = useRef<any>(selectedNode)
|
|
const [dispatchEventType, setDispatchEventType] = useState<DispatchEventType>(DispatchEventType.NONE);
|
|
// 使用自定义 hooks,只在visible为true且activeTab为chat时启用
|
|
useMousePosition(visible && activeTab === 'chat');
|
|
useNodeSelection(visible);
|
|
|
|
// Initialize IndexedDB
|
|
useEffect(() => {
|
|
indexedDBManager.init().catch(console.error);
|
|
}, []);
|
|
|
|
// Auto-save messages to IndexedDB when they change
|
|
useEffect(() => {
|
|
if (messages.length > 0 && sessionId) {
|
|
updateMessagesCache(messages);
|
|
}
|
|
}, [messages, sessionId]);
|
|
|
|
// useEffect(() => {
|
|
// if (messageDivRef.current) {
|
|
// messageDivRef.current.scrollTop = messageDivRef.current.scrollHeight
|
|
// }
|
|
// }, [messages])
|
|
|
|
useEffect(() => {
|
|
switch(dispatchEventType) {
|
|
case DispatchEventType.USAGE:
|
|
onUsage()
|
|
break;
|
|
case DispatchEventType.PARAMETERS:
|
|
onParameters()
|
|
break;
|
|
case DispatchEventType.DOWNSTREAM_NODES:
|
|
onDownstreamNodes();
|
|
break;
|
|
}
|
|
setDispatchEventType(DispatchEventType.NONE)
|
|
}, [dispatchEventType])
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'chat') return;
|
|
|
|
const fetchInstalledNodes = async () => {
|
|
const nodes = await getInstalledNodes();
|
|
console.log('[WorkflowChat] Received installed nodes:', nodes.length);
|
|
dispatch({ type: 'SET_INSTALLED_NODES', payload: nodes });
|
|
};
|
|
fetchInstalledNodes();
|
|
}, [activeTab]);
|
|
|
|
const showGuide = () => {
|
|
dispatch({ type: 'SET_MESSAGES', payload: [
|
|
{
|
|
id: generateUUID(),
|
|
role: 'showcase',
|
|
content: ''
|
|
}
|
|
]})
|
|
}
|
|
|
|
// 获取历史消息
|
|
const fetchMessages = async (sid: string) => {
|
|
try {
|
|
// First try to get from IndexedDB
|
|
const indexedSession = await indexedDBManager.getSession(sid);
|
|
if (indexedSession && indexedSession.messages.length > 0) {
|
|
dispatch({ type: 'SET_MESSAGES', payload: indexedSession.messages });
|
|
return;
|
|
}
|
|
|
|
// If not found in IndexedDB, try API
|
|
const data = await WorkflowChatAPI.fetchMessages(sid);
|
|
if (data?.length > 0) {
|
|
dispatch({ type: 'SET_MESSAGES', payload: data });
|
|
} else {
|
|
showGuide()
|
|
}
|
|
// Note: The localStorage cache is already updated in the fetchMessages API function
|
|
} catch (error) {
|
|
console.error('Error fetching messages:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'chat') return;
|
|
|
|
let sid = localStorage.getItem("sessionId");
|
|
if (sid) {
|
|
dispatch({ type: 'SET_SESSION_ID', payload: sid });
|
|
fetchMessages(sid);
|
|
} else {
|
|
sid = generateUUID();
|
|
dispatch({ type: 'SET_SESSION_ID', payload: sid });
|
|
localStorage.setItem("sessionId", sid);
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// 添加保存和恢复activeTab的逻辑
|
|
useEffect(() => {
|
|
// 从localStorage恢复activeTab状态
|
|
const savedTab = localStorage.getItem("activeTab") as TabType;
|
|
if (savedTab) {
|
|
dispatch({ type: 'SET_ACTIVE_TAB', payload: savedTab });
|
|
}
|
|
|
|
// 从localStorage恢复screenState状态(如果存在)
|
|
const savedScreenState = localStorage.getItem("screenState");
|
|
if (savedScreenState) {
|
|
dispatch({ type: 'SET_SCREEN_STATE', payload: JSON.parse(savedScreenState) });
|
|
}
|
|
|
|
const onToolBoxUsage = () => {
|
|
setDispatchEventType(DispatchEventType.USAGE);
|
|
}
|
|
|
|
const onToolBoxParameters = () => {
|
|
setDispatchEventType(DispatchEventType.PARAMETERS);
|
|
}
|
|
|
|
const onToolBoxDownstreamNodes = () => {
|
|
setDispatchEventType(DispatchEventType.DOWNSTREAM_NODES);
|
|
}
|
|
|
|
window.addEventListener(COPILOT_EVENTS.TOOLBOX_USAGE, onToolBoxUsage);
|
|
window.addEventListener(COPILOT_EVENTS.TOOLBOX_PARAMETERS, onToolBoxParameters);
|
|
window.addEventListener(COPILOT_EVENTS.TOOLBOX_DOWNSTREAMNODES, onToolBoxDownstreamNodes);
|
|
|
|
return () => {
|
|
window.removeEventListener(COPILOT_EVENTS.TOOLBOX_USAGE, onToolBoxUsage);
|
|
window.removeEventListener(COPILOT_EVENTS.TOOLBOX_PARAMETERS, onToolBoxParameters);
|
|
window.removeEventListener(COPILOT_EVENTS.TOOLBOX_DOWNSTREAMNODES, onToolBoxDownstreamNodes);
|
|
}
|
|
}, []);
|
|
|
|
// 当activeTab变化时保存到localStorage
|
|
useEffect(() => {
|
|
localStorage.setItem("activeTab", activeTab);
|
|
}, [activeTab]);
|
|
|
|
// 当screenState变化时保存到localStorage
|
|
useEffect(() => {
|
|
if (state.screenState) {
|
|
localStorage.setItem("screenState", JSON.stringify(state.screenState));
|
|
}
|
|
}, [state.screenState]);
|
|
|
|
// 使用防抖处理宽度调整
|
|
const handleMouseMoveForResize = React.useCallback((e: MouseEvent) => {
|
|
if (!isResizing) return;
|
|
|
|
const newWidth = window.innerWidth - e.clientX;
|
|
const clampedWidth = Math.min(
|
|
Math.max(300, newWidth),
|
|
window.innerWidth * 0.8
|
|
);
|
|
|
|
setWidth(clampedWidth);
|
|
}, [isResizing]);
|
|
|
|
const debouncedHandleMouseMoveForResize = React.useMemo(
|
|
() => debounce(handleMouseMoveForResize, 16),
|
|
[handleMouseMoveForResize]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isResizing) {
|
|
document.addEventListener('mousemove', debouncedHandleMouseMoveForResize);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', debouncedHandleMouseMoveForResize);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
debouncedHandleMouseMoveForResize.cancel();
|
|
};
|
|
}, [isResizing, debouncedHandleMouseMoveForResize]);
|
|
|
|
const handleMessageChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
setInput(event.target.value);
|
|
}
|
|
|
|
const handleStop = () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
abortControllerRef.current = null;
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
}
|
|
}
|
|
|
|
const handleSendMessage = async () => {
|
|
if (messages?.[0]?.role === 'showcase') {
|
|
dispatch({ type: 'CLEAR_MESSAGES' });
|
|
}
|
|
isAutoScroll.current = true
|
|
showcasIng.current = false;
|
|
dispatch({ type: 'SET_LOADING', payload: true });
|
|
if ((input.trim() === "" && !selectedNode) || !sessionId) return;
|
|
setLatestInput(input);
|
|
|
|
const traceId = generateUUID();
|
|
|
|
const userMessageId = generateUUID();
|
|
const userMessage: Message = {
|
|
id: userMessageId,
|
|
role: "user",
|
|
content: input,
|
|
trace_id: traceId,
|
|
};
|
|
|
|
if (uploadedImages.length > 0) {
|
|
userMessage.ext = [
|
|
{
|
|
type: 'img',
|
|
data: uploadedImages.map(img => ({
|
|
url: img.url,
|
|
name: img.file.name
|
|
}))
|
|
}
|
|
]
|
|
}
|
|
dispatch({ type: 'ADD_MESSAGE', payload: userMessage });
|
|
setInput("");
|
|
|
|
// 创建新的 AbortController
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
const modelExt = { type: "model_select", data: [selectedModel] };
|
|
let aiMessageId = generateUUID(); // 生成一个固定的消息ID
|
|
let isFirstResponse = true;
|
|
|
|
for await (const response of WorkflowChatAPI.streamInvokeServer(
|
|
sessionId,
|
|
input,
|
|
uploadedImages.map(img => ({
|
|
url: img.url,
|
|
name: img.file.name
|
|
})),
|
|
null,
|
|
modelExt,
|
|
traceId,
|
|
abortControllerRef.current.signal,
|
|
state.messages,
|
|
userMessageId // Pass the user message ID
|
|
)) {
|
|
const aiMessage: Message = {
|
|
id: aiMessageId,
|
|
role: "ai",
|
|
content: JSON.stringify(response),
|
|
format: response.format,
|
|
finished: response.finished,
|
|
name: "Assistant"
|
|
};
|
|
|
|
if (isFirstResponse) {
|
|
dispatch({ type: 'ADD_MESSAGE', payload: aiMessage });
|
|
isFirstResponse = false;
|
|
} else {
|
|
dispatch({ type: 'UPDATE_MESSAGE', payload: aiMessage });
|
|
// Update localStorage cache
|
|
const updatedMessages = state.messages.map(msg =>
|
|
msg.id === aiMessage.id && !msg.finished ? aiMessage : msg
|
|
);
|
|
updateMessagesCache(updatedMessages);
|
|
}
|
|
|
|
if (response.finished) {
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
abortControllerRef.current = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
// 用户主动中断,不显示错误
|
|
console.log('User aborted the request');
|
|
} else {
|
|
console.error('Error sending message:', error);
|
|
}
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
abortControllerRef.current = null;
|
|
} finally {
|
|
setUploadedImages([]);
|
|
}
|
|
};
|
|
|
|
const handleSendMessageWithContent = async (content: string) => {
|
|
if (!sessionId) return;
|
|
dispatch({ type: 'SET_LOADING', payload: true });
|
|
setLatestInput(content);
|
|
|
|
const traceId = generateUUID();
|
|
|
|
const userMessageId = generateUUID();
|
|
const userMessage: Message = {
|
|
id: userMessageId,
|
|
role: "user",
|
|
content: content,
|
|
trace_id: traceId,
|
|
};
|
|
|
|
dispatch({ type: 'ADD_MESSAGE', payload: userMessage });
|
|
|
|
// 创建新的 AbortController
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
const modelExt = { type: "model_select", data: [selectedModel] };
|
|
let aiMessageId = generateUUID();
|
|
let isFirstResponse = true;
|
|
|
|
for await (const response of WorkflowChatAPI.streamInvokeServer(
|
|
sessionId,
|
|
content,
|
|
uploadedImages.map(img => ({
|
|
url: img.url,
|
|
name: img.file.name
|
|
})),
|
|
null,
|
|
modelExt,
|
|
traceId,
|
|
abortControllerRef.current.signal,
|
|
state.messages,
|
|
userMessageId // Pass the user message ID
|
|
)) {
|
|
const aiMessage: Message = {
|
|
id: aiMessageId,
|
|
role: "ai",
|
|
content: JSON.stringify(response),
|
|
format: response.format,
|
|
finished: response.finished,
|
|
name: "Assistant"
|
|
};
|
|
|
|
if (isFirstResponse) {
|
|
dispatch({ type: 'ADD_MESSAGE', payload: aiMessage });
|
|
isFirstResponse = false;
|
|
} else {
|
|
dispatch({ type: 'UPDATE_MESSAGE', payload: aiMessage });
|
|
// Update localStorage cache
|
|
const updatedMessages = state.messages.map(msg =>
|
|
msg.id === aiMessage.id && !msg.finished ? aiMessage : msg
|
|
);
|
|
updateMessagesCache(updatedMessages);
|
|
}
|
|
|
|
if (response.finished) {
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
abortControllerRef.current = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
// 用户主动中断,不显示错误
|
|
console.log('User aborted the request');
|
|
} else {
|
|
console.error('Error sending message:', error);
|
|
}
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
abortControllerRef.current = null;
|
|
} finally {
|
|
setUploadedImages([]);
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (event: KeyboardEvent) => {
|
|
if (event.metaKey && event.key === "Enter") {
|
|
handleSendMessage();
|
|
}
|
|
}
|
|
|
|
const handleClearMessages = () => {
|
|
showcasIng.current = false;
|
|
dispatch({ type: 'CLEAR_MESSAGES' });
|
|
// Remove old session data
|
|
const oldSessionId = state.sessionId;
|
|
if (oldSessionId) {
|
|
localStorage.removeItem(`messages_${oldSessionId}`);
|
|
}
|
|
localStorage.removeItem("sessionId");
|
|
|
|
// Create new session
|
|
const newSessionId = generateUUID();
|
|
dispatch({ type: 'SET_SESSION_ID', payload: newSessionId });
|
|
localStorage.setItem("sessionId", newSessionId);
|
|
setTimeout(() => {
|
|
showGuide()
|
|
}, 500)
|
|
};
|
|
|
|
const avatar = (name?: string) => {
|
|
return `https://ui-avatars.com/api/?name=${name || 'User'}&background=random`;
|
|
}
|
|
|
|
const handleClose = () => {
|
|
onClose?.();
|
|
};
|
|
|
|
const handleOptionClick = (option: string) => {
|
|
setInput(option);
|
|
};
|
|
|
|
const handleSendMessageWithIntent = async (intent: string, ext?: any) => {
|
|
if (!sessionId || !selectedNode) return;
|
|
dispatch({ type: 'SET_LOADING', payload: true });
|
|
|
|
const nodeName = selectedNode[0].comfyClass || selectedNode[0].type;
|
|
|
|
const traceId = generateUUID();
|
|
const userMessage: Message = {
|
|
id: generateUUID(),
|
|
role: "user",
|
|
content: nodeName,
|
|
trace_id: traceId,
|
|
};
|
|
|
|
dispatch({ type: 'ADD_MESSAGE', payload: userMessage });
|
|
|
|
// 创建新的 AbortController
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
let aiMessageId = generateUUID();
|
|
let isFirstResponse = true;
|
|
|
|
for await (const response of WorkflowChatAPI.streamInvokeServer(
|
|
sessionId,
|
|
nodeName,
|
|
[],
|
|
intent,
|
|
ext,
|
|
traceId,
|
|
abortControllerRef.current.signal,
|
|
state.messages
|
|
)) {
|
|
const aiMessage: Message = {
|
|
id: aiMessageId,
|
|
role: "ai",
|
|
content: JSON.stringify(response),
|
|
format: response.format,
|
|
finished: response.finished,
|
|
name: "Assistant",
|
|
};
|
|
if (intent && intent !== '') {
|
|
aiMessage.metadata = {
|
|
intent: intent
|
|
}
|
|
}
|
|
|
|
if (isFirstResponse) {
|
|
dispatch({ type: 'ADD_MESSAGE', payload: aiMessage });
|
|
isFirstResponse = false;
|
|
} else {
|
|
dispatch({ type: 'UPDATE_MESSAGE', payload: aiMessage });
|
|
// Update localStorage cache
|
|
const updatedMessages = state.messages.map(msg =>
|
|
msg.id === aiMessage.id && !msg.finished ? aiMessage : msg
|
|
);
|
|
updateMessagesCache(updatedMessages);
|
|
}
|
|
|
|
if (response.finished) {
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
abortControllerRef.current = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
// 用户主动中断,不显示错误
|
|
console.log('User aborted the request');
|
|
} else {
|
|
console.error('Error sending message:', error);
|
|
}
|
|
dispatch({ type: 'SET_LOADING', payload: false });
|
|
abortControllerRef.current = null;
|
|
}
|
|
};
|
|
|
|
|
|
const onUsage = () => {
|
|
handleSendMessageWithContent(`Reply in ${navigator.language} language: How does the ${selectedNode?.[0]?.type} node work? I need its official usage guide.`)
|
|
}
|
|
|
|
const onParameters = () => {
|
|
handleSendMessageWithContent(`Reply in ${navigator.language} language: Show me the technical specifications for the ${selectedNode?.[0].type} node's inputs and outputs.`)
|
|
}
|
|
|
|
const getDownstreamSubgraphExt = () => {
|
|
const nodeTypeSet = new Set<string>();
|
|
|
|
function findUpstreamNodes(node: any, depth: number) {
|
|
if (!node || depth >= 1) return;
|
|
|
|
if (node.inputs) {
|
|
for (const input of Object.values(node.inputs)) {
|
|
const linkId = (input as any).link;
|
|
if (linkId && app.graph.links[linkId]) {
|
|
const originId = app.graph.links[linkId].origin_id;
|
|
const originNode = app.graph._nodes_by_id[originId];
|
|
if (originNode) {
|
|
nodeTypeSet.add(originNode.type);
|
|
findUpstreamNodes(originNode, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!!selectedNode[0]) {
|
|
findUpstreamNodes(selectedNode[0], 0);
|
|
return [{"type": "upstream_node_types", "data": Array.from(nodeTypeSet)}];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const onDownstreamNodes = () => {
|
|
handleSendMessageWithIntent('downstream_subgraph_search', getDownstreamSubgraphExt())
|
|
}
|
|
|
|
// Utility function to update localStorage cache for messages
|
|
const updateMessagesCache = async (messages: Message[]) => {
|
|
if (state.sessionId) {
|
|
localStorage.setItem(`messages_${state.sessionId}`, JSON.stringify(messages));
|
|
// Also save to IndexedDB
|
|
try {
|
|
await indexedDBManager.saveSession(state.sessionId, messages);
|
|
} catch (error) {
|
|
console.error('Failed to save session to IndexedDB:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAddMessage = (message: Message) => {
|
|
console.log('[WorkflowChat] Adding new message:', message);
|
|
const updatedMessages = [...state.messages, message];
|
|
dispatch({ type: 'ADD_MESSAGE', payload: message });
|
|
|
|
// Update the localStorage cache and IndexedDB with the new message
|
|
updateMessagesCache(updatedMessages);
|
|
};
|
|
|
|
const handleUpdateMessage = (message: Message) => {
|
|
// console.log('[WorkflowChat] Upadating message:', message);
|
|
const updatedMessages = state.messages.map(msg =>
|
|
msg.id === message.id && !msg.finished ? message : msg
|
|
);
|
|
dispatch({ type: 'UPDATE_MESSAGE', payload: message });
|
|
|
|
// Update the localStorage cache and IndexedDB with the new message
|
|
updateMessagesCache(updatedMessages);
|
|
};
|
|
|
|
const handleSelectSession = (sessionId: string, messages: Message[]) => {
|
|
// Clear current state and load new session
|
|
dispatch({ type: 'SET_SESSION_ID', payload: sessionId });
|
|
dispatch({ type: 'SET_MESSAGES', payload: messages });
|
|
localStorage.setItem("sessionId", sessionId);
|
|
localStorage.setItem(`messages_${sessionId}`, JSON.stringify(messages));
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
setIsResizing(true);
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
};
|
|
|
|
const uploadImage = async (file: File, id: string) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const response = await fetch(`${BASE_URL}/api/chat/imgfile2oss`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const data = await response.json();
|
|
await WorkflowChatAPI.uploadImage(file);
|
|
return data.success ? {
|
|
url: data.data,
|
|
file,
|
|
id
|
|
} : null;
|
|
}
|
|
|
|
const handleUploadImages = (files: FileList) => {
|
|
const newImages: UploadedImage[] = [];
|
|
const promises: Promise<{file: File; url: string; id: string} | null>[] = []
|
|
Array.from(files).forEach(file => {
|
|
const id = Math.random().toString(36).substr(2, 9)
|
|
promises.push(uploadImage(file, id))
|
|
newImages.push({
|
|
id,
|
|
file,
|
|
preview: URL.createObjectURL(file),
|
|
url: ''
|
|
})
|
|
})
|
|
setUploadedImages(prev => [...prev, ...newImages]);
|
|
Promise.all(promises).then((data) => {
|
|
data?.forEach(item => {
|
|
if (!!item) {
|
|
const index = newImages.findIndex(img => img.id === item.id)
|
|
newImages.splice(index, 1, {
|
|
id: item.id,
|
|
file: item?.file,
|
|
preview: URL.createObjectURL(item?.file),
|
|
url: item?.url
|
|
})
|
|
}
|
|
})
|
|
setUploadedImages([...newImages]);
|
|
});
|
|
// const newImages = Array.from(files).map(file => ({
|
|
// id: Math.random().toString(36).substr(2, 9),
|
|
// file,
|
|
// preview: URL.createObjectURL(file)
|
|
// }));
|
|
};
|
|
|
|
const handleRemoveImage = (imageId: string) => {
|
|
setUploadedImages(prev => {
|
|
const newImages = prev.filter(img => img.id !== imageId);
|
|
return newImages;
|
|
});
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
uploadedImages.forEach(image => URL.revokeObjectURL(image.preview));
|
|
};
|
|
}, [uploadedImages]);
|
|
|
|
useEffect(() => {
|
|
if (triggerUsage && onUsageTriggered && activeTab === 'chat') {
|
|
handleSendMessageWithIntent('node_explain');
|
|
onUsageTriggered();
|
|
}
|
|
}, [triggerUsage, activeTab]);
|
|
|
|
// 获取公告内容
|
|
useEffect(() => {
|
|
if (!visible || activeTab !== 'chat') return;
|
|
|
|
const fetchAnnouncement = async () => {
|
|
try {
|
|
// 检查今天是否已经显示过公告
|
|
const today = new Date().toDateString();
|
|
const lastShownDate = localStorage.getItem('announcementLastShownDate');
|
|
|
|
// 如果今天没有显示过公告,则显示
|
|
if (lastShownDate !== today) {
|
|
const message = await WorkflowChatAPI.fetchAnnouncement();
|
|
if (message && message.trim() !== '') {
|
|
setAnnouncement(message);
|
|
setShowAnnouncement(true);
|
|
// 记录今天的日期
|
|
localStorage.setItem('announcementLastShownDate', today);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching announcement:', error);
|
|
}
|
|
};
|
|
|
|
fetchAnnouncement();
|
|
}, [visible, activeTab]);
|
|
|
|
// 关闭公告
|
|
const handleCloseAnnouncement = () => {
|
|
setShowAnnouncement(false);
|
|
};
|
|
|
|
// Handle tab change
|
|
const handleTabChange = (tab: TabType) => {
|
|
dispatch({ type: 'SET_ACTIVE_TAB', payload: tab });
|
|
};
|
|
|
|
// Initialize the parameter debug tab component with lazy loading
|
|
// const parameterDebugTabComponent = React.useMemo(() => (
|
|
// <ParameterDebugTab />
|
|
// ), []);
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col h-full w-full bg-white relative"
|
|
style={{
|
|
display: visible ? 'flex' : 'none'
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-gray-300"
|
|
/>
|
|
|
|
<div className="flex h-full flex-col">
|
|
<ChatHeader
|
|
onClose={onClose}
|
|
onClear={handleClearMessages}
|
|
hasMessages={messages.length > 0}
|
|
title={`ComfyUI-Copilot`}
|
|
onSelectSession={handleSelectSession}
|
|
currentSessionId={sessionId}
|
|
/>
|
|
|
|
{/* Tab navigation */}
|
|
<div className="flex border-b border-gray-200 mt-2">
|
|
<TabButton
|
|
active={activeTab === 'chat'}
|
|
onClick={() => handleTabChange('chat')}
|
|
>
|
|
Chat
|
|
</TabButton>
|
|
<TabButton
|
|
active={activeTab === 'parameter-debug'}
|
|
onClick={() => handleTabChange('parameter-debug')}
|
|
>
|
|
GenLab
|
|
</TabButton>
|
|
</div>
|
|
|
|
{/* 将公告移到 ChatHeader 下方和Tab导航下方 */}
|
|
{showAnnouncement && announcement && activeTab === 'chat' && (
|
|
<Announcement
|
|
message={announcement}
|
|
onClose={handleCloseAnnouncement}
|
|
/>
|
|
)}
|
|
|
|
{/* Tab content - Both tabs are mounted but only the active one is displayed */}
|
|
{/* <div
|
|
className='flex-1 overflow-y-auto p-4 scroll-smooth h-0'
|
|
style={{ display: activeTab === 'chat' ? 'block' : 'none' }}
|
|
ref={messageDivRef}
|
|
> */}
|
|
<MessageList
|
|
messages={messages}
|
|
latestInput={latestInput}
|
|
onOptionClick={handleOptionClick}
|
|
installedNodes={installedNodes}
|
|
onAddMessage={handleAddMessage}
|
|
onUpdateMessage={handleUpdateMessage}
|
|
loading={loading}
|
|
isActive={activeTab === 'chat'}
|
|
/>
|
|
{/* </div> */}
|
|
|
|
<div
|
|
className="border-t px-4 py-3 border-gray-200 bg-white sticky bottom-0"
|
|
style={{ display: activeTab === 'chat' ? 'block' : 'none' }}
|
|
>
|
|
{selectedNode && (
|
|
<SelectedNodeInfo
|
|
nodeInfo={selectedNode}
|
|
onSendWithIntent={handleSendMessageWithIntent}
|
|
loading={loading}
|
|
onSendWithContent={handleSendMessageWithContent}
|
|
/>
|
|
)}
|
|
|
|
<ChatInput
|
|
input={input}
|
|
loading={loading}
|
|
onChange={handleMessageChange}
|
|
onSend={handleSendMessage}
|
|
onKeyPress={handleKeyPress}
|
|
onUploadImages={handleUploadImages}
|
|
uploadedImages={uploadedImages}
|
|
onRemoveImage={handleRemoveImage}
|
|
selectedModel={selectedModel}
|
|
onModelChange={setSelectedModel}
|
|
onStop={handleStop}
|
|
onAddDebugMessage={handleAddMessage}
|
|
/>
|
|
</div>
|
|
|
|
{/* ParameterDebugTab - Always mounted but conditionally displayed */}
|
|
<div
|
|
className="flex-1 flex flex-col h-0"
|
|
style={{ display: activeTab === 'parameter-debug' ? 'flex' : 'none' }}
|
|
>
|
|
<ParameterDebugTab />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |