diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx index 334f61a6..a4cb7294 100644 --- a/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx +++ b/qimingclaw/crates/agent-electron-client/src/renderer/App.tsx @@ -42,6 +42,10 @@ import AboutPage from "./components/pages/AboutPage"; import LogViewer from "./components/pages/LogViewer"; import PermissionsPage from "./components/pages/PermissionsPage"; import SessionsPage from "./components/pages/SessionsPage"; +import AiChatPage from "./components/pages/AiChatPage"; +import SkillsMarketplacePage from "./components/pages/SkillsMarketplacePage"; +import McpMarketplacePage from "./components/pages/McpMarketplacePage"; +import WorkflowMarketplacePage from "./components/pages/WorkflowMarketplacePage"; import MCPSettings from "./components/settings/MCPSettings"; import AppSidebar, { NavKey, @@ -94,6 +98,10 @@ export function useI18nLang(): I18nContextValue { type TabKey = | "client" | "sessions" + | "legacySessions" + | "skills" + | "mcpLibrary" + | "workflow" | "mcp" | "settings" | "dependencies" @@ -989,8 +997,18 @@ function App() { return; } + if (key === "skills") { + setActiveTab("skills"); + return; + } + if (key === "mcpLibrary") { - setActiveTab("mcp"); + setActiveTab("mcpLibrary"); + return; + } + + if (key === "workflow") { + setActiveTab("workflow"); return; } @@ -1003,6 +1021,12 @@ function App() { }, []); const handleLegacySelect = useCallback((key: LegacyTabKey) => { + if (key === "sessions") { + setActiveTab("legacySessions"); + setActiveNavKey("aiChat"); + return; + } + setActiveTab(key); const navByLegacyTab: Partial> = { client: "overview", @@ -1176,12 +1200,20 @@ function App() { /> )} {activeTab === "sessions" && ( + setSessionsAutoOpen(false)} + /> + )} + {activeTab === "legacySessions" && ( setSessionsAutoOpen(false)} + autoOpen={false} onWebviewChange={setWebviewActions} /> )} + {activeTab === "skills" && } + {activeTab === "mcpLibrary" && } + {activeTab === "workflow" && } {activeTab === "mcp" && } {activeTab === "settings" && } {activeTab === "dependencies" && } diff --git a/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/AiChatPage.tsx b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/AiChatPage.tsx new file mode 100644 index 00000000..47940943 --- /dev/null +++ b/qimingclaw/crates/agent-electron-client/src/renderer/components/pages/AiChatPage.tsx @@ -0,0 +1,1059 @@ +import React, { + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Button, + Empty, + Input, + Popover, + Select, + Spin, + Tabs, + Tooltip, + message, +} from "antd"; +import { + CopyOutlined, + LoadingOutlined, + PaperClipOutlined, + PlusOutlined, + ReloadOutlined, + RobotOutlined, + SearchOutlined, + SendOutlined, + StopOutlined, + ToolOutlined, +} from "@ant-design/icons"; +import { + AiAgentContext, + AiAttachment, + AiChatEvent, + AiConversationInfo, + AiManualComponent, + AiMessage, + AiModelOption, + AiProcessingInfo, + AiSelectedComponent, + AiSkillMention, + AiStreamController, + AiUploadFileInfo, + createAiConversation, + createLocalAssistantMessage, + createLocalUserMessage, + fetchAiConversationDetail, + fetchAiConversations, + fetchAiMessages, + fetchAiModelOptions, + fetchAtSkills, + resolveAiAgentContext, + sendAiMessageStream, + stopAiConversation, + updateAiConversationTopic, + uploadAiAttachment, +} from "../../services/core/aiChat"; +import styles from "../../styles/components/AiChatPage.module.css"; + +interface AiChatPageProps { + autoNew?: boolean; + onAutoNewConsumed?: () => void; +} + +const MESSAGE_PAGE_SIZE = 20; + +function formatConversationTime(value?: string): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const pad = (n: number) => String(n).padStart(2, "0"); + if (isToday) { + return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}`; + } + return `${pad(date.getMonth() + 1)}月${pad(date.getDate())}日 ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}`; +} + +function getConversationTitle(item?: AiConversationInfo | null): string { + return item?.topic?.trim() || "新的对话"; +} + +function toAttachment(file: AiUploadFileInfo): AiAttachment { + return { + fileKey: file.key || "", + fileUrl: file.url || "", + fileName: file.name, + mimeType: file.type, + }; +} + +function isMcpComponent(item: AiManualComponent): boolean { + return String(item.type || "").toLowerCase() === "mcp"; +} + +function normalizeProcessingInfo(data: any): AiProcessingInfo { + const result = data?.result || {}; + return { + ...data, + executeId: data?.executeId || result?.executeId || result?.id || data?.id, + }; +} + +function mergeChatEvent( + list: AiMessage[], + assistantId: string | number, + event: AiChatEvent, +): AiMessage[] { + const index = list.findIndex((item) => item.id === assistantId); + if (index < 0) return list; + + const current = list[index]; + let next: AiMessage = { ...current, requestId: event.requestId }; + const data = event.data || {}; + + if (event.eventType === "PROCESSING") { + const processing = normalizeProcessingInfo(data); + const processingList = [...(next.processingList || [])]; + const existingIndex = processingList.findIndex( + (item) => + item.executeId && + processing.executeId && + item.executeId === processing.executeId, + ); + if (existingIndex >= 0) { + processingList[existingIndex] = processing; + } else { + processingList.push(processing); + } + next = { + ...next, + status: "loading", + processingList, + }; + } + + if (event.eventType === "MESSAGE") { + const chunk = data.text || ""; + if (data.type === "THINK") { + next = { + ...next, + think: `${next.think || ""}${chunk}`, + status: data.finished ? "complete" : "incomplete", + }; + } else { + next = { + ...next, + text: `${next.text || ""}${chunk}`, + status: data.finished ? "complete" : "incomplete", + }; + } + } + + if (event.eventType === "FINAL_RESULT") { + next = { + ...next, + status: data?.success === false ? "error" : "complete", + finalResult: data, + }; + if (!next.text && data?.outputText) { + next.text = data.outputText; + } + } + + if (event.eventType === "ERROR") { + next = { + ...next, + status: "error", + text: next.text || event.error || data?.error || "请求失败", + }; + } + + const copy = [...list]; + copy.splice(index, 1, next); + return copy; +} + +function markLastAssistantStopped(list: AiMessage[]): AiMessage[] { + const copy = [...list]; + for (let i = copy.length - 1; i >= 0; i -= 1) { + if (copy[i].role === "ASSISTANT") { + const status = copy[i].status; + if (status === "loading" || status === "incomplete") { + copy[i] = { ...copy[i], status: "stopped" }; + } + break; + } + } + return copy; +} + +function renderMessageText(text?: string) { + if (!text) return null; + return
{text}
; +} + +function renderAttachments(attachments?: AiAttachment[]) { + if (!attachments?.length) return null; + return ( + + ); +} + +function renderProcessing(processingList?: AiProcessingInfo[]) { + if (!processingList?.length) return null; + return ( +
+ {processingList.map((item, index) => ( +
+ + + {item.name || item.type || "工具调用"} + + + {item.status || (item.result?.success === false ? "FAILED" : "")} + +
+ ))} +
+ ); +} + +export default function AiChatPage({ + autoNew, + onAutoNewConsumed, +}: AiChatPageProps) { + const [agentContext, setAgentContext] = useState(null); + const [conversations, setConversations] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(""); + const [selectedConversationId, setSelectedConversationId] = useState< + number | null + >(null); + const [conversationDetail, setConversationDetail] = + useState(null); + const [messages, setMessages] = useState([]); + const [messageInput, setMessageInput] = useState(""); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [selectedSkills, setSelectedSkills] = useState([]); + const [selectedComponents, setSelectedComponents] = useState< + AiSelectedComponent[] + >([]); + const [modelOptions, setModelOptions] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(); + const [skillPanelOpen, setSkillPanelOpen] = useState(false); + const [skillTab, setSkillTab] = useState<"all" | "recent" | "favorite">( + "all", + ); + const [skillKeyword, setSkillKeyword] = useState(""); + const [skillOptions, setSkillOptions] = useState([]); + const [mcpPanelOpen, setMcpPanelOpen] = useState(false); + const [loadingContext, setLoadingContext] = useState(true); + const [loadingList, setLoadingList] = useState(false); + const [loadingConversation, setLoadingConversation] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMoreMessages, setHasMoreMessages] = useState(false); + const [streaming, setStreaming] = useState(false); + const [currentRequestId, setCurrentRequestId] = useState(""); + const [uploading, setUploading] = useState(false); + + const fileInputRef = useRef(null); + const scrollRef = useRef(null); + const streamRef = useRef(null); + const topicUpdatedRef = useRef(false); + + const selectedConversation = useMemo( + () => + conversations.find((item) => item.id === selectedConversationId) || + conversationDetail, + [conversationDetail, conversations, selectedConversationId], + ); + + const manualComponents = conversationDetail?.agent?.manualComponents || []; + const mcpComponents = useMemo( + () => manualComponents.filter(isMcpComponent), + [manualComponents], + ); + + const selectedComponentIds = useMemo( + () => new Set(selectedComponents.map((item) => `${item.type}:${item.id}`)), + [selectedComponents], + ); + + const selectedSkillIds = useMemo( + () => selectedSkills.map((item) => item.targetId || item.id), + [selectedSkills], + ); + + const activeModelName = useMemo(() => { + const current = modelOptions.find((item) => item.id === selectedModelId); + return current?.name || current?.model || "默认模型"; + }, [modelOptions, selectedModelId]); + + const scrollToBottom = useCallback((smooth = false) => { + requestAnimationFrame(() => { + const node = scrollRef.current; + if (!node) return; + node.scrollTo({ + top: node.scrollHeight, + behavior: smooth ? "smooth" : "instant", + }); + }); + }, []); + + const refreshConversations = useCallback( + async (keyword = searchKeyword) => { + if (!agentContext) return; + setLoadingList(true); + try { + const list = await fetchAiConversations({ + agentId: agentContext.agentId, + limit: 30, + topic: keyword.trim() || undefined, + }); + setConversations(list); + } catch (error) { + message.error( + error instanceof Error ? error.message : "加载会话列表失败", + ); + } finally { + setLoadingList(false); + } + }, + [agentContext, searchKeyword], + ); + + const openConversation = useCallback( + async (conversationId: number) => { + setSelectedConversationId(conversationId); + setLoadingConversation(true); + setSkillPanelOpen(false); + setMcpPanelOpen(false); + topicUpdatedRef.current = false; + try { + const detail = await fetchAiConversationDetail(conversationId); + setConversationDetail(detail); + const initialMessages = detail.messageList || []; + setMessages(initialMessages); + setHasMoreMessages(initialMessages.length > 0); + + const defaultComponents = (detail.agent?.manualComponents || []) + .filter((item) => item.defaultSelected === 1) + .map((item) => ({ id: item.id, type: item.type })); + setSelectedComponents(defaultComponents); + setSelectedSkills([]); + setUploadedFiles([]); + setMessageInput(""); + scrollToBottom(false); + return detail; + } catch (error) { + message.error( + error instanceof Error ? error.message : "加载会话详情失败", + ); + return null; + } finally { + setLoadingConversation(false); + } + }, + [scrollToBottom], + ); + + const createAndOpenConversation = useCallback(async () => { + if (!agentContext) return null; + setLoadingConversation(true); + try { + const conversation = await createAiConversation(agentContext.agentId); + setConversations((prev) => [conversation, ...prev]); + return await openConversation(conversation.id); + } catch (error) { + message.error(error instanceof Error ? error.message : "新建对话失败"); + return null; + } finally { + setLoadingConversation(false); + } + }, [agentContext, openConversation]); + + useEffect(() => { + let disposed = false; + (async () => { + setLoadingContext(true); + try { + const context = await resolveAiAgentContext(); + if (disposed) return; + setAgentContext(context); + const [models, list] = await Promise.all([ + fetchAiModelOptions(context.agentId).catch(() => []), + fetchAiConversations({ agentId: context.agentId, limit: 30 }), + ]); + if (disposed) return; + setModelOptions(models); + setSelectedModelId(models[0]?.id); + setConversations(list); + if (list[0]) await openConversation(list[0].id); + } catch (error) { + message.error( + error instanceof Error ? error.message : "初始化 AI 对话失败", + ); + } finally { + if (!disposed) setLoadingContext(false); + } + })(); + return () => { + disposed = true; + streamRef.current?.abort(); + }; + }, []); + + useEffect(() => { + if (!agentContext) return; + const timer = setTimeout(() => { + refreshConversations(searchKeyword); + }, 300); + return () => clearTimeout(timer); + }, [agentContext, refreshConversations, searchKeyword]); + + useEffect(() => { + if (autoNew && agentContext) { + onAutoNewConsumed?.(); + createAndOpenConversation(); + } + }, [agentContext, autoNew, createAndOpenConversation, onAutoNewConsumed]); + + useEffect(() => { + if (!skillPanelOpen) return; + let disposed = false; + (async () => { + try { + const list = await fetchAtSkills({ + type: skillTab, + kw: skillKeyword.trim() || undefined, + pageSize: 20, + }); + if (!disposed) setSkillOptions(list); + } catch { + if (!disposed) setSkillOptions([]); + } + })(); + return () => { + disposed = true; + }; + }, [skillKeyword, skillPanelOpen, skillTab]); + + useEffect(() => { + scrollToBottom(false); + }, [messages, scrollToBottom]); + + const loadMoreMessages = async () => { + if (!selectedConversationId || loadingMore || !hasMoreMessages) return; + const first = messages[0] as AiMessage | undefined; + const index = Number(first?.index || 0); + setLoadingMore(true); + try { + const older = await fetchAiMessages({ + conversationId: selectedConversationId, + index, + size: MESSAGE_PAGE_SIZE, + }); + if (older.length > 0) { + setMessages((prev) => [...older, ...prev]); + } + setHasMoreMessages(older.length >= MESSAGE_PAGE_SIZE); + } catch (error) { + message.error( + error instanceof Error ? error.message : "加载历史消息失败", + ); + } finally { + setLoadingMore(false); + } + }; + + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (!files.length) return; + setUploading(true); + try { + const uploaded = await Promise.all(files.map(uploadAiAttachment)); + setUploadedFiles((prev) => [...prev, ...uploaded]); + } catch (error) { + message.error(error instanceof Error ? error.message : "附件上传失败"); + } finally { + setUploading(false); + } + }; + + const removeUploadedFile = (uid: string) => { + setUploadedFiles((prev) => prev.filter((item) => item.uid !== uid)); + }; + + const toggleSkill = (skill: AiSkillMention) => { + const skillId = skill.targetId || skill.id; + setSelectedSkills((prev) => { + if (prev.some((item) => (item.targetId || item.id) === skillId)) { + return prev.filter((item) => (item.targetId || item.id) !== skillId); + } + return [...prev, skill]; + }); + }; + + const toggleComponent = (component: AiManualComponent) => { + setSelectedComponents((prev) => { + const key = `${component.type}:${component.id}`; + if (prev.some((item) => `${item.type}:${item.id}` === key)) { + return prev.filter((item) => `${item.type}:${item.id}` !== key); + } + return [...prev, { id: component.id, type: component.type }]; + }); + }; + + const updateTopicAfterFirstMessage = async ( + conversationId: number, + text: string, + ) => { + if (topicUpdatedRef.current || !text.trim()) return; + topicUpdatedRef.current = true; + try { + const updated = await updateAiConversationTopic({ + id: conversationId, + firstMessage: text, + }); + setConversationDetail((prev) => + prev && prev.id === conversationId ? { ...prev, ...updated } : prev, + ); + setConversations((prev) => + prev.map((item) => + item.id === conversationId ? { ...item, ...updated } : item, + ), + ); + } catch { + /* topic update is best effort */ + } + }; + + const ensureConversationForSend = async () => { + if (selectedConversationId && conversationDetail) { + return conversationDetail; + } + const created = await createAndOpenConversation(); + if (!created) return null; + return created; + }; + + const sendMessage = async (overrideText?: string) => { + const text = (overrideText ?? messageInput).trim(); + if ((!text && uploadedFiles.length === 0) || streaming || uploading) return; + + const detail = await ensureConversationForSend(); + if (!detail || !agentContext) return; + + const conversationId = detail.id; + const attachments = uploadedFiles.map(toAttachment); + const localUserMessage = createLocalUserMessage(text, attachments); + const localAssistantMessage = createLocalAssistantMessage(); + const assistantId = localAssistantMessage.id; + + setMessages((prev) => [ + ...prev.map((item) => + item.status === "incomplete" + ? { ...item, status: "complete" as const } + : item, + ), + localUserMessage, + localAssistantMessage, + ]); + setMessageInput(""); + setUploadedFiles([]); + setSelectedSkills([]); + setStreaming(true); + setCurrentRequestId(""); + scrollToBottom(true); + + const stream = sendAiMessageStream( + { + conversationId, + message: text, + attachments, + debug: false, + selectedComponents, + sandboxId: agentContext.configId + ? String(agentContext.configId) + : undefined, + skillIds: selectedSkillIds, + modelId: selectedModelId, + }, + { + onEvent: (event) => { + if (event.requestId) setCurrentRequestId(event.requestId); + updateTopicAfterFirstMessage(conversationId, text); + setMessages((prev) => mergeChatEvent(prev, assistantId, event)); + }, + onError: (error) => { + message.error(error.message || "消息发送失败"); + setMessages((prev) => + prev.map((item) => + item.id === assistantId ? { ...item, status: "error" } : item, + ), + ); + }, + onClose: (reason) => { + setStreaming(false); + if (reason === "abort") { + setMessages((prev) => markLastAssistantStopped(prev)); + } + refreshConversations(searchKeyword); + }, + }, + ); + streamRef.current = stream; + }; + + const stopStreaming = async () => { + const requestId = currentRequestId; + streamRef.current?.abort(); + if (requestId) { + try { + await stopAiConversation(requestId); + } catch { + /* local abort already updated UI */ + } + } + setStreaming(false); + setMessages((prev) => markLastAssistantStopped(prev)); + }; + + const handleInputKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } + }; + + const skillPanel = ( +
+ setSkillTab(key as typeof skillTab)} + /> + setSkillKeyword(event.target.value)} + /> +
+ {skillOptions.length === 0 ? ( + + ) : ( + skillOptions.map((skill) => { + const skillId = skill.targetId || skill.id; + const active = selectedSkillIds.includes(skillId); + return ( + + ); + }) + )} +
+
+ ); + + const mcpPanel = ( +
+ {mcpComponents.length === 0 ? ( + + ) : ( + mcpComponents.map((component) => { + const active = selectedComponentIds.has( + `${component.type}:${component.id}`, + ); + return ( + + ); + }) + )} +
+ ); + + const renderConversationList = () => { + if (loadingList) { + return ( +
+ +
+ ); + } + if (!conversations.length) { + return ( +
+ +
+ ); + } + + return conversations.map((item) => { + const active = item.id === selectedConversationId; + return ( + + ); + }); + }; + + const renderEmptyChat = () => { + const suggestions = conversationDetail?.agent?.guidQuestionDtos || []; + return ( +
+
+

你好,我是陇电 AI 助手

+

我可以帮你处理工作任务、编写代码、分析数据、生成文档。

+ {suggestions.length > 0 && ( +
+ {suggestions.slice(0, 4).map((item, index) => { + const content = item.content || item.question || ""; + return ( + + ); + })} +
+ )} +
+ ); + }; + + const renderMessage = (item: AiMessage) => { + const isUser = item.role === "USER"; + return ( +
+ {!isUser &&
AI
} +
+ {item.think && ( +
+ 思考 + {item.think} +
+ )} + {renderMessageText(item.text)} + {renderAttachments(item.attachments)} + {renderProcessing(item.processingList)} + {item.status === "loading" && ( +
+ 正在生成... +
+ )} + {item.status === "incomplete" && ( +
+ 输出中... +
+ )} + {item.status === "stopped" && ( +
已停止
+ )} + {item.status === "error" && ( +
+ {item.finalResult?.error || "生成失败"} +
+ )} + {!isUser && item.text && item.status !== "loading" && ( +
+ + +
+ )} +
+
+ ); + }; + + if (loadingContext) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+
+ 陇电数字员工 > AI 对话 +
+
+ + {activeModelName} +
+
+ +
+ {loadingConversation ? ( +
+ +
+ ) : messages.length === 0 ? ( + renderEmptyChat() + ) : ( + <> + {hasMoreMessages && ( +
+ +
+ )} + {messages.map(renderMessage)} + + )} +
+ +