diff --git a/app/main/handlers/auxWindowManager/openAIConcurrentStream/index.js b/app/main/handlers/auxWindowManager/openAIConcurrentStream/index.js index ca749ac843..3fc3ff4dea 100644 --- a/app/main/handlers/auxWindowManager/openAIConcurrentStream/index.js +++ b/app/main/handlers/auxWindowManager/openAIConcurrentStream/index.js @@ -29,9 +29,13 @@ function register(manager, mainWindow) { } } + /** + * 子窗口打开后通过 fetch-concurrent-stream-contents 主动向主窗口拉取 rawData。 + * 这里做 requestId 中转:转发请求到主窗口,等待响应后再 resolve 子窗口的 invoke。 + */ ipcMain.handle(FETCH_CONTENTS, async (_event, frame) => { if (!frame?.session || !frame?.token || !mainWindow || mainWindow.isDestroyed()) { - return { contentEntries: [] } + return { rawData: [], execFileRecord: [], childrenTokens: [] } } const requestId = crypto.randomUUID() @@ -40,12 +44,12 @@ function register(manager, mainWindow) { return new Promise((resolve) => { const timeout = setTimeout(() => { ipcMain.removeAllListeners(responseChannel) - resolve({ contentEntries: [] }) + resolve({ rawData: [], execFileRecord: [], childrenTokens: [] }) }, 15000) ipcMain.once(responseChannel, (_responseEvent, data) => { clearTimeout(timeout) - resolve(data ?? { contentEntries: [] }) + resolve(data ?? { rawData: [], execFileRecord: [], childrenTokens: [] }) }) safeSendMain('fetch-concurrent-stream-contents-request', { requestId, ...frame }) @@ -53,7 +57,7 @@ function register(manager, mainWindow) { }) ipcMain.handle('open-ai-concurrent-stream-window', async (_event, data) => { - if (!data || typeof data !== 'object' || !Array.isArray(data.elements)) return + if (!data || typeof data !== 'object') return const singletonKey = buildSingletonKey(data) const title = typeof data.taskName === 'string' && data.taskName ? data.taskName : 'Concurrent Stream' return manager.create({ @@ -65,16 +69,6 @@ function register(manager, mainWindow) { height: 800, }) }) - - ipcMain.on('request-ai-concurrent-stream-refresh', (event, params) => { - for (const entry of manager.windows.values()) { - if (entry.win.isDestroyed()) continue - if (entry.meta.route !== ROUTE) continue - if (entry.win.webContents !== event.sender) continue - safeSendMain('refresh-ai-concurrent-stream', params) - return - } - }) } module.exports = { register, ROUTE } diff --git a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/AIConcurrentStream.tsx b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/AIConcurrentStream.tsx index 321ab5b91c..22f113b59c 100644 --- a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/AIConcurrentStream.tsx +++ b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/AIConcurrentStream.tsx @@ -1,43 +1,64 @@ -import React, { Suspense, lazy, startTransition, useEffect, useMemo, useState } from 'react' +import React, { lazy, memo, startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { yakitAuxWindow } from '@/services/electronBridge' -import ChatIPCContext from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' -import ConcurrentStreamSkeleton from '@/auxWindow/components/ConcurrentStreamSkeleton/ConcurrentStreamSkeleton' import { type ConcurrentStreamFramePayload, isConcurrentStreamFrame, } from '@/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame' -import { AIChatQSDataTypeEnum, type AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' -import { buildConcurrentStreamContext } from './buildConcurrentStreamContext' +import { AIChatQSDataTypeEnum, AIYakExecFileRecord, type AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' import { fetchConcurrentStreamContents } from './fetchConcurrentStreamContents' import styles from './AIConcurrentStream.module.scss' +import AIConcurrentStreamContent, { + AIConcurrentStreamDispatcher, + AIConcurrentStreamStore, +} from './useContext/AIConcurrentStreamContent' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import { useDebounceFn } from 'ahooks' +import ConcurrentStreamSkeleton from '@/auxWindow/components/ConcurrentStreamSkeleton/ConcurrentStreamSkeleton' -const ConcurrentStreamCard = lazy(() => import('@/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard')) -const AITaskDefaultGroupCard = lazy( - () => import('@/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard'), +// 子卡片按需加载,避免重型卡片(AINodeItem 及其下游 review/report/fuzz 等子卡) +// 全量进入 aux bundle,拉长 did-finish-load 与首次开窗耗时。 +const AIChildWindowTaskDefaultGroupCard = lazy( + () => + import('@/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard'), +) +const AIChildWindowConcurrentStreamCard = lazy( + () => + import('@/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard'), ) - -const { ipcRenderer } = window.require('electron') interface AIConcurrentStreamProps { windowId: string } -const AIConcurrentStream: React.FC = ({ windowId }) => { +const AIConcurrentStream: React.FC = memo(({ windowId }) => { const [frame, setFrame] = useState(null) const [contentVersion, setContentVersion] = useState(0) - const [contentEntries, setContentEntries] = useState | null>(null) - const [loadingContents, setLoadingContents] = useState(false) + const [loading, setLoading] = useState(true) + + // rawData/execFileRecord/childrenTokens 用 ref 存储,更新不触发渲染; + // 组件及子组件的重渲染由 contentVersion(renderNum)驱动 + const rawDataRef = useRef>(new Map()) + const execFileRecordRef = useRef>(new Map()) + const childrenTokensRef = useRef([]) useEffect(() => { if (!windowId) return - const applyFrame = (payload: Record) => { + const applyFrame = (payload: ConcurrentStreamFramePayload) => { if (!isConcurrentStreamFrame(payload)) return + const newFrame: ConcurrentStreamFramePayload = { + ...payload, + } + // 开窗时 frame 只携带轻量元数据 startTransition(() => { - setFrame(payload) - setContentEntries(null) - setContentVersion((v) => v + 1) + setFrame((v) => ({ + ...v, + ...newFrame, + renderNum: (v?.renderNum || newFrame.renderNum || 0) + 1, + })) }) + // 收到 frame 后,主动向主窗口拉取本次需要渲染的 rawData。 + fetchContents(newFrame) } const offInit = yakitAuxWindow.onInit((msg) => { @@ -58,89 +79,81 @@ const AIConcurrentStream: React.FC = ({ windowId }) => } }, [windowId]) - useEffect(() => { - if (!frame || contentVersion === 0) return - - let cancelled = false - setLoadingContents(true) - + const fetchContents = useMemoizedFn((frame) => { + setLoading(true) fetchConcurrentStreamContents(frame) .then((entries) => { - if (!cancelled) setContentEntries(entries) - }) - .catch(() => { - if (!cancelled) setContentEntries([]) + rawDataRef.current = entries.rawData + execFileRecordRef.current = entries.execFileRecord + childrenTokensRef.current = entries.childrenTokens }) .finally(() => { - if (!cancelled) setLoadingContents(false) + setTimeout(() => { + setContentVersion((v) => v + 1) + setLoading(false) + }, 200) }) + }) - return () => { - cancelled = true - } - }, [frame, contentVersion]) - - const contextValue = useMemo(() => { - if (!frame || !contentEntries) return null - return buildConcurrentStreamContext({ ...frame, contentEntries }) - }, [contentEntries, frame]) + // 首次拉取立即执行;后续刷新走 500ms 去抖,合并短时间内的多次推送 + const getRawDataDebounced = useDebounceFn((frame) => fetchContents(frame), { + wait: 500, + leading: true, + }).run + const getRawData = useMemoizedFn((frame) => { + getRawDataDebounced(frame) + }) const isTaskDefaultGroup = useMemo(() => { - if (!frame || !contentEntries) return false - const root = contentEntries.find(([key]) => key === frame.token)?.[1] + if (!frame) return false + // 优先用 frame 随身携带的 rootType,无需等待 rawData 拉取完成 + if (frame.rootType != null) return frame.rootType === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP + const root = rawDataRef.current.get(frame.token) return root?.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP - }, [contentEntries, frame]) - - const cardKey = `${frame?.session}:${frame?.token}:${frame?.chatType}` + }, [frame, contentVersion]) - const requestRefresh = () => { + // 刷新:通过 IPC 通知主窗口重新构建并推送最新 frame(含最新 rawData) + const requestRefresh = useMemoizedFn(() => { if (!frame) return - ipcRenderer.send('request-ai-concurrent-stream-refresh', { - type: 'openAIConcurrentStream', - data: { - session: frame.session, - token: frame.token, - chatType: frame.chatType, - }, - }) - } - - if (!frame || loadingContents || !contextValue) { + getRawData(frame) + }) + const store: AIConcurrentStreamStore = useMemo(() => { + return { + session: frame?.session ?? '', + token: frame?.token ?? '', + chatType: frame?.chatType ?? 'task', + childrenTokens: [...childrenTokensRef.current], + rawData: rawDataRef.current, + execFileRecord: execFileRecordRef.current, + renderNum: contentVersion, + } + }, [contentVersion]) + const dispatcher: AIConcurrentStreamDispatcher = useMemo(() => { + return { + requestRefresh, + } + }, []) + // frame 到达即可渲染卡片:rootType 已随 frame 下发,懒加载 chunk 与 rawData 拉取并行解析 + if (!frame || loading) { return } return ( - +
}> {isTaskDefaultGroup ? ( - + ) : ( - + )}
- + ) -} +}) export default AIConcurrentStream diff --git a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/buildConcurrentStreamContext.ts b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/buildConcurrentStreamContext.ts deleted file mode 100644 index 88ad8de766..0000000000 --- a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/buildConcurrentStreamContext.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { cloneDeep } from 'lodash' -import { - defaultDispatcherOfChatIPC, - type ChatIPCContextValue, -} from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' -import { defaultChatIPCData } from '@/pages/ai-agent/defaultConstant' -import { ChatDataStore } from '@/pages/ai-agent/store/ChatDataStore' -import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' -import type { ConcurrentStreamFramePayload } from '@/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame' - -export function buildConcurrentStreamContext({ - session, - contentEntries, -}: ConcurrentStreamFramePayload & { - contentEntries: Array<[string, AIChatQSData]> -}): ChatIPCContextValue { - const chatDataStore = new ChatDataStore() - chatDataStore.create(session) - - contentEntries.forEach(([mapKey, content]) => { - const chatData = chatDataStore.get(session) - if (!chatData) return - if (content.chatType === 'task') { - chatData.taskChat.contents.set(mapKey, content) - } else { - chatData.casualChat.contents.set(mapKey, content) - } - }) - - return { - store: { - chatIPCData: cloneDeep(defaultChatIPCData), - reviewInfo: undefined, - planReviewTreeKeywordsMap: new Map(), - reviewExpand: false, - }, - dispatcher: { - ...defaultDispatcherOfChatIPC, - chatIPCEvents: { - ...defaultDispatcherOfChatIPC.chatIPCEvents, - fetchChatDataStore: () => chatDataStore, - }, - }, - } -} diff --git a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/fetchConcurrentStreamContents.ts b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/fetchConcurrentStreamContents.ts index ef516b38aa..833cfe6d32 100644 --- a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/fetchConcurrentStreamContents.ts +++ b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/fetchConcurrentStreamContents.ts @@ -1,11 +1,32 @@ -import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' -import type { ConcurrentStreamFramePayload } from '@/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame' +import type { AIChatQSData, AIYakExecFileRecord } from '@/pages/ai-re-act/hooks/aiRender' +import type { + ConcurrentStreamFramePayload, + FramePayload, +} from '@/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame' const { ipcRenderer } = window.require('electron') +export interface FetchConcurrentStreamContentsResponse extends Omit {} +/** + * 子窗口通过 IPC 向主窗口拉取 task 相关的全部 content 数据。 + */ export async function fetchConcurrentStreamContents( frame: ConcurrentStreamFramePayload, -): Promise> { - const result = await ipcRenderer.invoke('fetch-concurrent-stream-contents', frame) - return result?.contentEntries ?? [] +): Promise { + try { + const result = await ipcRenderer.invoke('fetch-concurrent-stream-contents', frame) + const rawData: Array<[string, AIChatQSData]> = result?.rawData ?? [] + const execFileRecord: Array<[string, AIYakExecFileRecord[]]> = result?.execFileRecord ?? [] + return { + rawData: new Map(rawData), + execFileRecord: new Map(execFileRecord), + childrenTokens: result?.childrenTokens ?? [], + } + } catch (error) { + return { + rawData: new Map(), + execFileRecord: new Map(), + childrenTokens: [], + } + } } diff --git a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/AIConcurrentStreamContent.ts b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/AIConcurrentStreamContent.ts new file mode 100644 index 0000000000..fff6d8d11e --- /dev/null +++ b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/AIConcurrentStreamContent.ts @@ -0,0 +1,31 @@ +import { + ConcurrentStreamFramePayload, + FramePayload, +} from '@/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame' +import { createContext } from 'react' + +export interface AIConcurrentStreamStore extends ConcurrentStreamFramePayload, FramePayload {} + +export interface AIConcurrentStreamDispatcher { + requestRefresh: () => void +} + +export interface AIConcurrentStreamValue { + store: AIConcurrentStreamStore + dispatcher: AIConcurrentStreamDispatcher +} + +export default createContext({ + store: { + session: '', + token: '', + chatType: 'reAct', + childrenTokens: [], + rawData: new Map(), + renderNum: 0, + execFileRecord: new Map(), + }, + dispatcher: { + requestRefresh: () => {}, + }, +}) diff --git a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/useDispatcher.ts b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/useDispatcher.ts new file mode 100644 index 0000000000..8a95f4682a --- /dev/null +++ b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/useDispatcher.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react' +import AIConcurrentStreamValue, { AIConcurrentStreamDispatcher } from './AIConcurrentStreamContent' + +export default function useAIConcurrentStreamDispatcher(): AIConcurrentStreamDispatcher { + const { dispatcher } = useContext(AIConcurrentStreamValue) + return dispatcher +} diff --git a/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/useStore.ts b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/useStore.ts new file mode 100644 index 0000000000..47c4341db3 --- /dev/null +++ b/app/renderer/src/main/src/auxWindow/pages/AIConcurrentStream/useContext/useStore.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react' +import AIConcurrentStreamValue, { AIConcurrentStreamStore } from './AIConcurrentStreamContent' + +export default function useAIConcurrentStreamStore(): AIConcurrentStreamStore { + const { store } = useContext(AIConcurrentStreamValue) + return store +} diff --git a/app/renderer/src/main/src/auxWindow/types/types.ts b/app/renderer/src/main/src/auxWindow/types/types.ts index 1b88080d6b..4c3a997d97 100644 --- a/app/renderer/src/main/src/auxWindow/types/types.ts +++ b/app/renderer/src/main/src/auxWindow/types/types.ts @@ -1,6 +1,8 @@ +import { ConcurrentStreamFramePayload } from '@/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame' + export interface AuxWindowCreateOptions { route: string - payload?: Record + payload?: ConcurrentStreamFramePayload singletonKey?: string title?: string width?: number @@ -16,13 +18,13 @@ export interface AuxWindowInitPayload { windowId: string route: string title?: string - payload: Record + payload: ConcurrentStreamFramePayload } export interface AuxWindowPushPayload { windowId: string route: string - payload: Record + payload: ConcurrentStreamFramePayload } declare global { diff --git a/app/renderer/src/main/src/components/HTTPHistory.tsx b/app/renderer/src/main/src/components/HTTPHistory.tsx index c3f36572d2..b636602bc6 100644 --- a/app/renderer/src/main/src/components/HTTPHistory.tsx +++ b/app/renderer/src/main/src/components/HTTPHistory.tsx @@ -24,7 +24,6 @@ import { OutlineFilterIcon, OutlineLog2Icon, OutlineMessageCirclePlusIcon, - OutlinePlusIcon, OutlineSearchIcon, OutlineXIcon, } from '@/assets/icon/outline' @@ -63,9 +62,9 @@ import { } from '@/assets/commonProcessIcons' import { YakitSpin } from './yakitUI/YakitSpin/YakitSpin' import { YakitButton } from './yakitUI/YakitButton/YakitButton' -import { ClockIcon, RefreshIcon } from '@/assets/newIcon' +import { RefreshIcon } from '@/assets/newIcon' import { Tooltip } from 'antd' -import { AIInputFooterRightEnum, AIInputInnerFeatureEnum } from '@/pages/ai-agent/template/type' +import { AIInputFooterRightEnum } from '@/pages/ai-agent/template/type' import { YakitCheckbox } from './yakitUI/YakitCheckbox/YakitCheckbox' import ReactResizeDetector from 'react-resize-detector' import { RemoteHistoryGV } from '@/enums/history' @@ -78,7 +77,6 @@ import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { YakitSideTab } from './yakitSideTab/YakitSideTab' import { YakitTabsProps } from './yakitSideTab/YakitSideTabType' import { JSONParseLog } from '@/utils/tool' -import { histroyAiStore } from '@/pages/ai-agent/store/ChatDataStore' import { HistoryAIReActChatProvider, useHistoryAIReActChat } from './historyAIReActChat' import YakitCollapse from './yakitUI/YakitCollapse/YakitCollapse' import { YakitPopover } from './yakitUI/YakitPopover/YakitPopover' @@ -87,6 +85,8 @@ import { FiltersItemProps } from './TableVirtualResize/TableVirtualResizeType' import { HTTPFlowRuleDataFilter } from './HTTPFlowTable/HTTPFlowRuleDataFilter' import { useCampare } from '@/hook/useCompare/useCompare' import { useBuiltinTagList } from './HTTPFlowTable/useBuiltinTagList' +import { AISourceEnum } from '@/pages/ai-re-act/hooks/grpcApi' +import { YakitRoute } from '@/enums/yakitRoute' const { ipcRenderer } = window.require('electron') const { YakitPanel } = YakitCollapse @@ -444,7 +444,12 @@ const HTTPHistoryInner: React.FC = (props) => { export const HTTPHistory: React.FC = (props) => { return ( - + ) diff --git a/app/renderer/src/main/src/components/HistroryAIReActChat.tsx b/app/renderer/src/main/src/components/HistroryAIReActChat.tsx index 9ee8abdab5..76df042ef3 100644 --- a/app/renderer/src/main/src/components/HistroryAIReActChat.tsx +++ b/app/renderer/src/main/src/components/HistroryAIReActChat.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react' +import React, { memo, useEffect, useMemo, useRef } from 'react' import { FC } from 'react' import { AIReActChat } from '@/pages/ai-re-act/aiReActChat/AIReActChat' @@ -14,7 +14,7 @@ import { AISendResProps, } from '@/pages/ai-re-act/aiReActChat/AIReActChatType' import { getAIModelAvailableInfo, isForcedSetAIModal } from '@/pages/ai-agent/aiModelList/utils' -import { useDebounceFn, useMemoizedFn, useRequest, useSafeState } from 'ahooks' +import { useDebounceFn, useInViewport, useMemoizedFn, useRequest, useSafeState, useUpdateEffect } from 'ahooks' import { apiGetGlobalNetworkConfig } from '@/pages/spaceEngine/utils' import { defaultParams, GlobalNetworkConfig } from './configNetwork/ConfigNetworkPage' import { RemoteAIAgentGV } from '@/enums/aiAgent' @@ -22,44 +22,57 @@ import { AIAgentSetting } from '@/pages/ai-agent/aiAgentType' import { getRemoteValue } from '@/utils/kv' import { AIModelForm } from '@/pages/ai-agent/aiModelList/aiModelForm/AIModelForm' import useListenWidth from '@/pages/pluginHub/hooks/useListenWidth' +import { AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' +import { loadHistoryAIEmbeddedReviewPolicy, setHistoryAIReviewPolicy } from './historyAIReActChatStorage' +import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' +import { AIAgentSettingDefault } from '@/pages/ai-agent/defaultConstant' interface HistoryAIReActChatProps { - refRef: React.RefObject showFreeChat: boolean setShowFreeChat: React.Dispatch> aiReActChatRef: React.RefObject onStartRequest: (data: AIHandleStartParams) => Promise onSendRequest: (data: AISendParams) => Promise - setSetting: React.Dispatch> - inViewport: boolean className?: string title?: React.ReactNode mergeRemoteAIAgentSetting?: (cache: AIAgentSetting, prev: AIAgentSetting) => AIAgentSetting onChatReady?: () => void externalParameters: NonNullable + + source: AISource } -const HistroryAIReActChat: FC = (props) => { +const HistroryAIReActChat: FC = memo((props) => { const { - refRef, showFreeChat, setShowFreeChat, aiReActChatRef, onStartRequest, onSendRequest, - inViewport, - setSetting, className, title = 'AI', mergeRemoteAIAgentSetting, onChatReady, externalParameters, + source, } = props + const { setSetting } = useAIAgentDispatcher() + const { setting } = useAIAgentStore() + const [_, setGlobalNetworkConfig] = useSafeState(defaultParams) + const refRef = useRef(null) + + const [inViewport = true] = useInViewport(refRef) const chatWidth = useListenWidth(refRef) + const embeddedSettingCacheReadyRef = useRef(false) + const lastPersistedEmbeddedSettingRef = useRef<{ + ReviewPolicy?: AIAgentSetting['ReviewPolicy'] + }>({}) + const { data, run, loading } = useRequest( async () => { const res = await getAIModelAvailableInfo() @@ -74,9 +87,40 @@ const HistroryAIReActChat: FC = (props) => { run() onGetGlobalNetworkConfig() getAIModelListOption() + applyHistoryAIEmbeddedReviewPolicy() + getAIAgentChatSetting() } }, [inViewport]) + const applyHistoryAIEmbeddedReviewPolicy = useMemoizedFn(async () => { + const reviewPolicy = await loadHistoryAIEmbeddedReviewPolicy() + lastPersistedEmbeddedSettingRef.current = { ReviewPolicy: reviewPolicy } + setSetting((prev) => ({ + ...prev, + ReviewPolicy: reviewPolicy, + })) + }) + + useEffect(() => { + applyHistoryAIEmbeddedReviewPolicy().finally(() => { + embeddedSettingCacheReadyRef.current = true + }) + }, [applyHistoryAIEmbeddedReviewPolicy]) + + useUpdateEffect(() => { + if (!showFreeChat) return + applyHistoryAIEmbeddedReviewPolicy() + }, [showFreeChat, applyHistoryAIEmbeddedReviewPolicy]) + + useUpdateEffect(() => { + if (!embeddedSettingCacheReadyRef.current) return + const policy = setting.ReviewPolicy ?? AIAgentSettingDefault.ReviewPolicy ?? 'manual' + if (lastPersistedEmbeddedSettingRef.current.ReviewPolicy === policy) return + setHistoryAIReviewPolicy(policy).then(() => { + lastPersistedEmbeddedSettingRef.current = { ReviewPolicy: policy } + }) + }, [setting.ReviewPolicy]) + const getAIModelListOption = useDebounceFn( () => { isForcedSetAIModal({ @@ -92,31 +136,29 @@ const HistroryAIReActChat: FC = (props) => { apiGetGlobalNetworkConfig().then(setGlobalNetworkConfig) }) - useEffect(() => { - if (inViewport) { - // 获取缓存的全局配置数据 - getRemoteValue(RemoteAIAgentGV.AIAgentChatSetting) - .then((res) => { - if (!res) return - try { - const cache = JSON.parse(res) as AIAgentSetting - if (typeof cache !== 'object') return - const { ReviewPolicy: _ignoredPolicy, ...cacheWithoutReviewPolicy } = cache - setSetting((prev) => { - const next = mergeRemoteAIAgentSetting - ? mergeRemoteAIAgentSetting(cacheWithoutReviewPolicy as AIAgentSetting, prev) - : { ...prev, ...cacheWithoutReviewPolicy } - return { - ...next, - ReviewPolicy: prev.ReviewPolicy, - } - }) - } catch (error) {} - }) - .catch(() => {}) - } - return () => {} - }, [inViewport]) + /** 获取缓存的全局配置数据 */ + const getAIAgentChatSetting = useMemoizedFn(async () => { + getRemoteValue(RemoteAIAgentGV.AIAgentChatSetting) + .then((res) => { + if (!res) return + try { + const cache = JSON.parse(res) as AIAgentSetting + if (typeof cache !== 'object') return + const { ReviewPolicy: _ignoredPolicy, ...cacheWithoutReviewPolicy } = cache + setSetting((prev) => { + const next = mergeRemoteAIAgentSetting + ? mergeRemoteAIAgentSetting(cacheWithoutReviewPolicy as AIAgentSetting, prev) + : { ...prev, ...cacheWithoutReviewPolicy } + return { + ...next, + ReviewPolicy: prev.ReviewPolicy, + Source: source, + } + }) + } catch (error) {} + }) + .catch(() => {}) + }) useEffect(() => { if (!inViewport || loading || data === undefined || data) return @@ -162,7 +204,6 @@ const HistroryAIReActChat: FC = (props) => { // 有模型 → 正常聊天 return ( = (props) => { data, externalParameters, loading, - mergeRemoteAIAgentSetting, onSendRequest, onStartRequest, run, @@ -193,6 +233,6 @@ const HistroryAIReActChat: FC = (props) => { {resultRender}
) -} +}) export { HistroryAIReActChat } diff --git a/app/renderer/src/main/src/components/historyAIReActChat/TaskDetailsPopover.tsx b/app/renderer/src/main/src/components/historyAIReActChat/TaskDetailsPopover.tsx index dfcd6d960a..1cc5747342 100644 --- a/app/renderer/src/main/src/components/historyAIReActChat/TaskDetailsPopover.tsx +++ b/app/renderer/src/main/src/components/historyAIReActChat/TaskDetailsPopover.tsx @@ -9,8 +9,8 @@ import { AITaskExecutionDetails } from '@/pages/ai-agent/chatTemplate/aiTaskExec import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { yakitNotify } from '@/utils/notification' -import { useHistoryAIReActChat } from '../withHistoryAIReActChat' import styles from './historyAIReActChat.module.scss' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' export interface TaskDetailsPopoverProps { taskLabel?: string @@ -18,13 +18,13 @@ export interface TaskDetailsPopoverProps { const TaskDetailsPopover: React.FC = ({ taskLabel = '自由对话' }) => { const { t } = useI18nNamespaces(['yakitUi']) - const { historyAIReActChatBridge } = useHistoryAIReActChat() const [visible, setVisible] = useState(false) const [aiTaskDetails, setAITaskDetails] = useState<{ key: string; label: string; goal: string }>() + const store = useCurrentStore() const handleOpen = useMemoizedFn(() => { if (visible) return - const taskId = historyAIReActChatBridge.events.fetchCurrentCasualTaskID() + const taskId = store.getState().currentCasualTaskID if (!taskId) { yakitNotify('error', 'taskId不存在') return diff --git a/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx b/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx index 4d56a67b9c..524f881102 100644 --- a/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx +++ b/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx @@ -1,24 +1,15 @@ import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react' -import { useCreation, useInViewport, useMemoizedFn, useSafeState, useUpdateEffect } from 'ahooks' -import { cloneDeep } from 'lodash' +import { useCreation, useMemoizedFn, useSafeState, useUpdateEffect } from 'ahooks' +import { clone, cloneDeep } from 'lodash' import AIAgentContext, { AIAgentContextDispatcher, AIAgentContextStore, } from '@/pages/ai-agent/useContext/AIAgentContext' -import ChatIPCContent, { - AIChatIPCSendParams, - AISendConfigHotpatchParams, - AISendSyncMessageParams, - ChatIPCContextDispatcher, - ChatIPCContextStore, - defaultDispatcherOfChatIPC, -} from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' import { AIAgentSetting } from '@/pages/ai-agent/aiAgentType' import { AIMentionCommandParams } from '@/pages/ai-agent/components/aiMilkdownInput/aiMilkdownMention/aiMentionPlugin' import { AIAgentSettingDefault } from '@/pages/ai-agent/defaultConstant' -import { ChatDataStore } from '@/pages/ai-agent/store/ChatDataStore' -import { createActiveChatSessionId, getAIReActRequestParams } from '@/pages/ai-agent/utils' +import { createActiveChatSessionId, getAIReActRequestParams, onReStart } from '@/pages/ai-agent/utils' import { AISession } from '@/pages/ai-agent/type/aiChat' import { HandleStartParams } from '@/pages/ai-agent/aiAgentChat/type' import { @@ -30,7 +21,8 @@ import { AISendParams, AISendResProps, } from '@/pages/ai-re-act/aiReActChat/AIReActChatType' -import { AIAgentGrpcApi, AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIAgentGrpcApi, AIInputEvent, AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import { YakitRoute, type YakitRouteType } from '@/enums/yakitRoute' import { applyHttpFuzzRequestChangeToWebFuzzerPage, getWebFuzzerPageIsHttps, @@ -50,24 +42,21 @@ import { normalizeYaklangCodeChangeForReview, resetYakRunnerPatchWorkingDraft, } from '../pages/yakRunner/yakRunnerAiCodePatchApply' -import { ChatIPCSendType, UseChatIPCEvents } from '@/pages/ai-re-act/hooks/type' -import useChatIPC from '@/pages/ai-re-act/hooks/useChatIPC' -import { getAISourceFromChatDataStoreKey, getChatDataStoreKey } from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' import useGetSetState from '@/pages/pluginHub/hooks/useGetSetState' -import useDeleteAIImageByNode from '@/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/hooks/useDeleteAIImageByNode' import emiter from '@/utils/eventBus/eventBus' import { HistroryAIReActChat } from './HistroryAIReActChat' -import { loadHistoryAIEmbeddedReviewPolicy, setHistoryAIReviewPolicy } from './historyAIReActChatStorage' +import { useChatIPC } from '@/pages/ai-re-act/hooks/useChatIPC' +import { useStore } from 'zustand' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' export type HistoryAIReActChatExternalParameters = NonNullable export interface HistoryAIReActChatBridge { activeID?: string - events: UseChatIPCEvents + // events: UseChatIPCEvents onStop: () => void onNewChat: () => void - onChatFromHistory: (session: string) => void setActiveChat: React.Dispatch> syncSelectedHttpFlowIds: (ids: string[]) => void registerClearTableSelection: (fn: () => void) => void @@ -180,13 +169,13 @@ function normalizeStartUserQueryToTextDescription(event: AIInputEvent): AIInputE } export interface HistoryAIReActChatProviderProps { - cacheDataStore: ChatDataStore + source: AISource + /** 会话归属路由(不可变) */ + route: YakitRouteType + /** 会话当前归属 pageId(可变,可 rebind) */ + pageId: string focusModeLoop: HistoryAIReActFocusModeLoop children: React.ReactNode - /** Web Fuzzer 页签 id:AI 改包回写、请求附件、fuzz 状态推送等桥接用,与 SessionID 无关 */ - httpFuzzTabPageId?: string - /** Yak Runner 工作区 id:AI `yaklang_code_change` 审阅/写回桥接 */ - yakRunnerPageId?: string /** * - 在 `onStartRequest` / `onSendRequest` 内置(WebFuzzer 请求附件)处理之后执行 * - Irify「代码审计」用它把工程根路径附件追加到 `AttachedResourceInfo` @@ -199,22 +188,22 @@ export interface HistoryAIReActChatProviderProps { } export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProviderInner({ - cacheDataStore, + source, + route, + pageId, focusModeLoop, children, - httpFuzzTabPageId, - yakRunnerPageId, transformInputEvent, resolveStartExtraParams, mergeRemoteAIAgentSetting, }: HistoryAIReActChatProviderProps) { const aiReActChatRef = useRef(null) const [showFreeChat, setShowFreeChat] = useSafeState(false) - const refRef = useRef(null) - const [inViewport = true] = useInViewport(refRef) - - const [setting, setSetting, getSetting] = useGetSetState(() => cloneDeep(AIAgentSettingDefault)) + const [setting, setSetting, getSetting] = useGetSetState(() => ({ + ...cloneDeep(AIAgentSettingDefault), + Source: source, + })) const [activeChat, setActiveChat] = useSafeState() const casualLoadingRef = useRef(false) const initialRequestInCasualRef = useRef(null) @@ -224,55 +213,23 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid const pendingMentionRef = useRef(null) const chatReadyRef = useRef(false) const yakRunnerLastAttachedResourceInfoRef = useRef([]) - const embeddedSettingCacheReadyRef = useRef(false) - const lastPersistedEmbeddedSettingRef = useRef<{ - ReviewPolicy?: AIAgentSetting['ReviewPolicy'] - }>({}) - - const applyHistoryAIEmbeddedReviewPolicy = useMemoizedFn(async () => { - const reviewPolicy = await loadHistoryAIEmbeddedReviewPolicy() - lastPersistedEmbeddedSettingRef.current = { ReviewPolicy: reviewPolicy } - setSetting((prev) => ({ - ...prev, - ReviewPolicy: reviewPolicy, - })) - }) - - useEffect(() => { - applyHistoryAIEmbeddedReviewPolicy().finally(() => { - embeddedSettingCacheReadyRef.current = true - }) - }, [applyHistoryAIEmbeddedReviewPolicy]) + const bridgeSessionIdRef = useRef('') + const bridgeUnsubscribeRef = useRef<(() => void) | null>(null) - useUpdateEffect(() => { - if (!showFreeChat) return - applyHistoryAIEmbeddedReviewPolicy() - }, [showFreeChat, applyHistoryAIEmbeddedReviewPolicy]) - - useUpdateEffect(() => { - if (!inViewport) return - applyHistoryAIEmbeddedReviewPolicy() - }, [inViewport, applyHistoryAIEmbeddedReviewPolicy]) + // Web Fuzzer 页签 id:AI 改包回写、请求附件、fuzz 状态推送等桥接用,与 SessionID 无关 + const isHaveWebFuzzerPageId = useCreation(() => { + return route === YakitRoute.HTTPFuzzer && !!pageId + }, [route, pageId]) - useEffect(() => { - const onRefreshEmbeddedSetting = () => { - applyHistoryAIEmbeddedReviewPolicy() - } - emiter.on('onRefreshHistoryAIEmbeddedSetting', onRefreshEmbeddedSetting) - return () => { - emiter.off('onRefreshHistoryAIEmbeddedSetting', onRefreshEmbeddedSetting) - } - }, [applyHistoryAIEmbeddedReviewPolicy]) + // Yak Runner 工作区 id:AI `yaklang_code_change` 审阅/写回桥接 + const isHaveYakRunnerPageId = useCreation(() => { + return route === YakitRoute.YakScript && !!pageId + }, [route, pageId]) useUpdateEffect(() => { - if (!embeddedSettingCacheReadyRef.current) return - const policy = setting.ReviewPolicy ?? AIAgentSettingDefault.ReviewPolicy ?? 'manual' - if (lastPersistedEmbeddedSettingRef.current.ReviewPolicy === policy) return - setHistoryAIReviewPolicy(policy).then(() => { - lastPersistedEmbeddedSettingRef.current = { ReviewPolicy: policy } - emiter.emit('onRefreshHistoryAIEmbeddedSetting', '') - }) - }, [setting.ReviewPolicy]) + // 只有配置变化了才更新,SessionID不管 + if (activeChat?.SessionID) globalSessionEngine.updateSessionConfig(activeChat?.SessionID, getSetting()) + }, [setting]) useEffect(() => { if (!showFreeChat) { @@ -291,13 +248,13 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid }) const onHttpFuzzRequestChange = useMemoizedFn((data: AIAgentGrpcApi.HttpFuzzRequestChange) => { - if (!httpFuzzTabPageId) return + if (!isHaveWebFuzzerPageId) return // casual 问答期间:有完整 raw 时不自动写包,入队审阅(`op` 仅占位描述,不作为筛选项) if (casualLoadingRef.current) { const nextRaw = data?.request?.raw if (nextRaw != null && String(nextRaw).trim() !== '' && initialRequestInCasualRef.current != null) { - enqueueWebFuzzerCasualReplaceReview(httpFuzzTabPageId, { + enqueueWebFuzzerCasualReplaceReview(pageId, { original: initialRequestInCasualRef.current ?? '', change: data, }) @@ -305,13 +262,13 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid } } - applyHttpFuzzRequestChangeToWebFuzzerPage(httpFuzzTabPageId, data) + applyHttpFuzzRequestChangeToWebFuzzerPage(pageId, data) }) const onYaklangCodeChange = useMemoizedFn((data: AIAgentGrpcApi.YaklangCodeChange) => { - if (!yakRunnerPageId) return + if (!isHaveYakRunnerPageId) return - const editorNow = getYakRunnerPageActiveCodeString(yakRunnerPageId) ?? '' + const editorNow = getYakRunnerPageActiveCodeString(pageId) ?? '' const original = data.op === 'create' ? '' @@ -322,7 +279,7 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid : '' // op=patch:后端只给片段,这里合并成全量 replace,再走原有 diff UI - const normalized = normalizeYaklangCodeChangeForReview(yakRunnerPageId, data, original) + const normalized = normalizeYaklangCodeChangeForReview(pageId, data, original) if (!normalized) return const nextCode = normalized.code?.content @@ -332,7 +289,7 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid const isCreate = normalized.op === 'create' const createFileName = isCreate ? createYakRunnerGeneratedCodeFileName() : undefined const createPath = isCreate - ? resolveYaklangCreateTargetPath(yakRunnerPageId, yakRunnerLastAttachedResourceInfoRef.current, createFileName) + ? resolveYaklangCreateTargetPath(pageId, yakRunnerLastAttachedResourceInfoRef.current, createFileName) : undefined const change = isCreate ? { @@ -344,7 +301,7 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid } : normalized - enqueueYakRunnerCasualCodeReplaceReview(yakRunnerPageId, { + enqueueYakRunnerCasualCodeReplaceReview(pageId, { original, change, fileName: createFileName, @@ -355,34 +312,18 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid // AI `http_flow_fuzz_status` 推送:把每次最新的 `runtime_id` 静默推到当前 fuzzer 页签的处理器中。 // 用户点击「查看详情」会显式再次推送并要求打开抽屉,所以这里不主动打开。 const onGetHttpFlowFuzzStatus = useMemoizedFn((data: AIAgentGrpcApi.GetHttpFlowFuzzStatus) => { - if (!httpFuzzTabPageId) return + if (!isHaveWebFuzzerPageId) return const runtimeId = data?.runtime_id if (!runtimeId) return - pushAIFuzzStatusRuntimeIdToWebFuzzerPage(httpFuzzTabPageId, runtimeId, { source: 'auto' }) + pushAIFuzzStatusRuntimeIdToWebFuzzerPage(pageId, runtimeId, { source: 'auto' }) }) - const aiSource = useCreation( - () => getAISourceFromChatDataStoreKey(getChatDataStoreKey(cacheDataStore)) ?? 'ai', - [cacheDataStore], - ) - - const [chatIPCData, events] = useChatIPC({ - autoConnect: true, - aiSource, - cacheDataStore, - getSetting, - onHttpFuzzRequestChange, - onGetHttpFlowFuzzStatus, - onYaklangCodeChange, - }) - - const imageStoreKey = useCreation(() => getChatDataStoreKey(cacheDataStore), [cacheDataStore]) - const [, { onClearImage }] = useDeleteAIImageByNode() - - const { execute, casualLoading } = chatIPCData + const { onStart, onSend, onClose, onUpdatePageId } = useChatIPC(route, pageId) + const store = globalSessionEngine.ensureSession(activeChat?.SessionID || '').store + const casualLoading = useStore(store, (state) => state.casualLoading) useEffect(() => { - if (!httpFuzzTabPageId && !yakRunnerPageId) { + if (!isHaveWebFuzzerPageId && !isHaveYakRunnerPageId) { casualLoadingRef.current = false initialRequestInCasualRef.current = null initialCodeInCasualRef.current = null @@ -390,60 +331,79 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid } if (!casualLoadingRef.current && casualLoading) { - if (httpFuzzTabPageId) { - initialRequestInCasualRef.current = getWebFuzzerPageRequestString(httpFuzzTabPageId) ?? '' + if (isHaveWebFuzzerPageId) { + initialRequestInCasualRef.current = getWebFuzzerPageRequestString(pageId) ?? '' } - if (yakRunnerPageId) { - resetYakRunnerPatchWorkingDraft(yakRunnerPageId) - initialCodeInCasualRef.current = getYakRunnerPageActiveCodeString(yakRunnerPageId) ?? '' + if (isHaveYakRunnerPageId) { + resetYakRunnerPatchWorkingDraft(pageId) + initialCodeInCasualRef.current = getYakRunnerPageActiveCodeString(pageId) ?? '' } } else if (casualLoadingRef.current && !casualLoading) { initialRequestInCasualRef.current = null initialCodeInCasualRef.current = null - if (yakRunnerPageId) { - resetYakRunnerPatchWorkingDraft(yakRunnerPageId) + if (isHaveYakRunnerPageId) { + resetYakRunnerPatchWorkingDraft(pageId) } } casualLoadingRef.current = casualLoading - }, [casualLoading, httpFuzzTabPageId, yakRunnerPageId]) + }, [casualLoading, pageId, isHaveWebFuzzerPageId, isHaveYakRunnerPageId]) + + const unsubscribeBridgeEvents = useMemoizedFn(() => { + bridgeUnsubscribeRef.current?.() + bridgeUnsubscribeRef.current = null + bridgeSessionIdRef.current = '' + }) + + // 新版流处理器会更新 session store 内的版本字段;直接订阅该 session,避免依赖 Provider 的 context。 + const subscribeBridgeEvents = useMemoizedFn((sessionId: string) => { + if (!sessionId || bridgeSessionIdRef.current === sessionId) return + + unsubscribeBridgeEvents() + const { store: sessionStore, rawData } = globalSessionEngine.ensureSession(sessionId) + bridgeSessionIdRef.current = sessionId + bridgeUnsubscribeRef.current = sessionStore.subscribe((state, previousState) => { + if (bridgeSessionIdRef.current !== sessionId) return + + if (state.httpFuzzRequestUpdate !== previousState.httpFuzzRequestUpdate && rawData.httpFuzzRequest) { + onHttpFuzzRequestChange(clone(rawData.httpFuzzRequest)) + } + if (state.httpFlowFuzzStatusUpdate !== previousState.httpFlowFuzzStatusUpdate && rawData.httpFlowFuzzStatus) { + onGetHttpFlowFuzzStatus(clone(rawData.httpFlowFuzzStatus)) + } + if (state.yaklangCodeChangeUpdate !== previousState.yaklangCodeChangeUpdate && rawData.yaklangCodeChange) { + onYaklangCodeChange(clone(rawData.yaklangCodeChange)) + } + }) + }) + + useEffect(() => unsubscribeBridgeEvents, [unsubscribeBridgeEvents]) const activeID = useCreation(() => { return activeChat?.SessionID }, [activeChat]) + /** 切换会话 */ useUpdateEffect(() => { - events.onSwitchChat(activeChat?.SessionID, activeChat?.isCreate) - }, [activeChat]) - - const handleSendInteractiveMessage = useMemoizedFn((params: AIChatIPCSendParams, type: ChatIPCSendType) => { - const { value, id, optionValue } = params - if (!activeID) return - if (!id) return - - const info: AIInputEvent = { - IsInteractiveMessage: true, - InteractiveId: id, - InteractiveJSONInput: value, + if (activeChat) { + subscribeBridgeEvents(activeChat.SessionID) + onReStart({ activeChat, onStart }) } - events.onSend({ token: activeID, type, params: info, optionValue }) - }) - - const handleSendCasual = useMemoizedFn((params: AIChatIPCSendParams) => { - const targetParams = { ...params, FocusModeLoop: focusModeLoop } - handleSendInteractiveMessage(targetParams, 'casual') - }) + }, [activeID, subscribeBridgeEvents]) const onStartRequest = useMemoizedFn((data: AIHandleStartParams) => { + const sessionId = data.params.Params?.TimelineSessionID || activeChat?.SessionID + if (sessionId) subscribeBridgeEvents(sessionId) + const newChat: AIHandleStartExtraProps = resolveStartExtraParams?.(data) ?? { chatId: activeChat?.SessionID, } return new Promise((resolve) => { let params: AIInputEvent = { ...data.params, FocusModeLoop: focusModeLoop } - if (httpFuzzTabPageId) { - const raw = getWebFuzzerPageRequestString(httpFuzzTabPageId) - const isHttps = getWebFuzzerPageIsHttps(httpFuzzTabPageId) ?? false + if (isHaveWebFuzzerPageId) { + const raw = getWebFuzzerPageRequestString(pageId) + const isHttps = getWebFuzzerPageIsHttps(pageId) ?? false const sessionId = data.params.Params?.TimelineSessionID || activeChat?.SessionID || getSetting().TimelineSessionID params = attachWebFuzzerHttpRequestToEvent(params, sessionId, raw, isHttps) @@ -451,8 +411,8 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid if (transformInputEvent) { params = transformInputEvent(params) } - if (yakRunnerPageId) { - params = appendYakRunnerWorkspaceContextToEvent(yakRunnerPageId, params) + if (isHaveYakRunnerPageId) { + params = appendYakRunnerWorkspaceContextToEvent(pageId, params) params = normalizeStartUserQueryToTextDescription(params) yakRunnerLastAttachedResourceInfoRef.current = params.AttachedResourceInfo || [] } @@ -463,17 +423,14 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid }) }) - const onChatFromHistory = useMemoizedFn((session: string) => { - events.onDelChats([session]) - }) - /** 新建会话:清空 UI、断开旧连接,并预生成新的 TimelineSessionID */ const onNewChat = useMemoizedFn(() => { const currentID = activeChat?.SessionID - if (execute && currentID) { - events.onClose(currentID) + if (store.getState().execute && currentID) { + onClose([currentID]) } - events.onReset() + // events.onReset() + unsubscribeBridgeEvents() setActiveChat(undefined) setSetting((prev) => ({ ...prev, @@ -484,48 +441,24 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid aiReActChatRef.current?.setValue('') }) - const handleDelChats = useMemoizedFn((jsonString: string) => { - try { - const sessions: string[] = JSON.parse(jsonString) - if (!sessions.length || imageStoreKey === 'unknown') return - onClearImage({ - chatDataStoreKey: imageStoreKey, - sessionID: sessions, - }) - events.onDelChats(sessions) - } catch (error) {} - }) - - useEffect(() => { - emiter.on('onDelChats', handleDelChats) - return () => { - emiter.off('onDelChats', handleDelChats) - } - }, [handleDelChats]) - const onStop = useMemoizedFn(() => { - if (execute && activeID) { - events.onClose(activeID) + if (store.getState().execute && activeID) { + onClose([activeID]) } }) - const handleSend = useMemoizedFn((params: AIChatIPCSendParams) => { - const targetParams = { ...params, FocusModeLoop: focusModeLoop } - handleSendInteractiveMessage(targetParams, '') - }) - const onSendRequest = useMemoizedFn((data: AISendParams) => { let params: AIInputEvent = { ...data.params, FocusModeLoop: focusModeLoop } - if (httpFuzzTabPageId) { - const raw = getWebFuzzerPageRequestString(httpFuzzTabPageId) - const isHttps = getWebFuzzerPageIsHttps(httpFuzzTabPageId) ?? false + if (isHaveWebFuzzerPageId) { + const raw = getWebFuzzerPageRequestString(pageId) + const isHttps = getWebFuzzerPageIsHttps(pageId) ?? false params = attachWebFuzzerHttpRequestToEvent(params, activeChat?.SessionID, raw, isHttps) } if (transformInputEvent) { params = transformInputEvent(params) } - if (yakRunnerPageId) { - params = appendYakRunnerWorkspaceContextToEvent(yakRunnerPageId, params) + if (isHaveYakRunnerPageId) { + params = appendYakRunnerWorkspaceContextToEvent(pageId, params) yakRunnerLastAttachedResourceInfoRef.current = params.AttachedResourceInfo || [] } @@ -539,84 +472,37 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid /** 与输入框提交一致:执行中走自由输入,否则开启新会话 */ const handleSubmitQuery = useMemoizedFn((value: HandleStartParams) => { const sessionID = activeChat?.SessionID - if (execute && sessionID) { - const { extra, attachedResourceInfo } = getAIReActRequestParams(value) + if (store.getState().execute && sessionID) { + const { attachedResourceInfo } = getAIReActRequestParams(value) const chatMessage: AIInputEvent = { IsFreeInput: true, FreeInput: value.qs, AttachedResourceInfo: attachedResourceInfo, FocusModeLoop: value.focusMode ?? focusModeLoop, } - const onSend = (res: AISendResProps) => { + const onSendChat = (res: AISendResProps) => { const { params } = res - events.onSend({ + onSend({ token: sessionID, type: 'casual', params: { IsFreeInput: true, ...params, }, - extraValue: extra, }) emiter.emit('sessionData', JSON.stringify({ type: 'refresh', sessionId: sessionID })) aiReActChatRef.current?.setValue('') } onSendRequest({ params: chatMessage }) - .then(onSend) + .then(onSendChat) .catch(() => { - onSend({ params: chatMessage }) + onSendChat({ params: chatMessage }) }) return } aiReActChatRef.current?.handleStart(value) }) - const handleSendSyncMessage = useMemoizedFn((data: AISendSyncMessageParams) => { - if (!activeID) return - const { syncType, SyncJsonInput } = data - const params = { ...data.params, FocusModeLoop: focusModeLoop } - const info: AIInputEvent = { - IsSyncMessage: true, - SyncType: syncType, - SyncJsonInput, - Params: params, - } - events.onSend({ token: activeID, type: '', params: info }) - }) - - const handleSendConfigHotpatch = useMemoizedFn((data: AISendConfigHotpatchParams) => { - if (!activeID) return - const { hotpatchType } = data - - const params = { ...data.params, FocusModeLoop: focusModeLoop } - const info: AIInputEvent = { - IsConfigHotpatch: true, - HotpatchType: hotpatchType, - Params: params, - } - events.onSend({ token: activeID, type: '', params: info }) - }) - - const store: ChatIPCContextStore = useCreation(() => { - return { - chatIPCData, - planReviewTreeKeywordsMap: new Map(), - reviewExpand: false, - } - }, [chatIPCData]) - - const dispatcher: ChatIPCContextDispatcher = useCreation(() => { - return { - ...defaultDispatcherOfChatIPC, - chatIPCEvents: events, - handleSendCasual, - handleStop: onStop, - handleSend, - handleSendSyncMessage, - handleSendConfigHotpatch, - } - }, [events]) - const stores: AIAgentContextStore = useMemo(() => { return { setting: setting, @@ -629,16 +515,19 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid getSetting: getSetting, setSetting: setSetting, setActiveChat: setActiveChat, + onStart, + onSend, + onClose, + onUpdatePageId, } }, []) const historyAIReActChatBridge: HistoryAIReActChatBridge = useMemo( () => ({ activeID, - events, + // events, onStop, onNewChat, - onChatFromHistory, setActiveChat, syncSelectedHttpFlowIds: (ids: string[]) => { aiReActChatRef.current?.setHttpFlow?.(ids) @@ -672,7 +561,7 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid }) }, }), - [activeID, events, onStop, onNewChat, onChatFromHistory, setActiveChat, handleSubmitQuery], + [activeID, onStop, onNewChat, setActiveChat, handleSubmitQuery], ) const renderHistoryAIReActChat = useCallback( @@ -680,20 +569,18 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid ), - [inViewport, flushPendingMention, mergeRemoteAIAgentSetting, onSendRequest, onStartRequest, showFreeChat], + [flushPendingMention, mergeRemoteAIAgentSetting, onSendRequest, onStartRequest, showFreeChat, source], ) const contextValue = useMemo( @@ -709,9 +596,7 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid return ( - - {children} - + {children} ) }) diff --git a/app/renderer/src/main/src/enums/yakitRoute.ts b/app/renderer/src/main/src/enums/yakitRoute.ts index a44e2a5490..d9531f866e 100644 --- a/app/renderer/src/main/src/enums/yakitRoute.ts +++ b/app/renderer/src/main/src/enums/yakitRoute.ts @@ -131,3 +131,5 @@ export enum YakitRoute { /** 技能 */ AI_Forge = 'ai-forge', } + +export type YakitRouteType = `${YakitRoute}` diff --git a/app/renderer/src/main/src/pages/KnowledgeBase/compoment/KnowledgeBaseContent.tsx b/app/renderer/src/main/src/pages/KnowledgeBase/compoment/KnowledgeBaseContent.tsx index 6079625583..9caad8e85e 100644 --- a/app/renderer/src/main/src/pages/KnowledgeBase/compoment/KnowledgeBaseContent.tsx +++ b/app/renderer/src/main/src/pages/KnowledgeBase/compoment/KnowledgeBaseContent.tsx @@ -35,7 +35,6 @@ import { apiCancelDebugPlugin } from '@/pages/plugins/utils' import { KnowledgeBaseTableHeaderProps } from './KnowledgeBaseTableHeader' import { CreateKnowledgeBaseData } from '../TKnowledgeBase' -import { knowledgeBaseDataStore } from '@/pages/ai-agent/store/ChatDataStore' import { HistoryAIReActChatProvider, useHistoryAIReActChat } from '@/components/historyAIReActChat' import { AIAgentSetting } from '@/pages/ai-agent/aiAgentType' import { AIHandleStartParams } from '@/pages/ai-re-act/aiReActChat/AIReActChatType' @@ -46,7 +45,6 @@ import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' import { GuideFooter } from './GuideFooter' import { YakitResizeBox } from '@/components/yakitUI/YakitResizeBox/YakitResizeBox' import { OutlineMessageCirclePlusIcon, OutlineXIcon } from '@/assets/icon/outline' -import { OutlinePlusIcon } from '@/assets/newIcon' import { HoldGRPCStreamInfo } from '@/hook/useHoldGRPCStream/useHoldGRPCStreamType' import { InstallPluginModal } from './InstallPluginModal/InstallPluginModal' import { reseultKnowledgePlugin, useCheckKnowledgePlugin } from '../hooks/useCheckKnowledgePlugin' @@ -60,6 +58,7 @@ import { ImportModal } from './ImportModal' import { grpcFetchLocalPluginDetail } from '@/pages/pluginHub/utils/grpc' import { YakitModal } from '@/components/yakitUI/YakitModal/YakitModal' import { PluginExecuteResult } from '@/pages/plugins/operator/pluginExecuteResult/PluginExecuteResult' +import { AISourceEnum } from '@/pages/ai-re-act/hooks/grpcApi' interface KnowledgeBaseContentProps { knowledgeBaseID: string @@ -684,7 +683,9 @@ const KnowledgeBaseContent = forwardRef(func return ( = React.memo((props) => { checkAndShowDataMigration() }, []) + // 旧 aiChatMessageStore 预热/关闭已屏蔽;有旧库则删掉释放磁盘,没有则忽略 + // useEffect(() => { + // aiChatMessageStore.open().catch((err) => { + // yakitFailed('IndexedDB打开失败: ' + (err instanceof Error ? err.message : String(err))) + // }) + // return () => { + // aiChatMessageStore.close().catch((err) => { + // yakitFailed('IndexedDB关闭失败: ' + (err instanceof Error ? err.message : String(err))) + // }) + // } + // }, []) + + // 新 AI 会话持久化库:挂载预热、卸载关闭;顺带清理旧库 aiChatMessageDB useEffect(() => { - // 打开indexedDB - aiChatMessageStore.open().catch((err) => { - yakitFailed('IndexedDB打开失败: ' + (err instanceof Error ? err.message : String(err))) + try { + const req = indexedDB.deleteDatabase(LEGACY_AI_CHAT_MESSAGE_DB) + // 有则删、无则成功回调;失败/blocked 也不影响新库 + req.onerror = () => undefined + req.onblocked = () => undefined + } catch { + // 环境不支持 IndexedDB 时忽略 + } + + aiChatPersistStore.open().catch((err) => { + yakitFailed('AI会话IndexedDB打开失败: ' + (err instanceof Error ? err.message : String(err))) }) return () => { - aiChatMessageStore.close().catch((err) => { - yakitFailed('IndexedDB关闭失败: ' + (err instanceof Error ? err.message : String(err))) + aiChatPersistStore.close().catch((err) => { + yakitFailed('AI会话IndexedDB关闭失败: ' + (err instanceof Error ? err.message : String(err))) }) } }, []) diff --git a/app/renderer/src/main/src/pages/ai-agent/AIAgent.tsx b/app/renderer/src/main/src/pages/ai-agent/AIAgent.tsx index 2c98dee4c1..de2834a911 100644 --- a/app/renderer/src/main/src/pages/ai-agent/AIAgent.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/AIAgent.tsx @@ -29,6 +29,10 @@ import { AIBottomDetails } from './aiBottomDetails/AIBottomDetails' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { omit } from 'lodash' import { grpcDeleteAISession } from './grpc' +import { useChatIPC } from '../ai-re-act/hooks/useChatIPC' +import { AISourceEnum } from '../ai-re-act/hooks/grpcApi' +import { YakitRoute } from '@/enums/yakitRoute' +import { globalSessionEngine } from '../ai-re-act/hooks/ChatMultiSessionController' /** 清空用户缓存的固定值 */ export const AIAgentCacheClearValue = '20260113' @@ -91,9 +95,13 @@ export const AIAgent: React.FC = (props) => { // 缓存全局配置数据 useUpdateEffect(() => { const cache = omit(getSetting(), ['AIService', 'AIModelName']) + // 只有配置变化了才更新,SessionID不管 + if (activeChat?.SessionID) globalSessionEngine.updateSessionConfig(activeChat?.SessionID, getSetting()) setRemoteValue(RemoteAIAgentGV.AIAgentChatSetting, JSON.stringify(cache)) }, [setting]) + const { onStart, onSend, onClose, onUpdatePageId } = useChatIPC(YakitRoute.AI_Agent, YakitRoute.AI_Agent) + const store: AIAgentContextStore = useMemo(() => { return { setting: setting, @@ -105,6 +113,10 @@ export const AIAgent: React.FC = (props) => { getSetting: getSetting, setSetting: setSetting, setActiveChat: setActiveChat, + onStart, + onSend, + onClose, + onUpdatePageId, } }, []) @@ -136,6 +148,7 @@ export const AIAgent: React.FC = (props) => { EnableGoalMode: false, GoalMinIterations: AIAgentSettingDefault.Strategy?.GoalMinIterations, }, + Source: AISourceEnum.aiAgent, }) } catch (error) {} }) diff --git a/app/renderer/src/main/src/pages/ai-agent/AIAgentSideList.tsx b/app/renderer/src/main/src/pages/ai-agent/AIAgentSideList.tsx index ccaac441a5..2fb8653b63 100644 --- a/app/renderer/src/main/src/pages/ai-agent/AIAgentSideList.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/AIAgentSideList.tsx @@ -12,8 +12,6 @@ import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { AI_AGENT_HISTORY_AI_SOURCES } from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' const AIChatSetting = React.lazy(() => import('./AIChatSetting/AIChatSetting')) -const ForgeName = React.lazy(() => import('./forgeName/ForgeName')) -const AIToolList = React.lazy(() => import('./aiToolList/AIToolList')) const AIModelList = React.lazy(() => import('./aiModelList/AIModelList')) const HistoryChat = React.lazy(() => import('./historyChat/HistoryChat')) const AIMCP = React.lazy(() => import('./aiMCP/AIMCP')) diff --git a/app/renderer/src/main/src/pages/ai-agent/AIChatSetting/AIChatSetting.tsx b/app/renderer/src/main/src/pages/ai-agent/AIChatSetting/AIChatSetting.tsx index 14395926c2..db0b6fbf79 100644 --- a/app/renderer/src/main/src/pages/ai-agent/AIChatSetting/AIChatSetting.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/AIChatSetting/AIChatSetting.tsx @@ -37,16 +37,7 @@ const AIChatSetting: React.FC = memo((props) => { form && form.setFieldsValue({ ...(setting || {}) }) }, [setting]) - const handleFormChange = useMemoizedFn((changedValues, value) => { - if (!!changedValues.ReviewPolicy) { - emiter.emit('onRefreshAIReviewRuleSelect', JSON.stringify({ reviewPolicy: changedValues.ReviewPolicy })) - } - if (changedValues.AIReviewRiskControlScore !== undefined) { - emiter.emit( - 'onRefreshAIReviewRuleSelect', - JSON.stringify({ AIReviewRiskControlScore: changedValues.AIReviewRiskControlScore }), - ) - } + const handleFormChange = useMemoizedFn((changedValues) => { setSetting && setSetting((old) => ({ ...old, ...changedValues })) }) diff --git a/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/AIAgentChat.tsx b/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/AIAgentChat.tsx index a3e6b62525..7e4438abdd 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/AIAgentChat.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/AIAgentChat.tsx @@ -1,31 +1,17 @@ -import React, { memo, useEffect, useMemo, useRef, useState } from 'react' +import React, { memo, useEffect, useRef, useState } from 'react' import { AIAgentChatMode, AIAgentChatProps, AIReActTaskChatReviewProps, HandleStartParams } from './type' -import { useCreation, useDebounceFn, useInViewport, useMap, useMemoizedFn, useSafeState, useUpdateEffect } from 'ahooks' +import { useCreation, useDebounceFn, useInViewport, useMemoizedFn, useSafeState } from 'ahooks' import emiter from '@/utils/eventBus/eventBus' import { AIAgentTriggerEventInfo } from '../aiAgentType' import useAIAgentStore from '../useContext/useStore' import { getRemoteValue, setRemoteValue } from '@/utils/kv' import { RemoteAIAgentGV } from '@/enums/aiAgent' import { isForcedSetAIModal } from '../aiModelList/utils' -import useChatIPC from '@/pages/ai-re-act/hooks/useChatIPC' import useAIAgentDispatcher from '../useContext/useDispatcher' import cloneDeep from 'lodash/cloneDeep' -import { randomString } from '@/utils/randomUtil' -import ChatIPCContent, { - AIChatIPCSendParams, - AISendConfigHotpatchParams, - AISendSyncMessageParams, - ChatIPCContextDispatcher, - ChatIPCContextStore, -} from '../useContext/ChatIPCContent/ChatIPCContent' import { AIReActChatReview } from '@/pages/ai-agent/components/aiReActChatReview/AIReActChatReview' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { OutlineChevrondoubledownIcon, OutlineChevrondoubleupIcon } from '@/assets/icon/outline' -import { AIChatIPCStartParams, ChatIPCSendType, UseTaskChatState } from '@/pages/ai-re-act/hooks/type' -import useChatIPCDispatcher from '../useContext/ChatIPCContent/useDispatcher' -import useChatIPCStore from '../useContext/ChatIPCContent/useStore' -import { AIAgentGrpcApi, AIInputEvent, AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' -import { AIChatQSData, AIReviewType } from '@/pages/ai-re-act/hooks/aiRender' import { failed, yakitNotify } from '@/utils/notification' import { AIForgeForm, AIToolForm } from '../aiTriageChatTemplate/AITriageChatTemplate' import { grpcGetAIForge } from '../grpc' @@ -42,7 +28,6 @@ import useMultipleHoldGRPCStream from '@/pages/KnowledgeBase/hooks/useMultipleHo import { useKnowledgeBase } from '@/pages/KnowledgeBase/hooks/useKnowledgeBase' import { YakitRoute } from '@/enums/yakitRoute' import { apiCancelDebugPlugin } from '@/pages/plugins/utils' -import { aiChatDataStore } from '@/pages/ai-agent/store/ChatDataStore' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import classNames from 'classnames' import styles from './AIAgentChat.module.scss' @@ -50,22 +35,24 @@ import { AIChatContentRefProps } from '../aiChatContent/type' import { PageNodeItemProps } from '@/store/pageInfo' import { Trans } from 'react-i18next' import { AIInputWithParamsTemplate, aiInputWithParamsTemplate } from '../components/aiMilkdownInput/utils' +import { useStore } from 'zustand' +import { AIForgeFormSubmitParamsProps } from '../aiTriageChatTemplate/type' +import { useCurrentMeta, useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import { onReStart } from '../utils' const AIChatWelcome = React.lazy(() => import('../aiChatWelcome/AIChatWelcome')) -const taskChatIsEmpty = (taskChat?: UseTaskChatState) => { - if (!taskChat) return false - - const isHavePlan = !!taskChat.plan?.task_tree?.length - const isHaveStreams = !!taskChat.elements?.length - return isHavePlan || isHaveStreams -} - export const AIAgentChat: React.FC = memo((props) => { const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) const { activeChat } = useAIAgentStore() - const { setActiveChat, getSetting, setSetting } = useAIAgentDispatcher() + const { setActiveChat, setSetting, onStart, onClose } = useAIAgentDispatcher() + + /** 当前对话唯一ID */ + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) const aiReActChatRef = useRef(null) const aiChatWelcomeRef = useRef(null) @@ -81,31 +68,11 @@ export const AIAgentChat: React.FC = memo((props) => { }) useEffect(() => { - const chatData = aiChatDataStore.get(activeChat?.SessionID || '') - if (taskChatIsEmpty(chatData?.taskChat)) { - onSetKeyTask() - } else if (!!activeChat?.Id) { + if (!!activeChat?.SessionID) { onSetReAct() + onReStart({ activeChat, onStart }) } - }, [activeChat]) - - useEffect(() => { - if (mode === 'welcome') { - events.onReset() - } - }, [mode]) - - /**自由对话中触发任务开始 */ - const handleTaskStart = useMemoizedFn(() => { - onSetKeyTask() - }) - - const onSetKeyTask = useMemoizedFn(() => { - setMode('task') - setTimeout(() => { - emiter.emit('switchAIActTab', JSON.stringify({ key: AITabsEnum.Task_Content })) - }, 100) - }) + }, [activeChat?.SessionID]) const onSetReAct = useMemoizedFn(() => { setMode('re-act') @@ -113,154 +80,18 @@ export const AIAgentChat: React.FC = memo((props) => { emiter.emit('switchAIActTab', JSON.stringify({ key: AITabsEnum.Task_Content })) }, 100) }) - - // review数据中树的数据中需要的解释和关键词工具 - const [planReviewTreeKeywordsMap, { set: setPlanReviewTreeKeywords, reset: resetPlanReviewTreeKeywords }] = useMap< - string, - AIAgentGrpcApi.PlanReviewRequireExtra - >(new Map()) - - const [reviewInfo, setReviewInfo] = useState() - const [reviewExpand, setReviewExpand] = useState(true) - - const handleShowReview = useMemoizedFn((info: AIChatQSData) => { - setReviewExpand(true) - setReviewInfo(cloneDeep(info)) - }) - const handleShowReviewExtra = useMemoizedFn((info: AIAgentGrpcApi.PlanReviewRequireExtra) => { - setPlanReviewTreeKeywords(info.task_id, info) - }) - const handleReleaseReview = useMemoizedFn((type: ChatIPCSendType, id: string) => { - if (!reviewInfo) return - if ((reviewInfo.data as AIReviewType).id === id) { - // if (!delayLoading) yakitNotify("warning", "审阅自动执行,弹框将自动关闭") - handleStopAfterChangeState() - } - }) - - /** 当前对话唯一ID */ - const activeID = useCreation(() => { - return activeChat?.SessionID - }, [activeChat]) - - // 提问结束后缓存数据 - const handleChatingEnd = useMemoizedFn(() => { - handleStopAfterChangeState() - }) - - const setSessionChatName = (session: string, name: string) => { - setActiveChat?.((prev) => { - if (!prev) return prev - if (prev.SessionID !== session) return prev - return { ...prev, Title: name } - }) - emiter.emit( - 'sessionData', - JSON.stringify({ - type: 'updateSession', - sessionId: session, - updates: { Title: name }, - }), - ) - } - - const [syncIdInfoMap, { set: setSyncIdInfoMap, get: getSyncIdInfoMap, remove: removeSyncIdInfoMap }] = useMap< - string, - boolean - >(new Map()) - - const onSyncIDChange = useMemoizedFn((syncID: string) => { - const item = getSyncIdInfoMap(syncID) - if (!!item) { - removeSyncIdInfoMap(syncID) - } - }) - const [chatIPCData, events] = useChatIPC({ - autoConnect: true, - onEnd: handleChatingEnd, - onTaskReview: handleShowReview, - onTaskReviewExtra: handleShowReviewExtra, - onReviewRelease: handleReleaseReview, - onTaskStart: handleTaskStart, - setSessionChatName, - onSyncIDChange, - cacheDataStore: aiChatDataStore, - getSetting, - }) - const { execute } = chatIPCData - + /** 等自由对话渲染出来再发送 */ const handleStart = useMemoizedFn((value: HandleStartParams) => { setTimeout(() => { - aiReActChatRef.current?.handleStart(value) // 等自由对话渲染出来再发送 + aiReActChatRef.current?.handleStart(value) }) }) - const handleSendCasual = useMemoizedFn((params: AIChatIPCSendParams) => { - handleSendInteractiveMessage(params, 'casual') - }) - const handleSendTask = useMemoizedFn((params: AIChatIPCSendParams) => { - handleSendInteractiveMessage(params, 'task') - }) - const handleSend = useMemoizedFn((params: AIChatIPCSendParams) => { - handleSendInteractiveMessage(params, '') - }) - /**发送 IsInteractiveMessage 消息 */ - const handleSendInteractiveMessage = useMemoizedFn((params: AIChatIPCSendParams, type: ChatIPCSendType) => { - const { value, id, optionValue } = params - if (!activeID) return - if (!id) return - - const info: AIInputEvent = { - IsInteractiveMessage: true, - InteractiveId: id, - InteractiveJSONInput: value, - } - events.onSend({ token: activeID, type, params: info, optionValue }) - handleStopAfterChangeState() - }) - /**发送 IsSyncMessage 消息 */ - const handleSendSyncMessage = useMemoizedFn((data: AISendSyncMessageParams) => { - if (!activeID) return - const { syncType, SyncJsonInput, params, syncID } = data - const info: AIInputEvent = { - IsSyncMessage: true, - SyncType: syncType, - SyncJsonInput, - Params: params, - SyncID: syncID || randomString(8), - } - info.SyncID && setSyncIdInfoMap(info.SyncID, true) - events.onSend({ token: activeID, type: '', params: info }) - }) - - /**发送 IsConfigHotpatch 消息 */ - const handleSendConfigHotpatch = useMemoizedFn((data: AISendConfigHotpatchParams) => { - if (!activeID) return - const { hotpatchType, params, taskId } = data - const info: AIInputEvent = { - IsConfigHotpatch: true, - HotpatchType: hotpatchType, - Params: params, - } - if (!!taskId) { - info.TaskId = taskId - } - events.onSend({ token: activeID, type: '', params: info }) - }) - const onStop = useMemoizedFn(() => { - if (execute && activeID) { - events.onClose(activeID) - handleStopAfterChangeState() + if (execute && sessionId) { + onClose([sessionId]) } }) - /** 停止回答后的状态调整||清空Review状态 */ - const handleStopAfterChangeState = useMemoizedFn(() => { - // 清空review信息 - setReviewInfo(undefined) - resetPlanReviewTreeKeywords() - setReviewExpand(true) - }) useEffect(() => { getRemoteValue(RemoteAIAgentGV.AIAgentReplaceForgeNoPrompt) @@ -292,9 +123,10 @@ export const AIAgentChat: React.FC = memo((props) => { Strategy: { EnableMultiAgent: false, EnableGoalMode: false, GoalMinIterations: 0 }, })) setActiveChat?.(undefined) - setTimeout(() => { - setMode('welcome') - }, 100) + setMode('welcome') + // setTimeout(() => { + // setMode('welcome') + // }, 100) break // 替换当前使用的 forge 模板 case ReActChatEventEnum.OPEN_FORGE_FORM: @@ -320,29 +152,6 @@ export const AIAgentChat: React.FC = memo((props) => { } }, []) - useUpdateEffect(() => { - onHistoryAfter() - events.onSwitchChat(activeChat?.SessionID, activeChat?.isCreate) - }, [activeChat]) - - /**切换历史后的处理逻辑 */ - const onHistoryAfter = useMemoizedFn(() => { - if (mode === 'welcome') setMode('re-act') - }) - - const handleDelChats = useMemoizedFn((jsonString: string) => { - try { - const sessions: string[] = JSON.parse(jsonString) - events.onDelChats(sessions) - } catch (error) {} - }) - useEffect(() => { - emiter.on('onDelChats', handleDelChats) - return () => { - emiter.off('onDelChats', handleDelChats) - } - }, []) - //#region 使用 AI-Forge 模板/Tool 相关逻辑 const [activeTool, setActiveTool] = useState() const [replaceToolShow, setReplaceToolShow] = useState(false) @@ -369,7 +178,7 @@ export const AIAgentChat: React.FC = memo((props) => { yakitNotify('error', t('AIAgentChat.templateDataError')) return } - if (!chatIPCData.execute) { + if (!execute) { handleReplaceActiveForge(forge, useForge) } else { const m = YakitModalConfirm({ @@ -409,7 +218,7 @@ export const AIAgentChat: React.FC = memo((props) => { yakitNotify('error', t('AIAgentChat.templateDataError')) return } - if (!chatIPCData.execute) { + if (!execute) { handleReplaceActiveTool(toolValue.ID) } else { const m = YakitModalConfirm({ @@ -419,7 +228,7 @@ export const AIAgentChat: React.FC = memo((props) => { footerStyle: { padding: '0 24px 24px' }, content: (modalT) => (
- {!!chatIPCData.execute ? ( + {!!execute ? ( <> = memo((props) => { setActiveTool(undefined) }) - const handleSubmitForge = useMemoizedFn((request: AIStartParams, formValue: AIChatIPCStartParams['extraValue']) => { + const handleSubmitForge = useMemoizedFn((data: AIForgeFormSubmitParamsProps) => { + const { request, formValue } = data setMode('re-act') const description = `${t('AIAgentChat.useForgeTask', { name: request.ForgeName || '' })}${!!formValue ? t('AIAgentChat.params') : ''}` @@ -472,9 +282,6 @@ export const AIAgentChat: React.FC = memo((props) => { const qs = aiInputWithParamsTemplate(params) handleStart({ qs, - extraValue: { - showQS: qs, - }, }) handleClearActiveForge() }) @@ -490,9 +297,6 @@ export const AIAgentChat: React.FC = memo((props) => { })}${question ? `${t('AIAgentChat.input')}${question}` : ''}` handleStart({ qs, - extraValue: { - showQS: qs, - }, }) handleClearActiveTool() }) @@ -582,26 +386,6 @@ export const AIAgentChat: React.FC = memo((props) => { setReplaceToolShow(false) }) // #endregion - const store: ChatIPCContextStore = useCreation(() => { - return { - chatIPCData, - planReviewTreeKeywordsMap, - reviewInfo, - reviewExpand, - syncIdInfoMap, - } - }, [chatIPCData, planReviewTreeKeywordsMap, reviewInfo, reviewExpand, syncIdInfoMap]) - const dispatcher: ChatIPCContextDispatcher = useCreation(() => { - return { - chatIPCEvents: events, - handleSendCasual, - handleSendTask, - handleStop: onStop, - handleSend, - handleSendSyncMessage, - handleSendConfigHotpatch, - } - }, [events]) const [visible, setVisible] = useSafeState(false) const { clearAll } = useKnowledgeBase() @@ -641,7 +425,6 @@ export const AIAgentChat: React.FC = memo((props) => { const onChat = useMemoizedFn(() => { onSetReAct() }) - const onChatFromHistory = useMemoizedFn((session: string) => {}) useEffect(() => { emiter.on('defualtAIMentionCommandParams', konwledgeInputStringFn) @@ -686,45 +469,41 @@ export const AIAgentChat: React.FC = memo((props) => { { leading: true }, ).run - const chatIPCContextValue = useMemo(() => ({ store, dispatcher }), [store, dispatcher]) - return (
- -
- {mode === 'welcome' ? ( - loading...
}> - - - ) : ( - +
+ {mode === 'welcome' ? ( + loading...
}> + + + ) : ( + + )} +
+ {activeForge && ( + + )} + {activeTool && ( + )} -
- {activeForge && ( - - )} - {activeTool && ( - - )} -
-
+
= memo((props) => { export const AIReActTaskChatReview: React.FC = React.memo((props) => { const { t } = useI18nNamespaces(['aiAgent']) - const { reviewInfo, planReviewTreeKeywordsMap, footerExtra } = props - const { reviewExpand } = useChatIPCStore() - const { handleSendTask, handleSendSyncMessage } = useChatIPCDispatcher() + const { footerExtra } = props const [expand, setReviewExpand] = useState(true) - useEffect(() => { - setReviewExpand(reviewExpand) - }, [reviewExpand]) + + const store = useCurrentStore() + const rawData = useCurrentRawData() + const meta = useCurrentMeta() + const currentPlanReviewTokenRenderNum = useStore(store, (state) => state.currentPlanReviewToken?.renderNum) + const currentPlanReviewToken = useStore(store, (state) => state.currentPlanReviewToken?.token) + const currentPlanReviewExtraUpdate = useStore(store, (state) => state.currentPlanReviewExtraUpdate) + + const reviewInfo = useCreation(() => { + const currentPlanReviewToken = store.getState().currentPlanReviewToken + return rawData.contents.get(currentPlanReviewToken?.token) + }, [currentPlanReviewToken, currentPlanReviewTokenRenderNum]) + + const planReviewTreeKeywordsMap = useCreation(() => { + return meta.planReviewExtraData + }, [currentPlanReviewExtraUpdate]) + const handleExpand = useMemoizedFn(() => { setReviewExpand((old) => !old) }) @@ -795,6 +586,7 @@ export const AIReActTaskChatReview: React.FC = React
) }) + if (!reviewInfo) return null return (
= React >
diff --git a/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/type.ts b/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/type.ts index 192b0c5274..57429514f5 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/aiAgentChat/type.ts @@ -1,19 +1,14 @@ -import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' -import { AIAgentGrpcApi, AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' import { AIChatTextareaSubmit } from '../template/type' -import { AIChatIPCStartParams } from '@/pages/ai-re-act/hooks/type' import { ReactNode } from 'react' export interface AIAgentChatProps {} -export type AIAgentChatMode = 'welcome' | 're-act' | 'task' +export type AIAgentChatMode = 'welcome' | 're-act' export interface AIReActTaskChatReviewProps { - reviewInfo: AIChatQSData - planReviewTreeKeywordsMap: Map setScrollToBottom: (v: boolean) => void footerExtra: (v: ReactNode) => ReactNode } export interface HandleStartParams extends AIChatTextareaSubmit { attachedResourceInfo?: AIInputEvent['AttachedResourceInfo'] - extraValue?: AIChatIPCStartParams['extraValue'] } diff --git a/app/renderer/src/main/src/pages/ai-agent/aiAgentType.d.ts b/app/renderer/src/main/src/pages/ai-agent/aiAgentType.d.ts index 0a1c856ea9..e2e0c789b1 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiAgentType.d.ts +++ b/app/renderer/src/main/src/pages/ai-agent/aiAgentType.d.ts @@ -1,13 +1,7 @@ -import { CSSProperties, Dispatch, ReactNode, SetStateAction, MutableRefObject } from 'react' +import { Dispatch, SetStateAction } from 'react' import { AISession } from './type/aiChat' -import { AITreeNodeProps } from './aiTree/type' -import { HoldGRPCStreamProps, StreamResult } from '@/hook/useHoldGRPCStream/useHoldGRPCStreamType' import { AITabsEnum } from './defaultConstant' -import { AIAgentGrpcApi, AIStartParams } from '../ai-re-act/hooks/grpcApi' -import { AIChatQSData, AIStreamOutput, AITaskInfoProps } from '../ai-re-act/hooks/aiRender' -import { UseYakExecResultState, PlanLoadingStatus, CurrentExecTaskTree } from '../ai-re-act/hooks/type' -import { ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' -import { UseChatIPCEvents } from '@/pages/ai-re-act/hooks/type' +import { AIStartParams } from '../ai-re-act/hooks/grpcApi' export interface AIAgentProps { pageId: string } @@ -48,19 +42,14 @@ export interface EditChatNameModalProps { export interface AIChatLeftSideProps { expand: boolean setExpand: Dispatch> - taskTree: CurrentExecTaskTree['task_tree'] - taskName: CurrentExecTaskTree['root_task_name'] } // 对话框回答 export type AITabsEnumType = `${AITabsEnum}` export interface AIAgentChatStreamProps { - streams: ReActChatRenderItem[] - session: string defaultExpand?: boolean scrollToBottom: boolean - taskStatus: PlanLoadingStatus } // #endregion diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.module.scss b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.module.scss index 2a45597a81..283e7fc64d 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.module.scss @@ -14,139 +14,7 @@ position: relative; flex: 1; overflow: hidden; - .expand-retract-wrapper { - width: 100%; - border-bottom: 0; - z-index: 8; - .expand-retract-content { - @extend %display-column-center; - width: 100%; - flex: 1; - } - &:hover { - background-color: var(--Colors-Use-Neutral-Bg); - .expand-retract-animation-wrapper { - width: 54px; - } - } - } - .expand-retract-wrapper-collapsed { - &:hover { - background-color: var(--Colors-Use-Basic-Background); - cursor: default; - } - } - .header { - @extend %display-flex-center; - justify-content: space-between; - gap: 12px; - width: 100%; - flex: 1; - .title { - @extend %display-flex-center; - gap: 4px; - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 14px; - font-weight: 500; - line-height: 20px; - .chat-alt-icon { - svg { - width: 16px; - height: 16px; - color: var(--Colors-Use-Neutral-Disable); - } - } - .chat-title { - max-width: 250px; - @include mixin.text-ellipsis(); - } - } - .extra { - @extend %display-flex-center; - gap: 16px; - overflow-x: auto; - } - .info-token { - display: flex; - align-items: center; - gap: 4px; - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 12px; - font-weight: 400; - .token { - color: var(--Colors-Use-Neutral-Text-4-Help-text); - font-size: 12px; - font-weight: 400; - line-height: 16px; - } - } - - .echarts-wrapper { - @extend %display-flex-center; - gap: 4px; - color: var(--Colors-Use-Neutral-Text-4-Help-text); - font-size: 12px; - font-weight: 400; - line-height: 16px; - - .title { - @extend %display-flex-center; - gap: 4px; - .text { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - word-break: break-all; - } - - .tip { - color: var(--Colors-Use-Neutral-Text-4-Help-text); - line-height: 16px; - letter-spacing: 0.5px; - } - } - .intelligent { - color: var(--Colors-Use-Purple-Primary); - } - .intelligent-height { - color: var(--Colors-Use-Error-Primary); - } - .lightweight { - color: var(--Colors-Use-Warning-Primary); - } - .lightweight-height { - color: var(--Colors-Use-Error-Primary); - } - } - .divider-style { - width: 1px; - height: 12px; - border-left: 1px solid var(--Colors-Use-Neutral-Border); - } - } - .card-list-wrapper { - width: 100%; - opacity: 1; - transition: all 0.3s ease; - padding-top: 12px; - .card-item { - height: 50px; - font-size: 14px; - } - } - .card-list-wrapper-hidden { - padding-top: 0; - height: 0; - opacity: 0; - } - .expand-retract-animation-wrapper { - top: unset; - bottom: -12px; - } - .expand-retract-animation-wrapper-hidden { - display: none; - } .ai-chat-tab-wrapper { @extend %display-column-center; flex: 1; @@ -476,3 +344,62 @@ } } } + +.info-token { + display: flex; + align-items: center; + gap: 4px; + color: var(--Colors-Use-Neutral-Text-1-Title); + font-size: 12px; + font-weight: 400; + .token { + color: var(--Colors-Use-Neutral-Text-4-Help-text); + font-size: 12px; + font-weight: 400; + line-height: 16px; + } +} + +.echarts-wrapper { + @extend %display-flex-center; + gap: 4px; + color: var(--Colors-Use-Neutral-Text-4-Help-text); + font-size: 12px; + font-weight: 400; + line-height: 16px; + + .title { + @extend %display-flex-center; + gap: 4px; + .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-all; + } + + .tip { + color: var(--Colors-Use-Neutral-Text-4-Help-text); + line-height: 16px; + letter-spacing: 0.5px; + } + } + + .intelligent { + color: var(--Colors-Use-Purple-Primary); + } + .intelligent-height { + color: var(--Colors-Use-Error-Primary); + } + .lightweight { + color: var(--Colors-Use-Warning-Primary); + } + .lightweight-height { + color: var(--Colors-Use-Error-Primary); + } +} +.divider-style { + width: 1px; + height: 12px; + border-left: 1px solid var(--Colors-Use-Neutral-Border); +} diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.tsx index ef5e64eb0a..e8e9c75c6f 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIChatContent.tsx @@ -1,9 +1,7 @@ import React, { forwardRef, ReactNode, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { AIAgentTabPayload, AIChatContentProps } from './type' import styles from './AIChatContent.module.scss' -import { ExpandAndRetract } from '@/pages/plugins/operator/expandAndRetract/ExpandAndRetract' import { useCreation, useMemoizedFn } from 'ahooks' -import { HorizontalScrollCard } from '@/pages/plugins/operator/horizontalScrollCard/HorizontalScrollCard' import classNames from 'classnames' import { YakitSideTab } from '@/components/yakitSideTab/YakitSideTab' import { AITabs, AITabsEnum } from '../defaultConstant' @@ -18,40 +16,42 @@ import { import { YakitEmpty } from '@/components/yakitUI/YakitEmpty/YakitEmpty' import AIReActTaskChat from '@/pages/ai-re-act/aiReActTaskChat/AIReActTaskChat' import emiter from '@/utils/eventBus/eventBus' -import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import { OutlineClouddownloadIcon, OutlineNewspaperIcon, OutlinePlussmIcon } from '@/assets/icon/outline' -import { SolidChatalt2Icon } from '@/assets/icon/solid' -import useAiChatLog from '@/hook/useAiChatLog/useAiChatLog.ts' import { YakitResizeBox } from '@/components/yakitUI/YakitResizeBox/YakitResizeBox' -import { grpcExportAILogs } from '../grpc' -import useChatIPCStore from '../useContext/ChatIPCContent/useStore' import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' -import { onNewChat } from '../historyChat/HistoryChat' // import {SideSettingButton} from "../aiChatWelcome/AIChatWelcome" -import { Divider } from 'antd' import useAIAgentStore from '../useContext/useStore' import { useAIChatResizeBox } from './hooks/useAIChatResizeBox' -import { ExportAILogsModal } from '../components/ExportAILogsModal/ExportAILogsModal' -import { failed, yakitNotify } from '@/utils/notification' import { AIHandleStartParams, AIHandleStartResProps, AIReActChatRefProps, } from '@/pages/ai-re-act/aiReActChat/AIReActChatType' -import AIContextToken from './AIContextToken/AIContextToken' import OperationLog from '../components/aiFileSystemList/OperationLog/OperationLog' import AIGlobalLoading from '../aiGlobalLoading/AIGlobalLoading' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import { AIHorizontalScrollCard } from './aiHorizontalScrollCard/AIHorizontalScrollCard' export const AIChatContent: React.FC = React.memo( forwardRef((props, ref) => { - const { onChat, onChatFromHistory } = props + const { onChat } = props const { t, i18nRefresh } = useI18nNamespaces(['aiAgent', 'yakitUi', 'yakitRoute']) - const chatIPCStore = useChatIPCStore() - const { httpRunTimeIDs, riskRunTimeIDs, yakExecResult, taskChat, grpcFolders, execute, requestHistoryState } = - chatIPCStore.chatIPCData + + const store = useCurrentStore() + const rawData = useCurrentRawData() + const taskChatElementLength = useStore(store, (state) => state.taskChat.elements.length) + const execFileRecord = useStore(store, (state) => state.execFileRecord) + const grpcFolders = useStore(store, (state) => state.grpcFolders) + + const httpTabShow = useStore(store, (state) => state.httpTabShow) + const httpTabUpdate = useStore(store, (state) => state.httpTabUpdate) + const riskTabShow = useStore(store, (state) => state.riskTabShow) + const riskTabUpdate = useStore(store, (state) => state.riskTabUpdate) + const initLoading = useStore(store, (state) => state.initLoading) + const { activeChat } = useAIAgentStore() - const [isExpand, setIsExpand] = useState(true) + const [activeKey, setActiveKey] = useState(AITabsEnum.Task_Content) const [showFreeChat, setShowFreeChat] = useState(true) //自由对话展开收起 @@ -62,9 +62,6 @@ export const AIChatContent: React.FC = React.memo( const [hasFilePreview, setHasFilePreview] = useState(false) const [runTimeId, setRunTimeId] = useState() // 工具卡片跳转自带runTimeID - const [exportModalVisible, setExportModalVisible] = useState(false) - const [exportLoading, setExportLoading] = useState(false) - const RelatedRuntimeIDs = useMemo(() => { return activeChat?.RelatedRuntimeIDs ?? [] }, [activeChat?.RelatedRuntimeIDs]) @@ -85,40 +82,6 @@ export const AIChatContent: React.FC = React.memo( // #region 问题相关逻辑 - const onOpenExportModal = useMemoizedFn((e) => { - e.stopPropagation() - setExportModalVisible(true) - }) - - const onExportCancel = useMemoizedFn(() => { - setExportModalVisible(false) - }) - - const onExportOk = useMemoizedFn(async (data: { types: string[]; outputPath: string }) => { - if (!activeChat?.Id) { - failed(t('AIChatContent.noActiveChat')) - return - } - setExportLoading(true) - // - try { - await grpcExportAILogs( - { - SessionID: activeChat.SessionID, - ExportDataTypes: data.types, - OutputPath: data.outputPath, - }, - true, - ) - yakitNotify('success', t('YakitNotification.exportSuccess')) - setExportModalVisible(false) - } catch (error) { - failed(t('YakitNotification.exportFailed', { error: error + '' })) - } finally { - setExportLoading(false) - } - }) - const handleTabStateChange = useMemoizedFn((key: AITabsEnumType, value: AIAgentTabPayload['value']) => { setActiveKey(key) if (!value) { @@ -139,8 +102,8 @@ export const AIChatContent: React.FC = React.memo( } const { key, value } = payload - if (key === AITabsEnum.HTTP && httpRunTimeIDs.length === 0 && RelatedRuntimeIDs.length === 0) return - if (key === AITabsEnum.Risk && riskRunTimeIDs.length === 0 && RelatedRuntimeIDs.length === 0) return + if (key === AITabsEnum.HTTP && !httpTabShow && RelatedRuntimeIDs.length === 0) return + if (key === AITabsEnum.Risk && !riskTabShow && RelatedRuntimeIDs.length === 0) return handleTabStateChange(key, value) }) @@ -160,26 +123,22 @@ export const AIChatContent: React.FC = React.memo( {showId} ) - }, [httpRunTimeIDs, runTimeId]) + }, [runTimeId]) - const onExpand = useMemoizedFn((e) => { - e.stopPropagation() - setIsExpand(!isExpand) - }) const yakitTabs = useCreation(() => { let tab: YakitSideTabProps['yakitTabs'] = [AITabs[AITabsEnum.Task_Content], AITabs[AITabsEnum.File_System]] - if ((httpRunTimeIDs.length || RelatedRuntimeIDs.length) > 0) { + if (httpTabShow || !!RelatedRuntimeIDs.length) { tab.push(AITabs[AITabsEnum.HTTP]) } - if ((riskRunTimeIDs.length || RelatedRuntimeIDs.length) > 0) { + if (riskTabUpdate || !!RelatedRuntimeIDs.length) { tab.push(AITabs[AITabsEnum.Risk]) } - if (yakExecResult.execFileRecord.size > 0) { + if (execFileRecord.size > 0) { tab.push(AITabs[AITabsEnum.Operation_Log]) } return tab - }, [httpRunTimeIDs, riskRunTimeIDs, yakExecResult.execFileRecord, taskChat?.elements?.length]) + }, [httpTabShow, riskTabUpdate, execFileRecord.size, taskChatElementLength]) const [showHot, setShowHot] = useState(false) const prevRef = useRef<{ @@ -228,15 +187,15 @@ export const AIChatContent: React.FC = React.memo( }) const OperationLogList = useCreation(() => { - return Array.from(yakExecResult.execFileRecord.values()) + return Array.from(execFileRecord.values()) .flat() .sort((a, b) => b.order - a.order) - }, [yakExecResult.execFileRecord]) + }, [execFileRecord]) const tabContent = useMemo(() => { if (!activeKey) return null - const runTimeIds = [...new Set(!!runTimeId ? [runTimeId] : httpRunTimeIDs.concat(RelatedRuntimeIDs))] - const riskRunTimeIds = [...new Set(!!runTimeId ? [runTimeId] : riskRunTimeIDs.concat(RelatedRuntimeIDs))] + const runTimeIds = [...new Set(!!runTimeId ? [runTimeId] : rawData.httpRunTimeIDs.concat(RelatedRuntimeIDs))] + const riskRunTimeIds = [...new Set(!!runTimeId ? [runTimeId] : rawData.riskRunTimeIDs.concat(RelatedRuntimeIDs))] switch (activeKey) { case AITabsEnum.Task_Content: return ( @@ -269,9 +228,7 @@ export const AIChatContent: React.FC = React.memo( default: return null } - }, [activeKey, runTimeId, httpRunTimeIDs, riskRunTimeIDs, RelatedRuntimeIDs, filterTagDom, OperationLogList]) - - const { onOpenLogWindow } = useAiChatLog() + }, [activeKey, runTimeId, httpTabUpdate, riskTabUpdate, RelatedRuntimeIDs, filterTagDom, OperationLogList]) const onActiveKey = useMemoizedFn((key: AITabsEnumType) => { if (activeKey === key) { @@ -286,89 +243,28 @@ export const AIChatContent: React.FC = React.memo( setHasFilePreview(false) } }) - const onOpenLog = useMemoizedFn((e) => { - e.stopPropagation() - onOpenLogWindow() - }) - const { resizeBoxProps, emitResizeBox } = useAIChatResizeBox({ + const { resizeBoxProps } = useAIChatResizeBox({ activeKey, showFreeChat, timeLine, - taskChat, hasTaskTabs, hasFilePreview, }) - // useMount(() => { - // const onFilePreviewReady = () => { - // emitResizeBox({ - // secondRatio: "432px" - // }) - // } - // emiter.on("filePreviewReady", onFilePreviewReady) - // return () => { - // emiter.off("filePreviewReady", onFilePreviewReady) - // } - // }) const startRequest = useMemoizedFn((data: AIHandleStartParams) => { return new Promise((resolve) => { resolve({ params: data.params, onChat, - onChatFromHistory, }) }) }) return (
- - -
-
-
- -
{activeChat?.Title || t('AIChatContent.newChatTitle')}
- - } onClick={() => onNewChat()}> - {t('AIChatContent.newChat')} - - {/* */} -
-
- - } onClick={onOpenLog}> - {t('AIChatContent.log')} - - } onClick={onOpenExportModal}> - {t('AIChatContent.exportLog')} - -
-
- {yakExecResult.card.length > 0 ? ( - - ) : null} -
-
+ +
= React.memo( chatContainerHeaderClassName={classNames({ [styles['re-act-chat-container-header']]: !activeKey, })} - mode={!!activeKey ? 'task' : 'welcome'} showFreeChat={showFreeChat} setShowFreeChat={setShowFreeChat} startRequest={startRequest} @@ -410,12 +305,6 @@ export const AIChatContent: React.FC = React.memo(
-
) diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/AIContextToken.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/AIContextToken.tsx index 52acaa8fc5..cb5f9ff1fe 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/AIContextToken.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/AIContextToken.tsx @@ -1,4 +1,4 @@ -import React, { FC, memo } from 'react' +import { type FC, memo } from 'react' import ContextPressurePanel from './ContextPressurePanel' import ContextCostPanel from './ContextCostPanel' import ContextTokenSummary from './ContextTokenSummary' @@ -7,19 +7,17 @@ import { YakitButtonProp } from '@/components/yakitUI/YakitButton/YakitButton' import styles from '../AIChatContent.module.scss' const AIContextToken: FC<{ - session?: string - execute: boolean /** 仅展示详情 icon(嵌入 AI 侧栏 header 使用) */ iconOnly?: boolean /** 详情 icon 对应 YakitButton 的 props */ buttonProps?: Omit -}> = ({ session, execute, iconOnly, buttonProps }) => { +}> = ({ iconOnly, buttonProps }) => { return ( <> - {!iconOnly && } - {!iconOnly && } - {!iconOnly && } - + {!iconOnly && } + {!iconOnly && } + {!iconOnly && } + {!iconOnly &&
} ) diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextCostPanel.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextCostPanel.tsx index 78873e7fa2..2d6b9cef0b 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextCostPanel.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextCostPanel.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from 'react' +import React, { memo } from 'react' import { useCreation } from 'ahooks' import { cloneDeep } from 'lodash' import { ResponseSpeedEcharts } from '../../chatTemplate/AIEcharts' @@ -9,14 +9,19 @@ import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { useRafPolling } from '@/hook/useRafPolling/useRafPolling' import { CONTEXT_PERF_POLL_INTERVAL, ContextPerfPanelProps, useContextPerfStore } from './useContextPerfStore' import styles from '../AIChatContent.module.scss' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' -const ContextCostPanel: React.FC = ({ session, execute }) => { +const ContextCostPanel: React.FC = () => { const { t } = useI18nNamespaces(['aiAgent']) - const getPerfData = useContextPerfStore(session) - const getData = useCallback(() => getPerfData()?.firstCost ?? null, [getPerfData]) + + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + + const aiPerfData = useContextPerfStore() const { renderNumber, aiDataRef: firstCost } = useRafPolling({ - getData, + getData: () => aiPerfData.firstCost ?? null, interval: CONTEXT_PERF_POLL_INTERVAL, shouldStop: () => !execute, resetDeps: [execute], diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextDetailPopover.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextDetailPopover.tsx index aa23147b81..6758f3d958 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextDetailPopover.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextDetailPopover.tsx @@ -1,6 +1,5 @@ import React, { memo, useCallback, useMemo, useState } from 'react' import { useCreation } from 'ahooks' -import { cloneDeep, isEmpty } from 'lodash' import { OutlinePresentationchartlineIcon } from '@/assets/icon/outline' import { Tooltip } from 'antd' import { YakitButton, YakitButtonProp } from '@/components/yakitUI/YakitButton/YakitButton' @@ -13,18 +12,26 @@ import { isPerfDataChanged } from './utils' import { CONTEXT_PERF_POLL_INTERVAL, ContextPerfPanelProps, useContextPerfStore } from './useContextPerfStore' import AIEchartsDetails from './AIEchartsDetails' import styles from '../AIChatContent.module.scss' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import cloneDeep from 'lodash/cloneDeep' +import isEmpty from 'lodash/isEmpty' interface ContextDetailPopoverProps extends ContextPerfPanelProps { buttonProps?: Omit } -const ContextDetailPopover: React.FC = ({ session, execute, buttonProps }) => { +const ContextDetailPopover: React.FC = ({ buttonProps }) => { const { t } = useI18nNamespaces(['yakitUi']) const [visible, setVisible] = useState(false) - const getPerfData = useContextPerfStore(session) + + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + + const aiPerfData = useContextPerfStore() const { renderNumber, aiDataRef: perfData } = useRafPolling({ - getData: getPerfData, + getData: () => aiPerfData ?? null, interval: CONTEXT_PERF_POLL_INTERVAL, shouldStop: () => !execute, resetDeps: [execute], diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextPressurePanel.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextPressurePanel.tsx index ec16fa258c..fabf659b39 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextPressurePanel.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextPressurePanel.tsx @@ -1,6 +1,4 @@ -import React, { memo, useCallback } from 'react' -import { useCreation } from 'ahooks' -import { cloneDeep } from 'lodash' +import React, { memo } from 'react' import { ContextPressureEcharts } from '../../chatTemplate/AIEcharts' import { formatNumberUnits } from '../../utils' import { Tooltip } from 'antd' @@ -10,14 +8,21 @@ import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { useRafPolling } from '@/hook/useRafPolling/useRafPolling' import { CONTEXT_PERF_POLL_INTERVAL, ContextPerfPanelProps, useContextPerfStore } from './useContextPerfStore' import styles from '../AIChatContent.module.scss' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import cloneDeep from 'lodash/cloneDeep' +import useCreation from 'ahooks/lib/useCreation' -const ContextPressurePanel: React.FC = ({ session, execute }) => { +const ContextPressurePanel: React.FC = () => { const { t } = useI18nNamespaces(['aiAgent']) - const getPerfData = useContextPerfStore(session) - const getData = useCallback(() => getPerfData()?.pressure ?? null, [getPerfData]) + + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + + const getPerfData = useContextPerfStore() const { renderNumber, aiDataRef: pressure } = useRafPolling({ - getData, + getData: () => getPerfData.pressure ?? null, interval: CONTEXT_PERF_POLL_INTERVAL, shouldStop: () => !execute, resetDeps: [execute], diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextTokenSummary.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextTokenSummary.tsx index 5a5a9e354b..3d85fefcc1 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextTokenSummary.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/ContextTokenSummary.tsx @@ -1,6 +1,4 @@ -import React, { memo, useCallback } from 'react' -import { useCreation } from 'ahooks' -import { cloneDeep, isEmpty } from 'lodash' +import React, { memo } from 'react' import { formatNumberUnits } from '../../utils' import { OutlineArrowdownIcon, OutlineArrowupIcon } from '@/assets/icon/outline' import classNames from 'classnames' @@ -9,13 +7,20 @@ import { useRafPolling } from '@/hook/useRafPolling/useRafPolling' import { CONTEXT_PERF_POLL_INTERVAL, ContextPerfPanelProps, useContextPerfStore } from './useContextPerfStore' import { AIAgentGrpcApi } from '@/pages/ai-re-act/hooks/grpcApi' import styles from '../AIChatContent.module.scss' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCreation from 'ahooks/lib/useCreation' +import cloneDeep from 'lodash/cloneDeep' +import isEmpty from 'lodash/isEmpty' -const ContextTokenSummary: React.FC = ({ session, execute }) => { - const getPerfData = useContextPerfStore(session) - const getData = useCallback(() => getPerfData()?.consumption ?? null, [getPerfData]) +const ContextTokenSummary: React.FC = () => { + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + + const getPerfData = useContextPerfStore() const { renderNumber, aiDataRef: consumption } = useRafPolling({ - getData, + getData: () => getPerfData.consumption ?? null, interval: CONTEXT_PERF_POLL_INTERVAL, shouldStop: () => !execute, resetDeps: [execute], diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/useContextPerfStore.ts b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/useContextPerfStore.ts index 3abd9aee5f..d73279ab1f 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/useContextPerfStore.ts +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/AIContextToken/useContextPerfStore.ts @@ -1,20 +1,11 @@ -import { useCallback } from 'react' -import { aiChatDataStore } from '../../store/ChatDataStore' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { AIChatData } from '../../type/aiChat' +import { useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' export const CONTEXT_PERF_POLL_INTERVAL = 2000 -export interface ContextPerfPanelProps { - session?: string - execute: boolean -} +export interface ContextPerfPanelProps {} /** 读取当前 session 的性能数据 */ -export function useContextPerfStore(session?: string) { - const { chatIPCEvents } = useChatIPCDispatcher() - return useCallback((): AIChatData['aiPerfData'] | null => { - const store = chatIPCEvents.fetchChatDataStore?.() ?? aiChatDataStore - return store?.get(session ?? '')?.aiPerfData ?? null - }, [session, chatIPCEvents]) +export function useContextPerfStore() { + const rawData = useCurrentRawData() + return rawData.aiPerfData } diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/AIHorizontalScrollCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/AIHorizontalScrollCard.module.scss new file mode 100644 index 0000000000..cfe79360e9 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/AIHorizontalScrollCard.module.scss @@ -0,0 +1,96 @@ +@use '../../../../styles/mixin.scss' as mixin; + +%display-flex-center { + display: flex; + align-items: center; +} +%display-column-center { + display: flex; + align-items: center; + flex-direction: column; +} +.expand-retract-wrapper { + width: 100%; + border-bottom: 0; + z-index: 8; + .expand-retract-content { + @extend %display-column-center; + width: 100%; + flex: 1; + + .header { + @extend %display-flex-center; + justify-content: space-between; + gap: 12px; + width: 100%; + flex: 1; + .title { + @extend %display-flex-center; + gap: 4px; + color: var(--Colors-Use-Neutral-Text-1-Title); + font-size: 14px; + font-weight: 500; + line-height: 20px; + .chat-alt-icon { + svg { + width: 16px; + height: 16px; + color: var(--Colors-Use-Neutral-Disable); + } + } + .chat-title { + max-width: 250px; + @include mixin.text-ellipsis(); + } + } + .extra { + @extend %display-flex-center; + gap: 16px; + overflow-x: auto; + } + + .divider-style { + width: 1px; + height: 12px; + border-left: 1px solid var(--Colors-Use-Neutral-Border); + } + } + } + &:hover { + background-color: var(--Colors-Use-Neutral-Bg); + .expand-retract-animation-wrapper { + width: 54px; + } + } +} +.expand-retract-wrapper-collapsed { + &:hover { + background-color: var(--Colors-Use-Basic-Background); + cursor: default; + } +} + +.expand-retract-animation-wrapper { + top: unset; + bottom: -12px; +} + +.expand-retract-animation-wrapper-hidden { + display: none; +} + +.card-list-wrapper { + width: 100%; + opacity: 1; + transition: all 0.3s ease; + padding-top: 12px; + .card-item { + height: 50px; + font-size: 14px; + } +} +.card-list-wrapper-hidden { + padding-top: 0; + height: 0; + opacity: 0; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/AIHorizontalScrollCard.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/AIHorizontalScrollCard.tsx new file mode 100644 index 0000000000..3a51d9d44d --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/AIHorizontalScrollCard.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react' +import { AIHorizontalScrollCardProps } from './type' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { HorizontalScrollCard } from '@/pages/plugins/operator/horizontalScrollCard/HorizontalScrollCard' +import { useStore } from 'zustand' +import classNames from 'classnames' +import styles from './AIHorizontalScrollCard.module.scss' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { OutlinePlussmIcon, OutlineNewspaperIcon, OutlineClouddownloadIcon } from '@/assets/icon/outline' +import { SolidChatalt2Icon } from '@/assets/icon/solid' +import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' +import { ExpandAndRetract } from '@/pages/plugins/operator/expandAndRetract/ExpandAndRetract' +import { Divider } from 'antd' +import { onNewChat } from '../../historyChat/HistoryChat' +import AIContextToken from '../AIContextToken/AIContextToken' +import useAIAgentStore from '../../useContext/useStore' +import useAiChatLog from '@/hook/useAiChatLog/useAiChatLog.ts' +import { ExportAILogsModal } from '../../components/ExportAILogsModal/ExportAILogsModal' +import { failed, yakitNotify } from '@/utils/notification' +import { grpcExportAILogs } from '../../grpc' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' + +export const AIHorizontalScrollCard: React.FC = React.memo((props) => { + const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) + + const [isExpand, setIsExpand] = useState(true) + + const [exportModalVisible, setExportModalVisible] = useState(false) + const [exportLoading, setExportLoading] = useState(false) + + const { activeChat } = useAIAgentStore() + const { onOpenLogWindow } = useAiChatLog() + + const store = useCurrentStore() + const yakExecResultCard = useStore(store, (state) => state.card) + + const onExpand = useMemoizedFn((e) => { + e.stopPropagation() + setIsExpand(!isExpand) + }) + + const onOpenLog = useMemoizedFn((e) => { + e.stopPropagation() + onOpenLogWindow() + }) + const onOpenExportModal = useMemoizedFn((e) => { + e.stopPropagation() + setExportModalVisible(true) + }) + + const onExportCancel = useMemoizedFn(() => { + setExportModalVisible(false) + }) + + const onExportOk = useMemoizedFn(async (data: { types: string[]; outputPath: string }) => { + if (!activeChat?.Id) { + failed(t('AIChatContent.noActiveChat')) + return + } + setExportLoading(true) + // + try { + await grpcExportAILogs( + { + SessionID: activeChat.SessionID, + ExportDataTypes: data.types, + OutputPath: data.outputPath, + }, + true, + ) + yakitNotify('success', t('YakitNotification.exportSuccess')) + setExportModalVisible(false) + } catch (error) { + failed(t('YakitNotification.exportFailed', { error: error + '' })) + } finally { + setExportLoading(false) + } + }) + return ( + <> + +
+
+
+ +
{activeChat?.Title || t('AIChatContent.newChatTitle')}
+ + } onClick={() => onNewChat()}> + {t('AIChatContent.newChat')} + +
+
+ + } onClick={onOpenLog}> + {t('AIChatContent.log')} + + } onClick={onOpenExportModal}> + {t('AIChatContent.exportLog')} + +
+
+ {yakExecResultCard.length > 0 ? ( + + ) : null} +
+
+ + + ) +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/type.ts new file mode 100644 index 0000000000..db54642c60 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/aiHorizontalScrollCard/type.ts @@ -0,0 +1 @@ +export interface AIHorizontalScrollCardProps {} diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/hooks/useAIChatResizeBox.ts b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/hooks/useAIChatResizeBox.ts index a3c00a689f..bca26403fb 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/hooks/useAIChatResizeBox.ts +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/hooks/useAIChatResizeBox.ts @@ -3,7 +3,6 @@ import { useCreation } from 'ahooks' import { useRef, useState } from 'react' import { AITabsEnumType } from '../../aiAgentType' import { AITabsEnum } from '../../defaultConstant' -import type { UseTaskChatState } from '@/pages/ai-re-act/hooks/type' type ResizeBoxProps = Omit @@ -13,7 +12,6 @@ interface Params { activeKey?: AITabsEnumType showFreeChat: boolean timeLine: boolean - taskChat: UseTaskChatState /** 任务规划 tabs 是否有内容 */ hasTaskTabs: boolean /** 文件系统是否有预览文件 */ @@ -87,15 +85,7 @@ export function useAIChatResizeBox(params: Params) { ...computed, ...override, } - }, [ - params.activeKey, - params.showFreeChat, - params.timeLine, - params.hasTaskTabs, - params.hasFilePreview, - params.taskChat.elements?.length, - version, - ]) + }, [params.activeKey, params.showFreeChat, params.timeLine, params.hasTaskTabs, params.hasFilePreview, version]) return { resizeBoxProps, diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/type.ts b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/type.ts index fa05bf5916..d88d676d3b 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatContent/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatContent/type.ts @@ -6,7 +6,6 @@ export interface AIChatContentRefProps extends AIReActChatRefProps {} export interface AIChatContentProps { ref?: React.ForwardedRef onChat: () => void - onChatFromHistory: (sessionID: string) => void } export type AIAgentTabPayload = TabHTTPPayload | TabFileSystemPayload interface TabHTTPPayload { diff --git a/app/renderer/src/main/src/pages/ai-agent/aiChatWelcome/AIChatWelcome.tsx b/app/renderer/src/main/src/pages/ai-agent/aiChatWelcome/AIChatWelcome.tsx index 80ff98b7e5..f1bcd2ceb3 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiChatWelcome/AIChatWelcome.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiChatWelcome/AIChatWelcome.tsx @@ -48,22 +48,6 @@ import { reseultKnowledgePlugin, useCheckKnowledgePlugin } from '@/pages/Knowled import useGetAIMaterialsData, { getAIRecommendIconByType } from '@/pages/ai-re-act/hooks/useGetAIMaterialsData' import { AIMentionCommandParams } from '../components/aiMilkdownInput/aiMilkdownMention/aiMentionPlugin' -// const sideberRadioOptions = [ -// { -// value: "fileTree", -// label: "文件树" -// }, -// { -// value: "knoledge", -// label: "知识库" -// } -// ] - -// const getRandomItems = (array, count = 3) => { -// const shuffled = [...array].sort(() => 0.5 - Math.random()) -// return shuffled.slice(0, count) -// } - const randomAIMaterialsDataIsEmpty = (randObj) => { try { return ( @@ -83,7 +67,7 @@ enum AIChatWelcomeTabKeyEnum { const AIChatWelcome: React.FC = React.memo( forwardRef((props, ref) => { const { t, i18nRefresh } = useI18nNamespaces(['aiAgent']) - const { onTriageSubmit, onSetReAct, streams, api } = props + const { onTriageSubmit, streams, api } = props const aiChatTextareaRef = useRef({ setMention: () => {}, diff --git a/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelForm/AIModelForm.tsx b/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelForm/AIModelForm.tsx index 2f68be455d..d351f45a0c 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelForm/AIModelForm.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelForm/AIModelForm.tsx @@ -4,7 +4,6 @@ import { AIModelCheckResultProps, AIModelFormAddOptions, AIModelFormProps, - AIModelFormSetAIGlobalConfigOptions, AIModelFormUpdateOptions, } from './AIModelFormType' import { useCreation, useDebounceFn, useInViewport, useMemoizedFn } from 'ahooks' @@ -19,7 +18,6 @@ import { AIModelConfig, AIModelTypeFileName, AIProvider, - DEFAULT_AI_API_TYPE, grpcQueryAIProviderAll, grpcAIConfigHealthCheck, normalizeAIAPIType, @@ -50,7 +48,7 @@ import useAIGlobalConfig from '@/pages/ai-re-act/hooks/useAIGlobalConfig' const defaultFormValues = { Type: '', - api_type: DEFAULT_AI_API_TYPE, + api_type: 'chat_completions', model_type: AIModelTypeEnum.TierIntelligent, } /**是否认为是同一个ai model */ diff --git a/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelSelect/AIModelSelect.tsx b/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelSelect/AIModelSelect.tsx index 21ba29eb03..95708978c7 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelSelect/AIModelSelect.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiModelList/aiModelSelect/AIModelSelect.tsx @@ -35,7 +35,6 @@ import { } from '../../defaultConstant' import { AIModelFreeTag, getTipByType, OutlineAtomIconByStatus, setAIModal } from '../AIModelList' import { AIChatSelect } from '@/pages/ai-re-act/aiReviewRuleSelect/AIReviewRuleSelect' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' import { OutlineBrainIcon, OutlineCheckIcon, @@ -95,7 +94,6 @@ export const AIModelSelect: React.FC = React.memo((props) => const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) const { isOpen = true, mountContainer, className } = props //#region AI model - const { chatIPCData } = useChatIPCStore() const [aiType, setAIType] = useState('online') //暂时只有online,后续会加"local" @@ -278,9 +276,7 @@ export const AIModelSelect: React.FC = React.memo((props) => visionItem && list.push(visionItem) return list }, [selectIntelligentItem, lightweightModels, visionModels]) - const execute = useCreation(() => { - return chatIPCData.execute - }, [chatIPCData.execute]) + const onSelectPolicy = useMemoizedFn((value) => { setAIModelOptions((old) => { return { diff --git a/app/renderer/src/main/src/pages/ai-agent/aiPlanReviewTree/AIPlanReviewTree.tsx b/app/renderer/src/main/src/pages/ai-agent/aiPlanReviewTree/AIPlanReviewTree.tsx index 6247a70f04..b5b3c1542d 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiPlanReviewTree/AIPlanReviewTree.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiPlanReviewTree/AIPlanReviewTree.tsx @@ -283,7 +283,7 @@ const AIPlanReviewTreeItem: React.FC = React.memo((pr setItem(item, { label: 'description', value: extraInfo?.description || '' }) }, [extraInfo?.description]) useEffect(() => { - if (!item.tools.length) { + if (!item?.tools?.length) { onSetTool(extraInfo?.keywords || []) } }, [extraInfo?.keywords]) diff --git a/app/renderer/src/main/src/pages/ai-agent/aiRunModeSelect/AIRunModeSelect.tsx b/app/renderer/src/main/src/pages/ai-agent/aiRunModeSelect/AIRunModeSelect.tsx index 2bc23671fd..e4968f9db2 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiRunModeSelect/AIRunModeSelect.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiRunModeSelect/AIRunModeSelect.tsx @@ -16,11 +16,12 @@ import styles from './AIRunModeSelect.module.scss' import { YakitInputNumber } from '@/components/yakitUI/YakitInputNumber/YakitInputNumber' import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' -import { AIExecutionStrategy, AIInputEventHotPatchTypeEnum } from '@/pages/ai-re-act/hooks/grpcApi' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' +import { AIExecutionStrategy, AIInputEvent, AIInputEventHotPatchTypeEnum } from '@/pages/ai-re-act/hooks/grpcApi' import emiter from '@/utils/eventBus/eventBus' import { OutlineViewGridIcon } from '@/components/yakChat/icon' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' type ModeOptionKey = 'plan' | 'multiAgent' | 'goal' @@ -46,12 +47,13 @@ const ModeOptionList: { }, ] +/** TODO - 待修改为新版 */ const AIRunModeSelect: React.FC = memo(() => { const { setting, activeChat } = useAIAgentStore() - const { setSetting } = useAIAgentDispatcher() - const { handleSendConfigHotpatch } = useChatIPCDispatcher() - const { chatIPCData } = useChatIPCStore() - const execute = useCreation(() => !!chatIPCData.execute, [chatIPCData.execute]) + const { setSetting, onSend } = useAIAgentDispatcher() + const store = useCurrentStore() + const sessionId = useCurrentSessionId() + const execute = useStore(store, (state) => state.execute) const enablePlan = useCreation(() => { return !!setting?.EnablePlan @@ -60,12 +62,14 @@ const AIRunModeSelect: React.FC = memo(() => { useMemoizedFn((checked) => { if (execute) { // ai运行才能热更新 plan - handleSendConfigHotpatch({ - hotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_EnablePlan, - params: { + const info: AIInputEvent = { + IsConfigHotpatch: true, + HotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_EnablePlan, + Params: { EnablePlan: checked, }, - }) + } + onSend({ token: sessionId, type: '', params: info }) } setSetting?.((v) => ({ ...v, diff --git a/app/renderer/src/main/src/pages/ai-agent/aiTree/AITree.tsx b/app/renderer/src/main/src/pages/ai-agent/aiTree/AITree.tsx index 5bb8ccbc14..47e79646d2 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiTree/AITree.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiTree/AITree.tsx @@ -117,7 +117,8 @@ const AITreeNode: React.FC = memo( const { isStart, isEnd, isStartOfLevel, isEndOfLevel, isParentLast, levelDiff } = position const [infoShow, setInfoShow] = React.useState(false) - const onDetails = useMemoizedFn(() => { + const onDetails = useMemoizedFn((e) => { + e.stopPropagation() if (!data.task_id) { yakitNotify('error', 'task_id为空') return diff --git a/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/AITriageChatTemplate.tsx b/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/AITriageChatTemplate.tsx index 86e1983cae..016d783a58 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/AITriageChatTemplate.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/AITriageChatTemplate.tsx @@ -14,7 +14,7 @@ import { QSInputTextarea } from '../template/template' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { yakitNotify } from '@/utils/notification' import { CustomPluginExecuteFormValue } from '@/pages/plugins/operator/localPluginExecuteDetailHeard/LocalPluginExecuteDetailHeardType' -import { AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' +import { AISourceEnum, AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' import classNames from 'classnames' import styles from './AITriageChatTemplate.module.scss' @@ -213,6 +213,7 @@ export const AIForgeForm: React.FC = memo((props) => { const request: AIStartParams = { ForgeName: `${info.ForgeVerboseName || info.ForgeName}`, UserQuery: '', + Source: AISourceEnum.aiAgent, } if (isUIParams) { @@ -221,26 +222,26 @@ export const AIForgeForm: React.FC = memo((props) => { .validateFields() .then(async (value: any) => { const kvPair = getYakExecutorParam({ ...value }) - onSubmit( - { + onSubmit({ + request: { ...request, ForgeParams: kvPair, }, - value, - ) + formValue: value, + }) }) .catch(() => {}) } } else { - onSubmit( - { + onSubmit({ + request: { ...request, UserQuery: question.trim() || '', }, - { + formValue: { UserQuery: question.trim() || '', }, - ) + }) } setTimeout(() => { diff --git a/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/type.ts b/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/type.ts index 11fde671de..11599a9d34 100644 --- a/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/aiTriageChatTemplate/type.ts @@ -2,7 +2,7 @@ import { RefObject } from 'react' import { AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' import { AIForge } from '../type/forge' import { AITool } from '../type/aiTool' -import { AIChatIPCStartParams } from '@/pages/ai-re-act/hooks/type' +import { CustomPluginExecuteFormValue } from '@/pages/plugins/operator/localPluginExecuteDetailHeard/LocalPluginExecuteDetailHeardType' export interface AIForgeInfoOptProps { info: AIForge @@ -10,11 +10,15 @@ export interface AIForgeInfoOptProps { onClick?: (info: AIForge) => void } +export interface AIForgeFormSubmitParamsProps { + request: AIStartParams + formValue: CustomPluginExecuteFormValue | Record +} export interface AIForgeFormProps { wrapperRef?: RefObject info: AIForge onBack: () => void - onSubmit: (request: AIStartParams, form: AIChatIPCStartParams['extraValue']) => void + onSubmit: (params: AIForgeFormSubmitParamsProps) => void } export interface AIToolFormProps { diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.tsx b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.tsx index 3813358f49..968203f474 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/AIAgentChatTemplate.tsx @@ -5,13 +5,8 @@ import { OutlineChevronrightIcon } from '@/assets/icon/outline' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' import { grpcQueryAIToolDetails } from '../grpc' -import { - AIChatQSData, - AIChatQSDataTypeEnum, - AITaskStartInfo, - ReActChatRenderItem, -} from '@/pages/ai-re-act/hooks/aiRender' -import { AIAgentGrpcApi, AIEventQueryRequest, AIInputEventSyncTypeEnum } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIChatQSData, AIChatQSDataTypeEnum, ReActChatRenderElement } from '@/pages/ai-re-act/hooks/aiRender' +import { AIEventQueryRequest, AIInputEvent, AIInputEventSyncTypeEnum } from '@/pages/ai-re-act/hooks/grpcApi' import { taskAnswerToIconMap } from '../defaultConstant' import { AIChatListItem } from '../components/aiChatListItem/AIChatListItem' import StreamCard from '../components/StreamCard' @@ -27,14 +22,17 @@ import { PreWrapper } from '../components/ToolInvokerCard' import { YakitRadioButtons } from '@/components/yakitUI/YakitRadioButtons/YakitRadioButtons' import TimelineCard from './TimelineCard/TimelineCard' import AIMemoryList from './aiMemoryList/AIMemoryList' -import useChatIPCStore from '../useContext/ChatIPCContent/useStore' import TaskLoading from './TaskLoading/TaskLoading' import { YakitResizeBox, YakitResizeBoxProps } from '@/components/yakitUI/YakitResizeBox/YakitResizeBox' -import useChatIPCDispatcher from '../useContext/ChatIPCContent/useDispatcher' import { HistoryTaskTree } from './historyTaskTree/HistoryTaskTree' import { AIReviewParams } from '../components/aiReviewResult/AIReviewResult' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' -import useLoadHistory from '@/pages/ai-re-act/hooks/useLoadHistory' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useLoadOlder from '@/pages/ai-re-act/hooks/useLoadOlder' +import { useStore } from 'zustand' +import useAIAgentDispatcher from '../useContext/useDispatcher' +import { randomString } from '@/utils/randomUtil' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' export enum AIChatLeft { TaskTree = 'task-tree', @@ -43,13 +41,18 @@ export enum AIChatLeft { /** @name chat-左侧侧边栏 */ export const AIChatLeftSide: React.FC = memo((props) => { - const { taskTree, taskName } = props const { t, i18nRefresh } = useI18nNamespaces(['aiAgent']) - const { chatIPCData } = useChatIPCStore() - const { handleSendSyncMessage, chatIPCEvents } = useChatIPCDispatcher() + const { onSend } = useAIAgentDispatcher() + const sessionId = useCurrentSessionId() + + const store = useCurrentStore() + const rawData = useCurrentRawData() + + const taskChat = useStore(store, (state) => state.taskChat) + const execute = useStore(store, (state) => state.execute) + const memoryListUpdate = useStore(store, (state) => state.memoryListUpdate) - const { taskChat, memoryList } = useChatIPCStore().chatIPCData const [activeTab, setActiveTab] = useState(AIChatLeft.Timeline) const [expand, setExpand] = useControllableValue(props, { defaultValue: true, @@ -65,57 +68,28 @@ export const AIChatLeftSide: React.FC = memo((props) => { } }, [hasTaskTree]) - const planHistoryList = useCreation(() => { - return ( - chatIPCData.planHistoryList || { - total: 0, - records: [], - session_id: '', - } - ) - }, [chatIPCData.planHistoryList]) const length = useCreation(() => { - return memoryList?.memories?.length - }, [memoryList?.memories?.length]) - const getTaskInfo = useMemoizedFn(() => { - return chatIPCEvents.fetchCurrentTaskPlanID() - }) + return rawData?.memoryList?.memories?.length || 0 + }, [memoryListUpdate]) const handleCancelExpand = useMemoizedFn(() => { setExpand(false) }) const onSendPlayHistoryList = useMemoizedFn(() => { - handleSendSyncMessage({ syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS }) + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS, + + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) }) const renderDom = useMemoizedFn(() => { switch (activeTab) { case AIChatLeft.TaskTree: - const coordinatorId = getTaskInfo()?.coordinatorId || '' - const currentTaskItem: AIAgentGrpcApi.PlanHistory = { - coordinator_id: coordinatorId, - created_at: '', - created_at_unix: 0, - session_id: '', - task_progress: { - total_tasks: 0, - completed_tasks: 0, - skipped_tasks: 0, - aborted_tasks: 0, - current_index: 0, - current_task_index: '', - current_task: '', - current_goal: '', - phase: 'NotCompleted', - updated_at: 0, - }, - task_tree: taskTree, - updated_at: '', - updated_at_unix: 0, - root_task_name: taskName, - } - return + return case AIChatLeft.Timeline: return default: @@ -125,7 +99,7 @@ export const AIChatLeftSide: React.FC = memo((props) => { const handleTabChange = useMemoizedFn((value: AIChatLeft) => { setActiveTab(value) - if (chatIPCData.execute && value === AIChatLeft.TaskTree) { + if (execute && value === AIChatLeft.TaskTree) { onSendPlayHistoryList() } }) @@ -198,23 +172,39 @@ export const AIChatLeftSide: React.FC = memo((props) => { /** @name chat-信息流展示 */ const TYPE = 'task' export const AIAgentChatStream: React.FC = memo((props) => { - const { streams, scrollToBottom, taskStatus, session } = props + const { scrollToBottom } = props const listRootRef = useRef(null) - const { handleLoadMoreHistory, handleHasMoreHistory, fetchChatDataStore } = useChatIPCDispatcher().chatIPCEvents + const [highlightedItem, setHighlightedItem] = useState<{ index: number; token: number } | null>(null) + const highlightRafRef = useRef(0) + const highlightObserverRef = useRef(null) - const { - requestHistoryState: { taskLoadMoreLoading }, - } = useChatIPCStore().chatIPCData - - // 向上滚动加载 - const { firstItemIndex, handleLoadMore, isPrependingRef } = useLoadHistory({ - loading: taskLoadMoreLoading, - dataLength: streams.length, - SessionID: session, - fetchHasMore: () => handleHasMoreHistory(TYPE), - loadMore: () => handleLoadMoreHistory(TYPE), - }) + const session = useCurrentSessionId() + const store = useCurrentStore() + const rawData = useCurrentRawData() + + const streams = useStore(store, (state) => state.taskChat.elements) + + const { onRangeChange, firstItemIndex, handleLoadMore, isPrependingRef } = useLoadOlder(TYPE) + + useUpdateEffect(() => { + scrollToIndex('LAST') + }, [scrollToBottom]) + + // 向上加载历史(recovery_history)的在途状态,给 Header 转圈提示 + const grpcLoadMoreLoading = useStore(store, (state) => state.grpcLoadMoreLoading) + + useEffect(() => { + if (!highlightedItem) return + + const clearTimer = window.setTimeout(() => { + setHighlightedItem(null) + }, 1600) + + return () => { + window.clearTimeout(clearTimer) + } + }, [highlightedItem]) const { virtuosoRef, @@ -223,10 +213,14 @@ export const AIAgentChatStream: React.FC = memo((props) scrollToIndex, scrollToItemIndex: scrollToListItem, handleTotalListHeightChanged, - } = useVirtuosoAutoScroll({ total: streams.length, isPrependingRef }) + } = useVirtuosoAutoScroll({ + total: streams.length, + isPrependingRef, + }) const { locateToIndex } = useChatStreamLocateHighlight({ - scrollToIndex: scrollToListItem, + // Virtuoso scrollToIndex 接受绝对 index,定位下标需加 firstItemIndex 偏移 + scrollToIndex: (index, behavior) => scrollToListItem(index + firstItemIndex, behavior), listRootRef, }) @@ -234,15 +228,10 @@ export const AIAgentChatStream: React.FC = memo((props) scrollToIndex('LAST') }, [scrollToBottom]) - const renderItem = useCallback( - (index: number, stream: ReActChatRenderItem) => { - if (!stream.token) return null - const arrayIndex = index - firstItemIndex - const hasNext = streams.length - arrayIndex > 1 - return - }, - [firstItemIndex, streams.length], - ) + const renderItem = useCallback((_: number, stream: ReActChatRenderElement) => { + if (!stream.token) return null + return + }, []) const Item = useCallback( ({ children, style, 'data-index': dataIndex }) => (
@@ -252,18 +241,15 @@ export const AIAgentChatStream: React.FC = memo((props) [], ) - const Footer = useCallback( - () => , - [taskStatus], - ) + const Footer = useCallback(() => , []) const Header = useCallback( () => - taskLoadMoreLoading ? ( + grpcLoadMoreLoading ? (
) : null, - [taskLoadMoreLoading], + [grpcLoadMoreLoading], ) const components = useMemo( () => ({ @@ -275,17 +261,17 @@ export const AIAgentChatStream: React.FC = memo((props) ) const onTreeLocate = useMemoizedFn((id?: string) => { if (!id) return - const index = streams.findIndex((item) => { - if (item.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) return false - const chatItem = fetchChatDataStore()?.getContentMap({ - session, - chatType: item.chatType, - mapKey: item.token, - }) - if (!chatItem) return false - return (chatItem.data as AITaskStartInfo).taskId === id + const index = streams.findLastIndex((item) => { + const itemData = rawData.contents.get(item.token) + switch (itemData?.type) { + case AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP: + case AIChatQSDataTypeEnum.TASK_NODE_GROUP: + return itemData.data?.taskId === id + default: + return false + } }) - locateToIndex(index, 'auto') + if (index !== -1) locateToIndex(index, 'auto') }) useMount(() => { // 仅监听 Ready:由 AITaskContent 保证深度规划已可见后再发 @@ -296,24 +282,22 @@ export const AIAgentChatStream: React.FC = memo((props) }) return (
- + ref={virtuosoRef} key={session} scrollerRef={setScrollerRef} - firstItemIndex={firstItemIndex} atBottomStateChange={setIsAtBottomRef} style={{ height: '100%', width: '100%' }} data={streams} totalListHeightChanged={handleTotalListHeightChanged} totalCount={streams.length} itemContent={renderItem} + firstItemIndex={firstItemIndex} atBottomThreshold={100} - initialTopMostItemIndex={streams.length > 1 ? streams.length - 1 : 0} + initialTopMostItemIndex={streams.length > 1 ? { index: 'LAST' } : 0} skipAnimationFrameInResizeObserver - // overscan={20} - // atTopStateChange={handleAtTopStateChange} startReached={handleLoadMore} - // increaseViewportBy={{top: 160, bottom: 160}} + rangeChanged={onRangeChange} components={components} />
@@ -325,7 +309,8 @@ export const AIChatToolDrawerContent: React.FC = m const [toolList, setToolList] = useState([]) const [loading, setLoading] = useState(false) - const { yakExecResult } = useChatIPCStore().chatIPCData + const store = useCurrentStore() + const execFileRecord = useStore(store, (state) => state.execFileRecord) const getList = useMemoizedFn(() => { if (!callToolId) return @@ -352,7 +337,6 @@ export const AIChatToolDrawerContent: React.FC = m <> {toolList.map((info) => { const { id, Timestamp, type, data } = info - const { execFileRecord } = yakExecResult switch (type) { case AIChatQSDataTypeEnum.STREAM: case AIChatQSDataTypeEnum.TOOL_CALL_RESULT: { diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TaskLoading/TaskLoading.tsx b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TaskLoading/TaskLoading.tsx index b8b0b3872e..0ce80bdf0a 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TaskLoading/TaskLoading.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TaskLoading/TaskLoading.tsx @@ -1,10 +1,12 @@ import Loading from '@/components/Loading/Loading' -import { PlanLoadingStatus } from '@/pages/ai-re-act/hooks/type' import { FC, memo, useEffect, useRef, useState } from 'react' import styles from './TaskLoading.module.scss' import useAISystemStream from '@/pages/ai-re-act/hooks/useAISystemStream' import { useAISystemStreamText } from '@/store/aiSystemStream' import classNames from 'classnames' +import { useStore } from 'zustand' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' export const ScrollText: FC<{ text?: string }> = ({ text = '' }) => { const wrapperRef = useRef(null) @@ -42,17 +44,20 @@ export const ScrollText: FC<{ text?: string }> = ({ text = '' }) => { } const TaskLoading: FC<{ - taskStatus: PlanLoadingStatus className?: string -}> = ({ taskStatus, className }) => { +}> = ({ className }) => { + const store = useCurrentStore() + const task = useStore(store, (state) => state.taskStatus.task) + const isRunning = useStore(store, (state) => state.taskStatus.status === AITaskStatus.inProgress) + const plan = useStore(store, (state) => state.taskStatus.plan) const systemStream = useAISystemStreamText() const { displayValue, mode } = useAISystemStream({ - value: taskStatus.task, + value: task, systemStream, }) return (
- {taskStatus.loading && ( + {isRunning && ( <> -
{taskStatus.plan}
+
{plan}
{mode === 'value' ? displayValue : } diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TimelineCard/TimelineCard.tsx b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TimelineCard/TimelineCard.tsx index 527a2edb35..4132e2c538 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TimelineCard/TimelineCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/TimelineCard/TimelineCard.tsx @@ -10,10 +10,11 @@ import { YakitPopover } from '@/components/yakitUI/YakitPopover/YakitPopover' import { OutlineInformationcircleIcon } from '@/assets/icon/outline' import { useMemoizedFn, useSize } from 'ahooks' import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' -import useAIAgentStore from '../../useContext/useStore' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' import useLoadHistory from '@/pages/ai-re-act/hooks/useLoadHistory' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' +import { useStore } from 'zustand' const TYPE_COLOR_MAP: Record = { user_input: 'info', @@ -82,30 +83,28 @@ const VirtuosoListContainer = forwardRef(({ children, VirtuosoListContainer.displayName = 'VirtuosoListContainer' -const TYPE = 'timelineID' - const TimelineCard: FC = () => { - const { activeChat } = useAIAgentStore() - const { - reActTimelines, - // historyState: { timelinesLoading }, - requestHistoryState: { timelinesLoading }, - } = useChatIPCStore().chatIPCData - const { handleLoadMoreHistory, handleHasMoreHistory } = useChatIPCDispatcher().chatIPCEvents + const store = useCurrentStore() + const sessionId = useCurrentSessionId() + + const reActTimelines = useStore(store, (state) => state.reActTimelines) + const timelinesLoading = useStore(store, (state) => state.requestHistoryState.timelinesLoading) + + // 向上滚动加载历史 timeline + const { firstItemIndex, handleLoadMore, isPrependingRef } = useLoadHistory({ + loading: timelinesLoading, + dataLength: reActTimelines.length, + SessionID: sessionId, + fetchHasMore: () => globalSessionEngine.hasMoreTimeline(sessionId), + loadMore: () => globalSessionEngine.loadTimelineHistory(sessionId), + }) const { virtuosoRef, handleTotalListHeightChanged, setScrollerRef, setIsAtBottomRef } = useVirtuosoAutoScroll({ total: reActTimelines.length, + isPrependingRef, }) const containerRef = useRef(null) const size = useSize(containerRef) - const { firstItemIndex, handleLoadMore } = useLoadHistory({ - loading: timelinesLoading, - dataLength: reActTimelines.length, - SessionID: activeChat?.SessionID || '', - fetchHasMore: () => handleHasMoreHistory('timelines'), - loadMore: () => handleLoadMoreHistory('timelines'), - }) - const components = useMemo>( () => ({ Item: VirtuosoItemContainer, diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiMemoryList/AIMemoryList.tsx b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiMemoryList/AIMemoryList.tsx index 689ad3e8bf..7d3142eb4b 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiMemoryList/AIMemoryList.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiMemoryList/AIMemoryList.tsx @@ -1,6 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useState } from 'react' import { AIMemoryContentProps, AIMemoryEchartsProps, AIMemoryListProps, AIMemoryScoreEchartsProps } from './type' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' import { useCreation, useDebounceEffect, useDebounceFn, useMemoizedFn, useUpdateEffect } from 'ahooks' import styles from './AIMemoryList.module.scss' import { RollingLoadList } from '@/components/RollingLoadList/RollingLoadList' @@ -13,11 +12,15 @@ import ReactResizeDetector from 'react-resize-detector' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { OutlineTrashIcon } from '@/assets/icon/outline' import { YakitPopconfirm } from '@/components/yakitUI/YakitPopconfirm/YakitPopconfirm' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' import { grpcDeleteAIMemoryEntity } from '@/pages/memoryBase/utils' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import useAIAgentDispatcher from '../../useContext/useDispatcher' +import cloneDeep from 'lodash/cloneDeep' +import { DefaultMemoryList } from '@/pages/ai-re-act/hooks/defaultConstant' -const { ipcRenderer } = window.require('electron') const getScoreList = (data: AIAgentGrpcApi.MemoryEntry) => { return [ { @@ -52,9 +55,16 @@ const getScoreList = (data: AIAgentGrpcApi.MemoryEntry) => { } const AIMemoryList: React.FC = React.memo((props) => { const { t } = useI18nNamespaces(['aiAgent']) - const { chatIPCData } = useChatIPCStore() - const { handleStop } = useChatIPCDispatcher() - const { chatIPCEvents } = useChatIPCDispatcher() + + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const rawData = useCurrentRawData() + + const { onClose } = useAIAgentDispatcher() + + const memoryListUpdate = useStore(store, (state) => state.memoryListUpdate) + const execute = useStore(store, (state) => state.execute) + const [width, setWidth] = useState() const [loading, setLoading] = useState(false) @@ -64,23 +74,21 @@ const AIMemoryList: React.FC = React.memo((props) => { useDebounceEffect( () => { if (!isClearMemory) return - const token = chatIPCEvents.fetchToken() - ipcRenderer.once(`${token}-end`, () => { + onClose([sessionId], () => { onClearMemory() setIsClearMemory(false) }) - handleStop() }, [isClearMemory], { wait: 1000 }, ) const list = useCreation(() => { - return chatIPCData?.memoryList?.memories || [] - }, [chatIPCData?.memoryList?.memories]) + return rawData?.memoryList?.memories || [] + }, [memoryListUpdate]) const echartsData: AIMemoryEchartsProps['data'] = useCreation(() => { - const data = chatIPCData?.memoryList?.score_overview + const data = rawData?.memoryList?.score_overview const xData: string[] = [] const yData: number[] = [] Object.entries(data || {}).forEach(([key, value]) => { @@ -88,11 +96,11 @@ const AIMemoryList: React.FC = React.memo((props) => { yData.push(value) }) return { xData, yData } - }, [chatIPCData?.memoryList?.score_overview]) + }, [memoryListUpdate]) const onClearMemoryConfirm = useMemoizedFn((e) => { e.stopPropagation() - if (chatIPCData.execute) { + if (execute) { setIsClearMemory(true) } else { onClearMemory() @@ -100,11 +108,12 @@ const AIMemoryList: React.FC = React.memo((props) => { }) const onClearMemory = useMemoizedFn(() => { setLoading(true) + /** TODO - 多会话清空记忆库,数据清空后其余会话会有新数据往记忆库中增加,导致看起来清空失败 */ grpcDeleteAIMemoryEntity({ Filter: {}, }) .then(() => { - chatIPCEvents.handleResetTarget('memoryList') + rawData.memoryList = cloneDeep(DefaultMemoryList) }) .finally(() => { setTimeout(() => { diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiTaskExecutionDetails/AITaskExecutionDetails.tsx b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiTaskExecutionDetails/AITaskExecutionDetails.tsx index 0767f753c4..595b96be4b 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiTaskExecutionDetails/AITaskExecutionDetails.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/aiTaskExecutionDetails/AITaskExecutionDetails.tsx @@ -18,8 +18,6 @@ import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { YakitPopconfirm } from '@/components/yakitUI/YakitPopconfirm/YakitPopconfirm' import { AIToDoListItem } from '@/pages/ai-re-act/aiReActChat/aiToDoList/AIToDoList' import { useCreation, useInterval, useMemoizedFn, useSelections } from 'ahooks' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import useAIAgentStore from '../../useContext/useStore' import { ForgesAndSkillsDynamicItem, PlanItemDetailsData, TodoListCardData } from '@/pages/ai-re-act/hooks/aiRender' import cloneDeep from 'lodash/cloneDeep' import isEqual from 'lodash/isEqual' @@ -37,6 +35,7 @@ import { TableTotalAndSelectNumber } from '@/components/TableTotalAndSelectNumbe import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' import { AIAgentGrpcApi, + AIInputEvent, AIInputEventHotPatchTypeEnum, AIInputEventSyncTypeEnum, AIStartParams, @@ -52,11 +51,15 @@ import { YakitRadioButtons } from '@/components/yakitUI/YakitRadioButtons/YakitR import { timeDiffWithMoment } from '@/utils/timeUtil' import { AITaskActionItem, AITaskExecutionList } from './aiTaskExecutionList/AITaskExecutionList' import { AIToDoListDetail } from '@/pages/ai-re-act/aiReActChat/aiToDoList/AIToDoListDetail' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import useAIAgentDispatcher from '../../useContext/useDispatcher' +import { randomString } from '@/utils/randomUtil' export const AITaskExecutionDetails: React.FC = React.memo((props) => { const { taskId, taskGoal, taskName, onClose } = props - const { chatIPCEvents } = useChatIPCDispatcher() - const { activeChat } = useAIAgentStore() + const rawData = useCurrentRawData() + const store = useCurrentStore() const [planItemDetailsData, setPlanItemDetailsData] = useState() const perPlanItemDetailsDataUUIdRef = useRef('') @@ -73,21 +76,17 @@ export const AITaskExecutionDetails: React.FC = Rea }) const getData = useMemoizedFn(() => { if (!taskId) return - const sessionId = activeChat?.SessionID || '' - const chatData = chatIPCEvents.fetchChatDataStore()?.get(sessionId) - if (!chatData) return - let itemData: PlanItemDetailsData | undefined - const subTaskData = chatData.casualChat.planDetailsMap.get(taskId) + const subTaskData = rawData.casualChat.planDetailsMap.get(taskId) if (subTaskData) { itemData = subTaskData } else { - const mainPlanDetails = chatData.casualChat.planDetails - const currentCasualTaskId = chatIPCEvents.fetchCurrentCasualTaskID() + const mainPlanDetails = rawData.casualChat.planDetails + const currentCasualTaskId = store.getState().currentCasualTaskID if (mainPlanDetails.taskId === taskId || currentCasualTaskId === taskId) { itemData = mainPlanDetails } else { - itemData = chatData.taskChat.planDetailsMap.get(taskId) + itemData = rawData.taskChat.planDetailsMap.get(taskId) } } if (!itemData) return @@ -395,15 +394,19 @@ export const AITaskExecutionDetails: React.FC = Rea const AIBrowserProcesses: React.FC = React.memo((props) => { const { list } = props - const { handleSendSyncMessage } = useChatIPCDispatcher() + const sessionId = useCurrentSessionId() + const { onSend } = useAIAgentDispatcher() const onRemove = useMemoizedFn((processes: AIBrowserProcessesProps['list'][number]) => { - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_CLOSE_BROWSER, + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_CLOSE_BROWSER, + SyncID: randomString(8), SyncJsonInput: JSON.stringify({ process_id: processes.process_id, }), - }) + } + onSend({ token: sessionId, type: '', params: info }) }) return (
@@ -429,7 +432,9 @@ const AIBrowserProcesses: React.FC = React.memo((props) const AITaskDetailsAddPopover: React.FC = React.memo((props) => { const { title, type, onClose, taskId } = props - const { handleSendConfigHotpatch, handleSendSyncMessage } = useChatIPCDispatcher() + + const sessionId = useCurrentSessionId() + const { onSend } = useAIAgentDispatcher() const [keyword, setKeyword] = useState() const [loading, setLoading] = useState(false) @@ -650,17 +655,22 @@ const AITaskDetailsAddPopover: React.FC = React.me Type: item.type, } }) - handleSendConfigHotpatch({ - hotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_EnabledCapabilities, - params: { + const info: AIInputEvent = { + IsConfigHotpatch: true, + HotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_EnabledCapabilities, + Params: { EnabledCapabilities: enabledCapabilities, }, - taskId, - }) + TaskId: taskId, + } + onSend({ token: sessionId, type: '', params: info }) setTimeout(() => { - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_CAPABILITY_INVENTORY, - }) + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_CAPABILITY_INVENTORY, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: info }) }, 1000) onClose() }) @@ -760,14 +770,17 @@ const typeOptions = [ const AITaskDetailsCardList: React.FC = React.memo((props) => { const { type, colTitle, fixedList, dynamicList, taskId } = props - const { handleSendConfigHotpatch, handleSendSyncMessage } = useChatIPCDispatcher() + const sessionId = useCurrentSessionId() + const { onSend } = useAIAgentDispatcher() + const [configType, setConfigType] = useState<'fixed' | 'dynamic'>('fixed') const [visible, setVisible] = useState(false) const onRemove = useMemoizedFn((dynamicItem) => { - handleSendConfigHotpatch({ - hotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_DisabledCapabilities, - params: { + const info: AIInputEvent = { + IsConfigHotpatch: true, + HotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_DisabledCapabilities, + Params: { EnabledCapabilities: dynamicList .filter((ele) => isEqual(ele, dynamicItem)) .map((item) => ({ @@ -775,12 +788,16 @@ const AITaskDetailsCardList: React.FC = React.memo(( Type: getType(item.category), })), }, - taskId, - }) + TaskId: taskId, + } + onSend({ token: sessionId, type: '', params: info }) setTimeout(() => { - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_CAPABILITY_INVENTORY, - }) + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_CAPABILITY_INVENTORY, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: info }) }, 1000) }) const renderHeader = useMemoizedFn(() => { diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTree.tsx b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTree.tsx index ce9a3703f4..01d11dd546 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTree.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTree.tsx @@ -7,10 +7,8 @@ import { SendRecoverParams, } from './HistoryTaskTreeType' import { useCreation, useMemoizedFn, useUpdateEffect } from 'ahooks' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import { AIInputEventSyncTypeEnum, AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIAgentGrpcApi, AIInputEvent, AIInputEventSyncTypeEnum, AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' import { AITree } from '../../aiTree/AITree' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' import YakitCollapse from '@/components/yakitUI/YakitCollapse/YakitCollapse' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { formatTimestamp } from '@/utils/timeUtil' @@ -21,27 +19,64 @@ import { Tooltip } from 'antd' import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import useAIAgentStore from '../../useContext/useStore' -import { formatAIAgentSetting } from '../../utils' import useAIAgentDispatcher from '../../useContext/useDispatcher' import { randomString } from '@/utils/randomUtil' +import { useCurrentMeta, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import { formatAIAgentSetting, onReStart } from '../../utils' +import { DefaultPlanHistoryList } from '@/pages/ai-re-act/hooks/defaultConstant' +import cloneDeep from 'lodash/cloneDeep' export const HistoryTaskTree: React.FC = memo((props) => { - const { data, currentTaskItem } = props + const store = useCurrentStore() + const planHistoryList = useStore(store, (state) => state.planHistoryList ?? cloneDeep(DefaultPlanHistoryList)) + const taskTree = useStore(store, (state) => state.taskChat.plan.task_tree ?? []) + const taskName = useStore(store, (state) => state.taskChat.plan.root_task_name ?? '') + const coordinatorId = useStore(store, (state) => state.taskStatus.coordinatorId ?? '') + + const currentTaskItem = useCreation(() => { + const item: AIAgentGrpcApi.PlanHistory = { + coordinator_id: coordinatorId, + created_at: '', + created_at_unix: 0, + session_id: '', + task_progress: { + total_tasks: 0, + completed_tasks: 0, + skipped_tasks: 0, + aborted_tasks: 0, + current_index: 0, + current_task_index: '', + current_task: '', + current_goal: '', + phase: 'NotCompleted', + updated_at: 0, + }, + task_tree: taskTree, + updated_at: '', + updated_at_unix: 0, + root_task_name: taskName, + } + return item + }, [coordinatorId, taskTree, taskName]) const currentCoordinatorId = useCreation(() => { return currentTaskItem?.coordinator_id || '' }, [currentTaskItem?.coordinator_id]) - const [activeKey, setActiveKey] = useState(currentCoordinatorId || data.records[0]?.coordinator_id || '') + const [activeKey, setActiveKey] = useState( + currentCoordinatorId || planHistoryList.records[0]?.coordinator_id || '', + ) const historyContainerRef = useRef(null) useUpdateEffect(() => { - const firstItemId = data.records[0]?.coordinator_id || '' + const firstItemId = planHistoryList.records[0]?.coordinator_id || '' if (!!currentCoordinatorId) { setActiveKey(currentCoordinatorId) } else if (!!firstItemId) { setActiveKey(firstItemId) } - }, [currentCoordinatorId, data.records[0]]) + }, [currentCoordinatorId, planHistoryList.records[0]]) return (
= memo((props) => { /> )} - {data.records + {planHistoryList.records // 历史任务树会包含当前正在执行的任务树,需要将其过滤 .filter((ele) => ele.coordinator_id !== currentCoordinatorId) .map((item) => { @@ -107,46 +142,40 @@ export const HistoryTaskTree: React.FC = memo((props) => { export const AIHistoryContinueTask: React.FC = React.memo((props) => { const { coordinatorId, taskId } = props const { t } = useI18nNamespaces(['aiAgent']) - const { chatIPCData } = useChatIPCStore() - const { chatIPCEvents, handleSendSyncMessage } = useChatIPCDispatcher() + + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const isExecuting = useStore(store, (state) => state.taskStatus.status === AITaskStatus.inProgress) + const cancelTaskLoading = useStore(store, (state) => state.cancelTaskLoading) + const execute = useStore(store, (state) => state.execute) + const { activeChat } = useAIAgentStore() - const { getSetting } = useAIAgentDispatcher() + const { getSetting, onSend, onStart } = useAIAgentDispatcher() const [visible, setVisible] = useState(false) const sendRecoverParamsRef = useRef() - const taskStatus = useCreation(() => { - return chatIPCData.taskStatus - }, [chatIPCData.taskStatus]) - - const isExecuting = useCreation(() => { - return taskStatus.loading - }, [taskStatus.loading]) - const loading = useCreation(() => { - return sendRecoverParamsRef.current?.taskId === taskId && isExecuting - }, [isExecuting, taskId]) + return sendRecoverParamsRef.current?.taskId === taskId && cancelTaskLoading + }, [taskId, cancelTaskLoading]) - const getTaskInfo = useMemoizedFn(() => { - return chatIPCEvents.fetchCurrentTaskPlanID() - }) - const getTaskId = useMemoizedFn(() => { - const taskInfo = getTaskInfo() - return taskInfo?.taskID || '' - }) useUpdateEffect(() => { if (!isExecuting && sendRecoverParamsRef.current) { onSendRecover(sendRecoverParamsRef.current) } }, [isExecuting]) + /** + * TODO - 现在的版本中,任务中断后状态不一定是error + */ const isShow = useMemoizedFn(() => { - const currentCoordinatorId = getTaskInfo()?.coordinatorId || '' - const taskInfo = getTaskInfo() + const taskStatus = store.getState().taskStatus + const currentCoordinatorId = taskStatus?.coordinatorId || '' + let show = true - if (!chatIPCData.execute) return true + if (!execute) return true if (coordinatorId === currentCoordinatorId) { - show = taskInfo?.status !== AITaskStatus.inProgress && !chatIPCData?.taskStatus?.loading + show = taskStatus?.status !== AITaskStatus.inProgress } // 如果当前有任务正在等待被恢复 if (sendRecoverParamsRef.current) { @@ -155,63 +184,74 @@ export const AIHistoryContinueTask: React.FC = React sendRecoverParamsRef.current.coordinatorId === coordinatorId && sendRecoverParamsRef.current.taskId === taskId ) } - const isStopping = taskInfo?.status !== AITaskStatus.inProgress && taskStatus.loading - // 如果系统正处于正在停止/取消任务的全局 Loading 状态,或当前任务本身正处于停止进行中的状态 - if (chatIPCData.cancelTaskLoading || isStopping) { + // 停止/取消进行中:status 仍为 processing,用 cancelTaskLoading 表示停止中 + if (cancelTaskLoading) { return false } return show }) const onSendRecover = useMemoizedFn((params: SendRecoverParams) => { - const { coordinatorId, taskId } = params - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_PLAN_AND_EXEC, + const { coordinatorId } = params + + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_PLAN_AND_EXEC, SyncJsonInput: JSON.stringify({ coordinator_id: coordinatorId, start_task_id: taskId }), - }) - chatIPCEvents.resetCurrentTaskPlanID() + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) + store.getState().updateTaskLoadingStatus({ taskID: '', status: AITaskStatus.created, coordinatorId: '' }) + sendRecoverParamsRef.current = undefined }) const onRecover = useMemoizedFn(() => { - const currentTaskId = getTaskId() + const taskStatus = store.getState().taskStatus + const currentTaskId = taskStatus.taskID if (!coordinatorId) return sendRecoverParamsRef.current = { coordinatorId, taskId, } - chatIPCEvents.handleCancelLoadingChange('task', true) - if (taskStatus.loading && currentTaskId) { - // 先停止当前任务,等待任务停止成功后,再发送恢复的数据 - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, + store.getState().updateState({ + cancelTaskLoading: true, + }) + if (taskStatus.status === AITaskStatus.inProgress && currentTaskId) { + // 选停止当前任务,等待任务停止成功后,再发送恢复的数据 + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, SyncJsonInput: JSON.stringify({ task_id: currentTaskId }), - }) - } else if (chatIPCData.execute) { + + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) + } else if (execute) { onSendRecover(sendRecoverParamsRef.current) - } else if (activeChat?.SessionID && getSetting) { - const session = activeChat?.SessionID - chatIPCEvents.onStart( - { - token: session, - params: { - IsStart: true, - Params: { - ...formatAIAgentSetting(getSetting()), - UserQuery: '', - TimelineSessionID: session, - CoordinatorId: '', - Sequence: 1, - }, - }, - }, - () => { - sendRecoverParamsRef.current && onSendRecover(sendRecoverParamsRef.current) + } else if (activeChat?.SessionID) { + onReStart({ + setting: { + ...formatAIAgentSetting(getSetting()), + UserQuery: '', + TimelineSessionID: activeChat?.SessionID, + CoordinatorId: '', + Sequence: 1, }, - ) + activeChat, + onStart: (data) => onChatStart(data), + }) } }) + const onChatStart = useMemoizedFn((data) => { + onStart({ + ...data, + onSuccess: () => { + sendRecoverParamsRef.current && onSendRecover(sendRecoverParamsRef.current) + }, + }) + }) return isShow() ? ( { const { t } = useI18nNamespaces(['aiAgent']) const syncIdOfStopSubTask = useRef('') - const { syncIdInfoMap } = useChatIPCStore() - const { handleSendSyncMessage } = useChatIPCDispatcher() + const store = useCurrentStore() + const syncIDUpdate = useStore(store, (state) => state.syncIDUpdate) + const meta = useCurrentMeta() + const sessionId = useCurrentSessionId() + const { onSend } = useAIAgentDispatcher() const onCancelTask = useMemoizedFn(() => { if (isTask) { if (!taskId) return syncIdOfStopSubTask.current = randomString(8) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_SKIP_SUBTASK_IN_PLAN, + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_SKIP_SUBTASK_IN_PLAN, SyncJsonInput: JSON.stringify({ reason: '用户认为这个任务不需要执行', subtask_id: taskId }), - syncID: syncIdOfStopSubTask.current, - }) + + SyncID: syncIdOfStopSubTask.current, + } + onSend({ token: sessionId, type: 'task', params: info }) } else { - if (!taskId) return - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, SyncJsonInput: JSON.stringify({ task_id: taskId }), - }) + } + onSend({ token: sessionId, type: 'task', params: info }) } }) + const skipLoading = useCreation(() => { + return !!meta.syncIDMap?.get(syncIdOfStopSubTask.current) + }, [syncIDUpdate]) return ( } type="text" - loading={!!syncIdInfoMap?.get(syncIdOfStopSubTask.current)} + loading={skipLoading} onClick={(e) => { e.stopPropagation() }} diff --git a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTreeType.ts b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTreeType.ts index d0f29953dd..ba94fcf337 100644 --- a/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTreeType.ts +++ b/app/renderer/src/main/src/pages/ai-agent/chatTemplate/historyTaskTree/HistoryTaskTreeType.ts @@ -1,12 +1,7 @@ import { AIAgentGrpcApi } from '@/pages/ai-re-act/hooks/grpcApi' import { AITreeNodeProps } from '../../aiTree/type' -export interface HistoryTaskTreeProps { - /** 当前任务 */ - currentTaskItem: AIAgentGrpcApi.PlanHistory - // 历史任务数据源 - data: AIAgentGrpcApi.PlanHistoryList -} +export interface HistoryTaskTreeProps {} export interface HistoryTaskTreeItemProps { // 历史任务的单条记录 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.module.scss index d264971022..5dcd10b0f2 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.module.scss @@ -11,56 +11,6 @@ flex-direction: column; gap: 4px; - &-title { - height: 20px; - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - - &-left { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; - flex: 1; - - .icon, - .icon svg { - width: 16px; - height: 16px; - flex-shrink: 0; - color: var(--Colors-Use-Neutral-Disable); - } - - .text { - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 12px; - font-style: normal; - font-weight: 500; - line-height: 16px; - letter-spacing: 0.5px; - flex-shrink: 0; - } - - .time { - color: var(--Colors-Use-Neutral-Disable); - font-size: 11px; - font-style: normal; - font-weight: 400; - line-height: 14px; - letter-spacing: 0.5px; - @include mixin.text-ellipsis(1, false, false, true); - } - } - - &-right { - display: flex; - align-items: center; - flex-shrink: 0; - } - } - &-content { max-height: 156px; min-height: 0; diff --git a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.tsx index dcec6dbd13..a3d4b1f209 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupCard.tsx @@ -1,98 +1,27 @@ -import { - type ChatTaskDefaultGroup, - type ReActChatElement, - type ReActChatTaskElementSub, -} from '@/pages/ai-re-act/hooks/aiRender' -import { type FC, useEffect, useMemo, useState } from 'react' +import { type FC, memo, useState } from 'react' import classNames from 'classnames' -import { OutlineInformationcircleIcon } from '@/assets/icon/outline' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import styles from './AITaskDefaultGroupCard.module.scss' -import ConcurrentStreamCardActions from '../ConcurrentStreamCard/ConcurrentStreamCardActions/ConcurrentStreamCardActions' import { useBoolean } from 'ahooks' -import { formatTimestamp } from '@/utils/timeUtil' -import { useConcurrentStreamRefreshListener } from '../ConcurrentStreamCard/concurrentStream/useConcurrentStreamRefreshListener' import AITaskDefaultGroupContent from './AITaskDefaultGroupContent' +import AITaskDefaultGroupCardHeard from './aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard' const AITaskDefaultGroupCard: FC<{ - elements: ReActChatTaskElementSub[] - session: string token: string - chatType: ReActChatElement['chatType'] - hasNext?: boolean - isChildWindow?: boolean - onRefresh?: () => void -}> = ({ elements, session, token, chatType, hasNext, isChildWindow, onRefresh }) => { - const { t } = useI18nNamespaces(['aiAgent']) - const { fetchChatDataStore } = useChatIPCDispatcher().chatIPCEvents - const [expand, { toggle: expandToggle, setFalse: collapseExpand }] = useBoolean(true) +}> = memo(({ token }) => { + const [expand, { toggle: expandToggle }] = useBoolean(true) const [contentFocused, setContentFocused] = useState(false) - const raw = fetchChatDataStore()?.getContentMap({ - session, - chatType, - mapKey: token, - }) as ChatTaskDefaultGroup | undefined - - const framePayload = useMemo( - () => ({ - session, - token, - chatType, - elements, - taskName: t('ConcurrentStreamCard.systemInfo'), - }), - [chatType, elements, session, t, token], - ) - - useEffect(() => { - if (isChildWindow || !hasNext) return - collapseExpand() - }, [collapseExpand, hasNext, isChildWindow]) - - useConcurrentStreamRefreshListener(framePayload, session, token, chatType, !isChildWindow) - return (
-
-
- - - - {t('ConcurrentStreamCard.systemInfo')} - {raw?.Timestamp ? {formatTimestamp(raw.Timestamp)} : null} -
-
e.stopPropagation()}> - -
-
- {expand ? ( - - ) : null} + + + {expand ? : null}
) -} +}) export default AITaskDefaultGroupCard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupContent.tsx b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupContent.tsx index a8142483e7..755d93e486 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupContent.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/AITaskDefaultGroupContent.tsx @@ -1,16 +1,17 @@ -import { type ReActChatTaskElementSub } from '@/pages/ai-re-act/hooks/aiRender' import useClickFocus from '@/pages/ai-re-act/hooks/useClickFocus' import { type FC, useEffect, useLayoutEffect } from 'react' import classNames from 'classnames' import styles from './AITaskDefaultGroupCard.module.scss' import ConcurrentStreamContent from '../ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' const AITaskDefaultGroupContent: FC<{ - elements: ReActChatTaskElementSub[] - session: string - isChildWindow?: boolean + token: string onContentFocusChange?: (focused: boolean) => void -}> = ({ elements, session, isChildWindow, onContentFocusChange }) => { +}> = ({ token, onContentFocusChange }) => { + const store = useCurrentStore() + const childrenTokens = useStore(store, (state) => state.tasks[token]?.childrenTokens || []) const { ref: contentRef, isFocus } = useClickFocus() useEffect(() => { @@ -31,23 +32,16 @@ const AITaskDefaultGroupContent: FC<{ requestAnimationFrame(scrollToBottom) }) return () => cancelAnimationFrame(rafId) - }, [contentRef, elements.length]) - + }, [contentRef, childrenTokens.length]) return (
- +
) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard.module.scss new file mode 100644 index 0000000000..f6327a251f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard.module.scss @@ -0,0 +1,59 @@ +@use '../../../../../styles/mixin.scss' as mixin; +%display-flex-center { + display: flex; + align-items: center; +} +%display-column-center { + display: flex; + align-items: center; + flex-direction: column; +} +.ai-task-default-group-card-title { + height: 20px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + + &-left { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + flex: 1; + + .icon, + .icon svg { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--Colors-Use-Neutral-Disable); + } + + .text { + color: var(--Colors-Use-Neutral-Text-1-Title); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.5px; + flex-shrink: 0; + } + + .time { + color: var(--Colors-Use-Neutral-Disable); + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 14px; + letter-spacing: 0.5px; + @include mixin.text-ellipsis(1, false, false, true); + } + } + + &-right { + display: flex; + align-items: center; + flex-shrink: 0; + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard.tsx new file mode 100644 index 0000000000..b48421c55f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/AITaskDefaultGroupCardHeard.tsx @@ -0,0 +1,48 @@ +import { memo } from 'react' +import { useCreation } from 'ahooks' +import { AITaskDefaultGroupCardHeardProps } from './type' +import styles from './AITaskDefaultGroupCardHeard.module.scss' +import { OutlineInformationcircleIcon } from '@/assets/icon/outline' +import { formatTimestamp } from '@/utils/timeUtil' + +import ConcurrentStreamCardActions from '../../ConcurrentStreamCard/ConcurrentStreamCardActions/ConcurrentStreamCardActions' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { useCurrentStore, useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' + +const AITaskDefaultGroupCardHeard: React.FC = memo((props) => { + const { t } = useI18nNamespaces(['aiAgent']) + const { expandToggle, expand, token } = props + const store = useCurrentStore() + const renderNum = useStore(store, (state) => state.tasks[token]?.renderNum) + const rawData = useCurrentRawData() + const timeStamp = useCreation(() => { + if (!rawData) return 0 + const itemData = rawData.contents.get(token) + if (!itemData) return 0 + return itemData.Timestamp || 0 + }, [renderNum]) + return ( +
+
+ + + + {t('ConcurrentStreamCard.systemInfo')} + {timeStamp ? {formatTimestamp(timeStamp)} : null} +
+
e.stopPropagation()}> + +
+
+ ) +}) + +export default AITaskDefaultGroupCardHeard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/type.ts new file mode 100644 index 0000000000..abe25c1477 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/AITaskDefaultGroupCard/aiTaskDefaultGroupCardHeard/type.ts @@ -0,0 +1,5 @@ +export interface AITaskDefaultGroupCardHeardProps { + expandToggle: () => void + expand: boolean + token: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.module.scss index 227478960c..f177855c9a 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.module.scss @@ -22,27 +22,13 @@ padding: 4px 0; @include mixin.text-ellipsis; } -.content { + +.chat-card { + position: relative; width: 100%; - max-height: 500px; - background-color: var(--Colors-Use-Basic-Background); - padding: 4px; + padding: 4px 8px; + background-color: var(--Colors-Use-Neutral-Bg-Hover); border-radius: 8px; -} - -.concurrent-stream-content { - height: 100%; display: flex; flex-direction: column; } - -.task-name { - width: 100%; - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 12px; - font-weight: 500; - line-height: 16px; - letter-spacing: 0.5px; - min-width: 0; - @include mixin.text-ellipsis(1, false, false, true); -} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.tsx index 83c3202926..d59df5a894 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCard.tsx @@ -1,170 +1,138 @@ -import { useEffect, useMemo, type FC } from 'react' -import ChatCard from '../ChatCard' -import ModalInfo from '../ModelInfo' -import { useBoolean, useMemoizedFn } from 'ahooks' +import { memo, useEffect, useMemo, type FC } from 'react' +import { useBoolean, useCreation, useMemoizedFn } from 'ahooks' import styles from './ConcurrentStreamCard.module.scss' -import ConcurrentStreamContent from './ConcurrentStreamContent/ConcurrentStreamContent' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import { - AIChatQSDataTypeEnum, - ChatTaskDefaultGroup, - type ChatTaskNodeGroup, - type ReActChatElement, - type ReActChatTaskElementSub, -} from '@/pages/ai-re-act/hooks/aiRender' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' import { getAIStatusPresentation } from '../../utils/AIStatusUtils' -import { CHILD_CONTENT_WINDOW_STYLE } from './constants' import { useVectorStripeBg } from './hooks/useVectorStripeBg' import { useConcurrentStreamCardStyle } from './hooks/useConcurrentStreamCardStyle' -import ConcurrentStreamCardActions from './ConcurrentStreamCardActions/ConcurrentStreamCardActions' -import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' -import { useConcurrentStreamRefreshListener } from './concurrentStream/useConcurrentStreamRefreshListener' -import { AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' -import emiter from '@/utils/eventBus/eventBus' -import { yakitNotify } from '@/utils/notification' +import { useCurrentStore, useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import type { + ConcurrentStreamCardHeardWrapperProps, + ConcurrentStreamCardListWrapperProps, + ConcurrentStreamCardTipProps, +} from './type' +import classNames from 'classnames' +import ConcurrentStreamCardHeard from './concurrentStreamCardHeard/ConcurrentStreamCardHeard' +import ConcurrentStreamContent from './ConcurrentStreamContent/ConcurrentStreamContent' const ConcurrentStreamCard: FC<{ - elements: ReActChatTaskElementSub[] - session: string token: string - chatType: ReActChatElement['chatType'] - isChildWindow?: boolean - onRefresh?: () => void -}> = ({ session, elements, chatType, token, isChildWindow, onRefresh }) => { - const [expand, { toggle: expandToggle, setFalse: collapseExpand }] = useBoolean(isChildWindow || chatType !== 'reAct') - const { fetchChatDataStore, fetchCurrentTaskPlanID } = useChatIPCDispatcher().chatIPCEvents - - const { t } = useI18nNamespaces(['aiAgent']) - - const raw = fetchChatDataStore()?.getContentMap({ - session, - chatType, - mapKey: token, - }) as ChatTaskNodeGroup | ChatTaskDefaultGroup | undefined - - // 是否是默认任务分组 - const isTaskDefaultGroup = raw?.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP - const presentation = useMemo(() => getAIStatusPresentation(raw?.data?.status), [raw?.data?.status]) - const vectorBg = useVectorStripeBg(presentation.stripeColor) +}> = memo(({ token }) => { + const store = useCurrentStore() + const rawData = useCurrentRawData() - const titleText = useMemo(() => { - if (isTaskDefaultGroup) { - return t('ConcurrentStreamCard.systemInfo') - } - return raw?.data?.taskName || '' - }, [isTaskDefaultGroup, raw?.data?.taskName, t]) - - const framePayload = useMemo( - () => ({ - session, - token, - chatType, - elements, - taskName: titleText, - }), - [chatType, elements, session, titleText, token], + const renderNum = useStore(store, (state) => state.tasks[token]?.renderNum) + + const [expand, { toggle: expandToggle, setFalse: collapseExpand }] = useBoolean( + rawData.contents.get(token)?.chatType !== 'reAct', ) + const raw = useCreation(() => { + if (!rawData) return null + const itemData = rawData.contents.get(token) + if (!itemData) return null + return { ...itemData } as + | Extract + | undefined + }, [renderNum]) + useEffect(() => { - if (isChildWindow) return if (!raw?.data?.status) return if (raw.data.status !== 'processing') { collapseExpand() } - }, [collapseExpand, isChildWindow, raw?.data?.status]) - - useConcurrentStreamRefreshListener(framePayload, session, token, chatType, !isChildWindow) + }, [raw?.data?.status]) - const modalInfo = useMemo(() => { - if (!raw) return undefined - return { time: isTaskDefaultGroup ? 0 : raw.Timestamp, title: raw.AIModelName, icon: raw.AIService } - }, [isTaskDefaultGroup, raw]) + const presentation = useMemo(() => getAIStatusPresentation(raw?.data?.status), [raw?.data?.status]) - const coordinatorId = fetchCurrentTaskPlanID()?.coordinatorId - const taskId = raw?.data?.taskId - const showContinueTask = !!raw && !!coordinatorId && !!taskId && !isChildWindow - const showCancelTask = raw?.data?.status === 'processing' && !!taskId && !isChildWindow - const showStripeBg = !expand && !isChildWindow && !!vectorBg + const vectorBg = useVectorStripeBg(presentation.stripeColor) + const showStripeBg = !expand && !!vectorBg + // TODO - 使用className来控制样式,避免使用style const cardStyle = useConcurrentStreamCardStyle({ bgColor: presentation.bgColor, vectorBg, showStripe: showStripeBg, - isChildWindow, + isChildWindow: false, }) - const newElements = useMemo(() => { - if (elements.length === 0) return elements - return elements.filter((item) => item.type !== ('task-dependency-graph' as AIChatQSDataTypeEnum)) - }, [elements]) + return ( +
+ + {expand && ( + <> + + + + )} +
+ ) +}) - const showDetails = useMemo(() => { - if (isChildWindow) return false - if (!raw) return false - const status = raw?.data?.status - return status === AITaskStatus.created || status === AITaskStatus.inProgress - }, [raw?.data?.status]) +export default ConcurrentStreamCard + +const ConcurrentStreamCardHeardWrapper: FC = memo((props) => { + const { token, expand, expandToggle } = props + const store = useCurrentStore() + const rawData = useCurrentRawData() + + const renderNum = useStore(store, (state) => state.tasks[token]?.renderNum) + const coordinatorId = useStore(store, (state) => state.taskStatus.coordinatorId) - const onDetails = useMemoizedFn(() => { - const data = raw?.data - if (!data) return - if (!data.taskId) { - yakitNotify('error', 'taskId为空') - return + const raw = useCreation(() => { + if (!rawData) return undefined + const itemData = rawData.contents.get(token) + if (!itemData) return undefined + switch (itemData.type) { + case AIChatQSDataTypeEnum.TASK_NODE_GROUP: + return { ...itemData } + + default: + return undefined } - emiter.emit( - 'actionAITaskContentTab', - JSON.stringify({ - type: 'add', - params: { - key: data.taskId, - label: data.taskName, - goal: data.goal, - }, - }), - ) + }, [token, renderNum]) + + const onClickTitle = useMemoizedFn(() => { + expandToggle() }) return ( - {titleText}
} - titleExtra={modalInfo && } - style={cardStyle} - childStyle={isChildWindow ? CHILD_CONTENT_WINDOW_STYLE : undefined} - onClickTitle={() => { - if (!isChildWindow) expandToggle() - }} - titleMore={ - - } - > - {expand && ( -
-
{raw?.data?.goal}
- -
- )} - + ) -} +}) -export default ConcurrentStreamCard +export const ConcurrentStreamCardTip: FC = memo((props) => { + const { token } = props + const store = useCurrentStore() + const rawData = useCurrentRawData() + + const renderNum = useStore(store, (state) => state.tasks[token]?.renderNum) + + const goal = useCreation(() => { + const itemData = rawData.contents.get(token) + if (!itemData) return null + switch (itemData.type) { + case AIChatQSDataTypeEnum.TASK_NODE_GROUP: + return itemData?.data?.goal || '' + + default: + return '' + } + }, [renderNum]) + return
{goal}
+}) +const ConcurrentStreamCardListWrapper: FC = memo((props) => { + const { token } = props + const store = useCurrentStore() + const childrenTokens = useStore(store, (state) => state.tasks[token]?.childrenTokens || []) + + return +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCardActions/ConcurrentStreamCardActions.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCardActions/ConcurrentStreamCardActions.tsx index 18bc540b55..8204d4f4a6 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCardActions/ConcurrentStreamCardActions.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamCardActions/ConcurrentStreamCardActions.tsx @@ -6,22 +6,22 @@ import { OutlineChevronsUpDownIcon, OutlineListOneIcon, OutlineListTodoIcon, - OutlineRefreshIcon, } from '@/assets/icon/outline' import { AIHistoryContinueTask, AIHistorySkipTask } from '../../../chatTemplate/historyTaskTree/HistoryTaskTree' -import type { OpenAIConcurrentStreamPayload } from '@/utils/openWebsite' import { openAIConcurrentStream } from '@/utils/openWebsite' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import styles from '../ConcurrentStreamCard.module.scss' +import { useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useCreation, useMemoizedFn } from 'ahooks' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import { getTaskName } from '../concurrentStream/buildConcurrentStreamFramePayload' /** 卡片标题栏右侧操作区 */ interface ConcurrentStreamCardActionsProps { - isChildWindow?: boolean expand: boolean onExpandToggle: () => void - onRefresh?: () => void onDetails?: () => void - framePayload: OpenAIConcurrentStreamPayload + token: string showContinueTask: boolean showCancelTask: boolean showDetails: boolean @@ -30,39 +30,47 @@ interface ConcurrentStreamCardActionsProps { } const ConcurrentStreamCardActions: FC = ({ - isChildWindow, expand, onExpandToggle, - onRefresh, onDetails, - framePayload, showContinueTask, showCancelTask, showDetails, coordinatorId, taskId, + token, }) => { const { t } = useI18nNamespaces(['aiAgent']) - if (isChildWindow) { - return ( - - } - onClick={onRefresh} - className={styles['expand-btn']} - /> - - ) - } + const session = useCurrentSessionId() + const rawData = useCurrentRawData() + const chatType = useCreation(() => { + if (!rawData) return + const itemData = rawData.contents.get(token) + if (!itemData) return + return itemData.chatType + }, []) + + const openChildWindow = useMemoizedFn((e) => { + e?.stopPropagation() + if (!chatType) return + // 开窗只传轻量元数据 + // 只传基础类型数据 + openAIConcurrentStream({ + token, + session, + chatType, + rootType: rawData.contents.get(token)?.type, + taskName: getTaskName(rawData, token), + }) + }) + return ( <> {showContinueTask && coordinatorId != null && !!taskId && ( )} - {showCancelTask && !!taskId && } + {showCancelTask && !!taskId && } {showDetails && ( } type="text2" onClick={onDetails} /> @@ -73,10 +81,7 @@ const ConcurrentStreamCardActions: FC = ({ size="small" type="text" icon={} - onClick={(e) => { - e.stopPropagation() - openAIConcurrentStream(framePayload) - }} + onClick={openChildWindow} className={styles['expand-btn']} /> diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.module.scss index 7a292cf42f..f21547c5fa 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.module.scss @@ -16,6 +16,14 @@ } } +// 子窗口内容区外层容器:撑满父级高度 +.concurrent-stream-content-wrapper { + height: 100%; + display: flex; + flex-direction: column; + flex: 1; +} + .focused { overflow-y: overlay; } @@ -25,3 +33,11 @@ max-height: none; overflow: visible; } + +.content { + width: 100%; + max-height: 500px; + background-color: var(--Colors-Use-Basic-Background); + padding: 4px; + border-radius: 8px; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.tsx index ddd6e4c1ba..fafa740b78 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/ConcurrentStreamContent.tsx @@ -1,128 +1,127 @@ -import type { ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' import { type FC, memo, type RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useLatest, useMemoizedFn } from 'ahooks' +import { useCreation, useLatest, useMemoizedFn } from 'ahooks' import classNames from 'classnames' import useClickFocus from '../../../../ai-re-act/hooks/useClickFocus' import styles from './ConcurrentStreamContent.module.scss' import { AIChatListItem } from '../../aiChatListItem/AIChatListItem' +import type { ConcurrentStreamContentItemProps } from './type' +import { useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useAIItemKind from '@/pages/ai-re-act/hooks/useAIItemKind' +import { ReActChatRenderElement } from '@/pages/ai-re-act/hooks/aiRender' const PAGE_SIZE = 20 interface ConcurrentStreamContentProps { - session: string - elements: ReActChatRenderItem[] - isChildWindow?: boolean + childrenTokens: string[] /** 由父级承担滚动时传入,避免嵌套双滚动容器 */ scrollContainerRef?: RefObject } -const ConcurrentStreamContent: FC = memo( - ({ elements, session, isChildWindow, scrollContainerRef }) => { - const { ref: scrollRef, isFocus } = useClickFocus() - const contentMeasureRef = useRef(null) - const embedInParentScroll = !!scrollContainerRef - - /** 当前渲染起始下标,初始从末尾 PAGE_SIZE 处开始,新增元素始终可见 */ - const [startIndex, setStartIndex] = useState(() => Math.max(0, elements.length - PAGE_SIZE)) - /** 加载更多时保存旧滚动高度,用于恢复位置 */ - const prevScrollHeightRef = useRef(0) - const loadingMoreRef = useRef(false) - const startIndexLatest = useLatest(startIndex) - /** 用户是否在底部附近,用于决定新元素到来时是否跟随滚动 */ - const isAtBottomRef = useRef(true) - const isFirstMountRef = useRef(true) - - const getScrollEl = useMemoizedFn(() => scrollContainerRef?.current ?? scrollRef.current) - - /** 流式增高时,仅在底部则自动跟随 */ - useLayoutEffect(() => { - const scrollEl = getScrollEl() - const measureEl = contentMeasureRef.current - if (!scrollEl || !measureEl) return - - let rafId = 0 - const scrollToBottom = () => { - if (!isAtBottomRef.current) return - scrollEl.scrollTop = scrollEl.scrollHeight - } - - const observer = new ResizeObserver(() => { - cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(scrollToBottom) - }) - observer.observe(measureEl) +const ConcurrentStreamContent: FC = memo(({ childrenTokens, scrollContainerRef }) => { + const { ref: scrollRef, isFocus } = useClickFocus() + const contentMeasureRef = useRef(null) + const embedInParentScroll = !!scrollContainerRef + + /** 当前渲染起始下标,初始从末尾 PAGE_SIZE 处开始,新增元素始终可见 */ + const [startIndex, setStartIndex] = useState(() => Math.max(0, childrenTokens.length - PAGE_SIZE)) + /** 加载更多时保存旧滚动高度,用于恢复位置 */ + const prevScrollHeightRef = useRef(0) + const loadingMoreRef = useRef(false) + const startIndexLatest = useLatest(startIndex) + /** 用户是否在底部附近,用于决定新元素到来时是否跟随滚动 */ + const isAtBottomRef = useRef(true) + const isFirstMountRef = useRef(true) + + const getScrollEl = useMemoizedFn(() => scrollContainerRef?.current ?? scrollRef.current) + + /** 流式增高时,仅在底部则自动跟随 */ + useLayoutEffect(() => { + const scrollEl = getScrollEl() + const measureEl = contentMeasureRef.current + if (!scrollEl || !measureEl) return + + let rafId = 0 + const scrollToBottom = () => { + if (!isAtBottomRef.current) return + scrollEl.scrollTop = scrollEl.scrollHeight + } + + const observer = new ResizeObserver(() => { + cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(scrollToBottom) + }) + observer.observe(measureEl) - return () => { - cancelAnimationFrame(rafId) - observer.disconnect() - } - }, [getScrollEl, scrollContainerRef]) + return () => { + cancelAnimationFrame(rafId) + observer.disconnect() + } + }, [getScrollEl, scrollContainerRef]) - /** 挂载 / 新元素时置底:首次强制,后续仅在底部时跟随 */ - useLayoutEffect(() => { - const scrollEl = getScrollEl() - if (!scrollEl) return + /** 挂载 / 新元素时置底:首次强制,后续仅在底部时跟随 */ + useLayoutEffect(() => { + const scrollEl = getScrollEl() + if (!scrollEl) return - const force = isFirstMountRef.current - isFirstMountRef.current = false + const force = isFirstMountRef.current + isFirstMountRef.current = false - const scrollToBottom = (forceScroll = false) => { - if (!forceScroll && !isAtBottomRef.current) return - scrollEl.scrollTop = scrollEl.scrollHeight - } + const scrollToBottom = (forceScroll = false) => { + if (!forceScroll && !isAtBottomRef.current) return + scrollEl.scrollTop = scrollEl.scrollHeight + } - scrollToBottom(force) + scrollToBottom(force) - if (!force) { - if (isAtBottomRef.current) { - requestAnimationFrame(() => scrollToBottom(false)) - } - return + if (!force) { + if (isAtBottomRef.current) { + requestAnimationFrame(() => scrollToBottom(false)) } + return + } - let rafId = 0 - const rafId2 = requestAnimationFrame(() => { - rafId = requestAnimationFrame(() => scrollToBottom(true)) - }) - - return () => { - cancelAnimationFrame(rafId2) - cancelAnimationFrame(rafId) - } - }, [elements.length, getScrollEl, scrollContainerRef]) - - /** 加载旧数据后保持滚动位置不跳动 */ - useLayoutEffect(() => { - if (!loadingMoreRef.current) return - const scrollEl = getScrollEl() - if (!scrollEl) return - scrollEl.scrollTop = scrollEl.scrollHeight - prevScrollHeightRef.current - loadingMoreRef.current = false - }, [getScrollEl, startIndex]) - - const handleScroll = useMemoizedFn(() => { - const scrollEl = getScrollEl() - if (!scrollEl) return - isAtBottomRef.current = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 50 - // 滚动到顶部附近时加载更多旧数据 - if (scrollEl.scrollTop <= 50 && startIndexLatest.current > 0 && !loadingMoreRef.current) { - prevScrollHeightRef.current = scrollEl.scrollHeight - loadingMoreRef.current = true - setStartIndex((prev) => Math.max(0, prev - PAGE_SIZE)) - } + let rafId = 0 + const rafId2 = requestAnimationFrame(() => { + rafId = requestAnimationFrame(() => scrollToBottom(true)) }) - useEffect(() => { - if (!embedInParentScroll) return - const scrollEl = scrollContainerRef?.current - if (!scrollEl) return - scrollEl.addEventListener('scroll', handleScroll, { passive: true }) - return () => scrollEl.removeEventListener('scroll', handleScroll) - }, [embedInParentScroll, handleScroll, scrollContainerRef]) - - const visibleElements = elements.slice(startIndex) - - return ( + return () => { + cancelAnimationFrame(rafId2) + cancelAnimationFrame(rafId) + } + }, [childrenTokens.length, getScrollEl, scrollContainerRef]) + + /** 加载旧数据后保持滚动位置不跳动 */ + useLayoutEffect(() => { + if (!loadingMoreRef.current) return + const scrollEl = getScrollEl() + if (!scrollEl) return + scrollEl.scrollTop = scrollEl.scrollHeight - prevScrollHeightRef.current + loadingMoreRef.current = false + }, [getScrollEl, startIndex]) + + const handleScroll = useMemoizedFn(() => { + const scrollEl = getScrollEl() + if (!scrollEl) return + isAtBottomRef.current = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 50 + // 滚动到顶部附近时加载更多旧数据 + if (scrollEl.scrollTop <= 50 && startIndexLatest.current > 0 && !loadingMoreRef.current) { + prevScrollHeightRef.current = scrollEl.scrollHeight + loadingMoreRef.current = true + setStartIndex((prev) => Math.max(0, prev - PAGE_SIZE)) + } + }) + + useEffect(() => { + if (!embedInParentScroll) return + const scrollEl = scrollContainerRef?.current + if (!scrollEl) return + scrollEl.addEventListener('scroll', handleScroll, { passive: true }) + return () => scrollEl.removeEventListener('scroll', handleScroll) + }, [embedInParentScroll, handleScroll, scrollContainerRef]) + + return ( + + ) +}) export default ConcurrentStreamContent + +const ConcurrentStreamContentItem: FC = memo(({ token }) => { + const rawData = useCurrentRawData() + const getKind = useAIItemKind() + + const item: ReActChatRenderElement = useCreation(() => { + const data = rawData.contents.get(token) + return { + token, + kind: getKind(token) ?? 'item', + chatType: data?.chatType ?? 'reAct', + isHistory: false, + } + }, [token]) + return ( +
+ +
+ ) +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/type.ts new file mode 100644 index 0000000000..f9e80e5dd4 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/ConcurrentStreamContent/type.ts @@ -0,0 +1,3 @@ +export interface ConcurrentStreamContentItemProps { + token: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard.module.scss new file mode 100644 index 0000000000..ec8cf26f52 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard.module.scss @@ -0,0 +1,33 @@ +// 子窗口并发流卡片样式。 +// 内容对齐 ConcurrentStreamCard.module.scss / ConcurrentStreamContent.module.scss。 + +.chat-card { + position: relative; + width: 100%; + padding: 4px 8px; + background-color: var(--Colors-Use-Neutral-Bg-Hover); + border-radius: 8px; + display: flex; + flex-direction: column; +} + +.child-chat-card { + height: 100%; + background-color: var(--Colors-Use-Basic-Background); +} + +.goal { + color: var(--Colors-Use-Neutral-Text-2-Primary); + font-size: 12px; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.5px; + padding: 4px 0; + + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-all; + + flex-shrink: 0; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard.tsx new file mode 100644 index 0000000000..d282d8c151 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/AIChildWindowConcurrentStreamCard.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames' +import { type FC, memo } from 'react' +import styles from './AIChildWindowConcurrentStreamCard.module.scss' +import { type ChatTaskNodeGroup } from '@/pages/ai-re-act/hooks/aiRender' +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import useAIConcurrentStreamDispatcher from '@/auxWindow/pages/AIConcurrentStream/useContext/useDispatcher' +import useCreation from 'ahooks/lib/useCreation' +import AIChildWindowConcurrentStreamContent from '../../aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent' +import AIChildWindowConcurrentStreamCardHeard from './aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard' + +export interface AIChildWindowConcurrentStreamCardProps { + token: string +} + +/** 子窗口版并发流卡片(task_node_group 类型),数据从 auxWindow context 读取 */ +const AIChildWindowConcurrentStreamCard: FC = memo((props) => { + const { token } = props + + const { rawData, renderNum } = useAIConcurrentStreamStore() + const { requestRefresh } = useAIConcurrentStreamDispatcher() + + const itemData = useCreation(() => { + if (!rawData) return undefined + const itemData = rawData.get(token) + if (!itemData) return undefined + return itemData as ChatTaskNodeGroup + }, [renderNum]) + + return ( +
+ +
{itemData?.data.goal}
+ +
+ ) +}) + +export default AIChildWindowConcurrentStreamCard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard.module.scss new file mode 100644 index 0000000000..74b0d06d15 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard.module.scss @@ -0,0 +1,74 @@ +@use '../../../../../../styles/mixin.scss' as mixin; +%display-flex-center { + display: flex; + align-items: center; +} +%display-column-center { + display: flex; + align-items: center; + flex-direction: column; +} + +.chat-card-title { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0 8px; + + &-left { + display: flex; + align-items: center; + gap: 4px; + // padding: 4px 8px 0 0; + flex: 1; + min-width: 0; + } + + &-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + + svg { + width: 100%; + height: 100%; + color: var(--Colors-Use-Neutral-Text-2-Primary); + } + } + + &-text { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.5px; + color: var(--Colors-Use-Neutral-Text-1-Title); + @include mixin.text-ellipsis(1, false, false, true); + + width: 100%; + } + + &-extra { + flex-shrink: 0; + min-width: 0; + max-width: 100%; + @include mixin.text-ellipsis(1, false, false, true); + + &:empty { + display: none; + } + } + + &-more { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + align-items: center; + overflow: hidden; + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard.tsx new file mode 100644 index 0000000000..e863824284 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/AIChildWindowConcurrentStreamCardHeard.tsx @@ -0,0 +1,47 @@ +import { FC, memo, useMemo } from 'react' +import type { AIChildWindowConcurrentStreamCardHeardProps } from './type' +import styles from './AIChildWindowConcurrentStreamCardHeard.module.scss' +import { OutlineRefreshIcon } from '@/assets/icon/outline' +import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' +import { getAIStatusPresentation } from '@/pages/ai-agent/utils/AIStatusUtils' +import { Tooltip } from 'antd' +import ModalInfo from '../../../ModelInfo' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' + +const AIChildWindowConcurrentStreamCardHeard: FC = memo((props) => { + const { rowData, onRefresh } = props + const { t } = useI18nNamespaces(['aiAgent']) + + const presentation = useMemo(() => getAIStatusPresentation(rowData?.data?.status), [rowData?.data?.status]) + const titleText = useMemo(() => { + return rowData?.data?.taskName || '' + }, [rowData?.data?.taskName]) + const modalInfo = useMemo(() => { + if (!rowData) return undefined + return { time: rowData.Timestamp, title: rowData.AIModelName, icon: rowData.AIService } + }, [rowData?.Timestamp, rowData?.AIModelName, rowData?.AIService]) + return ( +
+
+
+ {presentation.icon &&
{presentation.icon}
} +
{titleText}
+
{modalInfo && }
+
+
+ + } + onClick={onRefresh} + className={styles['expand-btn']} + /> + +
+
+
+ ) +}) + +export default AIChildWindowConcurrentStreamCardHeard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/type.ts new file mode 100644 index 0000000000..1ab7f3b563 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/aiChildWindowConcurrentStreamCardHeard/type.ts @@ -0,0 +1,6 @@ +import { ChatTaskNodeGroup } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIChildWindowConcurrentStreamCardHeardProps { + rowData?: ChatTaskNodeGroup + onRefresh?: () => void +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/type.ts new file mode 100644 index 0000000000..6a1f15115d --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/aiChildWindowConcurrentStreamCard/type.ts @@ -0,0 +1,10 @@ +import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIChildWindowGroupItemProps { + token: string +} + +export interface AIChildWindowNodeItemWrapperProps { + itemData: AIChatQSData + renderNum: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/buildConcurrentStreamFramePayload.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/buildConcurrentStreamFramePayload.ts new file mode 100644 index 0000000000..e30d2b8ebe --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/buildConcurrentStreamFramePayload.ts @@ -0,0 +1,121 @@ +import { + AIChatQSDataTypeEnum, + AIYakExecFileRecord, + ChatStoreState, + type AIChatQSData, +} from '@/pages/ai-re-act/hooks/aiRender' +import { AIItemKind, getAIItemKind } from '@/pages/ai-re-act/hooks/useAIItemKind' +import i18n from '@/i18n/i18n' +import { FramePayload } from '../concurrentStreamFrame' + +/** store 的最小依赖接口 */ +interface BuildFrameStore { + getState: () => ChatStoreState +} + +interface BuildFrameRawData { + contents: Map +} + +export interface BuildConcurrentStreamFramePayloadParams { + token: string + session: string + chatType?: string + store: BuildFrameStore + rawData: BuildFrameRawData | null | undefined + /** + * 是否在返回的 frame 中填充 rawData(task 自身 + children + group 孙节点)。 + * - true(默认):用于需要立即拿到全部 rawData 的场景。 + * - false:仅返回元数据(rawData 为空 Map),用于打开子窗口时只发轻量 payload, + * 子窗 mount 后再通过 fetch-concurrent-stream-contents 拉取,避免开窗瞬间克隆大 Map。 + */ + withRawData: boolean +} + +/** 判断 childToken 在 store 中的类型 */ +function getKind(store: BuildFrameStore, childToken: string): AIItemKind | null { + const state = store.getState() + return getAIItemKind(state, childToken) +} + +const tOriginal = i18n.getFixedT(null, 'aiAgent') +/** 获取 task 节点的名称 */ +export function getTaskName(rawData: BuildFrameRawData | null | undefined, token: string): string { + const itemData = rawData?.contents.get(token) + if (!itemData) return '' + switch (itemData.type) { + case AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP: + return tOriginal('ConcurrentStreamCard.systemInfo') + case AIChatQSDataTypeEnum.TASK_NODE_GROUP: + return itemData.data?.taskName ?? '' + default: + return '' + } +} + +/** + * 从主窗口 store + rawData 构建 ConcurrentStreamFramePayload。 + * 收集 task 自身、所有 childrenTokens 节点、group 内子节点的原始数据, + */ +export function buildConcurrentStreamFramePayload( + params: BuildConcurrentStreamFramePayloadParams, +): FramePayload | null { + const { token, chatType, store, rawData, withRawData = true } = params + if (!chatType || !rawData) return null + + const frameRawData = new Map() + let execFileRecord = new Map() + const state = store.getState() + let childrenTokens: string[] = [] + + const handFileRecord = (record) => { + switch (record.type) { + case AIChatQSDataTypeEnum.TOOL_RESULT: + const fileRecord = state.execFileRecord.get(record.data.callToolId) + if (fileRecord) execFileRecord.set(record.data.callToolId, fileRecord) + break + + default: + break + } + } + if (withRawData) { + // task 自身数据 + const taskData = rawData.contents.get(token) + if (taskData) frameRawData.set(token, taskData) + const tokens = state.tasks[token]?.childrenTokens + // 遍历所有子节点 + for (const childToken of tokens) { + const kind = getKind(store, childToken) + if (!kind) continue + const childData = rawData.contents.get(childToken) + if (!childData) continue + frameRawData.set(childToken, childData) + + if (kind === 'item') { + handFileRecord(childData) + } + // group 下的所有子节点数据 + if (kind === 'group') { + const groupData = state.groups[childToken] + for (const grandChildToken of groupData?.childrenTokens || []) { + const grandChildData = rawData.contents.get(grandChildToken) + if (!grandChildData) continue + frameRawData.set(grandChildToken, grandChildData) + handFileRecord(grandChildData) + } + } + } + childrenTokens = state.tasks[token]?.childrenTokens || [] + } + + return { + // session, + // token, + // chatType: chatType as OpenAIConcurrentStreamPayload['chatType'], + childrenTokens: [...childrenTokens], + rawData: frameRawData, + execFileRecord: execFileRecord, + // taskName: getTaskName(rawData, token), + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/collectConcurrentStreamContentEntries.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/collectConcurrentStreamContentEntries.ts deleted file mode 100644 index ce4ea3d27b..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/collectConcurrentStreamContentEntries.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ChatDataStore } from '@/pages/ai-agent/store/ChatDataStore' -import type { AIChatQSData, ReActChatTaskElementSub } from '@/pages/ai-re-act/hooks/aiRender' -import type { ConcurrentStreamFramePayload } from '../concurrentStreamFrame' - -/** 从主窗 store 收集并发任务卡片所需的 content 条目 */ -export function collectConcurrentStreamContentEntries( - store: ChatDataStore | undefined, - frame: ConcurrentStreamFramePayload, -): Array<[string, AIChatQSData]> { - if (!store) return [] - - const { session, token, chatType, elements } = frame - const contentEntries: Array<[string, AIChatQSData]> = [] - - const rootContent = store.getContentMap({ session, chatType, mapKey: token }) - if (rootContent) { - contentEntries.push([token, rootContent]) - } - - const collect = (items: ReActChatTaskElementSub[]) => { - items.forEach((item) => { - const content = store.getContentMap({ - session, - chatType: item.chatType, - mapKey: item.token, - }) - if (content) { - contentEntries.push([item.token, content]) - } - if (item.kind === 'group') { - collect(item.children) - } - }) - } - - collect(elements) - return contentEntries -} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/concurrentStreamMainBridge.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/concurrentStreamMainBridge.ts index 336a31c05a..b6bdc528b5 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/concurrentStreamMainBridge.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/concurrentStreamMainBridge.ts @@ -1,61 +1,50 @@ -import type { ChatDataStore } from '@/pages/ai-agent/store/ChatDataStore' -import { - aiChatDataStore, - FlowAiStore, - histroyAiStore, - irifyAiCodeAuditPageAiStore, - knowledgeBaseDataStore, -} from '@/pages/ai-agent/store/ChatDataStore' -import { collectConcurrentStreamContentEntries } from './collectConcurrentStreamContentEntries' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' +import { buildConcurrentStreamFramePayload } from './buildConcurrentStreamFramePayload' import type { ConcurrentStreamFramePayload } from '../concurrentStreamFrame' const { ipcRenderer } = window.require('electron') const FETCH_REQUEST = 'fetch-concurrent-stream-contents-request' -const defaultStoreProviders: Array<() => ChatDataStore | undefined> = [ - () => aiChatDataStore, - () => knowledgeBaseDataStore, - () => histroyAiStore, - () => FlowAiStore, - () => irifyAiCodeAuditPageAiStore, -] - -const storeProviders = new Set<() => ChatDataStore | undefined>(defaultStoreProviders) - let bridgeReady = false - -function resolveStoreForSession(session: string): ChatDataStore | undefined { - for (const provider of storeProviders) { - const store = provider() - if (store?.get(session)) return store - } - for (const provider of storeProviders) { - const store = provider() - if (store) return store - } - return undefined -} - -export function registerConcurrentStreamStoreProvider(provider: () => ChatDataStore | undefined) { - storeProviders.add(provider) - return () => { - storeProviders.delete(provider) +let teardown: (() => void) | null = null + +/** + * 主窗口:响应 aux 子窗的 rawData 拉取。 + * 子窗通过 fetch-concurrent-stream-contents 拿到的是仅含元数据的 frame, + * 收到请求后从 globalSessionEngine 取最新 store + rawData,复用 buildConcurrentStreamFramePayload + * 收集 task 自身 / children / group 孙节点的原始数据,回传给子窗。 + * + * @returns teardown 函数,调用后卸载监听并允许重新 setup。 + */ +export function setupConcurrentStreamMainBridge() { + if (bridgeReady) return teardown + + const handler = (_event: unknown, payload: ConcurrentStreamFramePayload & { requestId: string }) => { + const { requestId, session, token, chatType } = payload + if (!requestId || !session || !token) return + + const { store, rawData } = globalSessionEngine.ensureSession(session) + // 子窗主动拉取全量数据,必须填充 rawData + execFileRecord + const full = buildConcurrentStreamFramePayload({ token, session, chatType, store, rawData, withRawData: true }) + const entries = full ? Array.from(full.rawData.entries()) : [] + const execFileRecord = full ? Array.from(full.execFileRecord.entries()) : [] + const childrenTokens = full ? full.childrenTokens : [] + + ipcRenderer.send(`fetch-concurrent-stream-contents-response-${requestId}`, { + rawData: entries, + execFileRecord, + childrenTokens, + }) } -} -/** 主窗口:响应 aux 子窗的内容拉取 */ -export function setupConcurrentStreamMainBridge() { - if (bridgeReady) return + ipcRenderer.on(FETCH_REQUEST, handler) bridgeReady = true - ipcRenderer.on(FETCH_REQUEST, (_event, payload: ConcurrentStreamFramePayload & { requestId: string }) => { - const { requestId, ...frame } = payload - if (!requestId || !frame.session || !frame.token) return - - const store = resolveStoreForSession(frame.session) - const contentEntries = collectConcurrentStreamContentEntries(store, frame) - - ipcRenderer.send(`fetch-concurrent-stream-contents-response-${requestId}`, { contentEntries }) - }) + teardown = () => { + ipcRenderer.removeListener(FETCH_REQUEST, handler) + bridgeReady = false + teardown = null + } + return teardown } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/useConcurrentStreamRefreshListener.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/useConcurrentStreamRefreshListener.ts deleted file mode 100644 index a92ec789e0..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStream/useConcurrentStreamRefreshListener.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect } from 'react' -import type { ReActChatElement } from '@/pages/ai-re-act/hooks/aiRender' -import { openAIConcurrentStream } from '@/utils/openWebsite' -import type { ConcurrentStreamFramePayload } from '../concurrentStreamFrame' - -const { ipcRenderer } = window.require('electron') - -/** 主窗口卡片监听子窗刷新请求,推送最新 frame 数据 */ -export function useConcurrentStreamRefreshListener( - framePayload: ConcurrentStreamFramePayload, - session: string, - token: string, - chatType: ReActChatElement['chatType'], - enabled = true, -) { - useEffect(() => { - if (!enabled) return - - const handleRefresh = (_event: unknown, params: { type?: string; data?: Record }) => { - if (params?.type !== 'openAIConcurrentStream') return - - const refreshData = params.data - if (refreshData?.session !== session || refreshData?.token !== token || refreshData?.chatType !== chatType) { - return - } - - openAIConcurrentStream(framePayload, { silent: true }) - } - - ipcRenderer.on('refresh-ai-concurrent-stream', handleRefresh) - - return () => { - ipcRenderer.removeListener('refresh-ai-concurrent-stream', handleRefresh) - } - }, [chatType, enabled, framePayload, session, token]) -} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/ConcurrentStreamCardHeard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/ConcurrentStreamCardHeard.module.scss new file mode 100644 index 0000000000..b6db072687 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/ConcurrentStreamCardHeard.module.scss @@ -0,0 +1,74 @@ +@use '../../../../../styles/mixin.scss' as mixin; +%display-flex-center { + display: flex; + align-items: center; +} +%display-column-center { + display: flex; + align-items: center; + flex-direction: column; +} + +.chat-card-title { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0 8px; + + &-left { + display: flex; + align-items: center; + gap: 4px; + // padding: 4px 8px 0 0; + flex: 1; + min-width: 0; + } + + &-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + + svg { + width: 100%; + height: 100%; + color: var(--Colors-Use-Neutral-Text-2-Primary); + } + } + + &-text { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.5px; + color: var(--Colors-Use-Neutral-Text-1-Title); + @include mixin.text-ellipsis(1, false, false, true); + + width: 100%; + } + + &-extra { + flex-shrink: 0; + min-width: 0; + max-width: 100%; + @include mixin.text-ellipsis(1, false, false, true); + + &:empty { + display: none; + } + } + + &-more { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + align-items: center; + overflow: hidden; + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/ConcurrentStreamCardHeard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/ConcurrentStreamCardHeard.tsx new file mode 100644 index 0000000000..199a6bf61f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/ConcurrentStreamCardHeard.tsx @@ -0,0 +1,86 @@ +import { FC, memo, useMemo } from 'react' +import ModalInfo from '../../ModelInfo' +import ConcurrentStreamCardActions from '../ConcurrentStreamCardActions/ConcurrentStreamCardActions' +import type { ConcurrentStreamCardHeardProps } from './type' +import styles from './ConcurrentStreamCardHeard.module.scss' +import { getAIStatusPresentation } from '@/pages/ai-agent/utils/AIStatusUtils' +import { AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' +import useCreation from 'ahooks/lib/useCreation' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import { yakitNotify } from '@/utils/notification' +import emiter from '@/utils/eventBus/eventBus' + +const ConcurrentStreamCardHeard: FC = memo((props) => { + const { token, isChildWindow, onClickTitle, rowData, coordinatorId, expand, expandToggle, onRefresh } = props + + const titleText = useMemo(() => { + return rowData?.data?.taskName || '' + }, [rowData?.data?.taskName]) + + const presentation = useMemo(() => getAIStatusPresentation(rowData?.data?.status), [rowData?.data?.status]) + + const showContinueTask = useCreation(() => { + return !!rowData && rowData.chatType === 'task' && !!coordinatorId && !!rowData?.data?.taskId && !isChildWindow + }, [rowData?.data?.taskId]) + + const showCancelTask = useCreation(() => { + return rowData?.data?.status === 'processing' && !!rowData?.data?.taskId && !isChildWindow + }, [rowData?.data?.status]) + + const modalInfo = useMemo(() => { + if (!rowData) return undefined + return { time: rowData.Timestamp, title: rowData.AIModelName, icon: rowData.AIService } + }, [rowData?.Timestamp, rowData?.AIModelName, rowData?.AIService]) + + const showDetails = useMemo(() => { + if (isChildWindow) return false + if (!rowData) return false + const status = rowData?.data?.status + return status === AITaskStatus.created || status === AITaskStatus.inProgress + }, [rowData?.data?.status]) + + const onDetails = useMemoizedFn(() => { + if (!rowData) return + const { data } = rowData + if (!data.taskId) { + yakitNotify('error', 'taskId为空') + return + } + emiter.emit( + 'actionAITaskContentTab', + JSON.stringify({ + type: 'add', + params: { + key: data.taskId, + label: data.taskName || '', + goal: data.goal, + }, + }), + ) + }) + + return ( +
+
+ {presentation.icon &&
{presentation.icon}
} +
{titleText}
+
{modalInfo && }
+
+
+ +
+
+ ) +}) + +export default ConcurrentStreamCardHeard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/type.ts new file mode 100644 index 0000000000..47705f6285 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamCardHeard/type.ts @@ -0,0 +1,13 @@ +import { ChatTaskNodeGroup } from '@/pages/ai-re-act/hooks/aiRender' + +export interface ConcurrentStreamCardHeardProps { + token: string + isChildWindow: boolean + expand: boolean + expandToggle: () => void + onClickTitle?: () => void + rowData?: ChatTaskNodeGroup + coordinatorId?: string + taskIndex?: string | null + onRefresh?: () => void +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame.ts index 5e3983aa35..97f9bc8592 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/concurrentStreamFrame.ts @@ -1,20 +1,34 @@ -import type { ReActChatElement, ReActChatTaskElementSub } from '@/pages/ai-re-act/hooks/aiRender' +import type { AIChatQSData, AIYakExecFileRecord, ChatListRenderType } from '@/pages/ai-re-act/hooks/aiRender' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' export interface ConcurrentStreamFramePayload { session: string token: string - chatType: ReActChatElement['chatType'] - elements: ReActChatTaskElementSub[] + chatType: ChatListRenderType taskName?: string + /** + * 根节点类型(token 在 rawData 中的 AIChatQSDataTypeEnum)。 + * 由主窗口开窗/推送时填入,子窗口无需等待 rawData 拉取完成即可据此选择卡片组件, + * 使懒加载 chunk 与 IPC 拉取并行、骨架屏提前切到 card 变体。 + */ + rootType?: AIChatQSDataTypeEnum + renderNum?: number } -export function isConcurrentStreamFrame(data: unknown): data is ConcurrentStreamFramePayload { - if (!data || typeof data !== 'object') return false - const record = data as Record +export interface FramePayload { + childrenTokens: string[] + /** childrenTokens 中各节点 token 对应的原始数据 */ + rawData: Map + execFileRecord: Map +} +export function isConcurrentStreamFrame(record: unknown): record is ConcurrentStreamFramePayload { + if (!record || typeof record !== 'object') return false return ( + 'session' in record && + 'token' in record && + 'chatType' in record && typeof record.session === 'string' && typeof record.token === 'string' && - typeof record.chatType === 'string' && - Array.isArray(record.elements) + typeof record.chatType === 'string' ) } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/hooks/useConcurrentStreamCardStyle.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/hooks/useConcurrentStreamCardStyle.ts index 4df1fa5eeb..613b144948 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/hooks/useConcurrentStreamCardStyle.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/hooks/useConcurrentStreamCardStyle.ts @@ -1,5 +1,5 @@ import { useMemo, type CSSProperties } from 'react' -import { CHILD_WINDOW_STYLE } from '../constants' +// import { CHILD_WINDOW_STYLE } from '../constants' interface BuildCardStyleOptions { bgColor: string @@ -8,15 +8,16 @@ interface BuildCardStyleOptions { isChildWindow?: boolean } -/** 收起时叠加斜纹背景,展开或子窗口仅显示状态色 */ +/** + * 收起时叠加斜纹背景,展开 + * 子窗口不需要这个样式 + */ export function buildConcurrentStreamCardStyle({ bgColor, vectorBg, showStripe, isChildWindow, }: BuildCardStyleOptions): CSSProperties { - if (isChildWindow) return CHILD_WINDOW_STYLE - const isGradientBg = bgColor.includes('gradient(') if (!showStripe) { diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/type.ts new file mode 100644 index 0000000000..5e60e7da0c --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/ConcurrentStreamCard/type.ts @@ -0,0 +1,13 @@ +export interface ConcurrentStreamCardListWrapperProps { + token: string +} + +export interface ConcurrentStreamCardTipProps { + token: string +} + +export interface ConcurrentStreamCardHeardWrapperProps { + token: string + expand: boolean + expandToggle: () => void +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx index 5d54983b2c..9e9f21d4c7 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/ToolInvokerCard.tsx @@ -1,4 +1,4 @@ -import { FC, memo, ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { FC, memo, ReactNode, useEffect, useRef, useState } from 'react' import ChatCard from './ChatCard' import styles from './ToolInvokerCard.module.scss' import classNames from 'classnames' @@ -9,14 +9,13 @@ import { AIChatQSDataTypeEnum, AIToolResult, AIYakExecFileRecord, - ReActChatBaseInfo, + ChatToolResult, } from '@/pages/ai-re-act/hooks/aiRender' import FileList from './FileList' -import type { ModalInfoProps } from './ModelInfo' import emiter from '@/utils/eventBus/eventBus' import { AITabsEnum } from '../defaultConstant' import { useClickAway, useCreation, useMemoizedFn } from 'ahooks' -import { AIAgentGrpcApi, AIEventQueryRequest } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIAgentGrpcApi, AIEventQueryRequest, AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' import { isToolStdoutStream } from '@/pages/ai-re-act/hooks/utils' import { OutlineArrownarrowrightIcon, @@ -31,11 +30,9 @@ import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { Divider, Tooltip } from 'antd' import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' import { formatTimestamp } from '@/utils/timeUtil' -import { OperationCardFooter, OperationCardFooterProps } from './OperationCardFooter/OperationCardFooter' -import useChatIPCDispatcher from '../useContext/ChatIPCContent/useDispatcher' +import { OperationCardFooter } from './OperationCardFooter/OperationCardFooter' import useAIAgentStore from '../useContext/useStore' import { YakitPopconfirm } from '@/components/yakitUI/YakitPopconfirm/YakitPopconfirm' -import { AIChatIPCSendParams } from '../useContext/ChatIPCContent/ChatIPCContent' import { AIReferenceNode } from '@/pages/ai-re-act/aiReActChatContents/AIReActChatContents' import { useStreamingChatContent } from './aiChatListItem/StreamingChatContent/hooks/useStreamingChatContent' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' @@ -44,6 +41,10 @@ import { YakitModal } from '@/components/yakitUI/YakitModal/YakitModal' import { setClipboardText } from '@/utils/clipboard' import { success } from '@/utils/notification' import useAINodeLabel from '@/pages/ai-re-act/hooks/useAINodeLabel' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import useAIAgentDispatcher from '../useContext/useDispatcher' +import { useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' /** @name AI工具按钮对应图标 */ const AIToolToIconMap: Record = { @@ -51,13 +52,9 @@ const AIToolToIconMap: Record = { } interface ToolInvokerCardProps { - titleText?: string - fileList?: AIYakExecFileRecord[] - modalInfo?: ModalInfoProps - operationInfo: OperationCardFooterProps - data: AIToolResult - chatType: ReActChatBaseInfo['chatType'] - token: string + itemData: ChatToolResult + renderNum: number + fileList: AIYakExecFileRecord[] } interface PreWrapperProps { code: string @@ -69,70 +66,44 @@ interface ToolStatusCardProps { status: AIToolResult['tool']['status'] | 'purple' | 'neutral' children?: ReactNode } -interface ToolStdoutCardProps extends ToolInvokerCardProps { - isChildWindow: boolean -} -interface ToolResultCardProps extends ToolInvokerCardProps { - isChildWindow: boolean -} +interface ToolStdoutCardProps extends ToolInvokerCardProps {} +interface ToolResultCardProps extends ToolInvokerCardProps {} const ToolInvokerCard: FC = (props) => { - const { data } = props - - // 判断路由,子窗口有些功能不展示 - const isChildWindow = useRef(isAuxOrChildWindow()) - const { nodeLabel } = useAINodeLabel(data.verboseName) - const titleText = nodeLabel || data.toolName - const renderContent = useMemoizedFn(() => { - // 过滤掉打开文件 - const operationInfo = { - ...props.operationInfo, - aiFilePath: isChildWindow.current ? undefined : props.operationInfo.aiFilePath, - } - switch (data.type) { - case 'stream': - return ( - - ) - case 'result': - return ( - - ) - case 'create': - return - default: - return null - } - }) - - return renderContent() + const { itemData } = props + + switch (itemData?.data?.type) { + case 'stream': + return + case 'result': + return + case 'create': + return + default: + return null + } } export default memo(ToolInvokerCard) /** tool loading - processing params */ -const ToolLoadingCard: React.FC = memo((props) => { - const { data, titleText } = props +const ToolLoadingCard: React.FC> = memo((props) => { + const { itemData, renderNum } = props const { t } = useI18nNamespaces(['aiAgent']) + const data = useCreation(() => { + return itemData.data + }, [renderNum]) + + const { nodeLabel } = useAINodeLabel(data.verboseName) const reason = useCreation(() => { return data?.tool?.reason || '' - }, [data?.tool?.reason]) + }, [renderNum]) return ( } - titleText={titleText} + titleText={nodeLabel || data.toolName} titleExtra={ !!reason ? ( @@ -154,17 +125,29 @@ const ToolLoadingCard: React.FC = memo((props) => { /**tool_**_stdout */ const ToolStdoutCard: React.FC = memo((props) => { - const { operationInfo, fileList, chatType, data, titleText } = props + const { fileList, itemData, renderNum } = props const { t } = useI18nNamespaces(['aiAgent']) + // 判断路由,子窗口有些功能不展示 + const isChildWindow = useRef(isAuxOrChildWindow()) + const sessionId = useCurrentSessionId() + const { onSend } = useAIAgentDispatcher() - const { activeChat } = useAIAgentStore() - const { handleSend } = useChatIPCDispatcher() + const data = useCreation(() => { + return itemData.data + }, [renderNum]) + + const { nodeLabel } = useAINodeLabel(data.verboseName) + + const operationInfo = useCreation(() => { + return { + callToolId: data.callToolId, + aiFilePath: isChildWindow.current ? '' : data.tool.dirPath, + } + }, [renderNum]) - // 获取流数据 + // TODO - 可以修改为新版 获取流数据 const { stream } = useStreamingChatContent({ - chatType, token: data.stream.EventUUID, - session: activeChat?.SessionID || '', }) const selectors = useCreation(() => { @@ -173,7 +156,7 @@ const ToolStdoutCard: React.FC = memo((props) => { const reason = useCreation(() => { return data?.tool?.reason || '' - }, [data?.tool?.reason]) + }, [renderNum]) const onToolExtra = useMemoizedFn((item: AIAgentGrpcApi.ReviewSelector) => { switch (item.value) { @@ -189,18 +172,16 @@ const ToolStdoutCard: React.FC = memo((props) => { const jsonInput = { suggestion: item.value, } - const params: AIChatIPCSendParams = { - value: JSON.stringify(jsonInput), - id: selectors.InteractiveId, + const info: AIInputEvent = { + IsInteractiveMessage: true, + InteractiveId: selectors.InteractiveId, + InteractiveJSONInput: JSON.stringify(jsonInput), } - handleSend(params) + onSend({ token: sessionId, type: '', params: info }) }) - const referenceNode = useCreation(() => { - return !!stream?.reference ? : <> - }, [stream?.reference]) return ( } titleMore={
@@ -236,7 +217,7 @@ const ToolStdoutCard: React.FC = memo((props) => { - {referenceNode} + {!!fileList?.length && } @@ -245,14 +226,38 @@ const ToolStdoutCard: React.FC = memo((props) => { /**tool result status:error/success/cancel */ const ToolResultCard: React.FC = memo((props) => { - const { modalInfo, operationInfo, fileList, data, chatType, token, isChildWindow, titleText } = props - const { t } = useI18nNamespaces(['aiAgent']) + const { renderNum, fileList, itemData } = props + const { t, i18n } = useI18nNamespaces(['aiAgent']) const { activeChat } = useAIAgentStore() - const { fetchChatDataStore } = useChatIPCDispatcher().chatIPCEvents const [loading, setLoading] = useState(false) const [expand, setExpand] = useState(false) + + // 判断路由,子窗口有些功能不展示 + const isChildWindow = useRef(isAuxOrChildWindow()) + + const data = useCreation(() => { + return itemData.data + }, [renderNum]) + + const { nodeLabel } = useAINodeLabel(data.verboseName) + + const modalInfo = useCreation(() => { + return { + time: itemData.Timestamp, + title: itemData.AIModelName, + icon: itemData.AIService, + } + }, []) + + const operationInfo = useCreation(() => { + return { + callToolId: data.callToolId, + aiFilePath: isChildWindow.current ? '' : data.tool.dirPath, + } + }, [renderNum]) + const expandToggle = useMemoizedFn(() => { setExpand((v) => !v) }) @@ -291,10 +296,9 @@ const ToolResultCard: React.FC = memo((props) => { const duration = useCreation(() => { return Math.round(data.durationSeconds * 10) / 10 }, [data.durationSeconds]) - const startTime = useCreation(() => { - return formatTimestamp(data.startTime) - }, [data.startTime]) + const sessionId = useCurrentSessionId() + const rawData = useCurrentRawData() const getListToolList = useMemoizedFn(() => { if (!data?.callToolId || !activeChat) return setLoading(true) @@ -303,14 +307,7 @@ const ToolResultCard: React.FC = memo((props) => { } grpcQueryAIToolDetails(params) .then((res) => { - const chatItem = fetchChatDataStore()?.getContentMap({ - session: activeChat?.SessionID, - chatType, - mapKey: token, - }) - if (!!chatItem && chatItem.type === AIChatQSDataTypeEnum.TOOL_RESULT) { - chatItem.data.tool.resultDetails = getResultDetails(res) - } + globalSessionEngine.updateToolResult(sessionId, itemData.id, { resultDetails: getResultDetails(res) }) }) .finally(() => setTimeout(() => { @@ -358,7 +355,7 @@ const ToolResultCard: React.FC = memo((props) => { return ( } titleMore={
@@ -383,7 +380,8 @@ const ToolResultCard: React.FC = memo((props) => { {t('ToolInvokerCard.httpTraffic')} {httpFlowDataCount} )} - {isChildWindow || ( + {/*
*/} + {isChildWindow.current || ( } onClick={getListToolList} /> diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aIFileChatContent/AIFileChatContent.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aIFileChatContent/AIFileChatContent.module.scss deleted file mode 100644 index fcec741a65..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/components/aIFileChatContent/AIFileChatContent.module.scss +++ /dev/null @@ -1,59 +0,0 @@ -@use '../../../../styles/mixin.scss' as mixin; - -.wrapper { - width: 100%; - display: flex; - gap: 4px; - flex-direction: column; - .qs { - width: fit-content; - border-radius: 8px; - background-color: var(--Colors-Use-Main-Focus); - } - .file { - width: fit-content; - width: 80%; - padding: 8px; - border-radius: 8px; - - border-radius: 8px; - background: var(--Colors-Use-Neutral-Bg-Hover); - &-content { - width: 100%; - &-item { - display: flex; - align-items: center; - width: 100%; - height: 24px; - border-bottom: 1px solid var(--Colors-Use-Neutral-Border); - &-left { - flex: 1; - width: 0; - gap: 4px; - display: flex; - cursor: pointer; - align-items: center; - &:hover p { - color: var(--Colors-Use-Main-Primary); - } - p { - color: var(--Colors-Use-Neutral-Text-2-Primary); - margin: 0; - @include mixin.text-ellipsis; - } - - svg { - width: 14px; - height: 14px; - } - } - .content-item-left { - cursor: default; - &:hover p { - color: var(--Colors-Use-Neutral-Text-2-Primary); - } - } - } - } - } -} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aIFileChatContent/AIFileChatContent.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aIFileChatContent/AIFileChatContent.tsx deleted file mode 100644 index 7ce446c9d7..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/components/aIFileChatContent/AIFileChatContent.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import type { AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' -import { FC } from 'react' -import styles from './AIFileChatContent.module.scss' -import { IconNotepadFileTypeDir } from '@/components/MilkdownEditor/icon/icon' -import { renderFileTypeIcon } from '@/components/MilkdownEditor/CustomFile/CustomFile' -import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import { OutlineChevronrightIcon } from '@/assets/icon/outline' -import { onOpenLocalFileByPath } from '@/pages/notepadManage/notepadManage/utils' -import { HandleStartParams } from '../../aiAgentChat/type' -import { useCreation, useMemoizedFn } from 'ahooks' -import { - isHaveFreeDialogFileList, - isHaveSelectForges, - isHaveSelectKnowledgeBases, - isHaveSelectTools, -} from '../aiChatListItem/AIChatListItem' -import { AIChatMentionSelectItem, AIMentionTypeItem } from '../aiChatMention/type' -import { iconMap } from '../../defaultConstant' -import emiter from '@/utils/eventBus/eventBus' -import { YakitRoute } from '@/enums/yakitRoute' -import classNames from 'classnames' -import { FileToChatQuestionList } from '../../template/type' - -interface AIFileChatContentProps { - qs: string - setting: AIInputEvent - extraValue?: HandleStartParams['extraValue'] -} - -function getFileExt(path: string): string { - const index = path.lastIndexOf('.') - return index > -1 ? path.slice(index + 1).toLowerCase() : '' -} - -const getFileIcon = (data: FileToChatQuestionList) => { - if (data.isFolder) return - return renderFileTypeIcon({ type: getFileExt(data.path) }) -} -/**@deprecated 废弃 由md编辑器代替 */ -const AIFileChatContent: FC = ({ qs, setting, extraValue }) => { - const attachedFilePathList: FileToChatQuestionList[] = useCreation(() => { - return isHaveFreeDialogFileList(extraValue) - }, [extraValue?.['freeDialogFileList']]) - - const selectForges: AIChatMentionSelectItem[] = useCreation(() => { - return isHaveSelectForges(extraValue) - }, [extraValue?.['selectForges']]) - - const selectTools: AIChatMentionSelectItem[] = useCreation(() => { - return isHaveSelectTools(extraValue) - }, [extraValue?.['selectTools']]) - const selectKnowledgeBases: AIChatMentionSelectItem[] = useCreation(() => { - return isHaveSelectKnowledgeBases(extraValue) - }, [extraValue?.['selectKnowledgeBases']]) - - const onOpenKnowledgeBases = useMemoizedFn(() => { - emiter.emit('menuOpenPage', JSON.stringify({ route: YakitRoute.AI_REPOSITORY })) - }) - const isPadding = useCreation(() => { - return ( - !!attachedFilePathList.length || !!selectForges.length || !!selectTools.length || !!selectKnowledgeBases.length - ) - }, [attachedFilePathList.length, selectForges.length, selectTools.length, selectKnowledgeBases.length]) - const renderList = useMemoizedFn( - (params: { title: string; list: AIChatMentionSelectItem[]; type: AIMentionTypeItem }) => { - const { title, list, type } = params - return ( -
-
{title}
- -
- {list.map((file) => { - return ( -
-
- {iconMap[type]} -

{file.name}

-
-
- ) - })} -
-
- ) - }, - ) - return ( -
-
- {qs} -
- {!!attachedFilePathList?.length && ( -
-
相关操作文件
-
- {attachedFilePathList.map((file) => { - return ( -
onOpenLocalFileByPath(file.path)} - > -
- {getFileIcon(file)} -

{file.path}

-
- - } /> -
- ) - })} -
-
- )} - {!!selectForges?.length && - renderList({ - title: '相关智能体', - list: selectForges, - type: 'forge', - })} - - {!!selectTools?.length && - renderList({ - title: '相关工具', - list: selectTools, - type: 'tool', - })} - {!!selectKnowledgeBases?.length && ( -
-
相关知识库
- -
- {selectKnowledgeBases.map((item) => { - return ( -
-
- {iconMap['knowledgeBase']} -

{item.name}

-
- - } /> -
- ) - })} -
-
- )} -
- ) -} - -export default AIFileChatContent diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.module.scss index 37abbdb93a..4b35d28978 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.module.scss @@ -1,21 +1,3 @@ -.question-wrapper.question-wrapper { - justify-content: flex-start; -} -.content-wrapper.content-wrapper { - max-width: 100%; - max-height: 150px; - overflow-y: auto; -} -.review-wrapper { - background: var(--Colors-Use-Neutral-Bg-Hover); - flex: none; -} - -.ai-mark-down-wrapper { - padding: 8px 12px; - background-color: var(--Colors-Use-Neutral-Bg-Hover); - border-radius: 8px; -} // 加权重 .file-content-wrapper.file-content-wrapper { background-color: transparent; diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.tsx index 0705406089..c9476145cf 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/AIChatListItem.tsx @@ -1,229 +1,36 @@ -import React, { useMemo } from 'react' -import { AIChatListItemProps } from './type' -import { useCreation, useMemoizedFn } from 'ahooks' -import { AIReActChatReview } from '../aiReActChatReview/AIReActChatReview' -import { AIReviewResult } from '../aiReviewResult/AIReviewResult' -import { AITriageChatContent } from '../aiTriageChat/AITriageChat' -import ToolInvokerCard from '../ToolInvokerCard' -import styles from './AIChatListItem.module.scss' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import DividerCard from '../DividerCard' -import { AIToolDecision } from '../aiToolDecision/AIToolDecision' -import { AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' -import AiFailPlanCard from '../aiFailPlanCard/AiFailPlanCard' -import classNames from 'classnames' -import { has, isArray } from 'lodash' -import { HandleStartParams } from '../../aiAgentChat/type' -import { AIChatMentionSelectItem } from '../aiChatMention/type' -import { AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' -import { FileToChatQuestionList } from '../../template/type' -import StreamingChatContent from './StreamingChatContent/StreamingChatContent' +import React from 'react' +import type { AIChatListItemProps } from './type' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useMemoizedFn } from 'ahooks' +import { useStore } from 'zustand' +import AIGroupItem from './aiGroupItem/AIGroupItem' +import AITaskItem from './aiTaskItem/AITaskItem' import StaticChatContent from './StaticChatContent/StaticChatContent' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' -import useAIAgentStore from '../../useContext/useStore' -import { AIManualIntervention } from '../aiManualIntervention/AIManualIntervention' -import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' -import { AIModelErrorPrompt } from './aiModelErrorPrompt/AIModelErrorPrompt' -import { AIHttpFlowFuzzStatusCard } from '../aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard' -import { AIReportFinishCard } from '../aiReportFinishCard/AIReportFinishCard' -const chatContentExtraProps = { - contentClassName: styles['content-wrapper'], - chatClassName: styles['question-wrapper'], -} -/**@description 额外参数中获取文件列表数据 */ -export const isHaveFreeDialogFileList = (extraValue: HandleStartParams['extraValue']): FileToChatQuestionList[] => { - if (has(extraValue, 'freeDialogFileList') && isArray(extraValue.freeDialogFileList)) { - return extraValue.freeDialogFileList - } - return [] -} -/**@description 额外参数中获取选中的forge */ -export const isHaveSelectForges = (extraValue: HandleStartParams['extraValue']): AIChatMentionSelectItem[] => { - if (has(extraValue, 'selectForges') && isArray(extraValue.selectForges)) { - return extraValue.selectForges - } - return [] -} -/**@description 额外参数中获取选中的 tool */ -export const isHaveSelectTools = (extraValue: HandleStartParams['extraValue']): AIChatMentionSelectItem[] => { - if (has(extraValue, 'selectTools') && isArray(extraValue.selectTools)) { - return extraValue.selectTools - } - return [] -} -/**@description 额外参数中获取选中的 KnowledgeBases */ -export const isHaveSelectKnowledgeBases = (extraValue: HandleStartParams['extraValue']): AIChatMentionSelectItem[] => { - if (has(extraValue, 'selectKnowledgeBases') && isArray(extraValue.selectKnowledgeBases)) { - return extraValue.selectKnowledgeBases - } - return [] -} -const isExtraShow = (extraValue: HandleStartParams['extraValue']) => { - return ( - isHaveFreeDialogFileList(extraValue).length > 0 || - isHaveSelectForges(extraValue).length > 0 || - isHaveSelectTools(extraValue).length > 0 || - isHaveSelectKnowledgeBases(extraValue).length > 0 - ) -} export const AIChatListItem: React.FC = React.memo((props) => { - const { item, type, hasNext, itemIndex, session: sessionProp } = props - const { t } = useI18nNamespaces(['aiAgent']) + const { item } = props - const { handleSendCasual } = useChatIPCDispatcher() - const { yakExecResult } = useChatIPCStore().chatIPCData - const { activeChat } = useAIAgentStore() - const session = sessionProp || activeChat?.SessionID - const aiStreamNodeProps = useCreation(() => { - switch (type) { - case 're-act': - return { - aiMarkdownProps: { - className: styles['ai-mark-down-wrapper'], - }, - } + const store = useCurrentStore() - default: - return {} - } - }, [type]) - - const isStream = useCreation(() => { - return ( - item.type === AIChatQSDataTypeEnum.STREAM || - item.type === AIChatQSDataTypeEnum.STREAM_GROUP || - item.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP || - item.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP - ) - }, [item.type]) - - const ChatItemRenderer = useMemoizedFn((itemData: AIChatQSData) => { - const { type, Timestamp, data, extraValue } = itemData - switch (type) { - case AIChatQSDataTypeEnum.QUESTION: - return ( - - ) - // case AIChatQSDataTypeEnum.STREAM: - // return - case AIChatQSDataTypeEnum.RESULT: - return - case AIChatQSDataTypeEnum.THOUGHT: - return ( - - ) - case AIChatQSDataTypeEnum.TOOL_RESULT: - const { execFileRecord } = yakExecResult - const fileList = execFileRecord.get(data.callToolId) - return ( - - ) - case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: - case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: - case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: - case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: - case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: - if (!!itemData.data.selected) { - if ( - type === AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE || - type === AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE - ) - return null - return - } else { - return ( - - ) - } - case AIChatQSDataTypeEnum.USER_MANUAL_INTERVENTION: - return - /** 该UI已无效,可以删除 */ - // case AIChatQSDataTypeEnum.TASK_INDEX_NODE: - // const dividerCardProps = { - // status: data?.status as AITaskStatus, - // desc: data?.goal, - // name: data?.taskName, - // success: 0, - // error: 0, - // } - // return - - case AIChatQSDataTypeEnum.TOOL_CALL_DECISION: - return - - case AIChatQSDataTypeEnum.HTTP_FLOW_FUZZ_STATUS: - return - - case AIChatQSDataTypeEnum.REPORT_FINISH: - return - - case AIChatQSDataTypeEnum.END_PLAN_AND_EXECUTION: - return ( - - ) - case AIChatQSDataTypeEnum.FAIL_PLAN_AND_EXECUTION: - case AIChatQSDataTypeEnum.FAIL_REACT: - return - case AIChatQSDataTypeEnum.AI_API_REQUEST_FAILED: - return - default: - return <> - } + // 防止没有数据的时候出错,所以没从item中取kind + const kind = useStore(store, (state) => { + if (state.items[item.token]) return 'item' + if (state.groups[item.token]) return 'group' + if (state.tasks[item.token]) return 'task' + return null }) const renderContent = useMemoizedFn(() => { - if (session === undefined) return null - if (isStream) - return ( - - ) - return ChatItemRenderer(contentItem)} /> + switch (kind) { + case 'item': + return + case 'group': + return + case 'task': + return + default: + return null + } }) return {renderContent()} }) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StaticChatContent/StaticChatContent.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StaticChatContent/StaticChatContent.tsx index eb5816d75a..85d28fdeb7 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StaticChatContent/StaticChatContent.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StaticChatContent/StaticChatContent.tsx @@ -1,26 +1,30 @@ -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import type { AIChatQSData, ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' -import { FC, memo, ReactNode, useRef, useMemo } from 'react' +import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' +import { useCurrentStore, useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { FC, memo, useMemo } from 'react' +import { useStore } from 'zustand' +import AINodeItem from '../aiNodeItem/AINodeItem' -type StaticChatContentProps = ReActChatRenderItem & { - render?: (contentItem: AIChatQSData) => ReactNode - session: string +type StaticChatContentProps = { + token: string + /** 组内 0-based 下标,由组列表 map 时透传;非组内场景不传 */ + groupIndex?: number } -const StaticChatContent: FC = ({ chatType, token, render, session, renderNum }) => { - const { fetchChatDataStore } = useChatIPCDispatcher().chatIPCEvents - +const StaticChatContent: FC = ({ token, groupIndex }) => { + const store = useCurrentStore() + const renderNum = useStore(store, (state) => state.items[token]?.renderNum) + const rawData = useCurrentRawData() const chatItem = useMemo(() => { - const raw = fetchChatDataStore()?.getContentMap({ session, chatType, mapKey: token }) - if (!raw) return null - if (raw.data != null && typeof raw.data === 'object') { - return { ...raw, data: Object.assign({}, raw.data) } as AIChatQSData + if (!rawData) return null + const itemData = rawData.contents.get(token) + if (!itemData) return null + if (itemData.data != null && typeof itemData.data === 'object') { + return { ...itemData, data: Object.assign({}, itemData.data) } as AIChatQSData } - return { ...raw } as AIChatQSData - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [renderNum, session, chatType, token]) + return { ...itemData } as AIChatQSData // 浅拷贝,深层数据更新引用没变,需要依赖renderNum + }, [token, renderNum]) if (!chatItem) return null - return <>{render?.(chatItem)} + return } export default memo(StaticChatContent) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.module.scss new file mode 100644 index 0000000000..0752e6ef02 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.module.scss @@ -0,0 +1,5 @@ +.ai-mark-down-wrapper { + padding: 8px 12px; + background-color: var(--Colors-Use-Neutral-Bg-Hover); + border-radius: 8px; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.tsx index 2f5989b1ef..0bc539d8cc 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/StreamingChatContent.tsx @@ -1,65 +1,54 @@ import { AIStreamNode } from '@/pages/ai-re-act/aiReActChatContents/AIReActChatContents' -import { AIChatQSDataTypeEnum, type ReActChatElement, type ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' -import { memo, type FC } from 'react' +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' +import { type FC, memo, useMemo } from 'react' import { useTypedStream } from './hooks/useTypedStream' -import AIGroupStreamCard from '../../aiGroupStreamCard/AIGroupStreamCard' -import AITaskDefaultGroupCard from '../../AITaskDefaultGroupCard/AITaskDefaultGroupCard' -import ConcurrentStreamCard from '../../ConcurrentStreamCard/ConcurrentStreamCard' - -type StreamCls = { className: string } | { aiMarkdownProps?: { className: string } } - -type StreamingChatContentProps = ReActChatRenderItem & { - streamClassName?: StreamCls - hasNext?: boolean - session: string - itemIndex?: number -} +import { useCreation, useMemoizedFn } from 'ahooks' +import styles from './StreamingChatContent.module.scss' type SingleStreamProps = { - chatType: ReActChatElement['chatType'] - token: string - streamClassName?: StreamCls - session: string - listItemIndex?: number + itemData: ChatStream + renderNum: number + sessionId: string } -const AIStreamCard: FC = ({ chatType, token, streamClassName, session, listItemIndex }) => { - const { stream } = useTypedStream({ chatType, token, session }) - if (!stream) return null +export const AIStreamCard: FC = memo(({ itemData, renderNum, sessionId }) => { + const getContent = useMemoizedFn(() => { + return itemData.data.content + }) - return ( - - ) -} + const getStatus = useMemoizedFn(() => { + return itemData.data.status + }) + // 仅获取用于显示的 content(已应用打字效果) + const { content } = useTypedStream({ + getContent: () => getContent(), + getStatus: () => getStatus(), + }) -const StreamingChatContent: FC = (props) => { - const { streamClassName, chatType, token, hasNext, session, itemIndex: listItemIndex } = props - if (props.kind === 'task') { - if (props.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP) { - return ( - - ) - } else { - return + const stream = useMemo(() => { + // 用打字机处理后的 content 覆盖原始 content,其余字段保持原始引用 + return { + ...itemData, + data: { + ...itemData.data, + content, + }, } - } - if (props.kind === 'group') { - return - } - return ( - - ) -} -export default memo(StreamingChatContent) + }, [content, renderNum]) + + const aiStreamNodeProps = useCreation(() => { + switch (itemData.chatType) { + case 'reAct': + return { + className: styles['ai-mark-down-wrapper'], + } + + default: + return { + className: '', + } + } + }, [itemData.chatType]) + if (!stream) return null + return +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useStreamingChatContent.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useStreamingChatContent.ts index 560b25c06d..ebe8953613 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useStreamingChatContent.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useStreamingChatContent.ts @@ -1,39 +1,60 @@ import { useCallback, useRef, useState } from 'react' -import { ChatStream, ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' import { useRafPolling } from '@/hook/useRafPolling/useRafPolling' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' +import { useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' export interface UseStreamingChatContentParams { - chatType: ReActChatRenderItem['chatType'] token: string - session: string } export interface UseStreamingChatContentResult { - /**渲染 */ - renderNumber: number - /** 流数据 */ + /** 流数据(原始引用,供需要 reference / selectors 等字段的调用方使用) */ stream: ChatStream | null /** 是否需要打字效果(经历过 start 状态) */ shouldType: boolean + /** 后端流是否已结束。结束后必须保证最终渲染的是真实完整内容,不能被打字机中间状态污染 */ + isFinished: boolean + /** 用于页面显示的最新 content(已通过轮询从 rawData 捞出,无需调用方再读 stream.data.content) */ + content: string } +/** + * 获取流式聊天内容并维护打字所需的状态语义。 + * + * 后端正常流式 chunk 不会 bump renderNum(见 aiStream.ts 的 handleStream), + * 而是直接原地累加 data.content,React 无法感知,因此这里用 useRafPolling 周期性 + * 从 rawData.contents 重新读取最新内容,直到 status === 'end' 停止轮询。 + * + * 本 hook 只负责"取数 + 状态语义化",打字平滑效果由 useStreamingTypewriter 负责。 + */ export function useStreamingChatContent(params: UseStreamingChatContentParams): UseStreamingChatContentResult { - const { chatType, token, session } = params + const { token } = params + + const rawData = useCurrentRawData() const hasStartedRef = useRef(false) const [shouldType, setShouldType] = useState(false) - const fetchChatDataStore = useChatIPCDispatcher().chatIPCEvents.fetchChatDataStore() - const { execute } = useChatIPCStore().chatIPCData - const getData = useCallback((): ChatStream | null => { - return fetchChatDataStore?.getContentMap({ session, chatType, mapKey: token }) as ChatStream - }, [fetchChatDataStore, session, chatType, token]) + // 关键:后端 stream chunk 是对 rawData.contents 中的"同一个对象引用"做原地累加 + // (见 aiStream.ts 的 handleStream:streamData.data.content += content),引用永远不变。 + // 若不 clone,shouldUpdate 比较的是同一个对象,prev.data.content === next.data.content 恒为 true, + // 轮询永远检测不到内容增长 → 不会触发重渲染 → 打字机吃不到后续批次 → 平滑效果失效。 + // 这里通过 clone 拍下快照,让 shouldUpdate 按值比较 content/status 字符串,从而正确感知增量。 + const cloneStream = useCallback((chatStreamItem: ChatStream): ChatStream => { + return { + ...chatStreamItem, + data: { ...chatStreamItem.data }, + reference: chatStreamItem.reference ? [...chatStreamItem.reference] : undefined, + } + }, []) - const { renderNumber, aiDataRef } = useRafPolling({ - getData, + const { aiDataRef } = useRafPolling({ + getData: () => { + const item = rawData.contents.get(token) as ChatStream + return item + }, interval: 300, + clone: cloneStream, shouldStop: (data) => { if (data.data.status === 'start') { hasStartedRef.current = true @@ -46,5 +67,10 @@ export function useStreamingChatContent(params: UseStreamingChatContentParams): return prev.data.content !== next.data.content || prev.data.status !== next.data.status }, }) - return { renderNumber, stream: aiDataRef, shouldType } + + const stream = aiDataRef + const content = stream?.data?.content || '' + const isFinished = stream?.data?.status === 'end' + + return { stream, shouldType, isFinished, content } } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useTypedStream.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useTypedStream.ts index 4ad14da370..25aba38e1f 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useTypedStream.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/StreamingChatContent/hooks/useTypedStream.ts @@ -1,13 +1,21 @@ // hooks/useTypedStream.ts -import { useMemo } from 'react' -import { ChatStream, ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' -import { useStreamingTypewriter } from './useStreamingTypewriter' -import { useStreamingChatContent } from './useStreamingChatContent' +import { useEffect, useRef, useState } from 'react' +import { useMemoizedFn } from 'ahooks' export interface UseTypedStreamOptions { - chatType: ReActChatRenderItem['chatType'] - token: string - session: string + /** + * 实时读取最新目标内容的 getter。 + * 必须是 getter 而非字符串 prop:逻辑上 stream chunk 对 rawData.contents 中的同一对象做原地累加 + * (见 aiStream.ts 的 handleStream:itemData.data.content += chunk),引用不变、不触发重渲染。 + * 若传字符串 prop,闭包会被冻结在挂载那一刻的快照,自驱动打字链与 poll 都读不到增长 → 打字失效。 + * getter 形式(如 () => itemData.data.content)每次调用都读到当前累加后的最新长度,无需父组件重渲染。 + */ + getContent: () => string + /** + * 实时读取最新流状态的 getter('start' | 'end' | undefined)。 + * 同样必须是 getter,理由同 getContent。 + */ + getStatus: () => 'start' | 'end' | undefined /** 单步最小输出字符数(下限),默认 2 */ step?: number /** 单步最大输出字符数(上限,保证每次渲染长度不会突然过大),默认 18 */ @@ -16,71 +24,170 @@ export interface UseTypedStreamOptions { interval?: number /** * 目标排空帧数,默认 9。 - * catchUpFrames * interval ≈ 270ms,接近后端 300ms 轮询间隔, + * catchUpFrames * interval ≈ 270ms,接近逻辑数据轮询间隔, * 让每批数据连续地铺满到下一批到来,消除"卡一会→突然输出一大段"的卡顿。 */ catchUpFrames?: number } export interface UseTypedStreamResult { - /** 处理后的流数据 */ - stream: ChatStream | null + /** 用于页面显示的 content(已应用打字效果) */ + content: string /** 是否正在打字 */ isTyping: boolean } /** - * 获取流式聊天内容并应用平滑打字效果 - * - 实时流式(start → end):启用打字效果 - * - 历史记录(直接 end):禁用打字效果,直接显示 + * 获取流式聊天内容并应用平滑打字效果,仅返回用于显示的 content。 + * 其余原始字段(reference、NodeId、status、ContentType、selectors 等)请通过 useStreamingChatContent 获取。 + * + * 数据通道设计(关键): + * - 调用方传入 getter(getContent / getStatus),而非字符串快照。 + * - 逻辑上 stream chunk 对 rawData.contents 中的同一对象做原地累加(见 aiStream.ts 的 handleStream), + * 既不换引用也不 bump renderNum,React 感知不到 content 增长。 + * - 若传字符串 prop,本 hook 闭包会被冻结在挂载那一刻的快照,自驱动打字链与 poll 都读不到增长 → 打字失效, + * 且只有 status: start→end 时 effect 才重跑 → 命中终态对齐 → 长文一次性爆发。 + * - getter 形式每次调用都读到当前累加后的最新长度,无需父组件重渲染,零 clone、零值比较。 + * + * 行为语义: + * - 实时流式(status === 'start'):启用打字效果,逐字揭示。 + * - 历史记录(挂载时已是 'end'):跳过打字,直接展示完整内容。 + * - 流结束(status === 'end'):交给打字机按 maxStep 节奏把剩余积压逐步打完,自然收尾,杜绝一帧爆发。 + * - 挂载时数据未到位(item 不存在 / status 为 undefined):不提前退出,靠心跳探测拉起。 + * + * 追上不停摆:打字追上当前目标后,若流未结束,scheduleStep 以 interval 节奏持续心跳探测 + * rawData.contents 中原地累加(引用不变、不 bump renderNum)的新 chunk,下一拍即恢复推进。 + * 原先靠 300ms setInterval poll 拉起,导致"追上→静默最多 300ms→突然输出一大段"的卡顿;心跳把探测延迟降到 interval。 */ export function useTypedStream(options: UseTypedStreamOptions): UseTypedStreamResult { - const { chatType, token, session, step = 2, maxStep = 18, interval = 30, catchUpFrames = 9 } = options - - // 获取流数据和是否需要打字效果 - const { renderNumber, stream: rawStream, shouldType } = useStreamingChatContent({ chatType, token, session }) - - const content = rawStream?.data?.content || '' - // 后端流是否已结束。结束后必须保证最终渲染的是真实完整内容,不能被打字机中间状态污染 - const isFinished = rawStream?.data?.status === 'end' - - // 平滑流式输出:将后端大块推送的内容逐字显示 - const { displayedContent, isTyping } = useStreamingTypewriter(content, { - step, - maxStep, - interval, - catchUpFrames, - enabled: shouldType, - finished: isFinished, + const { getContent, getStatus, step = 2, maxStep = 18, interval = 30, catchUpFrames = 9 } = options + + const [displayed, setDisplayed] = useState(() => getContent()) + const [isTyping, setIsTyping] = useState(false) + + // refs:避免在自驱动定时器闭包里依赖会变的 state,消除 effect 抖动与重复订阅 + const finishedRef = useRef(false) // 流已结束 → 排空后停止 + const displayedLenRef = useRef(0) // 已显示长度(与 displayed 同步,供闭包读取) + const typingRef = useRef(false) // isTyping 的 ref 镜像,切换时才 setState + const timerRef = useRef(null) // 打字单步定时器 + + // getter 用 ref 承载:防御性同步最新闭包,使本 hook 不受父组件重渲染时机影响 + const getContentRef = useRef(getContent) + const getStatusRef = useRef(getStatus) + getContentRef.current = getContent + getStatusRef.current = getStatus + + // 配置项用 ref 承载:effect 不依赖配置,变化在下一步即生效,无需重建整个调度 + const stepRef = useRef(step) + const maxStepRef = useRef(maxStep) + const intervalRef = useRef(interval) + const catchUpFramesRef = useRef(catchUpFrames) + stepRef.current = step + maxStepRef.current = maxStep + intervalRef.current = interval + catchUpFramesRef.current = catchUpFrames + + // 实时读最新内容与状态(通过 getter 读活属性,无 clone) + const readLatest = useMemoizedFn((): { content: string; status: 'start' | 'end' | undefined } => { + return { content: getContentRef.current(), status: getStatusRef.current() } + }) + + // 仅在状态真正切换时 setState,避免无谓重渲染 + const setTyping = useMemoizedFn((v: boolean) => { + if (typingRef.current !== v) { + typingRef.current = v + setIsTyping(v) + } }) - const stream = useMemo(() => { - if (!rawStream) return null + const clearTimer = useMemoizedFn(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + }) - // 如果禁用打字效果,直接返回原始流 - if (!shouldType) return rawStream + // 单步推进:读最新目标 → 自适应步长揭示 → 未追上则继续调度,追上则按节奏心跳探测 + // 追上时不停摆:流未结束期间以 interval 节奏继续 scheduleStep 探测逻辑数据原地累加的新 chunk, + // 一旦 content.length 增长,下一拍立即恢复推进,消除"追上→静默等 poll→突然输出一大段"的卡顿。 + // finishedRef 为 true 时(流已结束)排空即彻底停,按 maxStep 节奏收尾,杜绝一帧爆发。 + const scheduleStep = useMemoizedFn(() => { + clearTimer() + timerRef.current = window.setTimeout(() => { + timerRef.current = null + const { content, status } = readLatest() + if (status === 'end') finishedRef.current = true - // 流已结束:直接使用原始完整内容与原始状态,确保不被打字机截断状态污染 - if (isFinished) { - return { - ...rawStream, - data: { - ...rawStream.data, - content, - }, + const total = content.length + const len = displayedLenRef.current + if (len >= total) { + // 已追上当前目标 + if (finishedRef.current) { + // 流已结束:彻底停 + setTyping(false) + return + } + // 流未结束:停 typing,但保持心跳探测逻辑数据新 chunk(原地累加不触发重渲染,靠这里感知) + // 探测频率为 interval(约 30ms),远低于原 pollInterval(300ms),卡顿感消除 + setTyping(false) + scheduleStep() + return } + // 自适应步长:clamp(ceil(剩余 / catchUpFrames), step, maxStep) + // 既追得上逻辑数据批次,又保证单帧不超过 maxStep,杜绝"一帧爆发一大段" + const remaining = total - len + const dynamicStep = Math.min( + maxStepRef.current, + Math.max(stepRef.current, Math.ceil(remaining / catchUpFramesRef.current)), + ) + const next = Math.min(len + dynamicStep, total) + displayedLenRef.current = next + setDisplayed(content.slice(0, next)) + setTyping(next < total) + // 自驱动:仍积压则继续,追上则交由上面分支的心跳保持节奏 + scheduleStep() + }, intervalRef.current) + }) + + // 把历史态直接对齐到完整内容(仅用于挂载即 end 的历史记录分支,不进入打字循环) + const alignToFull = useMemoizedFn((content: string) => { + if (displayedLenRef.current !== content.length) { + displayedLenRef.current = content.length + setDisplayed(content) } + setTyping(false) + }) + + useEffect(() => { + // 重置本次调度的运行态(每次 effect 重跑都从干净状态开始) + finishedRef.current = false + displayedLenRef.current = 0 + typingRef.current = false + + // 初始采样:判定本组件是否需要打字(历史记录已是 end → 跳过打字直接对齐) + const initial = readLatest() + if (initial.status === 'end') finishedRef.current = true + + // 历史记录或已结束:直接展示完整,不启用任何调度 + if (finishedRef.current) { + alignToFull(initial.content) + return + } + + // 先跑一帧(挂载即可能已有积压);打字追上后由 scheduleStep 自身心跳保持探测, + // 无需独立的 setInterval poll——心跳节奏即 interval(约 30ms),远比原 pollInterval(300ms) 贴合, + // 消除"追上→静默等 poll→突然输出一大段"的卡顿。 + scheduleStep() - return { - ...rawStream, - data: { - ...rawStream.data, - content: displayedContent, - // 如果正在打字,保持 start 状态;否则使用原始状态 - status: isTyping ? 'start' : rawStream.data.status, - }, + return () => { + clearTimer() } - }, [renderNumber, rawStream, content, displayedContent, shouldType, isTyping, isFinished]) + // 不依赖 content/status:通过 getter 实时读活属性,自驱动链 + 心跳探测跨渲染持续运行。 + // 不依赖配置项:通过 ref 承载,变化在下一步即生效。 + // readLatest/scheduleStep/alignToFull/clearTimer 均为 useMemoizedFn 稳定引用,无需列入。 + // 仅挂载时建立一次调度;token 切换由父组件卸载/重建实例承担(key 绑定 token)。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) - return { stream, isTyping } + return { content: displayed, isTyping } } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/AIGroupItem.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/AIGroupItem.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/AIGroupItem.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/AIGroupItem.tsx new file mode 100644 index 0000000000..2f4b4a3c5a --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/AIGroupItem.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { AIGroupItemProps } from './type' +import AIGroupStreamCard from '../../aiGroupStreamCard/AIGroupStreamCard' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' + +const AIGroupItem: React.FC = React.memo((props) => { + const { token } = props + + const store = useCurrentStore() + const type = useStore(store, (state) => state.groups[token]?.type) + + switch (type) { + case AIChatQSDataTypeEnum.STREAM_GROUP: + return + + default: + return <> + } +}) + +export default AIGroupItem diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/type.ts new file mode 100644 index 0000000000..396240cff7 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiGroupItem/type.ts @@ -0,0 +1,3 @@ +export interface AIGroupItemProps { + token: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/AIModelErrorPrompt.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/AIModelErrorPrompt.tsx index f975de0d38..388416d4af 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/AIModelErrorPrompt.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/AIModelErrorPrompt.tsx @@ -9,40 +9,29 @@ import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { OutlinePencilaltIcon } from '@/assets/icon/outline' import useAIGlobalConfig from '@/pages/ai-re-act/hooks/useAIGlobalConfig' import { onEditAIModel } from '@/pages/ai-agent/aiModelList/AIModelList' -import { AIAgentGrpcApi } from '@/pages/ai-re-act/hooks/grpcApi' import { getFileNameByModelType, getModelLabelByModelType } from '@/pages/ai-agent/aiModelList/aiModelForm/AIModelForm' import styles from './AIModelErrorPrompt.module.scss' import { Tooltip } from 'antd' export const AIModelErrorPrompt: React.FC = React.memo((props) => { - const { item } = props + const { item, renderNum, isChildWindow } = props const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) const [aiGlobalConfigData, event] = useAIGlobalConfig() const aiGlobalConfig = useCreation(() => aiGlobalConfigData.aiGlobalConfig, [aiGlobalConfigData.aiGlobalConfig]) - const data: AIAgentGrpcApi.AIApiRequestFailedPayload = useCreation(() => { - return ( - item?.data || { - error_code: '-', - model_tier: '', - provider_name: '', - model_name: '', - cause: '', - liteforge_action: '', - } - ) - }, [item?.data]) + const modalInfo = useCreation(() => { return { - title: data.model_name, + title: item.data.model_name, time: item.Timestamp, - icon: data.provider_name, + icon: item.data.provider_name, } - }, [item.Timestamp, data]) + }, []) const onEdit = useMemoizedFn((e) => { e.stopPropagation() - const fileName = getFileNameByModelType(data.model_tier) + if (isChildWindow) return + const fileName = getFileNameByModelType(item.data.model_tier) if (!fileName) return - // todo 编辑事件,index为0是因为当前使用的ai模型是单选的且选中项一定是在第一个 + // NOTE - 编辑事件,index为0是因为当前使用的ai模型是单选的且选中项一定是在第一个 onEditAIModel({ aiGlobalConfig, index: 0, @@ -54,21 +43,29 @@ export const AIModelErrorPrompt: React.FC = React.memo( }, }) }) + const code = useCreation(() => { + return item.data.cause + }, [renderNum]) + const modelTier = useCreation(() => { + return item.data.model_tier + }, [renderNum]) return ( } titleMore={ - - } onClick={onEdit} /> - + !isChildWindow && ( + + } onClick={onEdit} /> + + ) } >
- {getModelLabelByModelType(data.model_tier)}使用错误,请点击右上角编辑进行处理 + {getModelLabelByModelType(modelTier)}使用错误{!isChildWindow && ',请点击右上角编辑进行处理'}
- +
) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/type.ts index 79ac63cade..0aafb2397f 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiModelErrorPrompt/type.ts @@ -1,5 +1,7 @@ -import { ChatApiRequestFailed } from '@/pages/ai-re-act/hooks/aiRender' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' export interface AIModelErrorPromptProps { - item: ChatApiRequestFailed + item: Extract + renderNum: number + isChildWindow: boolean } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/AINodeItem.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/AINodeItem.module.scss new file mode 100644 index 0000000000..37816612d9 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/AINodeItem.module.scss @@ -0,0 +1,13 @@ +.review-wrapper { + background: var(--Colors-Use-Neutral-Bg-Hover); + flex: none; +} + +.question-wrapper.question-wrapper { + justify-content: flex-start; +} +.content-wrapper.content-wrapper { + max-width: 100%; + max-height: 150px; + overflow-y: auto; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/AINodeItem.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/AINodeItem.tsx new file mode 100644 index 0000000000..b76986670b --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/AINodeItem.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { AINodeItemProps } from './type' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { + AITriageChatContentWrapper, + AIThought, +} from '../../aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper' +import { AIReActChatReview } from '../../aiReActChatReview/AIReActChatReview' +import styles from './AINodeItem.module.scss' +import { AIManualIntervention } from '../../aiManualIntervention/AIManualIntervention' +import { AIToolDecision } from '../../aiToolDecision/AIToolDecision' +import { AIHttpFlowFuzzStatusCard } from '../../aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard' +import { AIReportFinishCard } from '../../aiReportFinishCard/AIReportFinishCard' +import DividerCard from '../../DividerCard' +import { AITaskStatus } from '@/pages/ai-re-act/hooks/grpcApi' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import AiFailPlanCard from '../../aiFailPlanCard/AiFailPlanCard' +import { AIModelErrorPrompt } from '../aiModelErrorPrompt/AIModelErrorPrompt' +import AIStreamCardWrapper from '../../aiChatListItemWrapper/aiStreamCardWrapper/aiStreamCardWrapper' +import AIToolInvokerCardWrapper from '../../aiChatListItemWrapper/aiToolInvokerCardWrapper/AIToolInvokerCardWrapper' +import AIReviewResultWrapper from '../../aiChatListItemWrapper/aiReviewResultWrapper/AIReviewResultWrapper' +import AIGroupStreamNodeWrapper from '../../aiChatListItemWrapper/aiGroupStreamNodeWrapper/AIGroupStreamNodeWrapper' + +const AINodeItem: React.FC = React.memo((props) => { + const { itemData, renderNum, groupIndex } = props + const { t } = useI18nNamespaces(['aiAgent']) + switch (itemData.type) { + case AIChatQSDataTypeEnum.QUESTION: + return + case AIChatQSDataTypeEnum.RESULT: + return + case AIChatQSDataTypeEnum.THOUGHT: + return + + case AIChatQSDataTypeEnum.TOOL_RESULT: + return + + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: + if (!!itemData.data?.selected) { + if ( + itemData.type === AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE || + itemData.type === AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE + ) + return null + return + } else { + return ( + + ) + } + case AIChatQSDataTypeEnum.USER_MANUAL_INTERVENTION: + return + + case AIChatQSDataTypeEnum.TOOL_CALL_DECISION: + return + + case AIChatQSDataTypeEnum.HTTP_FLOW_FUZZ_STATUS: + return + + case AIChatQSDataTypeEnum.REPORT_FINISH: + return + + case AIChatQSDataTypeEnum.END_PLAN_AND_EXECUTION: + return ( + + ) + + case AIChatQSDataTypeEnum.FAIL_PLAN_AND_EXECUTION: + case AIChatQSDataTypeEnum.FAIL_REACT: + return + + case AIChatQSDataTypeEnum.AI_API_REQUEST_FAILED: + return + + case AIChatQSDataTypeEnum.STREAM: + if (!!itemData.parentGroupToken) { + // 组 + return + } else { + return + } + default: + return null + } +}) + +export default AINodeItem diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/type.ts new file mode 100644 index 0000000000..e9854e5b13 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiNodeItem/type.ts @@ -0,0 +1,9 @@ +import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AINodeItemProps { + itemData: AIChatQSData + /** 控制渲染,必传 */ + renderNum: number + /** 组内 0-based 下标,由组列表 map 时透传;仅 STREAM + parentGroupToken 分支使用 */ + groupIndex?: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/AITaskItem.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/AITaskItem.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/AITaskItem.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/AITaskItem.tsx new file mode 100644 index 0000000000..0458dd3582 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/AITaskItem.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import type { AITaskItemProps } from './type' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import ConcurrentStreamCard from '../../ConcurrentStreamCard/ConcurrentStreamCard' +import AITaskDefaultGroupCard from '../../AITaskDefaultGroupCard/AITaskDefaultGroupCard' + +const AITaskItem: React.FC = React.memo((props) => { + const { token } = props + const store = useCurrentStore() + + const type = useStore(store, (state) => state.tasks[token].type) + switch (type) { + case AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP: + return + case AIChatQSDataTypeEnum.TASK_NODE_GROUP: + return + default: + return <> + } +}) + +export default AITaskItem diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/type.ts new file mode 100644 index 0000000000..1a9aa996c2 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/aiTaskItem/type.ts @@ -0,0 +1,3 @@ +export interface AITaskItemProps { + token: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/type.ts index 1bb086b5c3..28715565c2 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItem/type.ts @@ -1,12 +1,5 @@ -import type { ReActChatRenderItem } from '@/pages/ai-re-act/hooks/aiRender' -import { AIAgentGrpcApi } from '@/pages/ai-re-act/hooks/grpcApi' -import { UseYakExecResultState } from '@/pages/ai-re-act/hooks/type' +import type { ReActChatRenderElement } from '@/pages/ai-re-act/hooks/aiRender' export interface AIChatListItemProps { - item: ReActChatRenderItem - type: 're-act' | 'task-agent' - tasksProps?: { tasks: AIAgentGrpcApi.PlanTask[]; yakExecResult: UseYakExecResultState } - hasNext?: boolean - itemIndex?: number - session?: string + item: ReActChatRenderElement } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiGroupStreamNodeWrapper/AIGroupStreamNodeWrapper.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiGroupStreamNodeWrapper/AIGroupStreamNodeWrapper.tsx new file mode 100644 index 0000000000..49007de795 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiGroupStreamNodeWrapper/AIGroupStreamNodeWrapper.tsx @@ -0,0 +1,35 @@ +import { FC, memo } from 'react' +import type { AIGroupStreamNodeWrapperProps } from './type' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { useCurrentRawData } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useCreation } from 'ahooks' +import { AIGroupStreamNode } from '../../aiGroupStreamCard/AIGroupStreamCard' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' + +const AIGroupStreamNodeWrapper: FC = memo((props) => { + const { itemData, renderNum, groupIndex } = props + // 其余原始字段通过 useCurrentRawData 获取,并订阅 renderNum 驱动重渲染 + const rawData = useCurrentRawData() + const currentSessionId = useCurrentSessionId() + const stream = useCreation(() => { + const rawStream = rawData.contents.get(itemData.id) + if (!rawStream) return null + switch (rawStream.type) { + case AIChatQSDataTypeEnum.STREAM: + return rawStream + + default: + return null + } + }, [renderNum]) + // seqNo 直接使用父组件在 map 时透传的 groupIndex 拼装, + // 下标由唯一负责排序的父组件决定,与渲染顺序天然一致;未传则不显示序号 + const seqNo = useCreation(() => { + if (!itemData.parentGroupToken || groupIndex == null) return '' + return `${groupIndex + 1}. ` + }, [renderNum, groupIndex]) + if (!stream) return null + return +}) + +export default AIGroupStreamNodeWrapper diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiGroupStreamNodeWrapper/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiGroupStreamNodeWrapper/type.ts new file mode 100644 index 0000000000..f98098a8e9 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiGroupStreamNodeWrapper/type.ts @@ -0,0 +1,8 @@ +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIGroupStreamNodeWrapperProps { + itemData: ChatStream + renderNum: number + /** 该节点在所属 group 子节点列表中的 0-based 下标,由负责排序的父组件透传,用于拼 seqNo */ + groupIndex?: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper.tsx new file mode 100644 index 0000000000..f88ef72c22 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import useGetChatDataStoreKey from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' +import { AITriageChatContent } from '../../aiTriageChat/AITriageChat' +import { AIThoughtProps, AITriageChatContentWrapperProps } from './type' +import { useCreation } from 'ahooks' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' + +export const AITriageChatContentWrapper: React.FC = React.memo((props) => { + const chatDataStoreKey = useGetChatDataStoreKey() + return +}) + +export const AIThought: React.FC = React.memo((props) => { + const { itemData, renderNum } = props + const { t, i18n } = useI18nNamespaces(['aiAgent']) + const newItemData = useCreation(() => { + return { + ...itemData, + data: `${t('AIChatListItem.thinking')}${itemData?.data}`, + } + }, [renderNum, i18n.language]) + return +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/type.ts new file mode 100644 index 0000000000..860866c4de --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiItemContentWrapper/type.ts @@ -0,0 +1,12 @@ +import { type AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { AINodeItemProps } from '../../aiChatListItem/aiNodeItem/type' +import { AITriageChatContentProps } from '../../aiTriageChat/type' + +interface AIItemContentWrapperProps { + renderNum: AINodeItemProps['renderNum'] +} +export interface AITriageChatContentWrapperProps extends Omit {} + +export interface AIThoughtProps extends AIItemContentWrapperProps { + itemData: Extract +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiReviewResultWrapper/AIReviewResultWrapper.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiReviewResultWrapper/AIReviewResultWrapper.tsx new file mode 100644 index 0000000000..08288d59d5 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiReviewResultWrapper/AIReviewResultWrapper.tsx @@ -0,0 +1,15 @@ +import { FC, memo } from 'react' +import type { AIReviewResultWrapperProps } from './type' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import { AIReviewResult } from '../../aiReviewResult/AIReviewResult' + +const AIReviewResultWrapper: FC = memo((props) => { + const { itemData, renderNum } = props + const store = useCurrentStore() + const taskLength = useStore(store, (state) => state.taskChat.elements.length) + const casualLength = useStore(store, (state) => state.casualChat.elements.length) + return +}) + +export default AIReviewResultWrapper diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiReviewResultWrapper/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiReviewResultWrapper/type.ts new file mode 100644 index 0000000000..831dd21ed4 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiReviewResultWrapper/type.ts @@ -0,0 +1,6 @@ +import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIReviewResultWrapperProps { + itemData: AIChatQSData + renderNum: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiStreamCardWrapper/aiStreamCardWrapper.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiStreamCardWrapper/aiStreamCardWrapper.tsx new file mode 100644 index 0000000000..d9d2d8e2a1 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiStreamCardWrapper/aiStreamCardWrapper.tsx @@ -0,0 +1,45 @@ +import { type FC, memo, useEffect, useState } from 'react' +import type { AIStreamCardWrapperProps } from './type' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useRafInterval from 'ahooks/lib/useRafInterval' +import { AIStreamCard } from '../../aiChatListItem/StreamingChatContent/StreamingChatContent' +import { AIChatQSDataTypeEnum, ChatStream } from '@/pages/ai-re-act/hooks/aiRender' +import { useStore } from 'zustand' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' + +const AIStreamCardWrapper: FC = memo((props) => { + const { token } = props + const [interval, setInterval] = useState(1000) + const [itemData, setItemData] = useState() + + const store = useCurrentStore() + const renderNum = useStore(store, (state) => state.items[token]?.renderNum) + const rawData = useCurrentRawData() + const sessionId = useCurrentSessionId() + useRafInterval(() => { + syncData() + }, interval) + + useEffect(() => { + syncData() + }, [token, renderNum]) + + /** TODO - 需要区分历史数据,历史数据没有打字机效果 */ + const syncData = useMemoizedFn(() => { + const item = rawData?.contents.get(token) + if (item?.type === AIChatQSDataTypeEnum.STREAM) { + setItemData(item) + if (item.data.status === 'end') { + setInterval(undefined) + } else if (item.data.status === 'start') { + setInterval(1000) + } + } + }) + + if (!itemData) return null + return +}) + +export default AIStreamCardWrapper diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiStreamCardWrapper/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiStreamCardWrapper/type.ts new file mode 100644 index 0000000000..7dcc443e91 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiStreamCardWrapper/type.ts @@ -0,0 +1,3 @@ +export interface AIStreamCardWrapperProps { + token: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiToolInvokerCardWrapper/AIToolInvokerCardWrapper.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiToolInvokerCardWrapper/AIToolInvokerCardWrapper.tsx new file mode 100644 index 0000000000..ec581b2fc8 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiToolInvokerCardWrapper/AIToolInvokerCardWrapper.tsx @@ -0,0 +1,20 @@ +import { FC, memo } from 'react' +import type { AIToolInvokerCardWrapperProps } from './type' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useCreation } from 'ahooks' +import { useStore } from 'zustand' +import ToolInvokerCard from '../../ToolInvokerCard' + +const AIToolInvokerCardWrapper: FC = memo((props) => { + const { itemData, renderNum } = props + const store = useCurrentStore() + const execFileRecord = useStore(store, (state) => state.execFileRecord) + const fileList = useCreation(() => { + if (!itemData?.data?.callToolId) return [] + return execFileRecord.get(itemData?.data?.callToolId) || [] + }, [renderNum, itemData?.data?.callToolId]) + + return +}) + +export default AIToolInvokerCardWrapper diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiToolInvokerCardWrapper/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiToolInvokerCardWrapper/type.ts new file mode 100644 index 0000000000..013bf29299 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChatListItemWrapper/aiToolInvokerCardWrapper/type.ts @@ -0,0 +1,6 @@ +import { ChatToolResult } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIToolInvokerCardWrapperProps { + itemData: ChatToolResult + renderNum: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent.module.scss new file mode 100644 index 0000000000..5ff393f873 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent.module.scss @@ -0,0 +1,42 @@ +// 子窗口并发流内容区样式。 +// 内容对齐 ConcurrentStreamContent.module.scss。 + +.concurrent-stream-content { + width: 100%; + max-height: 300px; + overflow-y: hidden; + &-item { + margin-bottom: 8px; + // 连续流卡片之间去掉间距(concurrent-stream-card 本身排除在外保留间距) + &:has(:global(.ai-stream-chat-content-wrapper)) { + & + + .concurrent-stream-content-item:has(:global(.ai-stream-chat-content-wrapper)):not( + :has(:global(.concurrent-stream-card)) + ) { + margin-top: -8px; + } + } + } +} + +// 子窗口内容区外层容器:撑满父级高度 +.concurrent-stream-content-wrapper { + height: 100%; + display: flex; + flex-direction: column; + flex: 1; + + overflow: hidden; +} + +.focused { + overflow-y: overlay; +} + +.content { + width: 100%; + max-height: 500px; + background-color: var(--Colors-Use-Basic-Background); + padding: 4px; + border-radius: 8px; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent.tsx new file mode 100644 index 0000000000..efe5dbef30 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent.tsx @@ -0,0 +1,46 @@ +import classNames from 'classnames' +import styles from './AIChildWindowConcurrentStreamContent.module.scss' +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import useClickFocus from '@/pages/ai-re-act/hooks/useClickFocus' +import React, { type FC, memo } from 'react' +import AIChildWindowGroupItem from '../aiChildWindowGroupItem/AIChildWindowGroupItem' +import AIChildWindowNodeItemWrapper from '../aiChildWindowNodeItemWrapper/AIChildWindowNodeItemWrapper' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' + +const AIChildWindowConcurrentStreamContent: FC = memo(() => { + const { childrenTokens, rawData, renderNum } = useAIConcurrentStreamStore() + const { ref: scrollRef, isFocus } = useClickFocus() + return ( +
+ +
+ ) +}) + +export default AIChildWindowConcurrentStreamContent diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupItem/AIChildWindowGroupItem.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupItem/AIChildWindowGroupItem.tsx new file mode 100644 index 0000000000..ed5f3d6158 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupItem/AIChildWindowGroupItem.tsx @@ -0,0 +1,22 @@ +import { useCreation } from 'ahooks' +import { type FC, memo } from 'react' +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import AIChildWindowGroupStreamCard from '../aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard' +import { AIChatQSDataTypeEnum } from '../../../../ai-re-act/hooks/aiRender' + +/** 子窗口版 group item,数据从 rawData Map 获取 */ +const AIChildWindowGroupItem: FC<{ token: string }> = memo(({ token }) => { + const { renderNum, rawData } = useAIConcurrentStreamStore() + const itemData = useCreation(() => { + return rawData?.get(token) + }, [renderNum, token]) + if (!itemData) return null + + switch (itemData.type) { + case AIChatQSDataTypeEnum.STREAM_GROUP: + return + default: + return null + } +}) +export default AIChildWindowGroupItem diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupItem/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupItem/type.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard.module.scss new file mode 100644 index 0000000000..615a69103d --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard.module.scss @@ -0,0 +1,24 @@ +// 子窗口 stream group 卡片样式。 +// 内容对齐 AIGroupStreamCard.module.scss。 + +.container { + width: 100%; + border-radius: 8px; + padding: 2px 4px 0; + display: flex; + flex-direction: column; + // gap: 4px; + border: 1px solid transparent; + // 遮罩底色变量:默认走基础背景,hover/focus 时由父级切换,子模块消费 + --group-stream-mask-bg: var(--Colors-Use-Basic-Background); + &:hover { + background: var(--Colors-Use-Neutral-Bg); + --group-stream-mask-bg: var(--Colors-Use-Neutral-Bg); + } +} +.container-focus { + border-color: var(--Colors-Use-Main-Hover); + background: var(--Colors-Use-Neutral-Bg); + // focus 时取消遮罩:变量置透明,子模块渐变自然透明,等效原 background: initial + --group-stream-mask-bg: transparent; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard.tsx new file mode 100644 index 0000000000..7102b03e94 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/AIChildWindowGroupStreamCard.tsx @@ -0,0 +1,137 @@ +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { useCreation } from 'ahooks' +import { type FC, memo, useState } from 'react' +import AIChildWindowNodeItemWrapper from '../aiChildWindowNodeItemWrapper/AIChildWindowNodeItemWrapper' +import type { + AIChildWindowGroupStreamCardHeardWrapperProps, + AIChildWindowGroupStreamCardListWrapperProps, + AIChildWindowGroupStreamCardProps, +} from './type' +import styles from './AIChildWindowGroupStreamCard.module.scss' +import AIGroupStreamCardHeard from '../../aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard' +import useAINodeLabel from '@/pages/ai-re-act/hooks/useAINodeLabel' +import { STREAM_MASK_THRESHOLD } from '../../aiGroupStreamCard/AIGroupStreamCard' +import classNames from 'classnames' +import useClickFocus from '@/pages/ai-re-act/hooks/useClickFocus' +import AIGroupStreamCardList from '../../aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList' +import React from 'react' +/** 子窗口版 stream group 卡片,从 rawData 中按 parentGroupToken 查找子节点 */ +const AIChildWindowGroupStreamCard: FC = memo((props) => { + const { token } = props + const { rawData, renderNum } = useAIConcurrentStreamStore() + const { ref: containerRef, isFocus } = useClickFocus() + + const [expand, setExpand] = useState(true) + // 按 token + renderNum 缓存该 group 的子节点,避免每次渲染都全量 forEach + const childItemTokens = useCreation(() => { + if (!rawData) return [] + const items: string[] = [] + rawData.forEach((value) => { + if (value.parentGroupToken === token) { + items.push(value.id) + } + }) + return items + }, [token, renderNum]) + const lastToken = useCreation(() => { + return childItemTokens.length > 0 ? childItemTokens[childItemTokens.length - 1] : '' + }, [childItemTokens.length]) + return ( +
+ + +
+ ) +}) + +export default AIChildWindowGroupStreamCard + +const AIChildWindowGroupStreamCardHeardWrapper: FC = memo((props) => { + const { token, lastToken, childrenTokensLength, setExpand, expand } = props + const { rawData, renderNum } = useAIConcurrentStreamStore() + const { getLabelByParams } = useAINodeLabel() + + const lastItem = useCreation(() => { + const lastItem = rawData.get(lastToken) + if (!lastItem) return undefined + switch (lastItem.type) { + case AIChatQSDataTypeEnum.STREAM: + return lastItem + + default: + return undefined + } + }, [lastToken]) + const shouldShowMask = useCreation(() => { + const lastItem = rawData.get(lastToken) + if (!lastItem) return false + switch (lastItem.type) { + case AIChatQSDataTypeEnum.STREAM: + const contentLength = lastItem.data?.content?.length || 0 + return contentLength > STREAM_MASK_THRESHOLD + + default: + return false + } + }, [lastToken]) + const nodeLabel = useCreation(() => { + const groupData = rawData.get(token) + if (!groupData) return '' + switch (groupData.type) { + case AIChatQSDataTypeEnum.STREAM_GROUP: + return getLabelByParams(groupData.data?.NodeIdVerbose) + + default: + return '' + } + }, [renderNum]) + return ( + + ) +}) + +const AIChildWindowGroupStreamCardListWrapper: React.FC = memo( + (props) => { + const { childItemTokens, expand } = props + const { rawData, renderNum } = useAIConcurrentStreamStore() + return ( + <> + { + const itemData = rawData.get(token) + if (!itemData) return + return ( + + ) + }} + /> + + ) + }, +) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/type.ts new file mode 100644 index 0000000000..7497388cc9 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamCard/type.ts @@ -0,0 +1,18 @@ +import type { Dispatch, SetStateAction } from 'react' + +export interface AIChildWindowGroupStreamCardProps { + token: string +} + +export interface AIChildWindowGroupStreamCardListWrapperProps { + childItemTokens: string[] + expand: boolean +} + +export interface AIChildWindowGroupStreamCardHeardWrapperProps { + token: string + lastToken: string + childrenTokensLength: number + expand: boolean + setExpand: Dispatch> +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamNode/AIChildWindowGroupStreamNode.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamNode/AIChildWindowGroupStreamNode.tsx new file mode 100644 index 0000000000..2cac9a3e61 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamNode/AIChildWindowGroupStreamNode.tsx @@ -0,0 +1,33 @@ +import { FC, memo } from 'react' +import type { AIChildWindowGroupStreamNodeProps } from './type' +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import useCreation from 'ahooks/lib/useCreation' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { AIGroupStreamNode } from '../../aiGroupStreamCard/AIGroupStreamCard' + +const AIChildWindowGroupStreamNode: FC = memo((props) => { + const { itemData, renderNum, groupIndex } = props + const { rawData, session } = useAIConcurrentStreamStore() + // 其余原始字段通过 useCurrentRawData 获取,并订阅 renderNum 驱动重渲染 + const stream = useCreation(() => { + const rawStream = rawData?.get(itemData.id) + if (!rawStream) return null + switch (rawStream.type) { + case AIChatQSDataTypeEnum.STREAM: + return rawStream + + default: + return null + } + }, [renderNum]) + // seqNo 直接使用父组件(AIChildWindowGroupStreamCard)在 map 时透传的 groupIndex, + // 下标由唯一负责排序的父组件决定,与渲染顺序天然一致;未传则不显示序号 + const seqNo = useCreation(() => { + if (!itemData.parentGroupToken || groupIndex == null) return '' + return `${groupIndex + 1}. ` + }, [renderNum, groupIndex]) + if (!stream) return null + return +}) + +export default AIChildWindowGroupStreamNode diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamNode/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamNode/type.ts new file mode 100644 index 0000000000..37c49ee678 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowGroupStreamNode/type.ts @@ -0,0 +1,8 @@ +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIChildWindowGroupStreamNodeProps { + itemData: ChatStream + renderNum: number + /** 该节点在所属 group 子节点列表中的 0-based 下标,由负责排序的父组件透传,用于拼 seqNo */ + groupIndex?: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowNodeItemWrapper/AIChildWindowNodeItemWrapper.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowNodeItemWrapper/AIChildWindowNodeItemWrapper.tsx new file mode 100644 index 0000000000..893983585b --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowNodeItemWrapper/AIChildWindowNodeItemWrapper.tsx @@ -0,0 +1,75 @@ +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { type FC, memo } from 'react' +import { AIModelErrorPrompt } from '../../aiChatListItem/aiModelErrorPrompt/AIModelErrorPrompt' +import { AIHttpFlowFuzzStatusCard } from '../../aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard' +import { AIReportFinishCard } from '../../aiReportFinishCard/AIReportFinishCard' +import { AIReviewResult } from '../../aiReviewResult/AIReviewResult' +import AIChildWindowGroupStreamNode from '../aiChildWindowGroupStreamNode/AIChildWindowGroupStreamNode' +import AIChildWindowStreamCard from '../aiChildWindowStreamCard/AIChildWindowStreamCard' +import AIChildWindowToolInvokerCard from '../aiChildWindowToolInvokerCard/AIChildWindowToolInvokerCard' +import { + AIThought, + AITriageChatContentWrapper, +} from '../../aiChatListItemWrapper/aiItemContentWrapper/AIItemContentWrapper' +import { AIToolDecision } from '../../aiToolDecision/AIToolDecision' +import AiFailPlanCard from '../../aiFailPlanCard/AiFailPlanCard' +import { AIChildWindowNodeItemWrapperProps } from './type' + +/** + * 子窗口版 node item 包装器。 + */ +const AIChildWindowNodeItemWrapper: FC = memo( + ({ itemData, groupIndex, renderNum }) => { + switch (itemData.type) { + case AIChatQSDataTypeEnum.TOOL_RESULT: + return + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: + if (!!itemData.data?.selected) { + if ( + itemData.type === AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE || + itemData.type === AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE + ) + return null + return + } + // 任务子窗口中没有未review的数据 + return null + + case AIChatQSDataTypeEnum.HTTP_FLOW_FUZZ_STATUS: + return + case AIChatQSDataTypeEnum.REPORT_FINISH: + return + case AIChatQSDataTypeEnum.AI_API_REQUEST_FAILED: + return + case AIChatQSDataTypeEnum.STREAM: + if (!!itemData.parentGroupToken) { + return + } else { + // 组 + return + } + case AIChatQSDataTypeEnum.QUESTION: + return null + case AIChatQSDataTypeEnum.RESULT: + return + case AIChatQSDataTypeEnum.THOUGHT: + return + case AIChatQSDataTypeEnum.USER_MANUAL_INTERVENTION: + return null + case AIChatQSDataTypeEnum.TOOL_CALL_DECISION: + return + case AIChatQSDataTypeEnum.END_PLAN_AND_EXECUTION: + return null + case AIChatQSDataTypeEnum.FAIL_PLAN_AND_EXECUTION: + case AIChatQSDataTypeEnum.FAIL_REACT: + return + default: + return null + } + }, +) +export default AIChildWindowNodeItemWrapper diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowNodeItemWrapper/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowNodeItemWrapper/type.ts new file mode 100644 index 0000000000..ced719be90 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowNodeItemWrapper/type.ts @@ -0,0 +1,7 @@ +import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIChildWindowNodeItemWrapperProps { + itemData: AIChatQSData + groupIndex?: number + renderNum: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowStreamCard/AIChildWindowStreamCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowStreamCard/AIChildWindowStreamCard.tsx new file mode 100644 index 0000000000..3041d782b1 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowStreamCard/AIChildWindowStreamCard.tsx @@ -0,0 +1,23 @@ +import { type FC, memo } from 'react' +import type { AIChildWindowStreamCardProps } from './type' +import { AIStreamCard } from '../../aiChatListItem/StreamingChatContent/StreamingChatContent' +import useCreation from 'ahooks/lib/useCreation' +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' + +const AIChildWindowStreamCard: FC = memo((props) => { + const { itemData, renderNum } = props + const { session } = useAIConcurrentStreamStore() + const data: ChatStream = useCreation(() => { + return { + ...itemData, + data: { + ...itemData.data, + status: 'end', + }, + } + }, [renderNum]) + return +}) + +export default AIChildWindowStreamCard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowStreamCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowStreamCard/type.ts new file mode 100644 index 0000000000..c0bf610940 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowStreamCard/type.ts @@ -0,0 +1,6 @@ +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIChildWindowStreamCardProps { + itemData: ChatStream + renderNum: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard.module.scss new file mode 100644 index 0000000000..50aa62fa34 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard.module.scss @@ -0,0 +1,22 @@ +// 子窗口 task default group 卡片样式。 +// 内容对齐 AITaskDefaultGroupCard.module.scss。 + +.ai-task-default-group-card { + width: 100%; + border-radius: 8px; + border: 1px solid var(--Colors-Use-Neutral-Bg-Hover); + background: var(--Colors-Use-Basic-Background); + max-height: 600px; + padding: 4px 8px; + display: flex; + flex-direction: column; + gap: 4px; + overflow: hidden; +} + +.child-window-card { + max-height: none; + height: 100%; + flex: 1; + min-height: 0; +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard.tsx new file mode 100644 index 0000000000..b5179c6bd1 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/AIChildWindowTaskDefaultGroupCard.tsx @@ -0,0 +1,36 @@ +import { useCreation } from 'ahooks' +import classNames from 'classnames' +import { type FC, memo } from 'react' +import styles from './AIChildWindowTaskDefaultGroupCard.module.scss' +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import useAIConcurrentStreamDispatcher from '@/auxWindow/pages/AIConcurrentStream/useContext/useDispatcher' +import type { AIChildWindowTaskDefaultGroupCardProps } from './type' +import AIChildWindowTaskDefaultGroupCardHeard from './aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard' +import AIChildWindowConcurrentStreamContent from '../aiChildWindowConcurrentStreamContent/AIChildWindowConcurrentStreamContent' + +const AIChildWindowTaskDefaultGroupCard: FC = memo((props) => { + const { token } = props + + const { rawData, renderNum } = useAIConcurrentStreamStore() + const { requestRefresh } = useAIConcurrentStreamDispatcher() + const timeStamp = useCreation(() => { + if (!rawData) return 0 + const itemData = rawData.get(token) + if (!itemData) return 0 + return itemData.Timestamp || 0 + }, [renderNum]) + + return ( +
+ + + +
+ ) +}) + +export default AIChildWindowTaskDefaultGroupCard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard.module.scss new file mode 100644 index 0000000000..c3c733f37e --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard.module.scss @@ -0,0 +1,62 @@ +// 子窗口并发流内容区样式。 +// 内容对齐 AITaskDefaultGroupCardHeard.module.scss。 + +@use '../../../../../../styles/mixin.scss' as mixin; +%display-flex-center { + display: flex; + align-items: center; +} +%display-column-center { + display: flex; + align-items: center; + flex-direction: column; +} +.ai-task-default-group-card-title { + height: 20px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + + &-left { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + flex: 1; + + .icon, + .icon svg { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--Colors-Use-Neutral-Disable); + } + + .text { + color: var(--Colors-Use-Neutral-Text-1-Title); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.5px; + flex-shrink: 0; + } + + .time { + color: var(--Colors-Use-Neutral-Disable); + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 14px; + letter-spacing: 0.5px; + @include mixin.text-ellipsis(1, false, false, true); + } + } + + &-right { + display: flex; + align-items: center; + flex-shrink: 0; + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard.tsx new file mode 100644 index 0000000000..4d156c723e --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/AIChildWindowTaskDefaultGroupCardHeard.tsx @@ -0,0 +1,37 @@ +import { FC, memo } from 'react' +import type { AIChildWindowTaskDefaultGroupCardHeardProps } from './type' +import styles from './AIChildWindowTaskDefaultGroupCardHeard.module.scss' +import { OutlineInformationcircleIcon, OutlineRefreshIcon } from '@/assets/icon/outline' +import { Tooltip } from 'antd' +import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' +import { formatTimestamp } from '@/utils/timeUtil' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' + +const AIChildWindowTaskDefaultGroupCardHeard: FC = memo((props) => { + const { t } = useI18nNamespaces(['aiAgent']) + const { timeStamp, onRefresh } = props + return ( +
+
+ + + + {t('ConcurrentStreamCard.systemInfo')} + {timeStamp ? {formatTimestamp(timeStamp)} : null} +
+
e.stopPropagation()}> + + } + onClick={onRefresh} + className={styles['expand-btn']} + /> + +
+
+ ) +}) + +export default AIChildWindowTaskDefaultGroupCardHeard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/type.ts new file mode 100644 index 0000000000..798332c85d --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/aiChildWindowTaskDefaultGroupCardHeard/type.ts @@ -0,0 +1,4 @@ +export interface AIChildWindowTaskDefaultGroupCardHeardProps { + timeStamp: number + onRefresh: () => void +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/type.ts new file mode 100644 index 0000000000..ffe791de62 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowTaskDefaultGroupCard/type.ts @@ -0,0 +1,3 @@ +export interface AIChildWindowTaskDefaultGroupCardProps { + token: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowToolInvokerCard/AIChildWindowToolInvokerCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowToolInvokerCard/AIChildWindowToolInvokerCard.tsx new file mode 100644 index 0000000000..6d70619938 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowToolInvokerCard/AIChildWindowToolInvokerCard.tsx @@ -0,0 +1,17 @@ +import useAIConcurrentStreamStore from '@/auxWindow/pages/AIConcurrentStream/useContext/useStore' +import { useCreation } from 'ahooks' +import { type FC, memo } from 'react' +import ToolInvokerCard from '../../ToolInvokerCard' +import type { AIChildWindowToolInvokerCardProps } from './type' + +const AIChildWindowToolInvokerCard: FC = memo((props) => { + const { itemData, renderNum } = props + const { execFileRecord } = useAIConcurrentStreamStore() + const fileList = useCreation(() => { + if (!itemData?.data?.callToolId) return [] + return execFileRecord?.get(itemData?.data?.callToolId) || [] + }, [renderNum, itemData?.data?.callToolId]) + return +}) + +export default AIChildWindowToolInvokerCard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowToolInvokerCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowToolInvokerCard/type.ts new file mode 100644 index 0000000000..830b729b6b --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiChildWindowItem/aiChildWindowToolInvokerCard/type.ts @@ -0,0 +1,6 @@ +import { ChatToolResult } from '@/pages/ai-re-act/hooks/aiRender' + +export interface AIChildWindowToolInvokerCardProps { + itemData: ChatToolResult + renderNum: number +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiFailPlanCard/AiFailPlanCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiFailPlanCard/AiFailPlanCard.tsx index 62a8da799f..c1fbbee39a 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiFailPlanCard/AiFailPlanCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiFailPlanCard/AiFailPlanCard.tsx @@ -1,5 +1,5 @@ -import type { FailTaskChatError } from '@/pages/ai-re-act/hooks/aiRender' -import type { FC } from 'react' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { memo, type FC } from 'react' import ChatCard from '../ChatCard' import styles from './AiFailPlanCard.module.scss' import useAINodeLabel from '@/pages/ai-re-act/hooks/useAINodeLabel' @@ -8,14 +8,23 @@ import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { OutlineChevronsDownUpIcon, OutlineChevronsUpDownIcon } from '@/assets/icon/outline' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { Tooltip } from 'antd' -import { useToggle } from 'ahooks' +import useCreation from 'ahooks/lib/useCreation' +import useToggle from 'ahooks/lib/useToggle' -const AiFailPlanCard: FC<{ item: FailTaskChatError }> = ({ item }) => { +const AiFailPlanCard: FC<{ + itemData: Extract< + AIChatQSData, + { type: AIChatQSDataTypeEnum.FAIL_REACT | AIChatQSDataTypeEnum.FAIL_PLAN_AND_EXECUTION } + > + renderNum: number +}> = ({ itemData, renderNum }) => { const { t } = useI18nNamespaces(['aiAgent']) - const { content } = item - const { nodeLabel } = useAINodeLabel(item.NodeIdVerbose) const [expand, { toggle }] = useToggle(false) + const { nodeLabel } = useAINodeLabel(itemData.data.NodeIdVerbose) + const content = useCreation(() => { + return itemData.data.content + }, [renderNum]) return ( = ({ item }) => { ) } -export default AiFailPlanCard +export default memo(AiFailPlanCard) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/FileTreeSystem/FileTreeSystem.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/FileTreeSystem/FileTreeSystem.tsx index da5c6eb2d4..8a8d81a245 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/FileTreeSystem/FileTreeSystem.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/FileTreeSystem/FileTreeSystem.tsx @@ -7,9 +7,10 @@ import { useCustomFolder } from '../store/useCustomFolder' import styles from './FileTreeSystem.module.scss' import FileTreeDrop from '@/pages/ai-agent/aiChatWelcome/FileTreeDrop/FileTreeDrop' import { Divider } from 'antd' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { useCreation } from 'ahooks' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' interface FileTreeSystemProps { /** 是否有文件预览数据,用于外层自由对话变大 */ @@ -19,10 +20,14 @@ interface FileTreeSystemProps { const FileTreeSystem = (props: FileTreeSystemProps) => { const { onFilePreviewChange } = props const { t } = useI18nNamespaces(['aiAgent']) + + const store = useCurrentStore() + // 单选 const [selected, setSelected] = useState() // ai的文件夹 - const { grpcFolders } = useChatIPCStore().chatIPCData + + const grpcFolders = useStore(store, (state) => state.grpcFolders) // 用户文件夹 const customFolder = useCustomFolder() diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/type.ts index cf56cda08b..2980d95b1e 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiFileSystemList/type.ts @@ -1,5 +1,5 @@ import type { YakitMenuItemType } from '@/components/yakitUI/YakitMenu/YakitMenu' -import type { UseYakExecResultState } from '@/pages/ai-re-act/hooks/type' +import { ChatStoreState } from '@/pages/ai-re-act/hooks/aiRender' import type { UseFileTreeEvents } from '@/pages/ai-re-act/hooks/useFileTree' import type { FileNodeProps } from '@/pages/yakRunner/FileTree/FileTreeType' import type { Dispatch, SetStateAction } from 'react' @@ -9,7 +9,7 @@ export enum TabKey { OperationLog = 'operation-log', } export interface AIFileSystemListProps { - execFileRecord: UseYakExecResultState['execFileRecord'] + execFileRecord: ChatStoreState['execFileRecord'] activeKey?: TabKey setActiveKey?: (key: TabKey) => void } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.module.scss index f47c1124be..c266c06c53 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.module.scss @@ -8,175 +8,18 @@ flex-direction: column; // gap: 4px; border: 1px solid transparent; + // 遮罩底色变量:默认走基础背景,hover/focus 时由父级切换,子模块消费 + --group-stream-mask-bg: var(--Colors-Use-Basic-Background); &:hover { background: var(--Colors-Use-Neutral-Bg); - .ai-mask { - background: linear-gradient( - to right, - var(--Colors-Use-Neutral-Bg) 0%, - rgba(255, 255, 255, 0) 10%, - rgba(255, 255, 255, 0) 90%, - transparent 100% - ); - } - .content { - &:not(.noMask)::before { - background: linear-gradient( - 180deg, - var(--Colors-Use-Neutral-Bg) 0%, - var(--Colors-Use-Neutral-Bg) 30.21%, - transparent 80% - ); - @supports (background: color-mix(in srgb, #000 50%, #fff)) { - background: linear-gradient( - 180deg, - var(--Colors-Use-Neutral-Bg) 0%, - color-mix(in srgb, var(--Colors-Use-Neutral-Bg) 60%, transparent) 30.21%, - transparent 80% - ); - } - } - &:not(.noMask)::after { - background: linear-gradient( - 180deg, - transparent 20%, - var(--Colors-Use-Neutral-Bg) 69.79%, - var(--Colors-Use-Neutral-Bg) 100% - ); - @supports (background: color-mix(in srgb, #000 50%, #fff)) { - background: linear-gradient( - 180deg, - transparent 20%, - color-mix(in srgb, var(--Colors-Use-Neutral-Bg) 60%, transparent) 69.79%, - var(--Colors-Use-Neutral-Bg) 100% - ); - } - } - } - } - .title { - display: flex; - align-items: center; - cursor: pointer; - gap: 2px; - .title-node-label { - margin-right: 8px; - display: flex; - align-items: center; - gap: 2px; - color: var(--Colors-Use-Neutral-Text-3-Secondary); - font-size: 11px; - font-weight: 500; - line-height: 14px; - .brain-icon { - width: 16px; - height: 16px; - color: var(--Colors-Use-Neutral-Disable); - svg { - width: 16px; - height: 16px; - } - } - } - & > :last-child { - color: var(--Colors-Use-Neutral-Text-3-Secondary); - } - .expand-btn { - color: var(--Colors-Use-Neutral-Text-3-Secondary); - } - .hidden-expand-btn { - display: none; - } - } - .content { - cursor: pointer; - display: grid; - grid-template-rows: 0fr; - // transition: grid-template-rows 0.3s ease-in-out; - // overflow-y: overlay; - max-height: 156px; - position: relative; - overflow: hidden; - - &:not(.noMask)::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 30%; - background: linear-gradient( - 180deg, - var(--Colors-Use-Basic-Background) 0%, - var(--Colors-Use-Basic-Background) 4%, - rgba(255, 255, 255, 0) 96% - ); - pointer-events: none; - z-index: 1; - } - - &:not(.noMask)::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 30%; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0) 4%, - var(--Colors-Use-Basic-Background) 96%, - var(--Colors-Use-Basic-Background) 100% - ); - pointer-events: none; - z-index: 1; - } - - &-inner { - height: 100%; - width: 100%; - min-height: 0; - flex-direction: column; - padding-top: 0; - // transition: padding-top 0.3s ease-in-out; - > * { - color: var(--Colors-Use-Neutral-Text-4-Help-text); - } - } - &.expand { - border-radius: 8px; - grid-template-rows: 1fr; - .content-inner { - font-size: 12px; - font-weight: 400; - letter-spacing: 0.5px; - scrollbar-gutter: stable; - } - } + --group-stream-mask-bg: var(--Colors-Use-Neutral-Bg); } } .container-focus { border-color: var(--Colors-Use-Main-Hover); background: var(--Colors-Use-Neutral-Bg); - .stream-text { - .ai-mask { - background: linear-gradient( - to right, - var(--Colors-Use-Neutral-Bg) 0%, - rgba(255, 255, 255, 0) 10%, - rgba(255, 255, 255, 0) 90%, - transparent 100% - ); - } - } - .content { - &:not(.noMask)::before { - background: initial; - } - &:not(.noMask)::after { - background: initial; - } - } + // focus 时取消遮罩:变量置透明,子模块渐变自然透明,等效原 background: initial + --group-stream-mask-bg: transparent; } .stream-text { overflow: hidden; @@ -221,9 +64,7 @@ letter-spacing: 0.5px; } } -.stream-text-hidden { - display: none; -} + .single-stream-text { border-left: 2px solid var(--Colors-Use-Neutral-Bg-Hover); margin: 0; diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx index 158c4365cc..1bca0f5468 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard.tsx @@ -1,16 +1,17 @@ -import { ChatReferenceMaterialPayload, ReActChatElement } from '@/pages/ai-re-act/hooks/aiRender' -import { type CSSProperties, useState, type FC, useRef, useEffect, useMemo } from 'react' +import { AIChatQSDataTypeEnum, ChatReferenceMaterialPayload, ChatStream } from '@/pages/ai-re-act/hooks/aiRender' +import { type CSSProperties, useState, type FC, useRef, useEffect, useMemo, memo } from 'react' import styles from './AIGroupStreamCard.module.scss' -import useAINodeLabel from '@/pages/ai-re-act/hooks/useAINodeLabel' -import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import { OutlineArrowsexpandIcon, OutlineChevronsDownUpIcon, OutlineChevronsUpDownIcon } from '@/assets/icon/outline' -import { YakitPopover } from '@/components/yakitUI/YakitPopover/YakitPopover' -import { YakitModal } from '@/components/yakitUI/YakitModal/YakitModal' -import { useTypedStream } from '../aiChatListItem/StreamingChatContent/hooks/useTypedStream' import classNames from 'classnames' -import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import useClickFocus from '../../../ai-re-act/hooks/useClickFocus' -import { Tooltip } from 'antd' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import useCreation from 'ahooks/lib/useCreation' +import { useStore } from 'zustand' +import { AIGroupStreamCardHeardWrapperProps, AIGroupStreamCardListWrapperProps } from './type' +import useAINodeLabel from '@/pages/ai-re-act/hooks/useAINodeLabel' +import AIGroupStreamCardHeard from './aiGroupStreamCardHeard/AIGroupStreamCardHeard' +import AIGroupStreamCardList from './aiGroupStreamCardList/AIGroupStreamCardList' +import { useTypedStream } from '../aiChatListItem/StreamingChatContent/hooks/useTypedStream' +import { AIReferenceNode } from '@/pages/ai-re-act/aiReActChatContents/AIReActChatContents' export const Code: FC<{ code: ChatReferenceMaterialPayload; style: CSSProperties }> = ({ code, style }) => { return ( @@ -22,237 +23,171 @@ export const Code: FC<{ code: ChatReferenceMaterialPayload; style: CSSProperties ) } -const AIStreamNode: FC<{ - chatType: ReActChatElement['chatType'] - token: string - index: number - session: string - nodeLabel?: string -}> = ({ chatType, token, index, session, nodeLabel }) => { - const { t } = useI18nNamespaces(['aiAgent']) - const { stream } = useTypedStream({ chatType, token, session }) - const [open, setOpen] = useState(false) - const [openPopover, setOpenPopover] = useState(false) +export const AIGroupStreamNode: FC<{ + itemData: ChatStream + renderNum: number + seqNo: string + sessionId: string +}> = memo(({ itemData, renderNum, seqNo, sessionId }) => { + // 仅获取用于显示的 content(已应用打字效果) + const { content } = useTypedStream({ + getContent: () => itemData.data.content, + getStatus: () => itemData.data.status, + }) + + const { getLabelByParams } = useAINodeLabel() + + const nodeLabel = useCreation(() => { + if (!itemData) return + switch (itemData.type) { + case AIChatQSDataTypeEnum.STREAM: + return getLabelByParams(itemData.data?.NodeIdVerbose) + + default: + return '' + } + }, []) - const onClose = () => { - setOpen(false) - } + const hidden = useCreation(() => { + return !itemData?.reference?.length + }, [renderNum, itemData?.reference?.length]) - if (!stream) return null return (
- - - - {index}. {stream.data.content} - -
- {index}. {nodeLabel} - { - setOpenPopover(false) - setOpen(true) - }} - type="text2" - icon={} - size="small" - /> -
- {!!stream.reference && } -
- } - > - - + {seqNo} + {content} + {!hidden && ( + + )}
) -} +}) -const BOTTOM_THRESHOLD = 10 -const STREAM_MASK_THRESHOLD = 170 +export const STREAM_MASK_THRESHOLD = 170 const AIGroupStreamCard: FC<{ - elements: ReActChatElement[] - hasNext?: boolean - session: string -}> = ({ elements, hasNext, session }) => { - const lastElement = elements[elements.length - 1] - const { stream } = useTypedStream({ chatType: lastElement?.chatType, token: lastElement?.token ?? '', session }) - const { nodeLabel } = useAINodeLabel(stream?.data.NodeIdVerbose) - const [expand, setExpand] = useState(true) - const content = stream?.data.content || '' - const shouldShowMask = useMemo(() => content.length > STREAM_MASK_THRESHOLD, [content]) - const contentRef = useRef(null) + token: string +}> = memo(({ token }) => { const { ref: containerRef, isFocus } = useClickFocus() + const [expand, setExpand] = useState(true) - const [isScroll, setIsScroll] = useState(false) - const allowAutoScrollRef = useRef(true) - - // 点击其他地方取消滚动 - useEffect(() => { - const handleClickOutside = (event: MouseEvent): void => { - const target = event.target as Node | null - if (!contentRef.current) return - if (!target) return + return ( +
+ + +
+ ) +}) +export default AIGroupStreamCard - if (!contentRef.current.contains(target)) { - setIsScroll(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) +const AIGroupStreamCardListWrapper: React.FC = memo((props) => { + const { expand, token } = props + const store = useCurrentStore() + const childrenTokens = useStore(store, (state) => state.groups[token]?.childrenTokens || []) + return +}) +const AIGroupStreamCardHeardWrapper: React.FC = memo((props) => { + const { expand, setExpand, token } = props + + const { getLabelByParams } = useAINodeLabel() + + const perHasNext = useRef(true) + + const store = useCurrentStore() + const rawData = useCurrentRawData() + const chatLength = useStore(store, (state) => state.casualChat.elements.length) + const taskChatLength = useStore(store, (state) => state.taskChat.elements.length) + const renderNum = useStore(store, (state) => state.groups[token]?.renderNum) + const childrenTokensLength = useStore(store, (state) => state.groups[token]?.childrenTokens.length || 0) + + /** 可能存在第一次拿到的数据为undefined */ + const groupData = useCreation(() => { + return rawData.contents.get(token) + }, [renderNum]) + + const lastToken = useCreation(() => { + if (!groupData) return '' + switch (groupData.type) { + case AIChatQSDataTypeEnum.STREAM_GROUP: + return groupData.data.lastToken + + default: + return '' } - }, []) + }, [renderNum]) - // 有滚动条的时候自动滚动到底部 - useEffect(() => { - const el = contentRef.current - if (!el || !expand) return + const lastItemRenderNum = useStore(store, (state) => state.items[lastToken]?.renderNum) - const onScroll = (): void => { - const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight - allowAutoScrollRef.current = distanceToBottom <= BOTTOM_THRESHOLD + const isLastActiveGroup = useCreation(() => { + if (expand === false) return false + if (perHasNext.current === false) return false + if (groupData?.chatType === 'reAct') { + perHasNext.current = store.getState().casualChat.elements[chatLength - 1]?.token === token + return perHasNext.current } - - el.addEventListener('scroll', onScroll, { passive: true }) - return () => el.removeEventListener('scroll', onScroll) - }, [expand]) - - useEffect(() => { - if (!expand) return - allowAutoScrollRef.current = true - }, [expand]) - - // 无滚动条的时候自动滚动到底部 - useEffect(() => { - const el = contentRef.current - if (!el || !expand) return - if (!allowAutoScrollRef.current) return - if (hasNext) return - requestAnimationFrame(() => { - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) - }) - let rafId = 0 - const observer = new ResizeObserver(() => { - if (!allowAutoScrollRef.current) return - cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(() => { - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) - }) - }) - observer.observe(el) - - return () => { - cancelAnimationFrame(rafId) - observer.disconnect() + perHasNext.current = store.getState().taskChat.elements[taskChatLength - 1]?.token === token + return perHasNext.current + }, [chatLength, taskChatLength, groupData?.chatType]) + + const nodeLabel = useCreation(() => { + if (!groupData) return '' + switch (groupData.type) { + case AIChatQSDataTypeEnum.STREAM_GROUP: + return getLabelByParams(groupData.data?.NodeIdVerbose) + + default: + return '' } - }, [elements.length, expand, hasNext]) + }, [renderNum]) useEffect(() => { - if (hasNext) { + if (isLastActiveGroup) { setExpand(false) } - }, [hasNext]) - if (!stream) return null - const collapseTooltip = !expand && elements.length > 1 ? `折叠${elements.length}条信息` : '' + }, [isLastActiveGroup]) + + const shouldShowMask = useMemo(() => { + const lastItem = rawData.contents.get(lastToken) + if (!lastItem) return false + switch (lastItem.type) { + case AIChatQSDataTypeEnum.STREAM: + const contentLength = lastItem.data?.content?.length || 0 + return contentLength > STREAM_MASK_THRESHOLD + + default: + return false + } + }, [lastToken, lastItemRenderNum]) + + const lastItem = useCreation(() => { + const lastItem = rawData.contents.get(lastToken) + if (!lastItem) return undefined + switch (lastItem.type) { + case AIChatQSDataTypeEnum.STREAM: + return lastItem + + default: + return undefined + } + }, [lastItemRenderNum]) return ( - -
-
{ - setExpand(!expand) - }} - > -
- {/* */} - {nodeLabel} -
-
- {shouldShowMask &&
} -

- {content} -

-
- - } - className={classNames(styles['expand-btn'], { - [styles['hidden-expand-btn']]: expand, - })} - /> - - - } - className={classNames(styles['expand-btn'], { - [styles['hidden-expand-btn']]: !expand, - })} - /> - -
-
-
setIsScroll(true)} - className={styles['content-inner']} - style={{ - overflow: isScroll ? 'overlay' : 'hidden', - }} - > - {elements.map((el, index) => ( - - ))} -
-
-
- + ) -} -export default AIGroupStreamCard +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard.module.scss new file mode 100644 index 0000000000..82c4d124a5 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard.module.scss @@ -0,0 +1,79 @@ +.title { + display: flex; + align-items: center; + cursor: pointer; + gap: 2px; + .title-node-label { + margin-right: 8px; + display: flex; + align-items: center; + gap: 2px; + color: var(--Colors-Use-Neutral-Text-3-Secondary); + font-size: 11px; + font-weight: 500; + line-height: 14px; + .brain-icon { + width: 16px; + height: 16px; + color: var(--Colors-Use-Neutral-Disable); + svg { + width: 16px; + height: 16px; + } + } + } + & > :last-child { + color: var(--Colors-Use-Neutral-Text-3-Secondary); + } + .expand-btn { + color: var(--Colors-Use-Neutral-Text-3-Secondary); + } + .hidden-expand-btn { + display: none; + } + .stream-text { + overflow: hidden; + text-align: left; + position: relative; + flex: 1; + width: 0; + color: var(--Colors-Use-Neutral-Text-1-Title); + font-size: 14px; + line-height: 20px; + display: flex; + align-items: center; + + .ai-mask { + width: 100%; + height: 100%; + position: absolute; + top: 0; + background: linear-gradient( + to right, + var(--group-stream-mask-bg, var(--Colors-Use-Basic-Background)) 0%, + rgba(255, 255, 255, 0) 10%, + rgba(255, 255, 255, 0) 90%, + transparent 100% + ); + } + p { + margin: 0; + max-width: 100%; + } + span { + display: flex; + white-space: nowrap; + font-weight: 400; + justify-content: flex-end; + color: var(--Colors-Use-Neutral-Text-4-Help-text); + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 14px; + letter-spacing: 0.5px; + } + } + .stream-text-hidden { + display: none; + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard.tsx new file mode 100644 index 0000000000..48a9a59648 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/AIGroupStreamCardHeard.tsx @@ -0,0 +1,65 @@ +import { OutlineChevronsUpDownIcon, OutlineChevronsDownUpIcon } from '@/assets/icon/outline' +import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' +import { Tooltip } from 'antd' +import classNames from 'classnames' +import { memo } from 'react' +import { useTypedStream } from '../../aiChatListItem/StreamingChatContent/hooks/useTypedStream' +import { AIGroupStreamCardHeardProps } from '../type' +import styles from './AIGroupStreamCardHeard.module.scss' +import useCreation from 'ahooks/lib/useCreation' + +const AIGroupStreamCardHeard: React.FC = memo((props) => { + const { expand, setExpand, lastItem, nodeLabel, shouldShowMask, childrenTokensLength } = props + + const { content } = useTypedStream({ + getContent: () => lastItem?.data.content ?? '', + getStatus: () => lastItem?.data.status ?? 'end', + }) + const collapseTooltip = useCreation(() => { + return !expand && childrenTokensLength > 1 ? `折叠${childrenTokensLength}条信息` : '' + }, [expand, childrenTokensLength]) + return ( + +
{ + setExpand(!expand) + }} + > +
{nodeLabel}
+
+ {shouldShowMask &&
} +

+ {content} +

+
+ + } + className={classNames(styles['expand-btn'], { + [styles['hidden-expand-btn']]: expand, + })} + /> + + + } + className={classNames(styles['expand-btn'], { + [styles['hidden-expand-btn']]: !expand, + })} + /> + +
+ + ) +}) + +export default AIGroupStreamCardHeard diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardHeard/type.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList.module.scss new file mode 100644 index 0000000000..4e693a214f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList.module.scss @@ -0,0 +1,66 @@ +.content { + cursor: pointer; + display: grid; + grid-template-rows: 0fr; + // transition: grid-template-rows 0.3s ease-in-out; + // overflow-y: overlay; + max-height: 156px; + position: relative; + overflow: hidden; + + &:not(.noMask)::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 30%; + background: linear-gradient( + 180deg, + var(--group-stream-mask-bg, var(--Colors-Use-Basic-Background)) 0%, + var(--group-stream-mask-bg, var(--Colors-Use-Basic-Background)) 4%, + rgba(255, 255, 255, 0) 96% + ); + pointer-events: none; + z-index: 1; + } + + &:not(.noMask)::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 30%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 4%, + var(--group-stream-mask-bg, var(--Colors-Use-Basic-Background)) 96%, + var(--group-stream-mask-bg, var(--Colors-Use-Basic-Background)) 100% + ); + pointer-events: none; + z-index: 1; + } + + &-inner { + height: 100%; + width: 100%; + min-height: 0; + flex-direction: column; + padding-top: 0; + // transition: padding-top 0.3s ease-in-out; + > * { + color: var(--Colors-Use-Neutral-Text-4-Help-text); + } + } + &.expand { + border-radius: 8px; + grid-template-rows: 1fr; + .content-inner { + font-size: 12px; + font-weight: 400; + letter-spacing: 0.5px; + scrollbar-gutter: stable; + } + } +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList.tsx new file mode 100644 index 0000000000..1a1a21dc34 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/AIGroupStreamCardList.tsx @@ -0,0 +1,88 @@ +import { useClickAway, useThrottleFn } from 'ahooks' +import classNames from 'classnames' +import { memo, useRef, useState, useEffect } from 'react' +import StaticChatContent from '../../aiChatListItem/StaticChatContent/StaticChatContent' +import { AIGroupStreamCardListProps } from '../type' +import styles from './AIGroupStreamCardList.module.scss' + +const BOTTOM_THRESHOLD = 10 + +const AIGroupStreamCardList: React.FC = memo((props) => { + const { expand, childrenTokens, rendItem } = props + + const contentRef = useRef(null) + const [isScroll, setIsScroll] = useState(false) + + const allowAutoScrollRef = useRef(true) + + useClickAway(() => { + if (isScroll) setIsScroll(false) + }, contentRef) + + /** 监听当前容器得滚动条是否在底部 */ + useEffect(() => { + const el = contentRef.current + if (!el || !expand) return + allowAutoScrollRef.current = true + el.addEventListener('scroll', onScroll, { passive: true }) + return () => el.removeEventListener('scroll', onScroll) + }, [expand]) + + const onScroll = useThrottleFn( + () => { + const el = contentRef.current + if (!el || !expand) return + const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight + allowAutoScrollRef.current = distanceToBottom <= BOTTOM_THRESHOLD + }, + { wait: 500 }, + ).run + + /** 展开得情况下,数据发生变化,滚动到底部 */ + useEffect(() => { + const el = contentRef.current + if (!el || !expand) return + if (!allowAutoScrollRef.current) return + requestAnimationFrame(() => { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + }) + let rafId = 0 + const observer = new ResizeObserver(() => { + if (!allowAutoScrollRef.current) return + cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + }) + }) + observer.observe(el) + + return () => { + cancelAnimationFrame(rafId) + observer.disconnect() + } + }, [childrenTokens.length, expand]) + + return ( +
+
setIsScroll(true)} + className={styles['content-inner']} + style={{ + overflow: isScroll ? 'overlay' : 'hidden', + }} + > + {childrenTokens.map((token, index) => + !!rendItem ? rendItem(token, index) : , + )} +
+
+ ) +}) + +export default AIGroupStreamCardList diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/aiGroupStreamCardList/type.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/type.ts new file mode 100644 index 0000000000..c0b1282089 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiGroupStreamCard/type.ts @@ -0,0 +1,28 @@ +import { ChatStream } from '@/pages/ai-re-act/hooks/aiRender' +import { Dispatch, ReactNode, SetStateAction } from 'react' + +export interface AIGroupStreamCardHeardProps { + expand: boolean + setExpand: Dispatch> + lastItem?: ChatStream + nodeLabel: string + shouldShowMask: boolean + childrenTokensLength: number +} + +export interface AIGroupStreamCardHeardWrapperProps { + expand: boolean + setExpand: Dispatch> + token: string +} + +export interface AIGroupStreamCardListWrapperProps { + expand: boolean + token: string +} + +export interface AIGroupStreamCardListProps { + expand: boolean + childrenTokens: string[] + rendItem?: (token: string, index: number) => ReactNode +} diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard.tsx index 3394997952..224a714d01 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/AIHttpFlowFuzzStatusCard.tsx @@ -1,6 +1,6 @@ import React, { memo, useLayoutEffect, useMemo, useRef, useState } from 'react' import classNames from 'classnames' -import { useMemoizedFn } from 'ahooks' +import { useCreation, useMemoizedFn } from 'ahooks' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import type { AIHttpFlowFuzzStatusCardProps } from './type' @@ -9,8 +9,6 @@ import { Tooltip } from 'antd' import YakitSolidLoading from '@/components/yakitUI/YakitSolidLoading/YakitSolidLoading' import ChatCard from '../ChatCard' import ModalInfo, { type ModalInfoProps } from '../ModelInfo' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { WebFuzzerAiStore } from '@/pages/ai-agent/store/ChatDataStore' import { hasWebFuzzerPageOnAIFuzzStatus, pushAIFuzzStatusRuntimeIdToWebFuzzerPage, @@ -18,6 +16,8 @@ import { import emiter from '@/utils/eventBus/eventBus' import { YakitRoute } from '@/enums/yakitRoute' import { yakitNotify } from '@/utils/notification' +import { usePageInfo } from '@/store/pageInfo' +import { shallow } from 'zustand/shallow' const STATS_TILE_PX = 91 const STATS_GAP_PX = 4 @@ -25,7 +25,7 @@ const STATS_GAP_PX = 4 const STATS_ROW_4_MIN_INNER_PX = 4 * STATS_TILE_PX + 3 * STATS_GAP_PX export const AIHttpFlowFuzzStatusCard: React.FC = memo((props) => { - const { item } = props + const { item, renderNum, isChildWindow } = props const { data, Timestamp, AIService, AIModelName } = item const { t } = useI18nNamespaces(['aiAgent']) @@ -35,29 +35,45 @@ export const AIHttpFlowFuzzStatusCard: React.FC = title: AIModelName, icon: AIService, }), - [Timestamp, AIModelName, AIService], + [], ) - const p = data.progress - const total = p?.total_requests ?? 0 - const ok = p?.successful_responses ?? 0 - const fail = p?.failed_requests ?? 0 - const avgMs = p?.average_response_ms + const total = useCreation(() => { + return data.progress?.total_requests ?? 0 + }, [renderNum]) - const { chatIPCEvents } = useChatIPCDispatcher() + const ok = useCreation(() => { + return data.progress?.successful_responses ?? 0 + }, [renderNum]) + const fail = useCreation(() => { + return data.progress?.failed_requests ?? 0 + }, [renderNum]) + + const avgMs = useCreation(() => { + return data.progress?.average_response_ms ?? 0 + }, [renderNum]) + + const { getCurrentSelectPageId, currentPageTabRouteKey } = usePageInfo( + (s) => ({ + getCurrentSelectPageId: s.getCurrentSelectPageId, + currentPageTabRouteKey: s.currentPageTabRouteKey, + }), + shallow, + ) // 「查看详情」点击: // - 若卡片所在的会话绑定了某个 Web Fuzzer 页签(`WebFuzzerAiStore`), // 则把本卡片的 `runtime_id` 显式推送到该页签,并打开 traffic analysis 抽屉。 // - 否则回退到全局打开「流量分析」路由页,并通过 `pageInfo` 携带本卡片的 `runtime_id`。 const handleViewDetail = useMemoizedFn(() => { + if (isChildWindow) return const runtimeId = data?.runtime_id if (!runtimeId) { yakitNotify('error', '该发包统计缺少 runtime_id,无法查看详情') return } - const store = chatIPCEvents.fetchChatDataStore() - const fuzzerPageId = store instanceof WebFuzzerAiStore ? store.fuzzerPageId : '' + const fuzzerPageId = + currentPageTabRouteKey === YakitRoute.WebsocketFuzzer ? getCurrentSelectPageId(currentPageTabRouteKey) : '' if (fuzzerPageId && hasWebFuzzerPageOnAIFuzzStatus(fuzzerPageId)) { // Web Fuzzer 页内:直接把本卡片的 runtime_id 推过去; // 当右侧无本地发包响应时空状态会切换为 history 表,已可见的 history 表则会按新 key 重新加载。 @@ -119,9 +135,11 @@ export const AIHttpFlowFuzzStatusCard: React.FC =
{data?.reason ?? t('AIHttpFlowFuzzStatusCard.title')}
- - {t('AIHttpFlowFuzzStatusCard.viewDetail')} - + {!isChildWindow && ( + + {t('AIHttpFlowFuzzStatusCard.viewDetail')} + + )}
diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/type.ts index 0e0ef4c6d5..7111f04c3c 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiHttpFlowFuzzStatusCard/type.ts @@ -2,4 +2,6 @@ import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' export interface AIHttpFlowFuzzStatusCardProps { item: Extract + renderNum: number + isChildWindow: boolean } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiManualIntervention/AIManualIntervention.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiManualIntervention/AIManualIntervention.tsx index 57719f5fae..c5cfa4581c 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiManualIntervention/AIManualIntervention.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiManualIntervention/AIManualIntervention.tsx @@ -9,7 +9,7 @@ import ChatCard from '../ChatCard' import { formatTimestamp } from '@/utils/timeUtil' export const AIManualIntervention: React.FC = memo((props) => { - const { info, timestamp } = props + const { info, renderNum } = props const { t } = useI18nNamespaces(['aiAgent']) const containerRef = useRef(null) @@ -19,6 +19,9 @@ export const AIManualIntervention: React.FC = memo((p setIsScroll(false) }, containerRef) + const timestamp = useCreation(() => { + return info.Timestamp + }, [renderNum]) const data: UserManualInterventionContext = useCreation(() => { if (info.type === AIChatQSDataTypeEnum.USER_MANUAL_INTERVENTION) { return info.data @@ -27,7 +30,7 @@ export const AIManualIntervention: React.FC = memo((p type: '', content: '', } - }, [info.data, info.type]) + }, [renderNum]) return ( + renderNum: number } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/AICustomFile.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/AICustomFile.tsx index 1d425e3067..01e91c8d24 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/AICustomFile.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/AICustomFile.tsx @@ -8,13 +8,11 @@ import classNames from 'classnames' import { yakitNotify } from '@/utils/notification' import { randomString } from '@/utils/randomUtil' import { Progress } from 'antd' -import useGetChatDataStoreKey from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' const { ipcRenderer } = window.require('electron') export const AICustomFile: React.FC = React.memo((props) => { - const { sessionId, chatDataStoreKey: chatDataStoreKeyProp } = props - const { chatDataStoreKey: chatDataStoreKeyFromStore } = useGetChatDataStoreKey() + const { sessionId, chatDataStoreKey } = props const { node, contentRef, view, selected, setAttrs } = useNodeViewContext() const [showSrc, setShowSrc] = useState('') @@ -58,13 +56,6 @@ export const AICustomFile: React.FC = React.memo((props) => { } }, []) - const resolveChatDataStoreKey = useMemoizedFn(() => { - if (chatDataStoreKeyFromStore && chatDataStoreKeyFromStore !== 'unknown') { - return chatDataStoreKeyFromStore - } - return chatDataStoreKeyProp || '' - }) - const onSaveLocal = useMemoizedFn(async (blobUrl: string) => { setShowSrc(blobUrl) try { @@ -79,7 +70,6 @@ export const AICustomFile: React.FC = React.memo((props) => { setProgress(0) const filename = `image_${Date.now()}.${suffix}` setAttrs({ alt: filename }) - const chatDataStoreKey = resolveChatDataStoreKey() if (!chatDataStoreKey) { yakitNotify('error', '图片保存失败: 无法识别当前 AI 存储路径') return diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/hooks/useDeleteAIImageByNode.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/hooks/useDeleteAIImageByNode.ts index 5788b5a8d1..359e7a5308 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/hooks/useDeleteAIImageByNode.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/aiCustomFile/hooks/useDeleteAIImageByNode.ts @@ -17,39 +17,53 @@ export interface AIClearImageParams extends Omit void } +export interface ClearAIImageEvents { + onData?: (progress: number) => void + onError?: () => void + onFinish?: () => void +} +export const handleClearAIImage = (params: AIClearImageParams, events?: ClearAIImageEvents) => { + const token = randomString(8) + + // 提取公共的清除监听器方法,避免内存泄漏 + const removeListeners = () => { + ipcRenderer.removeAllListeners(`delete-ai-image-progress-${token}`) + ipcRenderer.removeAllListeners(`delete-ai-image-finish-${token}`) + ipcRenderer.removeAllListeners(`delete-ai-image-err-${token}`) + } + ipcRenderer.on(`delete-ai-image-progress-${token}`, (e, progress: number) => { + events?.onData?.(progress) + }) + ipcRenderer.on(`delete-ai-image-err-${token}`, (e, err) => { + events?.onError?.() + }) + ipcRenderer.on(`delete-ai-image-finish-${token}`, (e) => { + events?.onFinish?.() + removeListeners() + }) + + const newParams: DeleteAIImageByNodeRequest = { + ...params, + token, + } + deleteAIImageByNode(newParams) +} + function useDeleteAIImageByNode( params?: UseDeleteAIImageByNodeParams, ): [UseDeleteAIImageByNodeState, UseDeleteAIImageByNodeEvents] - function useDeleteAIImageByNode(params?: UseDeleteAIImageByNodeParams) { const { isShowProgress, onFinish, onError } = params || {} const [progress, setProgress] = useState(0) const onClearImage = useMemoizedFn((params: AIClearImageParams) => { - const token = randomString(8) - - // 提取公共的清除监听器方法,避免内存泄漏 - const removeListeners = () => { - ipcRenderer.removeAllListeners(`delete-ai-image-progress-${token}`) - ipcRenderer.removeAllListeners(`delete-ai-image-finish-${token}`) - ipcRenderer.removeAllListeners(`delete-ai-image-err-${token}`) - } - ipcRenderer.on(`delete-ai-image-progress-${token}`, (e, progress: number) => { - if (isShowProgress) setProgress(progress) + handleClearAIImage(params, { + onData: (progress) => { + if (isShowProgress) setProgress(progress) + }, + onFinish: () => onFinish?.(), + onError: () => onError?.(), }) - ipcRenderer.on(`delete-ai-image-err-${token}`, (e, err) => { - onError?.() - }) - ipcRenderer.on(`delete-ai-image-finish-${token}`, (e) => { - onFinish?.() - removeListeners() - }) - - const newParams: DeleteAIImageByNodeRequest = { - ...params, - token, - } - deleteAIImageByNode(newParams) }) const state: UseDeleteAIImageByNodeState = useCreation(() => { return { diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts index 36ff93cc7a..34335bcb79 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiMilkdownInput/utils.ts @@ -3,8 +3,8 @@ import { editorViewCtx, parserCtx } from '@milkdown/kit/core' import { AIMentionCommandParams, aiMentionCustomId } from './aiMilkdownMention/aiMentionPlugin' import { AIHttpFlowCommandParams, aiHttpFlowCustomId } from './aiMilkdownHttpFlow/aiHttpFlowPlugin' import { AICodeBlockCommandParams, aiCodeBlockCustomId } from './aiCodeBlock/aiCustomCodeBlockPlugin' -import { AIChatIPCStartParams } from '@/pages/ai-re-act/hooks/type' import { imgTypes } from '@/components/MilkdownEditor/utils/utils' +import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' /**md编辑器中匹配出提及相关数据/纯文本 */ export const extractDataWithMilkdown = (editor: EditorMilkdownProps) => { @@ -122,7 +122,7 @@ export const setEditorValue = (editor: EditorMilkdownProps, value: string) => { export interface AIInputWithParamsTemplate { description: string - param: AIChatIPCStartParams['extraValue'] + param: AIChatQSData['extraValue'] } /** * diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReview.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReview.tsx index 2cf0b9fb53..38322e8fb3 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReview.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReview.tsx @@ -1,4 +1,4 @@ -import React, { type FC, forwardRef, ReactNode, useEffect, useImperativeHandle, useRef, useState } from 'react' +import React, { forwardRef, ReactNode, useEffect, useImperativeHandle, useRef, useState } from 'react' import { AIReActChatReviewProps, ForgeReviewFormProps, ForgeReviewFormRefProps } from './AIReActChatReviewType' import { OutlineArrowrightIcon, OutlineQuestionmarkcircleIcon, OutlineXIcon } from '@/assets/icon/outline' import { useCountDown, useCreation, useMemoizedFn, useUpdateEffect } from 'ahooks' @@ -10,7 +10,7 @@ import cloneDeep from 'lodash/cloneDeep' import isEqual from 'lodash/isEqual' import { YakitPopover } from '@/components/yakitUI/YakitPopover/YakitPopover' import AIPlanReviewTree from '@/pages/ai-agent/aiPlanReviewTree/AIPlanReviewTree' -import { handleFlatAITree } from '../../../ai-re-act/hooks/utils' +import { genExecTasks } from '../../../ai-re-act/hooks/utils' import { reviewListToTrees } from '@/pages/ai-agent/utils' import { grpcGetAIForge } from '@/pages/ai-agent/grpc' import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' @@ -18,64 +18,56 @@ import { YakParamProps } from '@/pages/plugins/pluginsType' import { ExecuteEnterNodeByPluginParams } from '@/pages/plugins/operator/localPluginExecuteDetailHeard/LocalPluginExecuteDetailHeard' import { CustomPluginExecuteFormValue } from '@/pages/plugins/operator/localPluginExecuteDetailHeard/LocalPluginExecuteDetailHeardType' import { getValueByType } from '@/pages/plugins/editDetails/utils' -import { AIAgentGrpcApi, AIInputEventSyncTypeEnum } from '../../../ai-re-act/hooks/grpcApi' +import { AIAgentGrpcApi, AIInputEventSyncTypeEnum, AIInputEvent, AITaskStatus } from '../../../ai-re-act/hooks/grpcApi' import classNames from 'classnames' import styles from './AIReActChatReview.module.scss' -import { AIChatIPCSendParams } from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' import { OutlineHandleColorsIcon, ColorsOutlineWarpIcon } from '@/assets/icon/colors' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' -import { - AIChatQSData, - AIChatQSDataTypeEnum, - AIReviewType, - AITaskInfoProps, - UIDetachedPlanReview, -} from '../../../ai-re-act/hooks/aiRender' +import { AIChatQSDataTypeEnum, AIReviewType, AITaskInfoProps } from '../../../ai-re-act/hooks/aiRender' import { AIForge } from '@/pages/ai-agent/type/forge' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useAIAgentDispatcher from '../../useContext/useDispatcher' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' import { randomString } from '@/utils/randomUtil' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' export const AIReActChatReview: React.FC = React.memo((props) => { - const { - info, - onSendAI, - onSendSyncMessage, - planReviewTreeKeywordsMap, - isEmbedded, - renderFooterExtra, - expand, - className, - } = props - const { type, data: review } = info + const { info, planReviewTreeKeywordsMap, isEmbedded, renderFooterExtra, expand, className, chatType, renderNum } = + props const { t, i18nRefresh } = useI18nNamespaces(['aiAgent', 'yakitUi']) - const { chatIPCData } = useChatIPCStore() - const { chatIPCEvents } = useChatIPCDispatcher() + + const { onSend } = useAIAgentDispatcher() + + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + const taskStatusRunning = useStore(store, (state) => state.taskStatus.status === AITaskStatus.inProgress) + const [reviewTreeOption, setReviewTreeOption] = useState() const [reviewTrees, setReviewTrees] = useState([]) const [currentPlansId, setCurrentPlansId] = useState('') const [forgeOption, setForgeOption] = useState() - const forgeReviewFormRef = useRef({ validateFields: () => {} }) + const forgeReviewFormRef = useRef({ validateFields: () => new Promise(() => {}) }) const initReviewTreesRef = useRef([]) - /** 取消当前任务后,等待 taskStatus.loading 置为 false 再提交 detached plan */ + /** 取消当前任务后,等待 status 离开 processing 再提交 detached plan */ const pendingDetachedPlanSubmitRef = useRef(false) useEffect(() => { pendingDetachedPlanSubmitRef.current = false - switch (type) { + switch (info.type) { case 'plan_review_require': case 'detached_plan_require': - const data = review as AIAgentGrpcApi.PlanReviewRequire - const list: AITaskInfoProps[] = [] - handleFlatAITree(list, data.plans.root_task) + const data = info.data as AIAgentGrpcApi.PlanReviewRequire + const list = genExecTasks(data.plans.root_task) initReviewTreesRef.current = [...list] setReviewTrees(list) setCurrentPlansId(data.plans_id) break - case 'require_user_interactive': - const { options } = review as AIAgentGrpcApi.AIReviewRequire + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + const { options } = info.data if (options && options.length > 0) { const value = options[0].prompt || options[0].prompt_title setRequireQS(value ? `${value}:` : '') @@ -85,19 +77,25 @@ export const AIReActChatReview: React.FC = React.memo((p default: break } - }, [type, review]) + }, [renderNum]) //#region ai评分 const [targetDate, setTargetDate] = useState() const [countdown] = useCountDown({ targetDate, }) useEffect(() => { - if (!chatIPCData.execute) return - const data = review as AIAgentGrpcApi.ToolUseReviewRequire - if (!!data?.aiReview?.seconds) { - setTargetDate(Date.now() + data.aiReview.seconds * 1000) + if (!execute) return + switch (info.type) { + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + const data = info.data + if (!!data?.aiReview?.seconds) { + setTargetDate(Date.now() + data.aiReview.seconds * 1000) + } + break + default: + break } - }, [review, chatIPCData.execute]) + }, [renderNum, execute]) //#endregion const reviewTitle = useCreation(() => { const subTitle = !!countdown ? ( @@ -109,7 +107,7 @@ export const AIReActChatReview: React.FC = React.memo((p <> ) let title = t('AIReActChatReview.error') - switch (type) { + switch (info.type) { case 'tool_use_review_require': title = t('AIReActChatReview.toolCall') break @@ -130,10 +128,11 @@ export const AIReActChatReview: React.FC = React.memo((p break } return { title: {title}, subTitle } - }, [type, countdown, i18nRefresh]) + }, [countdown, i18nRefresh]) const toolReview = useCreation(() => { - if (type !== 'tool_use_review_require') return null - const { tool, tool_description, reason, params } = review as AIAgentGrpcApi.ToolUseReviewRequire + if (info.type !== 'tool_use_review_require') return null + + const { tool, tool_description, reason, params } = info.data as AIAgentGrpcApi.ToolUseReviewRequire let paramsValue = '-' try { paramsValue = !!params ? JSON.stringify(params, null, 2) : '-' @@ -164,24 +163,24 @@ export const AIReActChatReview: React.FC = React.memo((p
) - }, [review, i18nRefresh]) + }, [renderNum, i18nRefresh]) const forgeReview = useCreation(() => { - if (type !== 'exec_aiforge_review_require') return null - const data = review as AIAgentGrpcApi.ExecForgeReview + if (info.type !== 'exec_aiforge_review_require') return null + const data = info.data as AIAgentGrpcApi.ExecForgeReview return - }, [review, forgeOption]) + }, [renderNum, forgeOption]) const aiRequireReview = useCreation(() => { - if (type === 'require_user_interactive') { - const data = review as AIAgentGrpcApi.AIReviewRequire + if (info.type === 'require_user_interactive') { + const data = info.data as AIAgentGrpcApi.AIReviewRequire const { prompt } = data return
{prompt}
} return null - }, [review]) + }, [renderNum]) const taskReview = useCreation(() => { - if (type === 'task_review_require') { - const data = review as AIAgentGrpcApi.TaskReviewRequire + if (info.type === 'task_review_require') { + const data = info.data as AIAgentGrpcApi.TaskReviewRequire const { task, short_summary, long_summary } = data return (
@@ -215,7 +214,7 @@ export const AIReActChatReview: React.FC = React.memo((p ) } return null - }, [review, i18nRefresh]) + }, [renderNum, i18nRefresh]) const planReview = useCreation(() => { if (reviewTrees.length > 0) { const list = !!reviewTreeOption ? reviewTrees : initReviewTreesRef.current @@ -251,7 +250,7 @@ export const AIReActChatReview: React.FC = React.memo((p }) const executeDetachedPlan = useMemoizedFn(() => { - const detachedReview = review as AIAgentGrpcApi.DetachedPlanRequire + const detachedReview = info.data as AIAgentGrpcApi.DetachedPlanRequire const syncPayload: { coordinator_id: string; plans?: AIAgentGrpcApi.DetachedPlan } = { coordinator_id: detachedReview.coordinator_id, } @@ -265,34 +264,41 @@ export const AIReActChatReview: React.FC = React.memo((p } } } - onSendSyncMessage?.({ - syncType: AIInputEventSyncTypeEnum.SYNC_EXECUTE_DETACHED_PLAN, - syncID: randomString(8), + const params: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_EXECUTE_DETACHED_PLAN, + SyncID: randomString(8), SyncJsonInput: JSON.stringify(syncPayload), - }) - chatIPCEvents.handleTaskReviewRelease(detachedReview.id) + } + globalSessionEngine.closeChatReview(sessionId, info.id) + onSend({ token: sessionId, type: '', params }) pendingDetachedPlanSubmitRef.current = false }) const submitDetachedPlan = useMemoizedFn(() => { - const taskId = chatIPCEvents.fetchCurrentTaskPlanID()?.taskID - if (chatIPCData.taskStatus.loading && taskId) { + const taskId = store.getState().taskStatus.taskID + if (taskStatusRunning && taskId) { pendingDetachedPlanSubmitRef.current = true - chatIPCEvents.handleCancelLoadingChange('task', true) - onSendSyncMessage?.({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, - SyncJsonInput: JSON.stringify({ task_id: taskId }), + store.getState().updateState({ + cancelTaskLoading: true, }) + const params: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, + SyncID: randomString(8), + SyncJsonInput: JSON.stringify({ task_id: taskId }), + } + onSend({ token: sessionId, type: '', params }) return } executeDetachedPlan() }) useUpdateEffect(() => { - if (!chatIPCData.taskStatus.loading && pendingDetachedPlanSubmitRef.current) { + if (!taskStatusRunning && pendingDetachedPlanSubmitRef.current) { executeDetachedPlan() } - }, [chatIPCData.taskStatus.loading]) + }, [taskStatusRunning]) /** 继续执行 */ const handleContinue = useMemoizedFn(() => { @@ -301,7 +307,7 @@ export const AIReActChatReview: React.FC = React.memo((p return } if (!isContinue) return - const find = ((review as AIAgentGrpcApi.ToolUseReviewRequire)?.selectors || []).find( + const find = ((info.data as AIAgentGrpcApi.ToolUseReviewRequire)?.selectors || []).find( (item) => item.value === 'continue', ) if (!find) return @@ -311,7 +317,7 @@ export const AIReActChatReview: React.FC = React.memo((p }) const noAIOptionsList = useCreation(() => { - const { selectors } = review as AIAgentGrpcApi.ToolUseReviewRequire + const { selectors } = info.data as AIAgentGrpcApi.ToolUseReviewRequire const allowShowInput: AIAgentGrpcApi.ReviewSelector[] = [] const showButton: AIAgentGrpcApi.ReviewSelector[] = [] if ( @@ -321,7 +327,7 @@ export const AIReActChatReview: React.FC = React.memo((p 'detached_plan_require', 'task_review_require', 'exec_aiforge_review_require', - ].includes(type) + ].includes(info.type) ) { selectors ?.filter((item) => item.value !== 'continue') @@ -334,7 +340,7 @@ export const AIReActChatReview: React.FC = React.memo((p }) } return { allowShowInput, showButton } - }, [review]) + }, [renderNum]) const noAIOptionsAllowShowInput = useCreation(() => { return ( @@ -351,28 +357,25 @@ export const AIReActChatReview: React.FC = React.memo((p ) ) }, [noAIOptionsList.allowShowInput]) - const handleShowEdit = useMemoizedFn((info: AIAgentGrpcApi.ReviewSelector) => { - switch (info.value) { + const handleShowEdit = useMemoizedFn((item: AIAgentGrpcApi.ReviewSelector) => { + switch (item.value) { case 'freedom-review': - setReviewTreeOption(info) + setReviewTreeOption(item) break case 'input_params': - setForgeOption(info) + setForgeOption(item) break case 'close': - if (type === AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE) { - chatIPCEvents.handleTaskReviewRelease((review as AIReviewType).id) - return - } + globalSessionEngine.closeChatReview(sessionId, info.id) break default: if (editShow) return - if (!info.allow_extra_prompt) { - const jsonInput: Record = { suggestion: info.value } - onSendAIByValue(JSON.stringify(jsonInput), info.value) + if (!item.allow_extra_prompt) { + const jsonInput: Record = { suggestion: item.value } + onSendAIByValue(JSON.stringify(jsonInput), item.value) return } - editInfo.current = cloneDeep(info) + editInfo.current = cloneDeep(item) setEditShow(true) break } @@ -431,25 +434,25 @@ export const AIReActChatReview: React.FC = React.memo((p } }) const aiOptionsLength = useCreation(() => { - if (type !== 'require_user_interactive') return 0 + if (info.type !== 'require_user_interactive') return 0 try { - const { options } = review as AIAgentGrpcApi.AIReviewRequire + const { options } = info.data as AIAgentGrpcApi.AIReviewRequire if (!options || options.length === 0) return 0 return options.length } catch (error) { return 0 } - }, [review]) + }, [renderNum]) const onSetAIOptionsSelect = useMemoizedFn((value?: string) => { setAIOptionsSelect(value) setRequireQS(value ? `${value}:` : '') }) const aiOptions = useCreation(() => { - if (type !== 'require_user_interactive') { + if (info.type !== 'require_user_interactive') { return null } - const { options } = review as AIAgentGrpcApi.AIReviewRequire + const { options } = info.data as AIAgentGrpcApi.AIReviewRequire return ( <>
@@ -480,28 +483,26 @@ export const AIReActChatReview: React.FC = React.memo((p
) - }, [review, requireQS, aiOptionsSelect, i18nRefresh]) + }, [renderNum, requireQS, aiOptionsSelect, i18nRefresh]) //#endregion // 是否显示继续执行按钮 const isContinue = useCreation(() => { - if (type === 'require_user_interactive') return false + if (info.type === 'require_user_interactive') return false - if (!review) return - const { selectors } = review as AIAgentGrpcApi.ToolUseReviewRequire + if (!info.data) return + const { selectors } = info.data as AIAgentGrpcApi.ToolUseReviewRequire if (!selectors || !Array.isArray(selectors) || selectors.length === 0) return false - const findIndex = (review as AIAgentGrpcApi.ToolUseReviewRequire).selectors.findIndex( - (item) => item.value === 'continue', - ) + const findIndex = selectors.findIndex((item) => item.value === 'continue') return findIndex !== -1 - }, [review, type]) + }, [renderNum]) const onSendAIByValue = useMemoizedFn((value: string, optionValue?: string) => { - const params: AIChatIPCSendParams = { - value, - id: (review as AIReviewType).id, - optionValue, + const params: AIInputEvent = { + IsInteractiveMessage: true, + InteractiveId: (info.data as AIReviewType).id, + InteractiveJSONInput: value, } - onSendAI(params) + onSend({ token: sessionId, type: chatType, params, optionValue }) }) const footerNode = useCreation(() => { const renderFooterRightExtra = () => { @@ -553,7 +554,7 @@ export const AIReActChatReview: React.FC = React.memo((p return (
{isContinue && renderFooterRightExtra()} - {type === 'require_user_interactive' && ( + {info.type === 'require_user_interactive' && ( {t('YakitButton.submitted')} @@ -564,7 +565,6 @@ export const AIReActChatReview: React.FC = React.memo((p isContinue, reviewTreeOption, forgeOption, - type, aiOptionsLength, isRequireQS, requireLoading, @@ -574,11 +574,11 @@ export const AIReActChatReview: React.FC = React.memo((p const reviewHeardExtra = useCreation(() => { let node: ReactNode = <> - switch (type) { + switch (info.type) { case 'tool_use_review_require': case 'exec_aiforge_review_require': /**NOTE 定义问题 */ - const toolReviewData = review as AIAgentGrpcApi.ToolUseReviewRequire + const toolReviewData = info.data as AIAgentGrpcApi.ToolUseReviewRequire if (!!toolReviewData.aiReview) { const { interactive_id, score, level } = toolReviewData.aiReview node = ( @@ -608,7 +608,7 @@ export const AIReActChatReview: React.FC = React.memo((p break } return node - }, [type, review, countdown, i18nRefresh]) + }, [renderNum, countdown, i18nRefresh]) return ( <> diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReviewType.d.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReviewType.d.ts index 529105c16c..9f072fa61f 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReviewType.d.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiReActChatReview/AIReActChatReviewType.d.ts @@ -1,21 +1,20 @@ -import { AIChatIPCSendParams } from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' import { AIChatQSData } from '../../../ai-re-act/hooks/aiRender' import { AIAgentGrpcApi } from '../../../ai-re-act/hooks/grpcApi' -import { ChatIPCContextDispatcher } from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' +import { ChatIPCSendType } from '@/pages/ai-re-act/hooks/type' export interface AIReActChatReviewProps { + chatType: ChatIPCSendType info: AIChatQSData - onSendAI: (params: AIChatIPCSendParams) => void planReviewTreeKeywordsMap?: Map isEmbedded?: boolean renderFooterExtra?: (node: React.ReactNode) => React.ReactNode expand: boolean className?: string - onSendSyncMessage?: ChatIPCContextDispatcher['handleSendSyncMessage'] + renderNum: number } export interface ForgeReviewFormRefProps { - validateFields: Promise + validateFields: () => Promise> } export interface ForgeReviewFormProps extends AIAgentGrpcApi.ExecForgeReview { ref: React.ForwardedRef diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCard.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCard.tsx index 88d7244762..d016af9dba 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCard.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCard.tsx @@ -1,5 +1,5 @@ import React, { memo, useState } from 'react' -import { useMemoizedFn } from 'ahooks' +import { useCreation, useMemoizedFn } from 'ahooks' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { OutlineDocumentIcon, OutlineDownloadIcon } from '@/assets/icon/outline' @@ -18,17 +18,25 @@ import ChatCard from '../ChatCard' const { ipcRenderer } = window.require('electron') export const AIReportFinishCard: React.FC = memo((props) => { - const { item } = props + const { item, renderNum, isChildWindow } = props const { data } = item const { t } = useI18nNamespaces(['aiAgent']) const [downloadLoading, setDownloadLoading] = useState(false) - const reportPath = data.reportPath - const title = data.title - const content = data.content + const reportPath = useCreation(() => { + return data.reportPath + }, [renderNum]) + + const title = useCreation(() => { + return data.title + }, [renderNum]) + + const content = useCreation(() => { + return data.content + }, [renderNum]) const handleOpenReport = useMemoizedFn(() => { - if (!reportPath) return + if (!reportPath || isChildWindow) return if (getCurrentPageTabRouteKey() === YakitRoute.Irify_AI_Code_Audit) { emiter.emit( 'onAiCodeAuditOpenTemporaryFile', @@ -47,7 +55,7 @@ export const AIReportFinishCard: React.FC = memo((props const handleDownloadReport = useMemoizedFn(async () => { try { - if (!reportPath) return + if (!reportPath || isChildWindow) return let code = await getCodeByPath(reportPath) if (!code) { yakitNotify('error', t('AIReportFinishCard.reportContentEmpty')) @@ -77,18 +85,20 @@ export const AIReportFinishCard: React.FC = memo((props - - } onClick={handleOpenReport} /> - - } - onClick={handleDownloadReport} - loading={downloadLoading} - /> -
+ !isChildWindow && ( +
+ + } onClick={handleOpenReport} /> + + } + onClick={handleDownloadReport} + loading={downloadLoading} + /> +
+ ) } > {content && } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCardType.d.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCardType.d.ts index 373c99c83e..ea0d98a534 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCardType.d.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiReportFinishCard/AIReportFinishCardType.d.ts @@ -2,4 +2,6 @@ import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' export interface AIReportFinishCardProps { item: Extract + renderNum: number + isChildWindow: boolean } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/AIReviewResult.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/AIReviewResult.tsx index 83ad691b72..3d466b30aa 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/AIReviewResult.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/AIReviewResult.tsx @@ -9,19 +9,17 @@ import { PreWrapper } from '../ToolInvokerCard' import { Tooltip } from 'antd' import { OutlineChevronsDownUpIcon, OutlineChevronsUpDownIcon } from '@/assets/icon/outline' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' import { CopyComponents } from '@/components/yakitUI/YakitTag/YakitTag' import { setClipboardText } from '@/utils/clipboard' import { success } from '@/utils/notification' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { isEmpty } from 'lodash' import classNames from 'classnames' +import { AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' export const AIReviewResult: React.FC = memo((props) => { - const { info, timestamp } = props + const { info, renderNum, taskLength, casualLength } = props const { t, i18nRefresh } = useI18nNamespaces(['aiAgent', 'yakitUi']) - const { type, data } = info - const { chatIPCData } = useChatIPCStore() const getChatType = useMemoizedFn(() => { return info.chatType @@ -29,13 +27,6 @@ export const AIReviewResult: React.FC = memo((props) => { const [expand, setExpand] = useState(false) - const taskLength = useCreation(() => { - return chatIPCData?.taskChat?.elements?.length - }, [chatIPCData?.taskChat?.elements?.length]) - const casualLength = useCreation(() => { - return chatIPCData?.casualChat?.elements?.length - }, [chatIPCData?.casualChat?.elements?.length]) - const isInit = useRef(true) useUpdateEffect(() => { @@ -51,42 +42,45 @@ export const AIReviewResult: React.FC = memo((props) => { } }, [casualLength]) const title = useCreation(() => { - switch (type) { - case 'plan_review_require': + switch (info.type) { + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: return t('AIReviewResult.planReview') - case 'task_review_require': + case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: return t('AIReviewResult.taskReview') - case 'tool_use_review_require': + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: return t('AIReviewResult.toolReview') - case 'exec_aiforge_review_require': + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: return t('AIReviewResult.appReview') - case 'require_user_interactive': + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: return t('AIReviewResult.userPrompt') default: return t('AIReviewResult.reviewDecision') } - }, [type, i18nRefresh]) + }, [i18nRefresh]) + const userAction = useCreation(() => { let btnText: string = '' let userInput: string = '' try { - switch (type) { - case 'plan_review_require': - case 'task_review_require': - case 'tool_use_review_require': - case 'exec_aiforge_review_require': - const userSelected = JSON.parse(data.selected || '') - if (data.optionValue === 'continue') { + switch (info.type) { + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + const userSelected = JSON.parse(info.data?.selected || '') + if (info.data.optionValue === 'continue') { btnText = t('YakitButton.runNow') } else { - const selectBtn = data.selectors.find((item) => item.value === data.optionValue) + const selectBtn = info.data.selectors.find((item) => item.value === info.data.optionValue) btnText = selectBtn ? selectBtn.prompt : t('AIReviewResult.unknownAction') } userInput = userSelected.extra_prompt || '' break - case 'require_user_interactive': - const aiSelected = JSON.parse(data.selected || '') - const aiSelectType = data.options.find((item) => (item.prompt || item.prompt_title) === data.optionValue) + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + const aiSelected = JSON.parse(info.data.selected || '') + const aiSelectType = info.data.options.find( + (item) => (item.prompt || item.prompt_title) === info.data.optionValue, + ) btnText = aiSelectType?.prompt || aiSelectType?.prompt_title || t('AIReviewResult.unknownAction') userInput = aiSelected.suggestion || '' break @@ -99,12 +93,12 @@ export const AIReviewResult: React.FC = memo((props) => { btnText, userInput, } - }, [type, data, i18nRefresh]) + }, [i18nRefresh]) const renderContent = useMemoizedFn(() => { let paramsValue = !!userAction.userInput ? : null - switch (type) { + switch (info.type) { case 'tool_use_review_require': - const { params } = data + const { params } = info.data try { paramsValue = !!paramsValue ? paramsValue : } catch (error) {} @@ -115,24 +109,26 @@ export const AIReviewResult: React.FC = memo((props) => { return paramsValue }) const isShowExpandBtn = useCreation(() => { - switch (type) { + switch (info.type) { case 'tool_use_review_require': return true default: return !!userAction.userInput } - }, [type, userAction.userInput]) + }, [userAction.userInput]) + const modalInfo = useCreation(() => { + return { + title: info.AIModelName, + time: info.Timestamp, + icon: info.AIService, + } + }, [renderNum]) return ( } title={title} subTitle={userAction.btnText} tip="" - modalInfo={{ - title: info.AIModelName, - time: timestamp, - icon: info.AIService, - }} + modalInfo={modalInfo} titleMore={ isShowExpandBtn ? (
diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/type.ts index 153127e353..8bff7509ea 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiReviewResult/type.ts @@ -5,7 +5,9 @@ import { ModalInfoProps } from '../ModelInfo' export interface AIReviewResultProps { info: AIChatQSData - timestamp: number + renderNum: number + casualLength?: number + taskLength?: number } export interface AISingHaveColorTextProps extends ChatCardProps { title: ReactNode diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiStreamChatContent/AIStreamChatContent.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiStreamChatContent/AIStreamChatContent.module.scss index f875886e5d..3e8b49894b 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiStreamChatContent/AIStreamChatContent.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiStreamChatContent/AIStreamChatContent.module.scss @@ -25,7 +25,6 @@ // @include mixin.mpb-color-text(); } .ai-stream-content { - padding: 4px 12px 4px 0; border-radius: 0 8px 8px 0; color: var(--Colors-Use-Neutral-Text-4-Help-text); font-size: 11px; @@ -34,6 +33,7 @@ white-space: nowrap; overflow: hidden; display: flex; + align-items: center; justify-content: flex-end; position: relative; .ai-mask { diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiTaskQuery/AITaskQuery.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiTaskQuery/AITaskQuery.tsx index ff6e50d288..f472ce7164 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiTaskQuery/AITaskQuery.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiTaskQuery/AITaskQuery.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useState } from 'react' import { AITaskQueryItemProps, AITaskQueryProps } from './type' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' import { @@ -11,43 +11,59 @@ import { } from '@/assets/icon/outline' import styles from './AITaskQuery.module.scss' import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' -import useChatIPCStore from '../../useContext/ChatIPCContent/useStore' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import { useCreation, useDebounceFn, useMemoizedFn } from 'ahooks' -import { AIInputEventSyncTypeEnum } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIInputEvent, AIInputEventSyncTypeEnum } from '@/pages/ai-re-act/hooks/grpcApi' import { Tooltip } from 'antd' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useAIAgentDispatcher from '../../useContext/useDispatcher' +import { randomString } from '@/utils/randomUtil' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import useDebounceFn from 'ahooks/lib/useDebounceFn' -export const AITaskQuery: React.FC = React.memo((props) => { +export const AITaskQuery: React.FC = React.memo(() => { const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) - const { chatIPCData } = useChatIPCStore() - const { handleSendSyncMessage } = useChatIPCDispatcher() const [loading, setLoading] = useState(false) - const questionQueue = useMemo(() => { - return chatIPCData.questionQueue - }, [chatIPCData.questionQueue]) + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const questionQueue = useStore(store, (state) => state.questionQueue) + const execute = useStore(store, (state) => state.execute) + const { onSend } = useAIAgentDispatcher() const [showList, setShowList] = useState(true) const onClearTaskQueue = useMemoizedFn(() => { - if (!chatIPCData.execute) return + if (!execute) return + if (!sessionId) return setLoading(true) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CLEAR_TASK, - params: {}, - }) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, - params: {}, - }) + + const clearTaskInfo: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CLEAR_TASK, + + Params: {}, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: clearTaskInfo }) + + const queueInfo: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, + + Params: {}, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: queueInfo }) + setTimeout(() => { setLoading(false) setShowList(false) }, 500) }) - return ( + return execute && questionQueue?.total > 0 ? (
{showList ? (
@@ -85,30 +101,43 @@ export const AITaskQuery: React.FC = React.memo((props) => { )}
+ ) : ( + <> ) }) const AITaskQueryItem: React.FC = React.memo((props) => { const { item } = props const { t } = useI18nNamespaces(['aiAgent']) - const { chatIPCData } = useChatIPCStore() const [upLoading, setUpLoading] = useState(false) const [removeLoading, setRemoveLoading] = useState(false) - const execute = useCreation(() => chatIPCData.execute, [chatIPCData.execute]) - const { handleSendSyncMessage } = useChatIPCDispatcher() + + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + const { onSend } = useAIAgentDispatcher() + const onTaskUp = useDebounceFn( () => { if (!execute || upLoading) return setUpLoading(true) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_JUMP_QUEUE, + const jumpInfo: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_JUMP_QUEUE, SyncJsonInput: JSON.stringify({ task_id: item.id }), - params: {}, - }) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, - params: {}, - }) + Params: {}, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: jumpInfo }) + + const queueInfo: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, + Params: {}, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: queueInfo }) + setTimeout(() => { setUpLoading(false) }, 500) @@ -119,15 +148,24 @@ const AITaskQueryItem: React.FC = React.memo((props) => { () => { if (!execute || removeLoading) return setRemoveLoading(true) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_REMOVE_TASK, + + const jumpInfo: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_REMOVE_TASK, SyncJsonInput: JSON.stringify({ task_id: item.id }), - params: {}, - }) - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, - params: {}, - }) + Params: {}, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: jumpInfo }) + + const queueInfo: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, + Params: {}, + SyncID: randomString(8), + } + onSend({ token: sessionId, type: '', params: queueInfo }) + setTimeout(() => { setRemoveLoading(false) }, 500) diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiToolDecision/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiToolDecision/type.ts index c3500a9bb9..d3eb306637 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiToolDecision/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiToolDecision/type.ts @@ -1,5 +1,6 @@ -import { ChatToolCallDecision } from '@/pages/ai-re-act/hooks/aiRender' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' export interface AIToolDecisionProps { - item: ChatToolCallDecision + item: Extract + renderNum: number } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.module.scss index ee71ec4d17..efd629de66 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.module.scss @@ -23,6 +23,10 @@ word-break: break-all; white-space: pre-wrap; + max-width: 100%; + max-height: 150px; + overflow-y: auto; + :global(.ai-milkdown-input) { word-break: normal; white-space: normal; @@ -35,7 +39,7 @@ } } .triage-chat-question { - justify-content: flex-end; + justify-content: flex-start; .content-wrapper { background: var(--Colors-Use-Main-Focus); } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.tsx index 4132e173a8..e6e848e65d 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/AITriageChat.tsx @@ -20,14 +20,26 @@ import { AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' import { AIChatTextareaSubmit } from '../../template/type' import { getAIReActRequestParams } from '../../utils' import { extractDataWithMilkdown } from '../aiMilkdownInput/utils' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import useGetChatDataStoreKey from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' +import useAIAgentDispatcher from '../../useContext/useDispatcher' export const AITriageChatContent: React.FC = memo((props) => { - const { isAnswer, content, contentClassName, chatClassName, extraValue } = props - const { chatDataStoreKey } = useGetChatDataStoreKey() + const { isAnswer, contentClassName, chatClassName, itemData, chatDataStoreKey, renderNum } = props + const [edit, setEdit] = useState(false) + const content = useCreation(() => { + if (itemData?.data) { + return itemData.data + } + return '' + }, [renderNum]) + + const extraValue = useCreation(() => { + if (itemData?.extraValue) { + return itemData.extraValue + } + return undefined + }, [renderNum]) const renderContent = useMemoizedFn(() => { if (!!extraValue?.showQS) { return ( @@ -42,7 +54,12 @@ export const AITriageChatContent: React.FC = memo((pro return (
{edit ? ( - setEdit(false)} /> + setEdit(false)} + chatDataStoreKey={chatDataStoreKey} + /> ) : ( <>
= memo((pro ) }) const AITriageChatContentEdit: React.FC = React.memo((props) => { - const { extraValue, content, onCancel } = props + const { extraValue, content, onCancel, chatDataStoreKey } = props const { activeChat } = useAIAgentStore() - const { chatIPCEvents } = useChatIPCDispatcher() - const { chatDataStoreKey } = useGetChatDataStoreKey() - + const { onSend } = useAIAgentDispatcher() const defaultValue = useCreation(() => { if (!!extraValue?.showQS) { return `${extraValue?.showQS}` @@ -117,7 +132,7 @@ const AITriageChatContentEdit: React.FC = React.me e.stopPropagation() onCancel() }) - const onSend = useMemoizedFn(() => { + const onSendEdit = useMemoizedFn(() => { // 发送消息逻辑 if (!editorMilkdown.current || !activeChat) return const qs = getMarkdownValue() @@ -131,7 +146,7 @@ const AITriageChatContentEdit: React.FC = React.me showQS: qs, focusMode: '', } - const { extra, attachedResourceInfo } = getAIReActRequestParams(value) + const { attachedResourceInfo } = getAIReActRequestParams(value) const chatMessage: AIInputEvent = { IsFreeInput: true, @@ -139,14 +154,13 @@ const AITriageChatContentEdit: React.FC = React.me AttachedResourceInfo: attachedResourceInfo, FocusModeLoop: value.focusMode, } - chatIPCEvents.onSend({ + onSend({ token: activeChat.SessionID, type: 'casual', params: { IsFreeInput: true, ...chatMessage, }, - extraValue: extra, }) onCancel() }) @@ -159,7 +173,7 @@ const AITriageChatContentEdit: React.FC = React.me if (!e.nativeEvent?.isComposing && keys?.join() === YakitKeyBoard.Enter) { e.stopPropagation() e.preventDefault() - onSend() + onSendEdit() } }) @@ -187,7 +201,7 @@ const AITriageChatContentEdit: React.FC = React.me disabled={disabled} onClick={(e) => { e.stopPropagation() - onSend() + onSendEdit() }} > 发送 diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/type.ts index f49b4b8c2f..0df6f482bc 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiTriageChat/type.ts @@ -1,15 +1,24 @@ -import { AIChatIPCStartParams } from '@/pages/ai-re-act/hooks/type' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '@/pages/ai-re-act/hooks/aiRender' +import { AINodeItemProps } from '../aiChatListItem/aiNodeItem/type' +import { ChatDataStoreKey } from '../../store/ChatDataStore' export interface AITriageChatContentProps { isAnswer?: boolean - content: string contentClassName?: string chatClassName?: string - extraValue?: AIChatIPCStartParams['extraValue'] + itemData: Extract< + AIChatQSData, + { type: AIChatQSDataTypeEnum.QUESTION | AIChatQSDataTypeEnum.RESULT | AIChatQSDataTypeEnum.THOUGHT } + > + + renderNum: AINodeItemProps['renderNum'] + + chatDataStoreKey: ChatDataStoreKey } export interface AITriageChatContentEditProps { onCancel: () => void - content: AITriageChatContentProps['content'] - extraValue?: AITriageChatContentProps['extraValue'] + content: string + extraValue?: AIChatQSData['extraValue'] + chatDataStoreKey: AITriageChatContentProps['chatDataStoreKey'] } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx index b043828c95..9c9c699555 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx @@ -10,9 +10,10 @@ import styles from './AIYaklangCode.module.scss' import { useCreation, useMemoizedFn, useThrottleEffect } from 'ahooks' import { NewHTTPPacketEditor } from '@/utils/editors' import { monaco as monacoApi } from 'react-monaco-editor' -import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' -import { WebFuzzerAiStore } from '@/pages/ai-agent/store/ChatDataStore' -import useGetChatDataStoreKey from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' +import useAIAgentStore from '../../useContext/useStore' +import { AISourceEnum } from '@/pages/ai-re-act/hooks/grpcApi' +import { usePageInfo } from '@/store/pageInfo' +import { shallow } from 'zustand/shallow' import { YakitMonacoDiffInline } from '@/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline' const CODE_BLOCK_MAX_HEIGHT = 200 @@ -141,17 +142,24 @@ export const AIYaklangCode: React.FC = React.memo((props) => ) } }) - const { chatIPCEvents } = useChatIPCDispatcher() + // const { chatIPCEvents } = useChatIPCDispatcher() + const { setting } = useAIAgentStore() + + const { getCurrentSelectPageId, currentPageTabRouteKey } = usePageInfo( + (s) => ({ + getCurrentSelectPageId: s.getCurrentSelectPageId, + currentPageTabRouteKey: s.currentPageTabRouteKey, + }), + shallow, + ) const webFuzzerAiStoreFuzzerPageId = useMemo((): string | undefined => { - const store = chatIPCEvents.fetchChatDataStore() - return store instanceof WebFuzzerAiStore ? store.fuzzerPageId : undefined - }, [chatIPCEvents]) - const { chatDataStoreKey } = useGetChatDataStoreKey() + return getCurrentSelectPageId(currentPageTabRouteKey) + }, [currentPageTabRouteKey]) const isWebFuzzerAiStore = useMemo(() => { - return chatDataStoreKey === 'WebFuzzerAiStore' - }, [chatDataStoreKey]) + return setting.Source === AISourceEnum.webFuzzer + }, [setting.Source]) const titleExtra = useMemo(() => { if (!modalInfo) return null diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/type.ts b/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/type.ts index 42e185dfe9..4a8f36b213 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/type.ts +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/type.ts @@ -5,7 +5,6 @@ import { ReactNode } from 'react' export interface AIYaklangCodeProps { content: string autoApplyStreamId?: string - autoApplyChatSessionId?: string listItemIndex?: number nodeLabel: string modalInfo: ModalInfoProps diff --git a/app/renderer/src/main/src/pages/ai-agent/defaultConstant.tsx b/app/renderer/src/main/src/pages/ai-agent/defaultConstant.tsx index 2c951dd66e..6045beca7f 100644 --- a/app/renderer/src/main/src/pages/ai-agent/defaultConstant.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/defaultConstant.tsx @@ -5,7 +5,6 @@ import { OutlineCogIcon, OutlineMCPIcon, OutlineSparklesIcon, - OutlineTemplateIcon, OutlineWrenchIcon, OutlineBookOpenTextIcon, OutlineBotIcon, @@ -32,7 +31,7 @@ import { MemfitIcon, } from './aiModelList/icon' import { UseChatIPCState } from '../ai-re-act/hooks/type' -import { AIAgentGrpcApi, AITaskStatus } from '../ai-re-act/hooks/grpcApi' +import { AIAgentGrpcApi, AISourceEnum, AITaskStatus } from '../ai-re-act/hooks/grpcApi' import { SolidCursorclickIcon, SolidHashtagIcon, @@ -122,6 +121,7 @@ export const AIAgentSettingDefault: AIAgentSetting = { EnableGoalMode: false, GoalMinIterations: 0, }, + Source: AISourceEnum.aiAgent, } /** mcp 自定义服务器配置类型选项 */ @@ -276,11 +276,6 @@ export const defaultChatIPCData: UseChatIPCState = { elements: [], toolListRenderNumber: 0, }, - yakExecResult: { - card: [], - execFileRecord: new Map(), - yakExecResultLogs: [], - }, taskChat: { plan: cloneDeep(DefaultCurrentExecTaskTree), elements: [], @@ -292,9 +287,9 @@ export const defaultChatIPCData: UseChatIPCState = { }, reActTimelines: [], memoryList: { ...DefaultMemoryList }, - taskStatus: { loading: false, plan: '', task: '' }, + taskStatus: { plan: '', task: '', taskID: '', status: AITaskStatus.created, coordinatorId: '' }, focusMode: '', - switchLoading: false, + initLoading: false, planHistoryList: cloneDeep(DefaultPlanHistoryList), cancelCasualLoading: false, cancelTaskLoading: false, diff --git a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx index 2821e777a2..988e21d36a 100644 --- a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChat.tsx @@ -1,7 +1,7 @@ import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import useAIAgentStore from '../useContext/useStore' import useAIAgentDispatcher from '../useContext/useDispatcher' -import { useDebounce, useMemoizedFn, useUpdateEffect } from 'ahooks' +import { useUpdateEffect } from 'ahooks' import { yakitNotify } from '@/utils/notification' import { ReActChatEventEnum } from '../defaultConstant' import { OutlineDesktopcomputerIcon, OutlineMessageCirclePlusIcon, OutlineSearchIcon } from '@/assets/icon/outline' @@ -15,20 +15,26 @@ import { YakitInput } from '@/components/yakitUI/YakitInput/YakitInput' import styles from './HistoryChat.module.scss' import { AIAgentTriggerEventInfo } from '../aiAgentType' import emiter from '@/utils/eventBus/eventBus' -import { grpcDeleteAISession } from '../grpc' +import { grpcDeleteAISession, grpcQueryAISession } from '../grpc' import { AISession } from '../type/aiChat' import { SideSettingButton } from '../aiChatWelcome/AIChatWelcome' import HistoryChatList, { DAY_MS, getChatTimestamp } from './HistoryChatList/HistoryChatList' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import useSessionList from './HistoryChatList/hook/useSessionList' -import type { AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import { type AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import type { YakitRouteType } from '@/enums/yakitRoute' import { JSONParseLog } from '@/utils/tool' import { getMainOperatorPageBodyContainer } from '@/utils/getMainOperatorPageBodyContainer' +import { handAIHistoryChatRemove } from './utils' +import { getImageStoreKeyByAISource } from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' import classNames from 'classnames' import { filterHistorySessionsBySource, getHistorySourceQuerySources, type HistorySourceFilter } from './source' - -const clearLocalChats = (sessions: AISession[]) => - emiter.emit('onDelChats', JSON.stringify(sessions.map((item) => item.SessionID))) +import useGetChatDataStoreKey from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import useDebounce from 'ahooks/lib/useDebounce' +import { usePageInfo } from '@/store/pageInfo' +import { shallow } from 'zustand/shallow' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' const HISTORY_SOURCE_FILTER_OPTIONS: { key: HistorySourceFilter @@ -85,13 +91,14 @@ const isSessionMatchSource = (session: AISession, sources: AISource[]) => { } interface HistoryChatProps { - /** 会话来源过滤,AI Agent 侧栏为 ['ai', ''],各业务页为 [source] */ + /** 会话来源过滤,AI Agent 侧栏为 ['ai', '',"im"],各业务页为 [source] */ aiSource: AISource[] /** 嵌入 Tooltip 等浮层场景:隐藏新建/固定按钮,弹层挂载到当前页面容器 */ embedded?: boolean } const HistoryChat = memo(({ aiSource, embedded }: HistoryChatProps) => { + const { setActiveChat, getSetting } = useAIAgentDispatcher() const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) const [historySourceFilter, setHistorySourceFilter] = useState('local') const enableHistorySourceFilter = useMemo(() => aiSource.includes('im'), [aiSource]) @@ -104,7 +111,10 @@ const HistoryChat = memo(({ aiSource, embedded }: HistoryChatProps) => { }, [aiSource, enableHistorySourceFilter, historySourceFilter]) const [{ sessions }, dispatcher] = useSessionList(historyQuerySources) const { activeChat } = useAIAgentStore() - const { setActiveChat } = useAIAgentDispatcher() + + const currentRouteKey = usePageInfo((state) => state.getCurrentPageTabRouteKey(), shallow) + const currentPageId = usePageInfo((state) => state.getCurrentSelectPageId(state.getCurrentPageTabRouteKey()), shallow) + const getPopupContainer = useMemoizedFn(() => getMainOperatorPageBodyContainer() || document.body) const popupContainer = embedded ? getPopupContainer : undefined const embeddedOverlayClass = styles['history-chat-embedded-overlay'] @@ -124,21 +134,57 @@ const HistoryChat = memo(({ aiSource, embedded }: HistoryChatProps) => { }, [enableHistorySourceFilter, historySourceFilter, sessions]) const [clearLoading, setClearLoading] = useState(false) + + const chatDataStoreKey = useGetChatDataStoreKey() + + /** 查询与删除条件匹配的完整历史集合,避免分页列表漏掉待删除 session。 */ + const queryTargetSessionIds = useMemoizedFn(async (sources?: AISource[], beforeTimestamp?: number) => { + const { Data } = await grpcQueryAISession( + { + Pagination: { Page: 1, Limit: -1, OrderBy: 'last_used_at', Order: 'desc' }, + Filter: sources?.length ? { Source: sources } : undefined, + }, + true, + ) + return Data.filter((session) => beforeTimestamp === undefined || getChatTimestamp(session) <= beforeTimestamp).map( + (session) => session.SessionID, + ) + }) + + /** 补齐尚未写入历史表、但当前路由内已经运行的会话。 */ + const getRouteSessionIds = useMemoizedFn((sources: AISource[]) => { + const route = currentRouteKey as YakitRouteType + return sources.flatMap((source) => globalSessionEngine.getSessionIdsBySourceAndRoute(source, route)) + }) + const handleClearAllChat = useMemoizedFn(async () => { if (clearLoading) return - if (!isGlobalAIAgentHistory && visibleSessions.length === 0) { + const sources = isGlobalAIAgentHistory ? aiSource : historyQuerySources + if (!isGlobalAIAgentHistory && visibleSessions.length === 0 && getRouteSessionIds(sources).length === 0) { yakitNotify('info', t('HistoryChat.noChatsToClear')) return } setClearLoading(true) try { - if (isGlobalAIAgentHistory) { - await grpcDeleteAISession({ DeleteAll: true }, true) - } else { - await grpcDeleteAISession({ Filter: { Source: historyQuerySources } }, true) - } - clearLocalChats(visibleSessions) + const filter = isGlobalAIAgentHistory ? { DeleteAll: true } : { Filter: { Source: historyQuerySources } } + const sessionIds = [ + ...new Set([ + ...(await queryTargetSessionIds(isGlobalAIAgentHistory ? undefined : sources)), + ...getRouteSessionIds(sources), + ]), + ] + await handAIHistoryChatRemove({ + grpcDeleteAISessionParams: filter, + handleClearAIImageParams: { chatDataStoreKey, sessionID: [] }, //删除全部只需要传chatDataStoreKey + deleteSessionsParams: { + // 全局历史页删全部来源;否则删当前 tab 对应来源(local=ai, feishu/dingtalk=im) + sources, + sessionIds, + route: currentRouteKey as YakitRouteType, + pageId: currentPageId || currentRouteKey, + }, + }) onNewChat() setActiveChat?.(undefined) dispatcher.setSessions?.([]) @@ -156,32 +202,39 @@ const HistoryChat = memo(({ aiSource, embedded }: HistoryChatProps) => { if (clearLoading) return const beforeTimestamp = Date.now() - days * DAY_MS - const deletedChats = visibleSessions.filter((item) => getChatTimestamp(item) <= beforeTimestamp) + let sessionIds: string[] = [] + + try { + sessionIds = await queryTargetSessionIds(historyQuerySources, beforeTimestamp) + } catch (e) { + yakitNotify('error', t('HistoryChat.clearFailed', { error: String(e) })) + return + } - if (deletedChats.length === 0) { + if (sessionIds.length === 0) { yakitNotify('info', t('HistoryChat.noChatsBeforeDays', { days })) return } setClearLoading(true) try { - const filter = - enableHistorySourceFilter && historySourceFilter !== 'local' - ? { - SessionID: deletedChats.map((item) => item.SessionID), - Source: historyQuerySources, - } - : { - BeforeTimestamp: beforeTimestamp, - Source: historyQuerySources, - } - await grpcDeleteAISession({ Filter: filter }, true) - - clearLocalChats(deletedChats) - - const deletedSessionIds = new Set(deletedChats.map((item) => item.SessionID)) - const nextChats = sessions.filter((item) => !deletedSessionIds.has(item.SessionID)) - const activeDeleted = !!activeChat && deletedChats.some((item) => item.SessionID === activeChat.SessionID) + const filter = { + BeforeTimestamp: beforeTimestamp, + Source: historyQuerySources, + } + const source = getSetting().Source || 'ai' + await handAIHistoryChatRemove({ + grpcDeleteAISessionParams: { Filter: filter }, + handleClearAIImageParams: { chatDataStoreKey: getImageStoreKeyByAISource(source), sessionID: sessionIds }, + deleteSessionsParams: { + sources: [source], + sessionIds, + route: currentRouteKey as YakitRouteType, + pageId: currentPageId || currentRouteKey, + }, + }) + const nextChats = sessions.filter((item) => getChatTimestamp(item) > beforeTimestamp) + const activeDeleted = !!activeChat && sessionIds.includes(activeChat.SessionID) if (nextChats.length === 0) { onNewChat() diff --git a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/HistoryChatList.tsx b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/HistoryChatList.tsx index fe8ccecb23..7f988a202e 100644 --- a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/HistoryChatList.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/HistoryChatList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState, type FC } from 'react' +import { memo, useMemo, useRef, useState, type FC } from 'react' import styles from './HistoryChatList.module.scss' import { OutlinePencilaltIcon, OutlineTrashIcon } from '@/assets/icon/outline' import { SolidChatalt2Icon, SolidUserIcon, SolidUsersIcon } from '@/assets/icon/solid' @@ -9,17 +9,27 @@ import classNames from 'classnames' import { YakitAIAgentPageID } from '../../defaultConstant' import { EditChatNameModal } from '../../UtilModals' import { AISession } from '../../type/aiChat' -import { useInfiniteScroll, useMemoizedFn } from 'ahooks' -import { grpcDeleteAISession, grpcUpdateAISessionTitle } from '../../grpc' +import { useCreation, useInfiniteScroll, useMemoizedFn } from 'ahooks' +import { grpcUpdateAISessionTitle } from '../../grpc' import useAIAgentStore from '../../useContext/useStore' import useAIAgentDispatcher from '../../useContext/useDispatcher' import { yakitNotify } from '@/utils/notification' import { onNewChat } from '../HistoryChat' -import emiter from '@/utils/eventBus/eventBus' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import type { SessionListDispatcher } from './hook/useSessionList' -import type { AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import { AITaskStatus, type AISource } from '@/pages/ai-re-act/hooks/grpcApi' import { getHistorySessionIconMeta, getSessionDisplayTitle } from '../source' +import { handAIHistoryChatRemove } from '../utils' +import useGetChatDataStoreKey, { AI_AGENT_HISTORY_AI_SOURCES } from '@/pages/ai-re-act/hooks/useGetChatDataStoreKey' +import { usePageInfo } from '@/store/pageInfo' +import { shallow } from 'zustand/shallow' +import type { YakitRouteType } from '@/enums/yakitRoute' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' +import { HistoryChatListItemProps } from './type' +import useCurrentSessionId from '@/pages/ai-re-act/hooks/useCurrentSessionId' +import { useStore } from 'zustand' +import { YakitSolidLoading } from '@/components/yakitUI/YakitSolidLoading/YakitSolidLoading' +import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' export const HOUR_MS = 60 * 60 * 1000 export const DAY_MS = 24 * HOUR_MS @@ -125,12 +135,15 @@ const HistoryChatList: FC<{ const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) const { activeChat } = useAIAgentStore() const { setActiveChat, setSetting } = useAIAgentDispatcher() + const currentRouteKey = usePageInfo((state) => state.getCurrentPageTabRouteKey(), shallow) + const currentPageId = usePageInfo((state) => state.getCurrentSelectPageId(state.getCurrentPageTabRouteKey()), shallow) const listRef = useRef(null) const chatTotalRef = useRef(0) const editInfo = useRef() - const [delLoading, setDelLoading] = useState([]) const [editShow, setEditShow] = useState(false) + const chatDataStoreKey = useGetChatDataStoreKey() + const activeSessionId = useMemo(() => { return activeChat?.SessionID || '' }, [activeChat]) @@ -194,51 +207,57 @@ const HistoryChatList: FC<{ editInfo.current = undefined }) + const { getSetting, onClose } = useAIAgentDispatcher() const handleDeleteChat = useMemoizedFn(async (info: AISession) => { - const { SessionID } = info - const isLoading = delLoading.includes(SessionID) - if (isLoading) return - const findIndex = sessionList.findIndex((item) => item.SessionID === SessionID) - if (findIndex === -1) { - yakitNotify('error', t('HistoryChatList.chatNotFound')) - return - } - setDelLoading((old) => [...old, SessionID]) + return new Promise(async (resolve, reject) => { + const { SessionID } = info - const newChats = sessionList.filter((item) => item.SessionID !== SessionID) - let active: AISession | undefined - if (newChats.length === 0) { - onNewChat() - } else { - active = getNextActiveChat(sessionList, findIndex) - } + const findIndex = sessionList.findIndex((item) => item.SessionID === SessionID) + if (findIndex === -1) { + yakitNotify('error', t('HistoryChatList.chatNotFound')) + reject() + return + } - setSessions && setSessions(newChats) + const newChats = sessionList.filter((item) => item.SessionID !== SessionID) + let active: AISession | undefined + if (newChats.length === 0) { + onNewChat() + } else { + active = getNextActiveChat(sessionList, findIndex) + } - if (activeSessionId === SessionID && active) { - handleSetActiveChat(active) - } + setSessions && setSessions(newChats) - try { - await grpcDeleteAISession({ Filter: { SessionID: [SessionID], Source: aiSource } }, true) - emiter.emit('onDelChats', JSON.stringify([SessionID])) - } catch (error) { - setSessions?.(sessionList) - if (activeSessionId === SessionID) { - handleSetActiveChat(info) + if (activeSessionId === SessionID && active) { + handleSetActiveChat(active) } - yakitNotify('error', t('HistoryChatList.deleteFailed', { error: String(error) })) - } finally { - setDelLoading((old) => old.filter((el) => el !== SessionID)) - } - }) - const handleSetActiveChat = useMemoizedFn((info: AISession) => { - // 暂时性逻辑,因为老版本的对话信息里没有请求参数,导致在新版本无法使用对话里的重新执行功能 - // 所以会提示警告,由用户决定是否删除历史对话 - // if (!info.request) { - // yakitNotify("warning", "当前对话无请求参数信息,无法使用重新执行功能") - // } + try { + const sessionIds = [SessionID] + const source = getSetting().Source || 'ai' + await handAIHistoryChatRemove({ + grpcDeleteAISessionParams: { Filter: { SessionID: [SessionID], Source: aiSource } }, + handleClearAIImageParams: { chatDataStoreKey, sessionID: sessionIds }, + deleteSessionsParams: { + sources: [source], + sessionIds, + route: currentRouteKey as YakitRouteType, + pageId: currentPageId || currentRouteKey, + }, + }) + resolve() + } catch (error) { + setSessions?.(sessionList) + if (activeSessionId === SessionID) { + handleSetActiveChat(info) + } + yakitNotify('error', t('HistoryChatList.deleteFailed', { error: String(error) })) + reject() + } + }) + }) + const onSetChat = useMemoizedFn((info: AISession) => { setSetting?.((old) => ({ ...old, SyncPerceptionTrigger: info?.StartParams?.SyncPerceptionTrigger ?? false, @@ -251,96 +270,158 @@ const HistoryChatList: FC<{ })) setActiveChat && setActiveChat(info) }) + const [closeLoading, setCloseLoading] = useState(false) + // 如果当前历史在aiagent页面中,直接切换会话;其余页面需要判断对话是否在执行,执行中需要先断开会话再设置新会话 + const handleSetActiveChat = useMemoizedFn((info: AISession) => { + if (aiSource.some((source) => AI_AGENT_HISTORY_AI_SOURCES.includes(source))) { + onSetChat(info) + return + } + const activeExecute = globalSessionEngine?.getSessionExecute(info.SessionID) + if (!!activeExecute) { + yakitNotify('info', '会话正在执行中') + return + } + const currentExecute = globalSessionEngine?.getSessionExecute(activeSessionId) + if (!!currentExecute) { + setCloseLoading(true) + onClose([activeSessionId], () => { + onSetChat(info) + setTimeout(() => { + setCloseLoading(false) + }, 200) + }) + } else { + onSetChat(info) + } + }) return ( -
- {groupedHistory.map((group) => { - return ( -
-
{t(group.label)}
- {group.list.map((item) => { - const { SessionID } = item - const displayTitle = getSessionDisplayTitle(item) - const delStatus = delLoading.includes(SessionID) - return ( -
handleSetActiveChat(item)} - > -
- -
- {displayTitle} -
-
- -
- - } - onClick={(e) => { - e.stopPropagation() - handleOpenEditName(item) - }} - /> - - { - e?.stopPropagation() - handleDeleteChat(item) - }} - > - } - onClick={(e) => e.stopPropagation()} - /> - -
-
- ) - })} -
- ) - })} - {loading &&
{t('YakitSpin.loading')}
} - - {editInfo.current && ( - - )} -
+ +
+ {groupedHistory.map((group) => { + return ( +
+
{t(group.label)}
+ {group.list.map((item) => { + return ( + + ) + })} +
+ ) + })} + {loading &&
{t('YakitSpin.loading')}
} + + {editInfo.current && ( + + )} +
+
) } export default HistoryChatList + +const HistoryChatListItem: FC = memo((props) => { + const { item, handleSetActiveChat, getPopupContainer, handleOpenEditName, overlayClassName, handleDeleteChat } = props + + const { t } = useI18nNamespaces(['aiAgent']) + const activeSessionId = useCurrentSessionId() + + const store = useCreation(() => { + return globalSessionEngine?.ensureSession(item.SessionID)?.store + }, [item.SessionID]) + + const casualLoading = useStore(store, (state) => state.casualLoading) + const taskLoading = useStore(store, (state) => state.taskStatus.status === AITaskStatus.inProgress) + + const [delLoading, setDelLoading] = useState(false) + const displayTitle = useCreation(() => { + return getSessionDisplayTitle(item) + }, [item]) + const handleDeleteChatItem = useMemoizedFn(async (info: AISession) => { + setDelLoading(true) + handleDeleteChat(info).finally(() => { + setTimeout(() => { + setDelLoading(false) + }, 200) + }) + }) + const loading = useCreation(() => { + return casualLoading || taskLoading + }, [casualLoading, taskLoading]) + return ( +
handleSetActiveChat(item)} + > +
+ {loading ? ( + + ) : ( + + )} +
+ {displayTitle} +
+
+ +
+ + } + onClick={(e) => { + e.stopPropagation() + handleOpenEditName(item) + }} + /> + + { + e?.stopPropagation() + handleDeleteChatItem(item) + }} + > + } + onClick={(e) => e.stopPropagation()} + /> + +
+
+ ) +}) diff --git a/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/type.ts b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/type.ts new file mode 100644 index 0000000000..3de7043dab --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/historyChat/HistoryChatList/type.ts @@ -0,0 +1,10 @@ +import { AISession } from '../../type/aiChat' + +export interface HistoryChatListItemProps { + item: AISession + handleSetActiveChat: (session: AISession) => void + getPopupContainer?: () => HTMLElement + handleOpenEditName: (session: AISession) => void + handleDeleteChat: (session: AISession) => Promise + overlayClassName?: string +} diff --git a/app/renderer/src/main/src/pages/ai-agent/historyChat/utils.ts b/app/renderer/src/main/src/pages/ai-agent/historyChat/utils.ts new file mode 100644 index 0000000000..49b1606fe4 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-agent/historyChat/utils.ts @@ -0,0 +1,42 @@ +import { AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import { + AIClearImageParams, + handleClearAIImage, +} from '../components/aiMilkdownInput/aiCustomFile/hooks/useDeleteAIImageByNode' +import { grpcDeleteAISession } from '../grpc' +import { DeleteAISessionRequest } from '../type/aiChat' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' +import type { YakitRouteType } from '@/enums/yakitRoute' + +export interface HandAIHistoryChatRemoveParams { + /** 删除grpc数据 */ + grpcDeleteAISessionParams: DeleteAISessionRequest + /** 删除图片数据 */ + handleClearAIImageParams: AIClearImageParams + /** 删除 Controller 内存中的 session(含双索引与业务池) */ + deleteSessionsParams: { + sessionIds: string[] + sources: AISource[] + route: YakitRouteType + pageId: string + } +} +/** + * @description 删除历史会话数据 + * 1.删除grpc数据 + * 2.删除图片数据 + * 3.删除store缓存数据 + * 4.删除indexdb + */ +export const handAIHistoryChatRemove = async (params: HandAIHistoryChatRemoveParams) => { + try { + const { grpcDeleteAISessionParams, handleClearAIImageParams, deleteSessionsParams } = params + + // 必须等待 session-end(含 5 秒 fallback)完成,才删除后端历史。 + // sessionId 是全局唯一的,显式目标集合已与本次 gRPC 删除条件对齐,不再按当前 page 截断。 + await globalSessionEngine.stopExecutingSessionsAndWait(deleteSessionsParams.sessionIds) + await grpcDeleteAISession(grpcDeleteAISessionParams, true) + handleClearAIImage(handleClearAIImageParams) + globalSessionEngine.deleteSessions(deleteSessionsParams) + } catch (_) {} +} diff --git a/app/renderer/src/main/src/pages/ai-agent/store/ChatDataStore.ts b/app/renderer/src/main/src/pages/ai-agent/store/ChatDataStore.ts index 172050a66b..9f972100ff 100644 --- a/app/renderer/src/main/src/pages/ai-agent/store/ChatDataStore.ts +++ b/app/renderer/src/main/src/pages/ai-agent/store/ChatDataStore.ts @@ -1,6 +1,6 @@ import { SetStateAction } from 'react' import type { AIChatData } from '../type/aiChat' -import { AIChatQSData, ReActChatBaseInfo } from '@/pages/ai-re-act/hooks/aiRender' +import type { AIChatQSData, ChatListRenderType } from '@/pages/ai-re-act/hooks/aiRender' import { AIModelTypeEnum } from '../defaultConstant' import { cloneDeep } from 'lodash' import { DefaultCurrentExecTaskTree, DefaultPlanItemDetailsData } from '@/pages/ai-re-act/hooks/defaultConstant' @@ -11,7 +11,7 @@ export type DeepPartial = { interface GetContentMapParams { session: string - chatType: ReActChatBaseInfo['chatType'] + chatType: ChatListRenderType mapKey: string } @@ -32,7 +32,6 @@ export class ChatDataStore { }, httpRunTimeIDs: [], riskRunTimeIDs: [], - yakExecResult: { card: [], execFileRecord: new Map(), yakExecResultLogs: [] }, aiPerfData: { consumption: { cache_hit_token: 0, diff --git a/app/renderer/src/main/src/pages/ai-agent/template/template.tsx b/app/renderer/src/main/src/pages/ai-agent/template/template.tsx index 4339acc243..f9150af966 100644 --- a/app/renderer/src/main/src/pages/ai-agent/template/template.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/template/template.tsx @@ -55,7 +55,6 @@ import OpenFileDropdown, { OpenFileDropdownItem } from '../aiChatWelcome/OpenFil import { UploadFileButton } from '@/pages/ai-re-act/aiReActChat/AIReActComponent' import { insertAtCurrentPosition } from '../components/aiMilkdownInput/customPlugin' import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' -import useChatIPCStore from '../useContext/ChatIPCContent/useStore' import useAIGlobalConfig from '@/pages/ai-re-act/hooks/useAIGlobalConfig' import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' import { @@ -68,6 +67,8 @@ import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { AIMilkdownInputRef } from '../components/aiMilkdownInput/type' import { AICodeBlockCommandParams } from '../components/aiMilkdownInput/aiCodeBlock/aiCustomCodeBlockPlugin' import AIRunModeSelect from '../aiRunModeSelect/AIRunModeSelect' +import { useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { useStore } from 'zustand' /** @name AI-Agent专用Textarea组件,行高为20px */ export const QSInputTextarea: React.FC> = memo( @@ -107,8 +108,9 @@ export const AIChatTextarea: React.FC = memo( onHttpFlowRemove, } = props const { t } = useI18nNamespaces(['aiAgent', 'yakitUi']) - const { chatIPCData } = useChatIPCStore() - const execute = useCreation(() => chatIPCData.execute, [chatIPCData.execute]) + + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) const [manualAdditionVisible, setManualAdditionVisible] = useState(false) diff --git a/app/renderer/src/main/src/pages/ai-agent/type/aiChat.d.ts b/app/renderer/src/main/src/pages/ai-agent/type/aiChat.d.ts index fced7003fb..713fa139d1 100644 --- a/app/renderer/src/main/src/pages/ai-agent/type/aiChat.d.ts +++ b/app/renderer/src/main/src/pages/ai-agent/type/aiChat.d.ts @@ -1,8 +1,9 @@ -import { UseChatIPCState } from '@/pages/ai-re-act/hooks/type' -import { AIAgentGrpcApi, AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' -import { PlanItemDetailsData, ReActChatRenderItem, TodoListCardData } from '@/pages/ai-re-act/hooks/aiRender' -import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' +import type { UseChatIPCState } from '@/pages/ai-re-act/hooks/type' +import type { AIAgentGrpcApi, AIInputEvent, AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' +import type { PlanItemDetailsData, SessionRenderContent } from '@/pages/ai-re-act/hooks/aiRender' +import type { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' import type { AISource } from '@/pages/ai-re-act/hooks/grpcApi' +import { PaginationSchema } from '@/pages/invoker/schema' /** 上下文字节统计 */ export interface AIContextStatsDetail { @@ -38,7 +39,6 @@ export interface AIChatData { httpRunTimeIDs: UseChatIPCState['httpRunTimeIDs'] /** 记录数据里所有的riskRunTimeIDs */ riskRunTimeIDs: UseChatIPCState['riskRunTimeIDs'] - yakExecResult: UseChatIPCState['yakExecResult'] /** 性能相关数据 */ aiPerfData: { /** 消耗Token */ @@ -164,3 +164,139 @@ export interface QueryAISessionRequest { Source?: AISource[] } } +export interface AIAgentChatData { + /** http_fuzz_request_change事件通知数据 */ + httpFuzzRequest?: AIAgentGrpcApi.HttpFuzzRequestChange + /** http_flow_fuzz_status事件通知数据 */ + httpFlowFuzzStatus?: AIAgentGrpcApi.GetHttpFlowFuzzStatus + /** 更新会话的标题 */ + sessionTitle?: string + /** 记忆列表 */ + memoryList: AIAgentGrpcApi.MemoryEntryList + /** 系统流信息(isSystem=true&type=stream) */ + systemStream: string + /** yaklang_code_change 数据 */ + yaklangCodeChange?: AIAgentGrpcApi.YaklangCodeChange + + /** + * 与 IDB sessionRender.grpcOffset 同步的事件游标。 + * start 时对齐后写入;仅加载后端存量历史(recovery 等)时更新。 + */ + grpcOffset: number + + /** timeline 历史分页游标(grpcQueryAIEvent 的 BeforeId),0 表示未拉过 */ + timelineBeforeId: number + /** timeline 历史是否已到尽头(Total===0 或 Events.length < LIMIT 时置 true) */ + timelineNoMore: boolean + + /** 记录数据里所有的httpRunTimeIDs */ + httpRunTimeIDs: string[] + /** 记录数据里所有的riskRunTimeIDs */ + riskRunTimeIDs: string[] + /** 性能相关数据 */ + aiPerfData: { + /** 消耗Token */ + consumption: AIAgentGrpcApi.Consumption + /** 上下文压力 */ + pressure: Record + /** 首字符响应耗时 */ + firstCost: Record + /** 总对话耗时 */ + totalCost: Record + /** 上下文字节统计 */ + contextStats: AIContextStatsDetail + /** 上下文成分 */ + contextSections: AIContextSectionsDetail + } + /** 自由对话(ReAct)会话 */ + casualChat: { + /** react 任务对应的详情数据 */ + planDetails: PlanItemDetailsData + /** 自由会话啊的子任务对应的详情数据 */ + planDetailsMap: Map + } + taskChat: { + /** 任务列表的子任务对应的详情数据 */ + planDetailsMap: Map + } + /** 会话内每条信息的详情 */ + contents: Map +} +export interface AIAgentChatMetaData { + /** 会话通信流建立成功后的UI回调触发事件 */ + onLinkSuccess?: (sessionId: string) => void + /** forceClose 传入的回调,在 grpc session-end 移除监听前执行 */ + onEnd?: () => void + /** 通过用户问题创建会话时的问题 */ + createChatQuestion?: AIInputEvent + + /** 建立会话后的ping请求测试连通性-ping的唯一ID和轮询定时器 */ + pingSyncID: string + pingTimer: ReturnType | null + + /** 自由对话的实时记忆列表 */ + casualMemoryList: AIAgentGrpcApi.MemoryEntryList + /** 任务规划的实时记忆列表 */ + taskMemoryList: AIAgentGrpcApi.MemoryEntryList + /** 通知消息的消失定时器 */ + notifyMessageTimer: NodeJS.Timeout | null + + /** 当前任务规划正在进行中的节点taskID */ + currentTaskPlanActiveNode: Set + + /** + * 任务规划结束门闩:end_plan_and_execution 与 react_task_status_changed 终态都到后才把 status 落到终态 + * 任一未到则 status 保持 processing(等待中) + */ + taskPlanEndGate: { + endReceived: boolean + pendingStatus?: 'completed' | 'aborted' | 'skipped' + } + + /** 历史数据: review_release先出现的历史review数据的id-release */ + historyReviewReleaseID: Record + + /** 当前plan_review对应的扩展数据ID */ + currentPlanReviewExtraId: string + /** 当前plan_review的异步详细数据 */ + planReviewExtraData: Map + + /** 记录tool_xxx_stderr的stream数据 */ + toolStderrStreamData: Map + + /** 记录都存在过的系统信息uuid, 只展示最新的一条系统信息 */ + systemEventUUID: string[] + + /** 顶部卡片临时缓冲区 */ + cardKVPair: Map + cardKVPaidTimer: NodeJS.Timeout | null + + /** 用于文件操作记录的计数器, 主要给文件操作记录加上order属性 */ + execFileRecordOrder: number + + /** 同步ID-是否已处理 */ + syncIDMap: Map + + /** + * 轮询获取最新问题队列的空队列次数 + * 如果超过3次,直接关闭轮询器,等待用户问问题后重启 + */ + queuePollingEmptyCount: number + /** 轮询获取最新问题队列的定时器 */ + queuePollingTimer: NodeJS.Timeout | null + + /** 轮询获取最新记忆列表的定时器 */ + memoryPollingTimer: NodeJS.Timeout | null + + /** + * start 时从 IDB 读出的渲染树暂存,pong 后消费 hydrate,避免二次读库。 + * 消费后应置 undefined。 + */ + pendingSessionRender?: SessionRenderContent + + /** + * 记录自由对话下成组agent任务的taskID + * 这里记录的是任务的taskID,不是任务的唯一值(问题id-任务id) + */ + casualSubTaskIDs: Set +} diff --git a/app/renderer/src/main/src/pages/ai-agent/useContext/AIAgentContext.ts b/app/renderer/src/main/src/pages/ai-agent/useContext/AIAgentContext.ts index 7a357936a0..d1180170bd 100644 --- a/app/renderer/src/main/src/pages/ai-agent/useContext/AIAgentContext.ts +++ b/app/renderer/src/main/src/pages/ai-agent/useContext/AIAgentContext.ts @@ -1,6 +1,9 @@ import { Dispatch, SetStateAction, createContext } from 'react' import { AIAgentSetting } from '../aiAgentType' import { AISession } from '../type/aiChat' +import { AIChatIPCStartParams, AIChatSendParams } from '@/pages/ai-re-act/hooks/type' +import { AIAgentSettingDefault } from '../defaultConstant' +import { useChatIPC } from '@/pages/ai-re-act/hooks/useChatIPC' export interface AIAgentContextStore { /** 全局配置 */ @@ -9,10 +12,14 @@ export interface AIAgentContextStore { activeChat?: AISession } -export interface AIAgentContextDispatcher { - setSetting?: Dispatch> - getSetting?: () => AIAgentSetting - setActiveChat?: Dispatch> +/** 上层 onStart 入参:route/pageId 由 useChatIPC 注入,调用方无需传递 */ +export interface UseChatIPCStartParams extends Omit { + onSuccess?: (sessionId: string) => void +} +export interface AIAgentContextDispatcher extends ReturnType { + setSetting: Dispatch> + getSetting: () => AIAgentSetting + setActiveChat: Dispatch> } export interface AIAgentContextValue { @@ -22,13 +29,17 @@ export interface AIAgentContextValue { export default createContext({ store: { - setting: {}, + setting: { ...AIAgentSettingDefault }, activeChat: undefined, }, dispatcher: { - setSetting: undefined, - getSetting: undefined, + setSetting: () => {}, + getSetting: () => AIAgentSettingDefault, + setActiveChat: () => {}, - setActiveChat: undefined, + onStart: () => {}, + onSend: () => {}, + onClose: () => {}, + onUpdatePageId: () => {}, }, }) diff --git a/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent.ts b/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent.ts deleted file mode 100644 index 4aae505e00..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createContext } from 'react' -import { defaultChatIPCData } from '../../defaultConstant' -import { cloneDeep } from 'lodash' -import { UseChatIPCEvents, UseChatIPCState } from '@/pages/ai-re-act/hooks/type' -import { AIAgentGrpcApi, AIInputEvent, AIStartParams } from '@/pages/ai-re-act/hooks/grpcApi' -import { AIChatQSData } from '@/pages/ai-re-act/hooks/aiRender' - -export interface ChatIPCContextStore { - chatIPCData: UseChatIPCState - reviewInfo?: AIChatQSData - planReviewTreeKeywordsMap: Map - reviewExpand: boolean - syncIdInfoMap?: Map -} - -export interface AIChatIPCSendParams { - /**InteractiveJSONInput */ - value: string - id: string - /**用户审阅过程中选择得btn */ - optionValue?: string -} -type MakeOptional = Omit & Partial> -export interface AISendSyncMessageParams { - syncType: AIInputEvent['SyncType'] - SyncJsonInput?: AIInputEvent['SyncJsonInput'] - params?: MakeOptional - syncID?: string -} -export interface AISendConfigHotpatchParams { - hotpatchType: AIInputEvent['HotpatchType'] - params: MakeOptional - taskId?: AIInputEvent['TaskId'] -} -export interface ChatIPCContextDispatcher { - /** useChatIPC的各种事件 */ - chatIPCEvents: UseChatIPCEvents - /** 发送自由对话 */ - handleSendCasual: (params: AIChatIPCSendParams) => void - /** 任务规划 */ - handleSendTask: (params: AIChatIPCSendParams) => void - /** 停止ai */ - handleStop: () => void - handleSend: (params: AIChatIPCSendParams) => void - /**发送 Sync-Type */ - handleSendSyncMessage: (params: AISendSyncMessageParams) => void - /**发送 Config-Hotpatch */ - handleSendConfigHotpatch: (params: AISendConfigHotpatchParams) => void -} - -export interface ChatIPCContextValue { - store: ChatIPCContextStore - dispatcher: ChatIPCContextDispatcher -} -export const defaultDispatcherOfChatIPC: ChatIPCContextDispatcher = { - chatIPCEvents: { - fetchToken: () => '', - fetchAIRequest: () => undefined, - fetchCurrentCasualTaskID: () => '', - fetchCurrentTaskPlanID: () => undefined, - onSwitchChat: () => {}, - onStart: () => {}, - onSend: () => {}, - onClose: () => {}, - onReset: () => {}, - handleTaskReviewRelease: () => {}, - fetchChatDataStore: () => undefined, - onDelChats: () => {}, - handleCancelLoadingChange: () => {}, - handleResetTarget: () => {}, - handleUserManualIntervention: () => {}, - handleLoadMoreHistory: () => {}, - handleHasMoreHistory: () => false, - resetCurrentTaskPlanID: () => {}, - }, - handleSendCasual: () => {}, - handleSendTask: () => {}, - handleSend: () => {}, - // handleStart: () => {}, - handleStop: () => {}, - handleSendSyncMessage: () => {}, - handleSendConfigHotpatch: () => {}, -} -export default createContext({ - store: { - chatIPCData: cloneDeep(defaultChatIPCData), - reviewInfo: undefined, - planReviewTreeKeywordsMap: new Map(), - reviewExpand: false, - }, - dispatcher: defaultDispatcherOfChatIPC, -}) diff --git a/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/useDispatcher.ts b/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/useDispatcher.ts deleted file mode 100644 index 2dba4b2c52..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/useDispatcher.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from 'react' -import ChatIPCContext, { ChatIPCContextDispatcher } from './ChatIPCContent' - -export default function useChatIPCDispatcher(): ChatIPCContextDispatcher { - const { dispatcher } = useContext(ChatIPCContext) - return dispatcher -} diff --git a/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/useStore.ts b/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/useStore.ts deleted file mode 100644 index 3f17a57293..0000000000 --- a/app/renderer/src/main/src/pages/ai-agent/useContext/ChatIPCContent/useStore.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from 'react' -import ChatIPCContext, { ChatIPCContextStore } from './ChatIPCContent' - -export default function useChatIPCStore(): ChatIPCContextStore { - const { store } = useContext(ChatIPCContext) - return store -} diff --git a/app/renderer/src/main/src/pages/ai-agent/utils/index.ts b/app/renderer/src/main/src/pages/ai-agent/utils/index.ts index 4d3e6b8147..d42a6d914a 100644 --- a/app/renderer/src/main/src/pages/ai-agent/utils/index.ts +++ b/app/renderer/src/main/src/pages/ai-agent/utils/index.ts @@ -1,13 +1,16 @@ import { AIAgentSetting } from '../aiAgentType' import isNil from 'lodash/isNil' import { AIAgentSettingDefault, AttachedResourceKeyEnum, AttachedResourceTypeEnum } from '../defaultConstant' -import { AIAgentGrpcApi, AIInputEvent, AttachedResourceInfo } from '../../ai-re-act/hooks/grpcApi' +import { AIAgentGrpcApi, AIInputEvent, AIStartParams, AttachedResourceInfo } from '../../ai-re-act/hooks/grpcApi' import { AITaskInfoProps } from '../../ai-re-act/hooks/aiRender' import { HandleStartParams } from '../aiAgentChat/type' import { AIMentionCommandParams } from '../components/aiMilkdownInput/aiMilkdownMention/aiMentionPlugin' import { omit } from 'lodash' import { randomString } from '@/utils/randomUtil' import { isIRify } from '@/utils/envfile' +import { UseChatIPCStartParams } from '../useContext/AIAgentContext' +import { AISession } from '../type/aiChat' +import { globalSessionEngine } from '@/pages/ai-re-act/hooks/ChatMultiSessionController' export const getPlanTaskLevel = (task: Pick) => task.level @@ -180,8 +183,8 @@ const getResourceInfoByMention = (mention: AIMentionCommandParams): AttachedReso } /** @name 将前端的结构转化为符合定义的结构 */ export const getAIReActRequestParams = (value: HandleStartParams) => { - const { extraValue, mentionList = [], imageList = [], httpFlowList = [], codeBlockList = [], showQS } = value - let extra: HandleStartParams['extraValue'] = {} + const { mentionList = [], imageList = [], httpFlowList = [], codeBlockList = [] } = value + let attachedResourceInfo: AIInputEvent['AttachedResourceInfo'] = [] for (let item of mentionList) { const addItem = getResourceInfoByMention(item) @@ -254,12 +257,7 @@ export const getAIReActRequestParams = (value: HandleStartParams) => { attachedResourceInfo = [...attachedResourceInfo, ...addItem] } - if (!!showQS) { - extra.showQS = showQS - } - extra = Object.assign(extraValue || {}, extra) return { - extra, attachedResourceInfo, } } @@ -269,3 +267,35 @@ export const getAIReActRequestParams = (value: HandleStartParams) => { export const createActiveChatSessionId = () => { return randomString(40) } + +interface ReStartParams { + /** 优先级高于 activeChat.StartParams */ + setting?: AIStartParams + activeChat: AISession + onStart: (params: UseChatIPCStartParams) => void +} +/** 重启会话 */ +export const onReStart = (props: ReStartParams) => { + const { setting, activeChat, onStart } = props + if (!activeChat?.SessionID) return + const execute = globalSessionEngine.getSessionExecute(activeChat.SessionID) + if (!execute) { + const request: AIStartParams = setting ?? { + ...AIAgentSettingDefault, + ...activeChat.StartParams, + PreferSessionCachedConfig: true, + UserQuery: '', + } + // 发送初始化参数 + const aiInputEvent: AIInputEvent = { + IsStart: true, + Params: { + ...request, + }, + } + onStart({ + token: activeChat?.SessionID, + params: aiInputEvent, + }) + } +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss index 14fc82295a..c34ab444b5 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss @@ -88,10 +88,6 @@ gap: 8px; background: var(--Colors-Use-Basic-Background); .chat-container { - .chat-header { - padding: 12px 8px 12px 0px; - margin-left: 8px; - } } } .ai-re-act-chat-hidden { @@ -103,111 +99,6 @@ overflow: hidden; height: 100%; @extend %display-column-center; - .chat-header { - @extend %display-flex-center; - // position: relative; - // min-width: 0; - gap: 12px; - padding: 12px 8px; - height: 48px; - .chat-header-title { - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 14px; - font-weight: 500; - line-height: 20px; - letter-spacing: 0.1px; - @extend %display-flex-center; - gap: 4px; - flex: 1; - min-width: 0; - overflow: hidden; - .chat-header-title-text { - flex-shrink: 0; - } - .chat-header-focus-mode { - overflow: hidden; - .text { - min-width: 0; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex-shrink: 1; - } - } - } - .chat-header-extra { - @extend %display-flex-center; - justify-content: flex-end; - gap: 8px; - flex-shrink: 0; - // position: absolute; - // right: 8px; - // top: 0; - // bottom: 0; - // z-index: 2; - // padding-left: 12px; - background: linear-gradient( - to right, - transparent, - var(--Colors-Use-Basic-Background) 12px, - var(--Colors-Use-Basic-Background) - ); - .info-token { - display: flex; - align-items: center; - gap: 4px; - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 12px; - font-weight: 400; - } - .token-tag { - height: 16px; - padding: 2px 6px; - display: flex; - align-items: center; - gap: 4px; - border-radius: 2px; - svg { - width: 12px; - height: 12px; - } - } - .upload-token { - background: var(--Colors-Use-Error-Bg-Hover); - color: var(--Colors-Use-Error-Primary); - svg { - color: var(--Colors-Use-Error-Primary); - } - } - .download-token { - background: var(--Colors-Use-Neutral-Bg-Hover); - color: var(--Colors-Use-Neutral-Text-3-Secondary); - svg { - color: var(--Colors-Use-Neutral-Text-3-Secondary); - } - } - - .divider-style { - width: 1px; - height: 12px; - border-left: 1px solid var(--Colors-Use-Neutral-Border); - } - .info-time { - color: var(--Colors-Use-Neutral-Text-1-Title); - font-size: 12px; - font-weight: 400; - line-height: 16px; - } - } - } - .todoList-wrapper { - height: 33px; - .to-do-list { - margin-left: 4px; - margin-right: 12px; - } - } } .chat-footer { @@ -349,98 +240,3 @@ transform: translateX(-50%); } } - -.history-chat-tooltip { - max-width: none; - - &:global(.ant-tooltip-placement-top), - &:global(.ant-tooltip-placement-topLeft), - &:global(.ant-tooltip-placement-topRight) { - padding-bottom: 4px; - - :global(.ant-tooltip-arrow) { - bottom: 1px; - } - } - - &:global(.ant-tooltip-placement-bottom), - &:global(.ant-tooltip-placement-bottomLeft), - &:global(.ant-tooltip-placement-bottomRight) { - padding-top: 4px; - - :global(.ant-tooltip-arrow) { - top: 1px; - } - } - - :global { - .ant-tooltip-inner { - padding: 0; - display: block; - min-height: unset; - background: var(--Colors-Use-Basic-Background); - border: 1px solid var(--Colors-Use-Neutral-Border); - color: inherit; - box-shadow: 0 4px 16px var(--Colors-Use-Basic-Shadow); - overflow: hidden; - } - - .ant-tooltip-arrow-content { - border: 1px solid var(--Colors-Use-Neutral-Border); - background-color: var(--Colors-Use-Basic-Background); - box-shadow: 0 4px 16px var(--Colors-Use-Basic-Shadow); - } - - .ant-tooltip-arrow-content::before { - opacity: 0; - } - } -} -.chat-locate-popover { - :global { - .ant-popover-inner-content { - padding: 12px; - } - } - .chat-locate-list { - display: flex; - flex-direction: column; - max-height: 240px; - overflow-y: auto; - width: 200px; - // gap: 4px; - } - - .chat-locate-item { - padding: 4px; - font-size: 14px; - line-height: 20px; - color: var(--Colors-Use-Neutral-Text-1-Title); - cursor: pointer; - max-width: 280px; - align-items: center; - border-radius: 4px; - @include mixin.text-ellipsis; - svg { - width: 16px; - height: 16px; - color: var(--Colors-Use-Neutral-Disable); - } - &:hover { - background-color: var(--Colors-Use-Neutral-Bg-Hover); - color: var(--Colors-Use-Main-Primary); - svg { - color: var(--Colors-Use-Main-Primary); - } - } - } -} - -.history-chat-tooltip-content { - width: 280px; - height: 300px; - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx index 49730a206f..a9ab501bbf 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.tsx @@ -2,53 +2,35 @@ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react import styles from './AIReActChat.module.scss' import { AIHandleStartResProps, AINotifyMessageProps, AIReActChatProps, AISendResProps } from './AIReActChatType' -import { AIChatTextarea } from '@/pages/ai-agent/template/template' import { AIReActChatContents } from '../aiReActChatContents/AIReActChatContents' import type { AIReActChatContentsRef } from '../aiReActChatContents/AIReActChatContentsType' import { AIChatTextareaRefProps, AIChatTextareaSubmit } from '@/pages/ai-agent/template/type' -import { useControllableValue, useCreation, useMemoizedFn } from 'ahooks' +import { useControllableValue, useCreation, useInViewport, useMemoizedFn } from 'ahooks' import { yakitNotify } from '@/utils/notification' -import { ColorsChatIcon } from '@/assets/icon/colors' import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' import classNames from 'classnames' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { ChevrondownButton, ChevronleftButton, RoundedStopButton } from './AIReActComponent' -import { Tooltip } from 'antd' -import { ClockIcon } from '@/assets/newIcon' -import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import HistoryChat from '@/pages/ai-agent/historyChat/HistoryChat' -import { AIInputEvent, AIInputEventSyncTypeEnum, AIStartParams } from '../hooks/grpcApi' +import { ChevrondownButton } from './AIReActComponent' +import { AIInputEvent, AIInputEventSyncTypeEnum, AISourceEnum, AIStartParams } from '../hooks/grpcApi' import { AITaskQuery } from '@/pages/ai-agent/components/aiTaskQuery/AITaskQuery' import { HandleStartParams } from '@/pages/ai-agent/aiAgentChat/type' import { formatAIAgentSetting, getAIReActRequestParams } from '@/pages/ai-agent/utils' -import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' import { AISession } from '@/pages/ai-agent/type/aiChat' import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' import { randomString } from '@/utils/randomUtil' import useAINodeLabel from '../hooks/useAINodeLabel' import useSessionId from '../hooks/useSessionId' -import useGetChatDataStoreKey, { - getAISourceFromChatDataStoreKey, - getAISourceListFromChatDataStoreKey, -} from '../hooks/useGetChatDataStoreKey' -import { AISendSyncMessageParams } from '@/pages/ai-agent/useContext/ChatIPCContent/ChatIPCContent' import emiter from '@/utils/eventBus/eventBus' -import { omit } from 'lodash' -import AIContextToken from '@/pages/ai-agent/aiChatContent/AIContextToken/AIContextToken' -import { AIToDoList } from './aiToDoList/AIToDoList' -import { cloneDeep } from 'lodash' -import { DefaultTodoListCardData } from '../hooks/defaultConstant' -import { TodoListCardData, AIChatQSDataTypeEnum } from '../hooks/aiRender' -import { OutlineLandPlotIcon, OutlineListTodoIcon } from '@/assets/icon/outline' -import TaskDetailsPopover from '@/components/historyAIReActChat/TaskDetailsPopover' -import { YakitPopover } from '@/components/yakitUI/YakitPopover/YakitPopover' -import { SolidChatIcon } from '@/assets/icon/solid' +import { useCurrentStore } from '../hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCurrentSessionId from '../hooks/useCurrentSessionId' +import { AIReactChatTextarea } from './aiReactChatTextarea/AIReactChatTextarea' +import { AIReActChatHeader } from './aiReActChatHeader/AIReActChatHeader' +import { AIToDoListWrapper } from './aiToDoListWrapper/AIToDoListWrapper' +import { globalSessionEngine } from '../hooks/ChatMultiSessionController' export const AIReActChat: React.FC = React.memo( forwardRef((props, ref) => { const { - mode, chatContainerClassName, chatContainerHeaderClassName, title = '自由对话', @@ -56,21 +38,13 @@ export const AIReActChat: React.FC = React.memo( startRequest, externalParameters, } = props - const { setActiveChat } = useAIAgentDispatcher() + const { setActiveChat, getSetting, onStart, onSend } = useAIAgentDispatcher() - const { chatDataStoreKey } = useGetChatDataStoreKey() - const historyChatAISource = useCreation( - () => getAISourceListFromChatDataStoreKey(chatDataStoreKey), - [chatDataStoreKey], - ) - const sessionRef = useRef(undefined) - const { chatIPCData } = useChatIPCStore() - const { chatIPCEvents, handleSendSyncMessage } = useChatIPCDispatcher() - const execute = useCreation(() => chatIPCData.execute, [chatIPCData.execute]) - const focusMode = useCreation(() => chatIPCData.focusMode, [chatIPCData.focusMode]) - const notifyMessage = useCreation(() => chatIPCData.notifyMessage, [chatIPCData.notifyMessage]) + const sessionId = useCurrentSessionId() + const store = useCurrentStore() const wrapperRef = useRef(null) + const [inViewPort = true] = useInViewport(wrapperRef) const [showFreeChat, setShowFreeChat] = useControllableValue(props, { defaultValue: true, @@ -81,12 +55,6 @@ export const AIReActChat: React.FC = React.memo( const { activeChat, setting } = useAIAgentStore() const { getSession } = useSessionId() - const contextTokenSession = useCreation(() => { - return activeChat?.SessionID || setting.TimelineSessionID - }, [activeChat?.SessionID, setting.TimelineSessionID]) - - const questionQueue = useCreation(() => chatIPCData.questionQueue, [chatIPCData.questionQueue]) - const aiChatTextareaRef = useRef({ setMention: () => {}, setValue: () => {}, @@ -119,6 +87,30 @@ export const AIReActChat: React.FC = React.memo( } } }, [activeChat?.SessionID]) + + // #region + /** + * 1.切换Session后设置当前选中的 SessionID ,如果该组件被卸载意外着当前没有任何对话在显示 + * 2.当该组件从不可见变可见的时候,需要设置当前选中的 SessionID + * */ + useEffect(() => { + if (activeChat?.SessionID) { + globalSessionEngine?.setActiveShowSession(activeChat?.SessionID) + } + return () => { + globalSessionEngine?.setActiveShowSession('') + } + }, [activeChat?.SessionID]) + useEffect(() => { + if (inViewPort) { + globalSessionEngine?.setActiveShowSession(activeChat?.SessionID ?? '') + + return () => { + globalSessionEngine?.setActiveShowSession('') + } + } + }, [inViewPort]) + //#endregion // #region 问题相关逻辑 // 初始化 AI ReAct const handleSubmit = useMemoizedFn((value: AIChatTextareaSubmit) => { @@ -126,7 +118,7 @@ export const AIReActChat: React.FC = React.memo( yakitNotify('error', '请先配置 AI ReAct 参数') return } - if (execute) { + if (store.getState().execute) { handleSend(value) } else { handleStart(value) @@ -139,19 +131,20 @@ export const AIReActChat: React.FC = React.memo( const { qs, sessionId } = value const sessionID = activeChat?.SessionID || '' // 判断历史还是新建 + const source = getSetting().Source ?? AISourceEnum.aiAgent // getSetting保证最新 const request: AIStartParams = { ...formatAIAgentSetting(setting), UserQuery: qs, CoordinatorId: '', Sequence: 1, PreferSessionCachedConfig: true, - Source: getAISourceFromChatDataStoreKey(chatDataStoreKey), + Source: source, } const session = getSession(sessionId) request.TimelineSessionID = session - const { extra, attachedResourceInfo } = getAIReActRequestParams(value) + const { attachedResourceInfo } = getAIReActRequestParams(value) // 发送初始化参数 const aiInputEvent: AIInputEvent = { IsStart: true, @@ -161,11 +154,12 @@ export const AIReActChat: React.FC = React.memo( AttachedResourceInfo: attachedResourceInfo, FocusModeLoop: value.focusMode, } - const onStart = (res: AIHandleStartResProps) => { - const { params, extraParams, onChat, onChatFromHistory } = res + const onStartChat = (res: AIHandleStartResProps) => { + const { params, extraParams, onChat } = res + let newChat: AISession | undefined = undefined if (!sessionID) { // 创建新的聊天记录 - const newChat: AISession = { + newChat = { Id: extraParams?.chatId || session, Title: qs || `AI Agent - ${new Date().toLocaleString()}`, question: qs, @@ -178,39 +172,43 @@ export const AIReActChat: React.FC = React.memo( LastUsedAt: new Date().getTime(), isCreate: true, } - - setActiveChat && setActiveChat(newChat) + // setActiveChat && setActiveChat(newChat) emiter.emit( 'sessionData', JSON.stringify({ type: 'prependSession', payload: { ...newChat, isCreate: false } }), ) // 新建的额外操作 onChat?.() - } else { - // 历史中的额外操作 - onChatFromHistory?.(sessionID) } aiChatTextareaRef.current.setMention({ mentionId: params.FocusModeLoop || randomString(8), mentionType: 'focusMode', mentionName: params.FocusModeLoop || '', }) - chatIPCEvents.onStart({ token: request.TimelineSessionID!, params, extraValue: extra }) + + onStart({ + token: session, + params, + onSuccess: () => { + // 必须成功链接后再设置 activeChat + if (!sessionID && newChat) setActiveChat && setActiveChat(newChat) + }, + }) } if (!!startRequest) { startRequest({ params: aiInputEvent, }) .then((res) => { - onStart(res) + onStartChat(res) }) .catch(() => { - onStart({ + onStartChat({ params: aiInputEvent, }) }) } else { - onStart({ + onStartChat({ params: aiInputEvent, }) } @@ -220,23 +218,22 @@ export const AIReActChat: React.FC = React.memo( const handleSend = useMemoizedFn((data: HandleStartParams) => { if (!activeChat?.SessionID) return try { - const { extra, attachedResourceInfo } = getAIReActRequestParams(data) + const { attachedResourceInfo } = getAIReActRequestParams(data) const chatMessage: AIInputEvent = { IsFreeInput: true, FreeInput: data.qs, AttachedResourceInfo: attachedResourceInfo, FocusModeLoop: data.focusMode, } - const onSend = (res: AISendResProps) => { + const onSendChat = (res: AISendResProps) => { const { params } = res - chatIPCEvents.onSend({ + onSend({ token: activeChat.SessionID, type: 'casual', params: { IsFreeInput: true, ...params, }, - extraValue: extra, }) emiter.emit('sessionData', JSON.stringify({ type: 'refresh', sessionId: activeChat.SessionID })) } @@ -245,17 +242,17 @@ export const AIReActChat: React.FC = React.memo( .then((res) => { const { params } = res // 发送到服务端 - onSend({ + onSendChat({ params, }) }) .catch(() => { - onSend({ + onSendChat({ params: chatMessage, }) }) } else { - onSend({ + onSendChat({ params: chatMessage, }) } @@ -265,11 +262,11 @@ export const AIReActChat: React.FC = React.memo( // #endregion const isShowRetract = useCreation(() => { - return mode === 'task' && showFreeChat - }, [mode, showFreeChat]) + return showFreeChat + }, [showFreeChat]) const isShowExpand = useCreation(() => { - return mode === 'task' && !showFreeChat - }, [mode, showFreeChat]) + return !showFreeChat + }, [showFreeChat]) const handleSwitchShowFreeChat = useMemoizedFn((v) => { setShowFreeChat(v) }) @@ -278,127 +275,23 @@ export const AIReActChat: React.FC = React.memo( aiChatTextareaRef?.current?.setValue(value ?? '') }) - const cancelCasualLoading = useCreation(() => { - return chatIPCData.cancelCasualLoading - }, [chatIPCData.cancelCasualLoading]) - const casualLoading = useCreation(() => { - return chatIPCData.casualLoading - }, [chatIPCData.casualLoading]) const handleStopCasualTask = useMemoizedFn(() => { - const currentCasualTaskID = chatIPCEvents.fetchCurrentCasualTaskID() - if (!chatIPCData.execute || !currentCasualTaskID) return - - chatIPCEvents.handleCancelLoadingChange('reAct', true) - const params: AISendSyncMessageParams = { - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, + const currentCasualTaskID = store.getState().currentCasualTaskID + if (!store.getState().execute || !currentCasualTaskID) return + + store.getState().updateState({ + cancelCasualLoading: true, + }) + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, SyncJsonInput: JSON.stringify({ task_id: currentCasualTaskID }), + SyncID: randomString(8), } - handleSendSyncMessage(params) + onSend({ token: sessionId, type: 'casual', params: info }) }) - const getPlanDetails = useMemoizedFn(() => { - if (!activeChat?.SessionID) return - return chatIPCEvents.fetchChatDataStore()?.get(activeChat?.SessionID)?.casualChat?.planDetails - }) - - const reActTaskId: string = useCreation(() => { - if (!activeChat?.SessionID) return '' - return getPlanDetails()?.taskId ?? '' - }, [chatIPCData.casualChat?.toolListRenderNumber, activeChat?.SessionID]) - - const todoData: TodoListCardData = useCreation(() => { - if (!activeChat?.SessionID) return cloneDeep(DefaultTodoListCardData) - try { - return getPlanDetails()?.todoList || cloneDeep(DefaultTodoListCardData) - } catch (error) { - return cloneDeep(DefaultTodoListCardData) - } - }, [chatIPCData.casualChat?.toolListRenderNumber, activeChat?.SessionID]) - const aiReActChatContentsRef = useRef(null) - const casualConcurrentTaskList = useCreation(() => { - return chatIPCData.casualChat.elements - .filter((item) => item.kind === 'task' && item.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP) - .map((item) => item.token) - }, [chatIPCData.casualChat.elements]) - - const getCasualConcurrentTaskName = useMemoizedFn((token: string) => { - const contentMap = chatIPCEvents.fetchChatDataStore()?.get(activeChat?.SessionID || '')?.casualChat?.contents - const chatData = contentMap?.get(token) - return ( - (chatData?.data as { taskName?: string; goal?: string })?.taskName || - (chatData?.data as { goal?: string })?.goal || - token - ) - }) - - const onScrollToConcurrentTask = useMemoizedFn((token: string) => { - const index = chatIPCData.casualChat.elements.findIndex((item) => item.kind === 'task' && item.token === token) - if (index !== -1) { - aiReActChatContentsRef.current?.scrollToItemIndex(index, 'smooth') - } - }) - - const getTaskId = useMemoizedFn(() => { - return chatIPCEvents.fetchCurrentCasualTaskID() - }) - - const defaultTaskTabLabel = useCreation(() => { - return typeof title === 'string' ? title : '自由对话' - }, [title]) - - const emitTaskContentTab = useMemoizedFn((type: 'add' | 'update', label?: string, nextTaskId?: string) => { - const taskId = nextTaskId || getTaskId() - const sessionId = activeChat?.SessionID - if (!taskId || !sessionId) return false - if (chatDataStoreKey !== 'aiChatDataStore') return false - emiter.emit( - 'actionAITaskContentTab', - JSON.stringify({ - type, - params: { - key: sessionId, - taskId, - label: label || activeChat?.Title || defaultTaskTabLabel, - goal: '', - }, - }), - ) - return true - }) - - const syncCasualTaskTab = useMemoizedFn(() => { - const sessionId = activeChat?.SessionID - if (!getTaskId() || !sessionId) return - if (chatDataStoreKey !== 'aiChatDataStore') return - emitTaskContentTab('add') - sessionRef.current = sessionId - }) - - const onDetails = useMemoizedFn(() => { - if (!getTaskId()) { - yakitNotify('error', 'taskId不存在') - return - } - if (chatDataStoreKey !== 'aiChatDataStore') { - yakitNotify('info', '当前会话不属于 AIAgent 数据源,无法查看任务详情') - return - } - syncCasualTaskTab() - }) - - useEffect(() => { - if (sessionRef.current && sessionRef.current !== activeChat?.SessionID) { - sessionRef.current = undefined - } - }, [activeChat?.SessionID]) - - useEffect(() => { - if (!activeChat?.Title || !activeChat?.SessionID) return - if (sessionRef.current !== activeChat.SessionID) return - if (!reActTaskId) return - emitTaskContentTab('update', activeChat.Title, reActTaskId) - }, [activeChat?.Title, activeChat?.SessionID, emitTaskContentTab, reActTaskId]) return ( <> @@ -415,124 +308,28 @@ export const AIReActChat: React.FC = React.memo( })} >
-
-
- - {title} - {focusMode && ( - - 场景:{focusMode} - - )} -
-
- {isShowRetract && ( - <> - {!!casualConcurrentTaskList.length && ( - - {casualConcurrentTaskList.map((token) => ( -
onScrollToConcurrentTask(token)} - > - {getCasualConcurrentTaskName(token)} -
- ))} -
- } - placement="bottom" - > - }> - 子Agent任务 - - - )} - {externalParameters?.rightIcon ? ( - <> - {getTaskId() && externalParameters.rightIcon.taskDetails && } - {externalParameters.rightIcon.dataDetails && ( - - )} - {externalParameters.rightIcon.history && ( - - -
- } - > - } title="" /> - - )} - {externalParameters.rightIcon.add} - {externalParameters.rightIcon.close} - - ) : ( - <> - {getTaskId() && ( - } - onClick={onDetails} - > - 任务详情 - - )} - handleSwitchShowFreeChat(false)} /> - - )} - - )} -
-
- {reActTaskId === chatIPCEvents.fetchCurrentCasualTaskID() && todoData?.items?.length > 0 && ( -
- -
- )} - + + +
- {execute && questionQueue?.total > 0 && } - {execute && notifyMessage?.content && } - + +
- - {casualLoading && ( - - )} -
- } - chatDataStoreKey={chatDataStoreKey} - {...omit(externalParameters, 'rightIcon')} + handleSubmit={handleSubmit} + externalParameters={externalParameters} + handleStopCasualTask={handleStopCasualTask} />
@@ -549,10 +346,14 @@ export const AIReActChat: React.FC = React.memo( }), ) -const AINotifyMessage: React.FC = React.memo((props) => { - const { notifyMessage } = props +const AINotifyMessage: React.FC = React.memo(() => { + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) + const notifyMessage = useStore(store, (state) => state.notifyMessage) + const { nodeLabel } = useAINodeLabel(notifyMessage?.label) - return ( + + return execute && notifyMessage?.content ? (
{nodeLabel}
@@ -564,5 +365,7 @@ const AINotifyMessage: React.FC = React.memo((props) => {
+ ) : ( + <> ) }) diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.d.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.d.ts deleted file mode 100644 index e7437e2b9a..0000000000 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AIAgentChatMode } from '@/pages/ai-agent/aiAgentChat/type' -import { AIChatQSData } from '../hooks/aiRender' - -export interface AIReActChatProps { - mode: AIAgentChatMode - chatContainerClassName?: string - chatContainerHeaderClassName?: string - showFreeChat: boolean - setShowFreeChat: (show: boolean) => void - title?: React.ReactNode - aiChatTextareaRef: ForwardedRef - handleSendAfter?: () => void - otherComponentCallsProps?: Partial<{ - chevronleftButton: React.ReactNode - remoteButtion: React.ReactNode - }> -} - -export interface AIReActLogProps { - logs: AIChatQSData[] - setLogVisible: (visible: boolean) => void -} - -export interface AIReActTimelineMessageProps { - message?: string - loading: boolean - setLoading: (loading: boolean) => void -} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.ts index e13e4031f3..5048f094de 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChatType.ts @@ -1,9 +1,8 @@ -import { AIAgentChatMode, HandleStartParams } from '@/pages/ai-agent/aiAgentChat/type' +import { HandleStartParams } from '@/pages/ai-agent/aiAgentChat/type' import { AIChatQSData } from '../hooks/aiRender' import { AIInputEvent } from '../hooks/grpcApi' import React from 'react' import { AIChatTextareaProps, AIChatTextareaRefProps } from '@/pages/ai-agent/template/type' -import { UseChatIPCState } from '../hooks/type' import { YakitButtonProp } from '@/components/yakitUI/YakitButton/YakitButton' export type DataDetailsButtonProps = Omit @@ -21,7 +20,6 @@ export interface AIHandleStartResProps { params: AIInputEvent extraParams?: AIHandleStartExtraProps onChat?: () => void - onChatFromHistory?: (sessionID: string) => void } export interface AISendParams { params: AIInputEvent @@ -46,7 +44,6 @@ type ExternalParametersRightIcon = Partial<{ [RightIconType.taskDetails]: boolean }> export interface AIReActChatProps { - mode: AIAgentChatMode chatContainerClassName?: string chatContainerHeaderClassName?: string showFreeChat: boolean @@ -68,9 +65,7 @@ export interface AIReActChatProps { } } -export interface AINotifyMessageProps { - notifyMessage: UseChatIPCState['notifyMessage'] -} +export interface AINotifyMessageProps {} export interface AIReActLogProps { logs: AIChatQSData[] setLogVisible: (visible: boolean) => void diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/AIReActChatHeader.module.scss b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/AIReActChatHeader.module.scss new file mode 100644 index 0000000000..6a56be2ccd --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/AIReActChatHeader.module.scss @@ -0,0 +1,147 @@ +@use '../../../../styles/mixin.scss' as mixin; + +%display-flex-center { + display: flex; + align-items: center; +} +.chat-header { + @extend %display-flex-center; + // position: relative; + // min-width: 0; + gap: 12px; + padding: 12px 8px 12px 0px; + margin-left: 8px; + height: 48px; + .chat-header-title { + color: var(--Colors-Use-Neutral-Text-1-Title); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.1px; + @extend %display-flex-center; + gap: 4px; + flex: 1; + min-width: 0; + overflow: hidden; + .chat-header-title-text { + flex-shrink: 0; + } + .chat-header-focus-mode { + overflow: hidden; + .text { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 1; + } + } + } + .chat-header-extra { + @extend %display-flex-center; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; + background: linear-gradient( + to right, + transparent, + var(--Colors-Use-Basic-Background) 12px, + var(--Colors-Use-Basic-Background) + ); + } +} + +.history-chat-tooltip { + max-width: none; + + &:global(.ant-tooltip-placement-top), + &:global(.ant-tooltip-placement-topLeft), + &:global(.ant-tooltip-placement-topRight) { + padding-bottom: 4px; + + :global(.ant-tooltip-arrow) { + bottom: 1px; + } + } + + &:global(.ant-tooltip-placement-bottom), + &:global(.ant-tooltip-placement-bottomLeft), + &:global(.ant-tooltip-placement-bottomRight) { + padding-top: 4px; + + :global(.ant-tooltip-arrow) { + top: 1px; + } + } + + :global { + .ant-tooltip-inner { + padding: 0; + display: block; + min-height: unset; + background: var(--Colors-Use-Basic-Background); + border: 1px solid var(--Colors-Use-Neutral-Border); + color: inherit; + box-shadow: 0 4px 16px var(--Colors-Use-Basic-Shadow); + overflow: hidden; + } + + .ant-tooltip-arrow-content { + border: 1px solid var(--Colors-Use-Neutral-Border); + background-color: var(--Colors-Use-Basic-Background); + box-shadow: 0 4px 16px var(--Colors-Use-Basic-Shadow); + } + + .ant-tooltip-arrow-content::before { + opacity: 0; + } + } +} +.chat-locate-popover { + :global { + .ant-popover-inner-content { + padding: 12px; + } + } + .chat-locate-list { + display: flex; + flex-direction: column; + max-height: 240px; + overflow-y: auto; + width: 200px; + // gap: 4px; + } + + .chat-locate-item { + padding: 4px; + font-size: 14px; + line-height: 20px; + color: var(--Colors-Use-Neutral-Text-1-Title); + cursor: pointer; + max-width: 280px; + align-items: center; + border-radius: 4px; + @include mixin.text-ellipsis; + svg { + width: 16px; + height: 16px; + color: var(--Colors-Use-Neutral-Disable); + } + &:hover { + background-color: var(--Colors-Use-Neutral-Bg-Hover); + color: var(--Colors-Use-Main-Primary); + svg { + color: var(--Colors-Use-Main-Primary); + } + } + } +} +.history-chat-tooltip-content { + width: 280px; + height: 300px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/AIReActChatHeader.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/AIReActChatHeader.tsx new file mode 100644 index 0000000000..bf3f6523e8 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/AIReActChatHeader.tsx @@ -0,0 +1,261 @@ +import React, { useEffect, useRef } from 'react' +import classNames from 'classnames' +import { Tooltip } from 'antd' +import { useStore } from 'zustand' + +import styles from './AIReActChatHeader.module.scss' +import { ColorsChatIcon } from '@/assets/icon/colors' +import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' +import TaskDetailsPopover from '@/components/historyAIReActChat/TaskDetailsPopover' +import AIContextToken from '@/pages/ai-agent/aiChatContent/AIContextToken/AIContextToken' +import HistoryChat from '@/pages/ai-agent/historyChat/HistoryChat' +import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' +import { ClockIcon } from '@/assets/newIcon' +import { OutlineLandPlotIcon, OutlineListTodoIcon } from '@/assets/icon/outline' +import { useCurrentRawData, useCurrentStore } from '@/pages/ai-re-act/hooks/useCurrentDataBySession' +import { AIReActChatHeaderExternalRightIconProps, AIReActChatHeaderProps, AIReActSubAgentTaskProps } from './type' +import { ChevronleftButton } from '../AIReActComponent' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' +import useCreation from 'ahooks/lib/useCreation' +import { yakitNotify } from '@/utils/notification' +import emiter from '@/utils/eventBus/eventBus' +import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' +import { AISource, AISourceEnum } from '../../hooks/grpcApi' +import { YakitPopover } from '@/components/yakitUI/YakitPopover/YakitPopover' +import { SolidChatIcon } from '@/assets/icon/solid' +import useAIItemKind from '../../hooks/useAIItemKind' +import { AIChatQSDataTypeEnum } from '../../hooks/aiRender' +import { AI_AGENT_HISTORY_AI_SOURCES } from '../../hooks/useGetChatDataStoreKey' + +export const AIReActChatHeader: React.FC = React.memo((props) => { + const { + title, + chatContainerHeaderClassName, + isShowRetract, + externalParameters, + handleSwitchShowFreeChat, + scrollToItemIndex, + } = props + + const { activeChat } = useAIAgentStore() + const { getSetting } = useAIAgentDispatcher() + + // 内部订阅 Store 数据 + const store = useCurrentStore() + const focusMode = useStore(store, (state) => state.focusMode) + const currentCasualTaskID = useStore(store, (state) => state.currentCasualTaskID) + + const sessionRef = useRef(undefined) + + useEffect(() => { + if (sessionRef.current && sessionRef.current !== activeChat?.SessionID) { + sessionRef.current = undefined + } + }, [activeChat?.SessionID]) + + useEffect(() => { + if (!activeChat?.Title || !activeChat?.SessionID) return + if (sessionRef.current !== activeChat.SessionID) return + emitTaskContentTab('update', activeChat.Title) + }, [activeChat?.Title, activeChat?.SessionID, currentCasualTaskID]) + + const defaultTaskTabLabel = useCreation(() => { + return typeof title === 'string' ? title : '自由对话' + }, [title]) + + const emitTaskContentTab = useMemoizedFn((type: 'add' | 'update', label?: string) => { + const sessionId = activeChat?.SessionID + const taskId = currentCasualTaskID + if (!taskId || !sessionId) return false + if (getSetting()?.Source !== 'ai') return false + emiter.emit( + 'actionAITaskContentTab', + JSON.stringify({ + type, + params: { + key: sessionId, + taskId, + label: label || activeChat?.Title || defaultTaskTabLabel, + goal: '', + }, + }), + ) + return true + }) + + const syncCasualTaskTab = useMemoizedFn(() => { + const sessionId = activeChat?.SessionID + if (!currentCasualTaskID || !sessionId) return + if (getSetting().Source !== AISourceEnum.aiAgent) return false + emitTaskContentTab('add') + sessionRef.current = sessionId + }) + + const onDetails = useMemoizedFn(() => { + if (!currentCasualTaskID) { + yakitNotify('error', 'currentCasualTaskID不存在') + return + } + if (getSetting().Source !== AISourceEnum.aiAgent) { + yakitNotify('info', '当前会话不属于 AIAgent 数据源,无法查看任务详情') + return + } + syncCasualTaskTab() + }) + return ( +
+
+ + {title} + {focusMode && ( + + 场景:{focusMode} + + )} +
+
+ {isShowRetract && ( + <> + + {!!externalParameters?.rightIcon ? ( + + ) : ( + <> + {currentCasualTaskID && ( + } onClick={onDetails}> + 任务详情 + + )} + handleSwitchShowFreeChat(false)} /> + + )} + + )} +
+
+ ) +}) + +const AIReActSubAgentTask: React.FC = React.memo((props) => { + const { scrollToItemIndex } = props + const store = useCurrentStore() + const rawData = useCurrentRawData() + const getKind = useAIItemKind() + const casualChatElementLength = useStore(store, (state) => state.casualChat?.elements?.length || 0) + + const onScrollToConcurrentTask = useMemoizedFn((token: string) => { + const elements = store.getState().casualChat?.elements || [] + const index = elements.findIndex((item) => item.token === token) + if (index !== -1) { + scrollToItemIndex?.(index, 'smooth') + } + }) + + const casualConcurrentTaskList = useCreation(() => { + const list: string[] = [] + const elements = store.getState().casualChat?.elements || [] + for (const item of elements) { + const kind = getKind(item.token) + if (kind !== 'task') continue + const itemContent = rawData.contents.get(item.token) + if (itemContent?.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP) { + list.push(item.token) + } + } + return list + }, [casualChatElementLength]) + + const getCasualConcurrentTaskName = useMemoizedFn((token: string) => { + const contentMap = rawData?.contents + const chatData = contentMap?.get(token) + switch (chatData?.type) { + case AIChatQSDataTypeEnum.TASK_NODE_GROUP: + return chatData?.data?.taskName || chatData?.data?.goal || token + + default: + return token + } + }) + + return ( + <> + {!!casualConcurrentTaskList.length && ( + + {casualConcurrentTaskList.map((token) => ( +
onScrollToConcurrentTask(token)}> + {getCasualConcurrentTaskName(token)} +
+ ))} +
+ } + placement="bottom" + > + }> + 子Agent任务 + + + )} + + ) +}) + +const AIReActChatHeaderExternalRightIcon: React.FC = React.memo((props) => { + const { rightIcon } = props + + const store = useCurrentStore() + const currentCasualTaskID = useStore(store, (state) => state.currentCasualTaskID) + + const { setting } = useAIAgentStore() + + const aiSource: AISource[] = useCreation(() => { + switch (setting?.Source) { + case AISourceEnum.flow: + return ['flow'] + case AISourceEnum.knowledgeBase: + return ['knowledgeBase'] + case AISourceEnum.webFuzzer: + return ['webFuzzer'] + case AISourceEnum.irify: + return ['irify'] + case AISourceEnum.yakRunner: + return ['yakRunner'] + case AISourceEnum.im: + return ['im'] + case AISourceEnum.history: + return ['history'] + default: + return AI_AGENT_HISTORY_AI_SOURCES // AI Agent 侧栏历史会话:包含 ai、im 来源与兼容老数据的空 source + } + }, [setting?.Source]) + + return !!rightIcon ? ( + <> + {currentCasualTaskID && rightIcon.taskDetails && } + {rightIcon.dataDetails && ( + + )} + {rightIcon.history && ( + + +
+ } + > + } title="" /> + + )} + {rightIcon.add} + {rightIcon.close} + + ) : ( + <> + ) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/type.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/type.ts new file mode 100644 index 0000000000..0bf1620190 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReActChatHeader/type.ts @@ -0,0 +1,24 @@ +import { AIReActChatContentsRef } from '../../aiReActChatContents/AIReActChatContentsType' +import { AIReActChatProps } from '../AIReActChatType' +import React from 'react' + +export interface AIReActChatHeaderProps { + title?: React.ReactNode + chatContainerHeaderClassName?: string + isShowRetract?: boolean + externalParameters?: AIReActChatProps['externalParameters'] + + source?: string + onDetails?: (e: React.MouseEvent) => void + handleSwitchShowFreeChat: (show: boolean) => void + + scrollToItemIndex?: AIReActSubAgentTaskProps['scrollToItemIndex'] +} + +export interface AIReActChatHeaderExternalRightIconProps { + rightIcon?: NonNullable['rightIcon'] +} + +export interface AIReActSubAgentTaskProps { + scrollToItemIndex?: AIReActChatContentsRef['scrollToItemIndex'] +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/AIReactChatTextarea.module.scss b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/AIReactChatTextarea.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/AIReactChatTextarea.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/AIReactChatTextarea.tsx new file mode 100644 index 0000000000..0756382325 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/AIReactChatTextarea.tsx @@ -0,0 +1,41 @@ +import React, { forwardRef } from 'react' +import { AIReactChatTextareaProps } from './type' +import { AIChatTextarea } from '@/pages/ai-agent/template/template' +import styles from '../AIReActChat.module.scss' +import { RoundedStopButton } from '../AIReActComponent' +import omit from 'lodash/omit' +import { useStore } from 'zustand' +import { useCurrentStore } from '../../hooks/useCurrentDataBySession' +import useGetChatDataStoreKey from '../../hooks/useGetChatDataStoreKey' + +export const AIReactChatTextarea: React.FC = React.memo( + forwardRef((props, ref) => { + const { handleSubmit, externalParameters, handleStopCasualTask } = props + + const store = useCurrentStore() + const cancelCasualLoading = useStore(store, (state) => state.cancelCasualLoading) + const casualLoading = useStore(store, (state) => state.casualLoading) + + const chatDataStoreKey = useGetChatDataStoreKey() + return ( + + {casualLoading && ( + + )} +
+ } + chatDataStoreKey={chatDataStoreKey} + {...omit(externalParameters, 'rightIcon')} + /> + ) + }), +) diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/type.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/type.ts new file mode 100644 index 0000000000..744795bfd5 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiReactChatTextarea/type.ts @@ -0,0 +1,9 @@ +import { AIChatTextareaRefProps, AIChatTextareaSubmit } from '@/pages/ai-agent/template/type' +import { AIReActChatProps } from '../AIReActChatType' + +export interface AIReactChatTextareaProps { + ref?: React.ForwardedRef + handleSubmit: (v: AIChatTextareaSubmit) => void + externalParameters: AIReActChatProps['externalParameters'] + handleStopCasualTask: () => void +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoList/icon.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoList/icon.tsx index 90ef8d4fff..1d8bb5dd55 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoList/icon.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoList/icon.tsx @@ -1,7 +1,6 @@ import Icon from '@ant-design/icons' import { CustomIconComponentProps } from '@ant-design/icons/lib/components/Icon' import React from 'react' -import { v4 as uuidv4 } from 'uuid' interface IconProps extends CustomIconComponentProps { onClick: (e: React.MouseEvent) => void diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/AIToDoListWrapper.module.scss b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/AIToDoListWrapper.module.scss new file mode 100644 index 0000000000..eb3a06b360 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/AIToDoListWrapper.module.scss @@ -0,0 +1,7 @@ +.todoList-wrapper { + height: 33px; + .to-do-list { + margin-left: 4px; + margin-right: 12px; + } +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/AIToDoListWrapper.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/AIToDoListWrapper.tsx new file mode 100644 index 0000000000..2b8dd31fc8 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/AIToDoListWrapper.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { AIToDoListWrapperProps } from './type' +import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' +import { useCurrentRawData, useCurrentStore } from '../../hooks/useCurrentDataBySession' +import useCreation from 'ahooks/lib/useCreation' +import cloneDeep from 'lodash/cloneDeep' +import { useStore } from 'zustand' +import { TodoListCardData } from '../../hooks/aiRender' +import { DefaultTodoListCardData } from '../../hooks/defaultConstant' +import { AIToDoList } from '../aiToDoList/AIToDoList' +import styles from './AIToDoListWrapper.module.scss' + +export const AIToDoListWrapper: React.FC = React.memo((props) => { + const { activeChat } = useAIAgentStore() + const store = useCurrentStore() + const rawData = useCurrentRawData() + const currentCasualTaskID = useStore(store, (state) => state.currentCasualTaskID) + const todoListUpdate = useStore(store, (state) => state.casualChat?.todoListUpdate) + + const todoData: TodoListCardData = useCreation(() => { + if (!activeChat?.SessionID) return cloneDeep(DefaultTodoListCardData) + try { + return rawData.casualChat.planDetails?.todoList || cloneDeep(DefaultTodoListCardData) + } catch (error) { + return cloneDeep(DefaultTodoListCardData) + } + }, [todoListUpdate, activeChat?.SessionID]) + + const reActTaskId: string = useCreation(() => { + if (!activeChat?.SessionID) return '' + return rawData.casualChat.planDetails?.taskId ?? '' + }, [todoListUpdate, activeChat?.SessionID]) + + return ( + <> + {reActTaskId === currentCasualTaskID && todoData?.items?.length > 0 && ( +
+ +
+ )} + + ) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/type.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/type.ts new file mode 100644 index 0000000000..3df5c11ffc --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/aiToDoListWrapper/type.ts @@ -0,0 +1 @@ +export interface AIToDoListWrapperProps {} diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.module.scss b/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.module.scss index f3d483f1ca..74c5139fef 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.module.scss +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.module.scss @@ -63,15 +63,3 @@ cursor: pointer; z-index: 1; } -// .ai-text-markdown-reference-node { -// } -// .ai-yaklang-reference-node { -// padding-top: 8px; -// } -// .ai-text-plain-reference-node { -// } -// .ai-log-tool-reference-node { -// } -// .ai-stream-chat-reference-node { -// padding: 0 12px 8px 12px; -// } diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.tsx index 5c8ef93fbf..0fc2ca32b9 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContents.tsx @@ -1,13 +1,6 @@ -import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react' -import classNames from 'classnames' -import { - AIReActChatContentsPProps, - AIReferenceNodeProps, - AIReActChatContentsRef, - AIStreamNodeProps, -} from './AIReActChatContentsType' +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState, useEffect } from 'react' +import { AIReActChatContentsPProps, AIReferenceNodeProps, AIStreamNodeProps } from './AIReActChatContentsType' import styles from './AIReActChatContents.module.scss' -import { useCreation } from 'ahooks' import { AIMarkdown } from '@/pages/ai-agent/components/aiMarkdown/AIMarkdown' import { AIStreamChatContent } from '@/pages/ai-agent/components/aiStreamChatContent/AIStreamChatContent' import StreamCard from '@/pages/ai-agent/components/StreamCard' @@ -20,40 +13,31 @@ import { AIStreamContentType } from '../hooks/defaultConstant' import { Virtuoso } from 'react-virtuoso' import useVirtuosoAutoScroll from '../hooks/useVirtuosoAutoScroll' import useChatStreamLocateHighlight from '../hooks/useChatStreamLocateHighlight' -import { ChatReferenceMaterialPayload, ReActChatRenderItem } from '../hooks/aiRender' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' +import { ReActChatRenderElement, ChatReferenceMaterialPayload } from '../hooks/aiRender' import Loading from '@/components/Loading/Loading' import { ScrollText } from '@/pages/ai-agent/chatTemplate/TaskLoading/TaskLoading' -import { showYakitModal } from '@/components/yakitUI/YakitModal/YakitModalConfirm' -import { YakitEditor } from '@/components/yakitUI/YakitEditor/YakitEditor' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' +import { YakitModal } from '@/components/yakitUI/YakitModal/YakitModal' import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' -import useLoadHistory from '../hooks/useLoadHistory' import { YakitSpin } from '@/components/yakitUI/YakitSpin/YakitSpin' import AITextSyntaxFlow from '@/pages/ai-agent/components/aiTextSyntaxFlow/AITextSyntaxFlow' +import { useCurrentStore } from '../hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCreation from 'ahooks/lib/useCreation' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' +import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' +import { globalSessionEngine } from '../hooks/ChatMultiSessionController' +import useLoadOlder from '../hooks/useLoadOlder' +import { Code } from '@/pages/ai-agent/components/aiGroupStreamCard/AIGroupStreamCard' -const getAIReferenceNodeByType = (contentType?: string) => { - switch (contentType) { - case AIStreamContentType.TEXT_MARKDOWN: - return styles['ai-text-markdown-reference-node'] - case AIStreamContentType.CODE_YAKLANG: - case AIStreamContentType.CODE_HTTP_REQUEST: - return styles['ai-yaklang-reference-node'] - case AIStreamContentType.TEXT_PLAIN: - return styles['ai-text-plain-reference-node'] - case AIStreamContentType.LOG_TOOL: - return styles['ai-log-tool-reference-node'] - default: - return styles['ai-stream-chat-reference-node'] - } -} export const AIStreamNode: React.FC = React.memo((props) => { - const { stream, aiMarkdownProps, listItemIndex, streamChatSessionId } = props + const { stream, aiMarkdownProps, listItemIndex, sessionId } = props const { reference } = stream const { NodeId, content, NodeIdVerbose, CallToolID, ContentType, status } = stream.data // 是否仍在流式输出(结束态 status 为 'end',历史消息亦为 'end',据此控制流式淡入效果) const streaming = status !== 'end' - const { yakExecResult } = useChatIPCStore().chatIPCData + const store = useCurrentStore() + const execFileRecord = useStore(store, (state) => state.execFileRecord) const { nodeLabel } = useAINodeLabel(NodeIdVerbose) const modalInfo: ModalInfoProps = useCreation(() => { @@ -64,16 +48,14 @@ export const AIStreamNode: React.FC = React.memo((props) => { } }, [stream.Timestamp, stream.AIModelName, stream.AIService]) const referenceNode = useCreation(() => { - const className = getAIReferenceNodeByType(ContentType) - return !!reference ? : <> - }, [reference, ContentType]) + return !!reference ? : <> + }, [reference, sessionId]) if (ContentType?.startsWith('code/')) { return ( = React.memo((props) => { /> ) case AIStreamContentType.TEXT_PLAIN: { - const { execFileRecord } = yakExecResult const fileList = execFileRecord.get(CallToolID) return ( = React.memo((props) => { } }) const TYPE = 'reAct' -export const AIReActChatContents = React.memo( - forwardRef((props, ref) => { - const { chats } = props - const listRootRef = useRef(null) - const { - casualTitle, - requestHistoryState: { casualLoadMoreLoading }, - execute, - } = useChatIPCStore().chatIPCData +export const AIReActChatContents: React.FC = React.memo( + forwardRef((_, ref) => { + const listRootRef = useRef(null) const { activeChat } = useAIAgentStore() - const { handleLoadMoreHistory, handleHasMoreHistory } = useChatIPCDispatcher().chatIPCEvents + const store = useCurrentStore() + const casualChatElements = useStore(store, (state) => state.casualChat.elements) + const chatLength = useStore(store, (state) => state.casualChat.elements.length) + const casualTitle = useStore(store, (state) => state.casualTitle) + const execute = useStore(store, (state) => state.execute) + // 向上加载历史(recovery_history)的在途状态,给 Header 转圈提示 + const grpcLoadMoreLoading = useStore(store, (state) => state.grpcLoadMoreLoading) + + const { onRangeChange, firstItemIndex, handleLoadMore, isPrependingRef } = useLoadOlder(TYPE) - const chatLength = useCreation(() => chats.elements.length, [chats.elements.length]) - // 向上滚动加载 - const { firstItemIndex, handleLoadMore, isPrependingRef } = useLoadHistory({ - loading: casualLoadMoreLoading, - dataLength: chatLength, - SessionID: activeChat?.SessionID || '', - fetchHasMore: () => handleHasMoreHistory(TYPE), - loadMore: () => handleLoadMoreHistory(TYPE), - }) const { virtuosoRef, setScrollerRef, setIsAtBottomRef, handleTotalListHeightChanged, scrollToItemIndex } = useVirtuosoAutoScroll({ total: chatLength, @@ -152,21 +126,20 @@ export const AIReActChatContents = React.memo( }) const { locateToIndex } = useChatStreamLocateHighlight({ - scrollToIndex: scrollToItemIndex, + // Virtuoso scrollToIndex 接受绝对 index,定位下标需加 firstItemIndex 偏移 + scrollToIndex: (index, behavior) => scrollToItemIndex(index + firstItemIndex, behavior), listRootRef, }) - useImperativeHandle(ref, () => ({ scrollToItemIndex: locateToIndex }), [locateToIndex]) + useImperativeHandle(ref, () => ({ scrollToItemIndex: (index, behavior) => locateToIndex(index, behavior) }), []) - const renderItem = useCallback( - (index: number, item?: ReActChatRenderItem) => { - if (!item?.token) return null - const arrayIndex = index - firstItemIndex - const hasNext = chatLength - arrayIndex > 1 - return - }, - [chatLength, firstItemIndex], - ) + const renderItem = useCallback((_, item?: ReActChatRenderElement) => { + if (!item?.token) return null + // TODO - + // 如果token变化,可能存在以下情况 + // 例如group中list监听数组长度变化确认更新,会出现长度没变token变化,list层不会渲染,token变化的组件拿不到最新的token一直是旧的 + return + }, []) const Item = useCallback( ({ children, style, 'data-index': dataIndex }) => (
@@ -200,12 +173,12 @@ export const AIReActChatContents = React.memo( }, [casualTitle, execute, chatLength]) const Header = useCallback( () => - casualLoadMoreLoading ? ( + grpcLoadMoreLoading ? (
) : null, - [casualLoadMoreLoading], + [grpcLoadMoreLoading], ) const components = useMemo( () => ({ @@ -215,24 +188,27 @@ export const AIReActChatContents = React.memo( }), [Footer, Header, Item], ) + // const rawData = useCurrentRawData() + // console.log('casualChat.elements', casualChatElements, store.getState().items) return (
1 ? chats.elements.length - 1 : 0} + firstItemIndex={firstItemIndex} + initialTopMostItemIndex={chatLength > 1 ? { index: 'LAST' } : 0} components={components} + // increaseViewportBy={{ top: 1200, bottom: 0 }} atBottomThreshold={50} skipAnimationFrameInResizeObserver - // atTopStateChange={handleAtTopStateChange} startReached={handleLoadMore} - // increaseViewportBy={{ top: 200, bottom: 0 }} + rangeChanged={onRangeChange} className={styles['re-act-contents-list']} />
@@ -240,29 +216,71 @@ export const AIReActChatContents = React.memo( }), ) -/** 挂到 body,避免 Virtuoso 滚出视口时卸载列表项导致弹窗消失 */ -export const openAIReferenceModal = (referenceList: ChatReferenceMaterialPayload, title = '参考资料') => { - const code = referenceList.map((item) => item.payload).join('\n') - const modal = showYakitModal({ - title, - cancelButtonProps: { style: { display: 'none' } }, - bodyStyle: { height: 500 }, - content: , - onOk: () => modal.destroy(), +export const AIReferenceNode: React.FC = React.memo((props) => { + const { referenceList, sessionId, title = '' } = props + const { t } = useI18nNamespaces(['aiAgent']) + + const [open, setOpen] = useState(false) + const [modelCode, setModelCode] = useState([]) + const [modelLoading, setModelLoading] = useState(false) + + const hidden = useCreation(() => { + return !referenceList?.length + }, [referenceList?.length]) + + const onClose = useMemoizedFn(() => { + setOpen(false) }) -} -export const AIReferenceNode: React.FC = React.memo((props) => { - const { referenceList, className } = props - return ( - { - e.stopPropagation() - openAIReferenceModal(referenceList) - }} - > - [参考资料] - - ) + /** 按 token 列表异步获取参考资料完整数据 */ + const fetchReference = useMemoizedFn(async (): Promise => { + if (!referenceList.length || !sessionId) return [] + try { + const items = await globalSessionEngine.getSessionReferenceMaterials(sessionId, referenceList) + return items.map((item) => item.content) + } catch { + return [] + } + }) + + // modal 打开时拉取数据 + useEffect(() => { + if (!open) return + let cancelled = false + setModelLoading(true) + fetchReference() + .then((code) => { + if (!cancelled) setModelCode(code) + }) + .finally(() => { + if (!cancelled) setModelLoading(false) + }) + return () => { + cancelled = true + } + }, [open]) + + const openModel = useMemoizedFn(() => { + setOpen(true) + }) + return !hidden ? ( + <> + {open && ( + + + + + + )} + + {t('AIStreamNode.viewReference')} + + + ) : null }) diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContentsType.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContentsType.ts index a45adba03d..984525b73e 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContentsType.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChatContents/AIReActChatContentsType.ts @@ -1,8 +1,8 @@ -import type { ChatReferenceMaterialPayload, ChatStream } from '../hooks/aiRender' -import type { UseCasualChatState } from '../hooks/type' +import type { ForwardedRef } from 'react' +import type { ChatStream } from '../hooks/aiRender' export interface AIReActChatContentsPProps { - chats: UseCasualChatState + ref: ForwardedRef } export interface AIReActChatContentsRef { @@ -14,11 +14,15 @@ export interface AIStreamNodeProps { aiMarkdownProps?: { className: string } /** 当前流所在列表项在 `chats.elements` 中的下标(用于 WebFuzzer 自动改包防覆盖) */ listItemIndex?: number - /** 当前 ReAct 的 SessionID */ - streamChatSessionId?: string + /** 当前 SessionID */ + sessionId: string } export interface AIReferenceNodeProps { - referenceList: ChatReferenceMaterialPayload - className?: string + /** 参考资料 token 列表(payload 存 IDB,完整弹框交互后置) */ + referenceList: string[] + /** 当前会话 sessionId,必传 */ + sessionId: string + /** 弹窗标题前缀(如序号+节点标签),默认空 */ + title?: string } diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChat.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChat.tsx index 0769052a27..b9be2da941 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChat.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChat.tsx @@ -19,7 +19,6 @@ import styles from './AIReActTaskChat.module.scss' import { AIAgentChatStream, AIChatLeftSide } from '@/pages/ai-agent/chatTemplate/AIAgentChatTemplate' import { useControllableValue, useCreation, useMemoizedFn, useUpdateEffect } from 'ahooks' import classNames from 'classnames' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' import { ChevrondownButton } from '../aiReActChat/AIReActComponent' import { OutlineArrowscollapseIcon, @@ -30,13 +29,11 @@ import { OutlineInformationcircleIcon, OutlinePlay2Icon, OutlinePositionIcon, - RedoDotIcon, } from '@/assets/icon/outline' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { AIChatQSData, AIChatQSDataTypeEnum, AIReviewType } from '../hooks/aiRender' +import { AIChatQSData, AIChatQSDataTypeEnum } from '../hooks/aiRender' import { YakitPopconfirm } from '@/components/yakitUI/YakitPopconfirm/YakitPopconfirm' -import { AIInputEventHotPatchTypeEnum, AIInputEventSyncTypeEnum, AITaskStatus } from '../hooks/grpcApi' +import { AIInputEvent, AIInputEventHotPatchTypeEnum, AIInputEventSyncTypeEnum, AITaskStatus } from '../hooks/grpcApi' import { Form, Tooltip } from 'antd' import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' import emiter from '@/utils/eventBus/eventBus' @@ -53,6 +50,10 @@ import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' import { has } from 'lodash' import { AITaskContent } from '../aiTaskContent/AITaskContent' import { useTaskChatExtraAction } from './useTaskChatExtraAction' +import { useCurrentMeta, useCurrentStore } from '../hooks/useCurrentDataBySession' +import { useStore } from 'zustand' +import useCurrentSessionId from '../hooks/useCurrentSessionId' +import { globalSessionEngine } from '../hooks/ChatMultiSessionController' const AIReActTaskChat: React.FC = React.memo((props) => { const { setShowFreeChat, setTimeLine, onTaskTabsChange } = props @@ -148,29 +149,23 @@ export default AIReActTaskChat export const AIReActTaskChatContent: React.FC = React.memo((props) => { const { scrollToBottom, onScrollToBottom } = props - const { reviewInfo, chatIPCData } = useChatIPCStore() const { t } = useI18nNamespaces(['aiAgent']) - const { activeChat } = useAIAgentStore() - const { taskChat } = chatIPCData - const { onExtraAction, getTaskId } = useTaskChatExtraAction() + const { onExtraAction } = useTaskChatExtraAction() - const streams = useCreation(() => { - return taskChat.elements - }, [taskChat.elements]) + const store = useCurrentStore() + const streams = useStore(store, (state) => state.taskChat.elements) + const execute = useStore(store, (state) => state.execute) + const taskId = useStore(store, (state) => state.taskStatus.taskID) + const currentPlanReviewToken = useStore(store, (state) => state.currentPlanReviewToken) return ( <>
- +
- {!reviewInfo && streams.length > 0 && ( + {!currentPlanReviewToken.token && streams.length > 0 && (
- {chatIPCData.execute && ( + {execute && ( = Rea {t('AIReActTaskChatContent.globalDirective')} - {chatIPCData.execute && !!getTaskId() && } + {execute && !!taskId && } } @@ -228,9 +223,12 @@ export const AIManualAdditionPopover: React.FC = R export const AIInputSettingPopover: React.FC = React.memo((props) => { const { children } = props + const { onSend } = useAIAgentDispatcher() + + const sessionId = useCurrentSessionId() + const { setting, activeChat } = useAIAgentStore() const { setSetting } = useAIAgentDispatcher() - const { handleSendConfigHotpatch } = useChatIPCDispatcher() const [visible, setVisible] = useControllableValue(props, { defaultValue: false, valuePropName: 'visible', @@ -239,12 +237,14 @@ export const AIInputSettingPopover: React.FC = React const [form] = Form.useForm() const onHotSyncPerceptionTrigger = useMemoizedFn((value: boolean) => { - handleSendConfigHotpatch({ - hotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_SyncPerceptionTrigger, - params: { + const info: AIInputEvent = { + IsConfigHotpatch: true, + HotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_SyncPerceptionTrigger, + Params: { SyncPerceptionTrigger: value, }, - }) + } + onSend({ token: sessionId, type: 'casual', params: info }) if (activeChat?.SessionID) { emiter.emit( 'sessionData', @@ -311,35 +311,41 @@ export const AIInputSettingPopover: React.FC = React const AIManualAddition: React.FC = React.memo((props) => { const { chatType, onCancel } = props - const { handleSendSyncMessage, chatIPCEvents } = useChatIPCDispatcher() - const { chatIPCData, syncIdInfoMap } = useChatIPCStore() + + const { onSend } = useAIAgentDispatcher() + + const sessionId = useCurrentSessionId() + const meta = useCurrentMeta() + const store = useCurrentStore() + const taskStatus = useStore(store, (state) => state.taskStatus) + const execute = useStore(store, (state) => state.execute) + const syncIDUpdate = useStore(store, (state) => state.syncIDUpdate) + const [prompt, setPrompt] = useState() const currentCoordinatorIdRef = useRef('') const syncIdOfAddToContext = useRef('') const syncIdOfAddAndReExecute = useRef('') - const taskStatus = useCreation(() => chatIPCData?.taskStatus, [chatIPCData?.taskStatus]) - useUpdateEffect(() => { - if (!taskStatus.loading && currentCoordinatorIdRef.current) { + if (taskStatus.status !== AITaskStatus.inProgress && currentCoordinatorIdRef.current) { onSendRecover(currentCoordinatorIdRef.current) } - }, [taskStatus.loading]) + }, [taskStatus.status]) useEffect(() => { if ( - (syncIdOfAddToContext.current && !syncIdInfoMap?.get(syncIdOfAddToContext.current)) || - (syncIdOfAddAndReExecute.current && !syncIdInfoMap?.get(syncIdOfAddAndReExecute.current)) + (syncIdOfAddToContext.current && !meta.syncIDMap?.get(syncIdOfAddToContext.current)) || + (syncIdOfAddAndReExecute.current && !meta.syncIDMap?.get(syncIdOfAddAndReExecute.current)) ) { onReset() } - }, [syncIdInfoMap]) + }, [syncIDUpdate]) useEffect(() => { - if (chatIPCData.execute) return + if (execute) return onReset() - }, [chatIPCData.execute]) + }, [execute]) const onReset = useMemoizedFn(() => { onCancel() @@ -353,27 +359,37 @@ const AIManualAddition: React.FC = React.memo((props) => // 加入上下文后,停止任务再恢复任务 syncIdOfAddAndReExecute.current = randomString(8) onAddToContext(syncIdOfAddAndReExecute.current) - const info = chatIPCEvents.fetchCurrentTaskPlanID() - const taskId = info?.taskID - const coordinatorId = info?.coordinatorId + const taskId = taskStatus.taskID + const coordinatorId = taskStatus.coordinatorId if (!coordinatorId) return currentCoordinatorIdRef.current = coordinatorId - chatIPCEvents.handleCancelLoadingChange('task', true) - if (taskStatus?.loading && taskId) { + + store.getState().updateState({ + cancelTaskLoading: true, + }) + + if (taskStatus.status === AITaskStatus.inProgress && taskId) { // 选停止当前任务,等待任务停止成功后,再发送恢复的数据 - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, SyncJsonInput: JSON.stringify({ task_id: taskId }), - }) + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) } else { onSendRecover(coordinatorId) } }) const onSendRecover = useMemoizedFn((coordinatorId: string) => { - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_PLAN_AND_EXEC, + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_PLAN_AND_EXEC, SyncJsonInput: JSON.stringify({ coordinator_id: coordinatorId }), - }) + + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) currentCoordinatorIdRef.current = '' }) const getTypeBySyncID = useMemoizedFn(() => { @@ -383,11 +399,14 @@ const AIManualAddition: React.FC = React.memo((props) => }) const onAddToContext = useMemoizedFn((syncID: string) => { if (!prompt?.trim()) return - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_USER_INTERVENTION, + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_USER_INTERVENTION, SyncJsonInput: JSON.stringify({ content: prompt }), - syncID: syncID, - }) + + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) onAddToList() }) const onAddToList = useMemoizedFn(() => { @@ -400,8 +419,16 @@ const AIManualAddition: React.FC = React.memo((props) => AIService: '', AIModelName: '', } - chatIPCEvents.handleUserManualIntervention(chatData) + globalSessionEngine.pushDataToSession(sessionId, chatData) }) + + const addAndReExecuteLoading = useCreation(() => { + return !!syncIdOfAddAndReExecute.current && !!meta.syncIDMap?.get(syncIdOfAddAndReExecute.current) + }, [syncIDUpdate]) + + const addAndToContextLoading = useCreation(() => { + return !!syncIdOfAddToContext.current && !!meta.syncIDMap?.get(syncIdOfAddToContext.current) + }, [syncIDUpdate]) return (
e.stopPropagation()}>
人工介入
@@ -419,9 +446,9 @@ const AIManualAddition: React.FC = React.memo((props) => e.stopPropagation()} - loading={!!syncIdInfoMap?.get(syncIdOfAddAndReExecute.current)} + loading={addAndReExecuteLoading} className={styles['add-and-reexecute-btn']} - disabled={!!syncIdInfoMap?.get(syncIdOfAddToContext.current)} + disabled={addAndToContextLoading} > 加入并重新执行 @@ -431,8 +458,8 @@ const AIManualAddition: React.FC = React.memo((props) => syncIdOfAddToContext.current = randomString(8) onAddToContext(syncIdOfAddToContext.current) }} - loading={!!syncIdInfoMap?.get(syncIdOfAddToContext.current)} - disabled={!!syncIdInfoMap?.get(syncIdOfAddAndReExecute.current)} + loading={addAndToContextLoading} + disabled={addAndReExecuteLoading} > 加入上下文 @@ -593,31 +620,17 @@ const AIPlanPrompt: React.FC = React.memo( export const AIRenderTaskFooterExtra: React.FC = React.memo((props) => { const { onExtraAction, btnProps, children } = props const { t } = useI18nNamespaces(['aiAgent']) - const { chatIPCEvents } = useChatIPCDispatcher() - const { chatIPCData } = useChatIPCStore() - - const taskChat = useCreation(() => { - return chatIPCData.taskChat - }, [chatIPCData.taskChat]) - - const taskStatus = useCreation(() => { - return chatIPCData.taskStatus - }, [chatIPCData.taskStatus]) - - const cancelTaskLoading = useCreation(() => { - return chatIPCData.cancelTaskLoading - }, [chatIPCData.cancelTaskLoading]) - const getTaskInfo = useMemoizedFn(() => { - return chatIPCEvents.fetchCurrentTaskPlanID() - }) + const store = useCurrentStore() + + const cancelTaskLoading = useStore(store, (state) => state.cancelTaskLoading) + const status = useStore(store, (state) => state.taskStatus.status) const renderBtn = useMemoizedFn(() => { - switch (getTaskInfo()?.status) { + switch (status) { case AITaskStatus.inProgress: return ( { - chatIPCEvents.handleCancelLoadingChange('task', true) onExtraAction('stopTask', '') }} title={t('AIRenderTaskFooterExtra.cancelTaskConfirm')} @@ -638,14 +651,16 @@ export const AIRenderTaskFooterExtra: React.FC = R case AITaskStatus.error: case AITaskStatus.skipped: case AITaskStatus.cancel: - return !taskStatus.loading ? ( + return ( } radius="28px" size="large" onClick={() => { - chatIPCEvents.handleCancelLoadingChange('task', true) + store.getState().updateState({ + cancelTaskLoading: true, + }) onExtraAction('recover', '') }} loading={cancelTaskLoading} @@ -653,18 +668,6 @@ export const AIRenderTaskFooterExtra: React.FC = R > {t('AIRenderTaskFooterExtra.continueTask')} - ) : ( - } - className={styles['task-button']} - radius="28px" - size="large" - colors="danger" - loading={true} - > - {t('AIRenderTaskFooterExtra.stoppingTask')} - ) default: return null @@ -703,7 +706,6 @@ export const AIRenderTaskFooterExtra: React.FC = R }) export const AIReActTaskChatLeftSide: React.FC = React.memo((props) => { - const { taskChat } = useChatIPCStore().chatIPCData const [leftExpand, setLeftExpand] = useControllableValue(props, { defaultValue: true, valuePropName: 'leftExpand', @@ -716,12 +718,7 @@ export const AIReActTaskChatLeftSide: React.FC = R [styles['content-left-side-hidden']]: !leftExpand, })} > - +
setLeftExpand(true)}>
任务列表
diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatReviewBar.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatReviewBar.tsx index 1d612b2034..e79541b888 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatReviewBar.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatReviewBar.tsx @@ -1,6 +1,5 @@ -import React, { type FC, type ReactNode } from 'react' +import { memo, type FC, type ReactNode } from 'react' import { AIReActTaskChatReview } from '@/pages/ai-agent/aiAgentChat/AIAgentChat' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' import { AIRenderTaskFooterExtra } from './AIReActTaskChat' import { useTaskChatExtraAction } from './useTaskChatExtraAction' @@ -25,18 +24,9 @@ const renderReviewFooterExtra = (onExtraAction: ReturnType void -}> = ({ setScrollToBottom }) => { - const { reviewInfo, planReviewTreeKeywordsMap } = useChatIPCStore() +}> = memo(({ setScrollToBottom }) => { const { onExtraAction } = useTaskChatExtraAction() - - if (!reviewInfo) return null - return ( - + ) -} +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatType.d.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatType.d.ts index 4599013704..4b38e19835 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatType.d.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/AIReActTaskChatType.d.ts @@ -1,6 +1,7 @@ import { YakitButtonProp } from '@/components/yakitUI/YakitButton/YakitButton' import { ReactNode } from 'react' import { AIStartParams } from '../hooks/grpcApi' +import { ChatListRenderType } from '../hooks/aiRender' export interface AIReActTaskChatProps { setShowFreeChat: (show: boolean) => void @@ -69,6 +70,6 @@ export interface AIPlanPromptProps { } export interface AIManualAdditionProps { - chatType: ReActChatBaseInfo['chatType'] + chatType: ChatListRenderType onCancel: () => void } diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/useTaskChatExtraAction.ts b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/useTaskChatExtraAction.ts index 2d599e2972..735708aa03 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/useTaskChatExtraAction.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActTaskChat/useTaskChatExtraAction.ts @@ -1,42 +1,86 @@ -import { useMemoizedFn } from 'ahooks' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { AIReviewType } from '../hooks/aiRender' -import { AIInputEventSyncTypeEnum } from '../hooks/grpcApi' +import { type AIInputEvent, AIInputEventSyncTypeEnum, AITaskStatus } from '../hooks/grpcApi' +import useCurrentSessionId from '../hooks/useCurrentSessionId' +import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' +import { randomString } from '@/utils/randomUtil' +import { useStore } from 'zustand' +import { useCurrentStore, useCurrentRawData } from '../hooks/useCurrentDataBySession' +import useCreation from 'ahooks/lib/useCreation' +import useMemoizedFn from 'ahooks/lib/useMemoizedFn' +import { globalSessionEngine } from '../hooks/ChatMultiSessionController' -/** 只适用于任务规划的content footer下,不适用于子任务的上的继续 */ +/** + * 只适用于任务规划的content footer下,不适用于子任务的上的继续 + */ export const useTaskChatExtraAction = () => { - const { reviewInfo, chatIPCData } = useChatIPCStore() - const { handleSendSyncMessage, chatIPCEvents } = useChatIPCDispatcher() - const getTaskInfo = useMemoizedFn(() => chatIPCEvents.fetchCurrentTaskPlanID()) - const getTaskId = useMemoizedFn(() => getTaskInfo()?.taskID) + const { onSend } = useAIAgentDispatcher() - const onSendPlayHistoryList = useMemoizedFn(() => { - chatIPCData.execute && handleSendSyncMessage({ syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS }) + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + + const rawData = useCurrentRawData() + const execute = useStore(store, (state) => state.execute) + + const currentPlanReviewToken = useStore(store, (state) => state.currentPlanReviewToken) + + const reviewInfo = useCreation(() => { + return rawData.contents.get(currentPlanReviewToken.token) + }, [currentPlanReviewToken.renderNum]) + + /** + * 停止任务后会返回结束标识,然后清空review id + * 防止hooks出现意外,UI层暂时保留该逻辑 + */ + const closeChatReview = useMemoizedFn(() => { + if (!!reviewInfo) { + globalSessionEngine.closeChatReview(sessionId, reviewInfo.id) + } }) - /**取消当前指定任务 */ - const onStopTask = useMemoizedFn(() => { - const taskId = getTaskId() + + const sendReactCancelTask = useMemoizedFn(() => { + const taskId = store.getState().taskStatus.taskID if (!taskId) return - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, - SyncJsonInput: JSON.stringify({ task_id: taskId }), + store.getState().updateState({ + cancelTaskLoading: true, }) - if (!!reviewInfo) { - chatIPCEvents.handleTaskReviewRelease((reviewInfo.data as AIReviewType).id) + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_REACT_CANCEL_TASK, + SyncJsonInput: JSON.stringify({ task_id: taskId }), + + SyncID: randomString(8), } + onSend({ token: sessionId, type: 'task', params: info }) + }) + + const onSendPlayHistoryList = useMemoizedFn(() => { + if (execute) { + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS, + + SyncID: randomString(8), + } + onSend({ token: sessionId, type: 'task', params: info }) + } + }) + /**取消当前指定任务 */ + const onStopTask = useMemoizedFn(() => { + sendReactCancelTask() + closeChatReview() onSendPlayHistoryList() }) /**取消当前执行的子任务 */ - const onStopSubTask = useMemoizedFn((syncID: string) => { - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_SKIP_SUBTASK_IN_PLAN, + const onStopSubTask = useMemoizedFn(() => { + const info: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_SKIP_SUBTASK_IN_PLAN, SyncJsonInput: JSON.stringify({ reason: '用户认为这个任务不需要执行', skip_current_task: true }), - syncID, - }) - if (!!reviewInfo) { - chatIPCEvents.handleTaskReviewRelease((reviewInfo.data as AIReviewType).id) + + SyncID: randomString(8), } + onSend({ token: sessionId, type: 'task', params: info }) + /** 目前多任务并发,出现子任务后review自动走的继续执行,不会出现review */ + closeChatReview() setTimeout(() => { onSendPlayHistoryList() }, 500) @@ -44,27 +88,27 @@ export const useTaskChatExtraAction = () => { /** @description 在任务规划的content footer下,继续按钮的出现在UI上意味着该任务肯定已经停止 */ const onRecover = useMemoizedFn(() => { - const info = getTaskInfo() - const coordinatorId = info?.coordinatorId + const coordinatorId = store.getState().taskStatus.coordinatorId if (!coordinatorId) return - handleSendSyncMessage({ - syncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_PLAN_AND_EXEC, + const params: AIInputEvent = { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_PLAN_AND_EXEC, SyncJsonInput: JSON.stringify({ coordinator_id: coordinatorId }), - }) - chatIPCEvents.resetCurrentTaskPlanID() - if (!!reviewInfo) { - chatIPCEvents.handleTaskReviewRelease((reviewInfo.data as AIReviewType).id) + SyncID: randomString(8), } + onSend({ token: sessionId, type: 'task', params }) + store.getState().updateTaskLoadingStatus({ taskID: '', status: AITaskStatus.created, coordinatorId: '' }) + closeChatReview() }) - const onExtraAction = useMemoizedFn((type: 'stopTask' | 'stopSubTask' | 'recover', syncID: string) => { + const onExtraAction = useMemoizedFn((type: 'stopTask' | 'stopSubTask' | 'recover') => { switch (type) { case 'stopTask': onStopTask() break case 'stopSubTask': - onStopSubTask(syncID) + onStopSubTask() break case 'recover': onRecover() @@ -74,5 +118,5 @@ export const useTaskChatExtraAction = () => { } }) - return { onExtraAction, getTaskId } + return { onExtraAction } } diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReviewRuleSelect/AIReviewRuleSelect.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiReviewRuleSelect/AIReviewRuleSelect.tsx index 3af925326e..674f523f43 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReviewRuleSelect/AIReviewRuleSelect.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReviewRuleSelect/AIReviewRuleSelect.tsx @@ -4,7 +4,7 @@ import styles from './AIReviewRuleSelect.module.scss' import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' import useAIAgentDispatcher from '@/pages/ai-agent/useContext/useDispatcher' import classNames from 'classnames' -import { useClickAway, useControllableValue, useCreation, useInViewport, useMemoizedFn } from 'ahooks' +import { useClickAway, useControllableValue, useCreation, useMemoizedFn, useUpdateEffect } from 'ahooks' import { YakitSelect } from '@/components/yakitUI/YakitSelect/YakitSelect' import { AIAgentSettingDefault, @@ -16,22 +16,23 @@ import { OutlineSirenIcon } from '@/assets/icon/outline' import { YakitPopover } from '@/components/yakitUI/YakitPopover/YakitPopover' import { FormItemSlider } from '@/pages/ai-agent/AIChatSetting/AIChatSetting' import { YakitButton } from '@/components/yakitUI/YakitButton/YakitButton' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' -import { AIInputEventHotPatchTypeEnum, AIStartParams } from '../hooks/grpcApi' +import { AIInputEvent, AIInputEventHotPatchTypeEnum, AIStartParams } from '../hooks/grpcApi' import isEqual from 'lodash/isEqual' -import emiter from '@/utils/eventBus/eventBus' -import { JSONParseLog } from '@/utils/tool' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { isNil } from 'lodash' +import useCurrentSessionId from '../hooks/useCurrentSessionId' +import { useCurrentStore } from '../hooks/useCurrentDataBySession' +import { useStore } from 'zustand' const AIReviewRuleSelect: React.FC = React.memo((props) => { const { t } = useI18nNamespaces(['aiAgent']) - const { setting } = useAIAgentStore() - const { setSetting } = useAIAgentDispatcher() + /** 当前对话唯一ID */ + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const execute = useStore(store, (state) => state.execute) - const { chatIPCData } = useChatIPCStore() - const { handleSendConfigHotpatch } = useChatIPCDispatcher() + const { setting } = useAIAgentStore() + const { setSetting, onSend } = useAIAgentDispatcher() const [visible, setVisible] = useState(false) const [open, setOpen] = useState(false) @@ -40,7 +41,7 @@ const AIReviewRuleSelect: React.FC = React.memo((props) = const modelValue = useCreation(() => { return setting?.ReviewPolicy || AIAgentSettingDefault.ReviewPolicy - }, [setting?.ReviewPolicy, chatIPCData.execute]) + }, [setting?.ReviewPolicy, execute]) const [selectReviewPolicy, setSelectReviewPolicy] = useState() const displayReviewPolicy = useCreation(() => { @@ -48,9 +49,6 @@ const AIReviewRuleSelect: React.FC = React.memo((props) = return modelValue }, [open, selectReviewPolicy, modelValue]) - const refRef = useRef(null) - const [inViewport = true] = useInViewport(refRef) - const aiReviewRiskControlScore = useCreation(() => { return setting?.AIReviewRiskControlScore || AIAgentSettingDefault.AIReviewRiskControlScore }, [setting?.AIReviewRiskControlScore]) @@ -67,51 +65,41 @@ const AIReviewRuleSelect: React.FC = React.memo((props) = } }, [aiReviewRiskControlScore, visible]) - const onRefreshAIReviewRuleSelect = useMemoizedFn((res: string) => { - if (!chatIPCData.execute) return - const data = JSONParseLog(res) - if (!!data?.reviewPolicy) { - handHotpatchReviewPolicy(data?.reviewPolicy as AIStartParams['ReviewPolicy']) - } - if (!!data?.aiReviewRiskControlScore) { - handHotpatchAIReviewRiskControlScore(data?.aiReviewRiskControlScore) - } - }) - - const onRefreshHistoryAIEmbeddedSetting = useMemoizedFn(() => { - if (open) return - setSelectReviewPolicy(undefined) - if (visible) return - setAIReviewRiskControlScore(undefined) - }) - - useEffect(() => { - if (!inViewport) return - emiter.on('onRefreshAIReviewRuleSelect', onRefreshAIReviewRuleSelect) - emiter.on('onRefreshHistoryAIEmbeddedSetting', onRefreshHistoryAIEmbeddedSetting) - return () => { - emiter.off('onRefreshAIReviewRuleSelect', onRefreshAIReviewRuleSelect) - emiter.off('onRefreshHistoryAIEmbeddedSetting', onRefreshHistoryAIEmbeddedSetting) - } - }, [inViewport, onRefreshAIReviewRuleSelect, onRefreshHistoryAIEmbeddedSetting]) - + //#region 热更新 Review 规则 + useUpdateEffect(() => { + if (!!setting.ReviewPolicy) handHotpatchReviewPolicy(setting.ReviewPolicy) + }, [setting.ReviewPolicy]) + /** 热更新 Review 规则 */ const handHotpatchReviewPolicy = useMemoizedFn((value: AIStartParams['ReviewPolicy']) => { - handleSendConfigHotpatch({ - hotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_AgreePolicy, - params: { + if (!sessionId) return + const info: AIInputEvent = { + IsConfigHotpatch: true, + HotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_AgreePolicy, + Params: { ReviewPolicy: value, }, - }) + } + onSend({ token: sessionId, type: '', params: info }) }) + //#endregion + //#region 热更新 AIReviewRiskControlScore + useUpdateEffect(() => { + if (!!setting.AIReviewRiskControlScore) handHotpatchAIReviewRiskControlScore(setting.AIReviewRiskControlScore) + }, [setting.AIReviewRiskControlScore]) + /** 热更新 AIReviewRiskControlScore */ const handHotpatchAIReviewRiskControlScore = useMemoizedFn((value: number) => { - handleSendConfigHotpatch({ - hotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_RiskControlScore, - params: { - AIReviewRiskControlScore: value, + if (!sessionId) return + const info: AIInputEvent = { + IsConfigHotpatch: true, + HotpatchType: AIInputEventHotPatchTypeEnum.HotPatchType_RiskControlScore, + Params: { + AIReviewRiskControlScore: value ?? 0, }, - }) + } + onSend({ token: sessionId, type: '', params: info }) }) + //#endregion const onSelectModel = useMemoizedFn((value: AIStartParams['ReviewPolicy']) => { setSetting && setSetting((old) => ({ ...old, ReviewPolicy: value })) @@ -125,7 +113,7 @@ const AIReviewRuleSelect: React.FC = React.memo((props) = setVisible(v) if ( !v && - chatIPCData.execute && + execute && !isNil(selectAIReviewRiskControlScore) && !isEqual(selectAIReviewRiskControlScore, aiReviewRiskControlScore) ) { @@ -145,7 +133,7 @@ const AIReviewRuleSelect: React.FC = React.memo((props) = }) const onSetOpen = useMemoizedFn((v: boolean) => { setOpen(v) - if (!v && chatIPCData.execute && !isEqual(selectReviewPolicy, modelValue)) { + if (!v && execute && !isEqual(selectReviewPolicy, modelValue)) { handHotpatchReviewPolicy(selectReviewPolicy) } if (v) { @@ -187,7 +175,7 @@ const AIReviewRuleSelect: React.FC = React.memo((props) = ) }) return ( -
+
{ diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiTaskContent/AITaskContent.tsx b/app/renderer/src/main/src/pages/ai-re-act/aiTaskContent/AITaskContent.tsx index 3e5da65145..d672b46c50 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiTaskContent/AITaskContent.tsx +++ b/app/renderer/src/main/src/pages/ai-re-act/aiTaskContent/AITaskContent.tsx @@ -12,8 +12,10 @@ import { AITaskExecutionDetails } from '@/pages/ai-agent/chatTemplate/aiTaskExec import { AIReActTaskChatContent } from '../aiReActTaskChat/AIReActTaskChat' import { AIReActTaskChatReviewBar } from '../aiReActTaskChat/AIReActTaskChatReviewBar' import useGetSetState from '@/pages/pluginHub/hooks/useGetSetState' -import useChatIPCStore from '@/pages/ai-agent/useContext/ChatIPCContent/useStore' + import { useEnsureTaskPlanLocate } from './hooks/useEnsureTaskPlanLocate' +import { useStore } from 'zustand' +import { useCurrentStore } from '../hooks/useCurrentDataBySession' interface TabsItemProps extends YakitTabsProps { taskId: string @@ -26,11 +28,11 @@ export const AITaskContent: React.FC = React.memo((props) => const { tabBarExtraContent, onTabsChange } = props const { t, i18nRefresh } = useI18nNamespaces(['aiAgent', 'yakitUi', 'yakitRoute']) - const { - chatIPCData: { taskChat }, - } = useChatIPCStore() + const store = useCurrentStore() + const taskChat = useStore(store, (state) => state.taskChat) + const [tabs, setTabs, getTabs] = useGetSetState([]) - const [activeKey, setActiveKey] = useState(TASK_CONTENT_KEY) + const [activeKey, setActiveKey] = useState('taskContent') const [scrollToBottom, setScrollToBottom] = useState(false) /** 任务规划关闭后用 display:none 保留,不销毁 */ const [taskPlanMounted, setTaskPlanMounted] = useState(false) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/AIAgentLogEmitter.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/AIAgentLogEmitter.ts new file mode 100644 index 0000000000..6ffb2ce0da --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/AIAgentLogEmitter.ts @@ -0,0 +1,91 @@ +import { AIAgentGrpcApi, AIOutputEvent } from './grpcApi' +import { formatTimestamp } from '@/utils/timeUtil' + +export class AIAgentLogEmitter { + // 临时缓冲区:存储还没接收完的 stream 字符串 + // 键名设计:由于要区分 session,使用 `${sessionId}_${eventUUID}` 作为唯一 Key + private streamBuffer = new Map() + + /** 发送日志数据到日志窗口的逻辑 */ + private emitToOtherWindow( + sessionId: string, + Timestamp: AIOutputEvent['Timestamp'], + level: string, + message: string, + isStream?: boolean, + ) { + const { ipcRenderer } = window.require('electron') + // 主进程用 ipcMain.handle 注册,必须用 invoke;send 不会触发 handle + void ipcRenderer.invoke('forward-ai-chat-log-data', { + sessionId, + level: level, + message: message, + timestamp: formatTimestamp(Timestamp), + isStream: isStream, + }) + } + + /** 🪐 核心分发入口:接收数据处理层传来的数据,决定是暂存还是直接发送 */ + public dispatch(params: { + session: string + type: 'log' | 'stream' + Timestamp: AIOutputEvent['Timestamp'] + /** log类型专属,stream类型该字段值为空 */ + log?: AIAgentGrpcApi.Log + /** stream类型专属,log类型该字段值为空 */ + stream?: { + NodeId: AIOutputEvent['NodeId'] + EventUUID: AIOutputEvent['EventUUID'] + content: string + status: 'start' | 'end' + } + }) { + const { session, type, Timestamp, log, stream } = params + + if (type === 'log') { + if (!log) return + this.emitToOtherWindow(session, Timestamp, log.level, log.message) + } + + if (type === 'stream') { + if (!stream) return + + const { NodeId, EventUUID, content, status } = stream + const mapKey = `${session}_${EventUUID}` + const currentText = this.streamBuffer.get(mapKey) || '' + const newChunk = currentText + content + if (status === 'end') { + // 拼接完成,直接发送 + this.emitToOtherWindow(session, Timestamp, NodeId, newChunk, true) + this.streamBuffer.delete(mapKey) + } else { + // 追加到临时缓冲区 + this.streamBuffer.set(mapKey, newChunk) + } + } + } + + /** 🛑 当会话被关闭时,清理可能因为异常断开而残留的 Buffer 内存 */ + public clearSessionBuffer(sessionId: string) { + for (const key of this.streamBuffer.keys()) { + if (key.startsWith(`${sessionId}_`)) { + this.streamBuffer.delete(key) + } + } + } + + /** 清空日志窗口里的所有内容 */ + public clearLogsWindow(sessionId: string) { + const { ipcRenderer } = window.require('electron') + ipcRenderer.invoke('clear-ai-chat-log-data') + } + /** 关闭日志窗口 */ + public closeLogsWindow(sessionId: string) { + this.clearSessionBuffer(sessionId) + const { ipcRenderer } = window.require('electron') + ipcRenderer.send('close-ai-chat-window') + } +} + +// 导出单例 +export const aiAgentLogEmitter = new AIAgentLogEmitter() diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/ChatMultiSessionController.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/ChatMultiSessionController.ts new file mode 100644 index 0000000000..793b7f1520 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/ChatMultiSessionController.ts @@ -0,0 +1,1719 @@ +import type { AIAgentChatData, AIAgentChatMetaData } from '@/pages/ai-agent/type/aiChat' +import { + AIInputEventSyncTypeEnum, + AISource, + AISourceEnum, + type AIAgentGrpcApi, + type AIEventQueryRequest, + type AIInputEvent, + type AIOutputEvent, + type AIStartParams, +} from './grpcApi' +import { createChatStore } from './chatStore' +import { Uint8ArrayToString } from '@/utils/str' +import { + AIAgentSettingDefault, + AIModelTypeEnum, + AttachedResourceKeyEnum, + AttachedResourceTypeEnum, +} from '@/pages/ai-agent/defaultConstant' +import cloneDeep from 'lodash/cloneDeep' +import { + DefaultMemoryList, + DefaultPlanItemDetailsData, + DefaultTaskPlanEndGate, + DefaultTaskPlanStatus, +} from './defaultConstant' +import { grpcAIMessageHandlers } from './grpcStreamHandler/grpcAIOutputEventHandlers' +import { genExecTasks, handleTaskPlanEnd, pushLogToOtherWindow } from './utils' +import type { AIChatIPCStartParams, AIChatSendParams, AIFileSystemPin } from './type' +import { yakitNotify } from '@/utils/notification' +import { type AIChatQSData, AIChatQSDataTypeEnum, type AIToolResult, type SessionRenderContent } from './aiRender' +import { aiAgentLogEmitter } from './AIAgentLogEmitter' +import { v4 as uuidv4 } from 'uuid' +import moment from 'moment' +import type { YakitRouteType } from '@/enums/yakitRoute' +import { grpcQueryAIEvent } from '@/pages/ai-agent/grpc' +import aiChatPersistStore from './persist/aiChatPersistStore' +import { + drainSessionContentWrites, + persistIndependentItem, + persistToolResultIfTerminal, +} from './persist/contentPersistHelper' + +const { ipcRenderer } = window.require('electron') + +/** 检查渲染树(element) 是否存在有效数据 */ +const hasSessionRenderTree = (content?: SessionRenderContent): boolean => { + if (!content) return false + return ( + (content.casualElements?.length || 0) > 0 || + (content.taskElements?.length || 0) > 0 || + Object.keys(content.items || {}).length > 0 || + Object.keys(content.groups || {}).length > 0 || + Object.keys(content.tasks || {}).length > 0 + ) +} + +// #region 生成初始化数据 +/** 生成AI-Agent会话数据实例 */ +const genAIAgentChatData = (): AIAgentChatData => { + const defaultData: AIAgentChatData = { + httpFuzzRequest: undefined, + httpFlowFuzzStatus: undefined, + sessionTitle: '', + memoryList: DefaultMemoryList, + systemStream: '', + yaklangCodeChange: undefined, + + grpcOffset: 0, + + timelineBeforeId: 0, + timelineNoMore: false, + + httpRunTimeIDs: [], + riskRunTimeIDs: [], + aiPerfData: { + consumption: { + cache_hit_token: 0, + input_consumption: 0, + output_consumption: 0, + consumption_uuid: '', + tier_consumption: { + [AIModelTypeEnum.TierIntelligent]: { + cache_hit_token: 0, + input_consumption: 0, + output_consumption: 0, + }, + [AIModelTypeEnum.TierLightweight]: { + cache_hit_token: 0, + input_consumption: 0, + output_consumption: 0, + }, + [AIModelTypeEnum.TierVision]: { + cache_hit_token: 0, + input_consumption: 0, + output_consumption: 0, + }, + }, + }, + pressure: { + [AIModelTypeEnum.TierIntelligent]: [], + [AIModelTypeEnum.TierLightweight]: [], + [AIModelTypeEnum.TierVision]: [], + }, + firstCost: { + [AIModelTypeEnum.TierIntelligent]: [], + [AIModelTypeEnum.TierLightweight]: [], + [AIModelTypeEnum.TierVision]: [], + }, + totalCost: { + [AIModelTypeEnum.TierIntelligent]: [], + [AIModelTypeEnum.TierLightweight]: [], + [AIModelTypeEnum.TierVision]: [], + }, + contextStats: { + prompt_bytes: 0, + prompt_tokens: 0, + data: { + times: [], + total_prompt_bytes: [], + total_prompt_tokens: [], + role_order: [], + role_labels: {}, + role_series: {}, + role_tokens: {}, + }, + }, + contextSections: { summary: new Map(), sections: [] }, + }, + + casualChat: { + planDetails: DefaultPlanItemDetailsData, + planDetailsMap: new Map(), + }, + taskChat: { + planDetailsMap: new Map(), + }, + contents: new Map(), + } + return cloneDeep(defaultData) +} + +/** 生成AI-Agent会话的临时记录数据 */ +const genAIAgentChatMetaData = (): AIAgentChatMetaData => { + return { + createChatQuestion: undefined, + onEnd: undefined, + pingSyncID: '', + pingTimer: null, + casualMemoryList: cloneDeep(DefaultMemoryList), + taskMemoryList: cloneDeep(DefaultMemoryList), + notifyMessageTimer: null, + currentTaskPlanActiveNode: new Set(), + taskPlanEndGate: cloneDeep(DefaultTaskPlanEndGate), + historyReviewReleaseID: {}, + currentPlanReviewExtraId: '', + planReviewExtraData: new Map(), + toolStderrStreamData: new Map(), + systemEventUUID: [], + cardKVPair: new Map(), + cardKVPaidTimer: null, + execFileRecordOrder: 1, + syncIDMap: new Map(), + queuePollingEmptyCount: 0, + queuePollingTimer: null, + memoryPollingTimer: null, + casualSubTaskIDs: new Set(), + } +} +// #endregion + +// #region session-source-route-pageId 索引管理相关公共定义和工具方法 +/** page 归属键:`${route}::${pageId}`,pageId 为当前归属 */ +type PageKey = string +interface SessionOwner { + /** 不可变:注册后锁死 */ + readonly route: YakitRouteType + /** 不可变:注册后锁死 */ + readonly source: AISource + /** 可变:始终存当前 page */ + pageId: string +} + +/** 生成 route::pageId 的唯一标识 */ +const makePageKey = (route: YakitRouteType, pageId: string): PageKey => `${route}::${pageId}` + +/** + * 从渲染树快照收集「首屏」正文 token: + * casual / task 两侧顶层 elements 各取最后 topCount 条,并展开 group/task 的 childrenTokens。 + * 最多两层:group 的 children 是叶子 item;task 的 children 可能是 group 或 item, + * group 下不再嵌套 group,所以固定两层展开即可。 + */ +const collectTopLevelContentTokens = (content: SessionRenderContent, topCount: number): string[] => { + const tokenSet = new Set() + const appendFromElements = (elements: SessionRenderContent['casualElements']) => { + const top = elements.slice(-topCount) + for (const el of top) { + tokenSet.add(el.token) + if (el.kind === 'group') { + const group = content.groups[el.token] + group?.childrenTokens?.forEach((t) => tokenSet.add(t)) + } else if (el.kind === 'task') { + const task = content.tasks[el.token] + task?.childrenTokens?.forEach((childToken) => { + tokenSet.add(childToken) + // task 的 child 可能是 group,再展开一层 group 的 children(叶子 item) + const childGroup = content.groups[childToken] + childGroup?.childrenTokens?.forEach((t) => tokenSet.add(t)) + }) + } + } + } + appendFromElements(content.casualElements || []) + appendFromElements(content.taskElements || []) + return [...tokenSet] +} +// #endregion + +export class ChatMultiSessionController { + // #region 常量定义 + /** 渲染树-element debounce 落库 IDB 延迟时间 */ + private static readonly RENDER_PERSIST_DEBOUNCE_MS = 3000 + /** cancel 后等待真实 session-end 的最长时间,超时则合成 end */ + private static readonly SESSION_END_FALLBACK_MS = 5000 + /** 恢复会话时首屏灌入 contents 的顶层条数(两侧列表各自截取) */ + private static readonly INITIAL_CONTENT_TOP_COUNT = 20 + /** recovery_history 单次拉取条数 */ + private static readonly RECOVERY_HISTORY_LIMIT = 60 + /** ping请求探连成功的轮询时间 */ + private static readonly PING_POLLING_INTERVAL = 3000 + // #endregion + + // #region session-source-route-pageId 索引管理相关变量和逻辑 + /** + * 正向索引:按「当前」page 关页 / 全删 + * pageId 换绑后旧 PageKey 不再包含该 session + */ + private pageSessionMap = new Map>>() + /** + * 反向索引:按 sessionId O(1) 定位;换绑时只改 pageId 并搬动正向索引 + */ + private sessionOwnerMap = new Map() + + /** 将 session 写入 pageSessionMap 正向索引 */ + private addToPageSessionMap(owner: SessionOwner, sessionId: string) { + const pageKey = makePageKey(owner.route, owner.pageId) + let sourceMap = this.pageSessionMap.get(pageKey) + if (!sourceMap) { + sourceMap = new Map() + this.pageSessionMap.set(pageKey, sourceMap) + } + let sessionSet = sourceMap.get(owner.source) + if (!sessionSet) { + sessionSet = new Set() + sourceMap.set(owner.source, sessionSet) + } + sessionSet.add(sessionId) + } + + /** 从 pageSessionMap 正向索引摘除;空 Set/Map 则清理 */ + private removeFromPageSessionMap(owner: SessionOwner, sessionId: string) { + const pageKey = makePageKey(owner.route, owner.pageId) + const sourceMap = this.pageSessionMap.get(pageKey) + if (!sourceMap) return + + const sessionSet = sourceMap.get(owner.source) + if (!sessionSet) return + + sessionSet.delete(sessionId) + if (sessionSet.size === 0) { + sourceMap.delete(owner.source) + } + if (sourceMap.size === 0) { + this.pageSessionMap.delete(pageKey) + } + } + + /** + * session 会话建立时注册归属索引,并标识当前会话已建立连接 + * + * - route / source 建立后不可变 + * - 若 session 已存在且仅 pageId 不同,走 rebind 而非重复注册 + */ + private registerSessionChannel( + sessionId: string, + owner: { route: YakitRouteType; pageId: string; source?: AIStartParams['Source'] }, + ) { + const source: AISource = owner.source || 'ai' + const existing = this.sessionOwnerMap.get(sessionId) + + if (existing) { + // 禁止改 route / source + // 但是session已经存在,直接启动即可,不需要rebind + if (existing.route !== owner.route || existing.source !== source) { + console.error(`[ChatMultiSessionController] registerSessionChannel: session 已存在且 route/source 不可变`, { + sessionId, + existing, + next: { route: owner.route, pageId: owner.pageId, source }, + }) + this.readyChannels.add(sessionId) + return + } + // 仅 pageId 不同 → rebind + if (existing.pageId !== owner.pageId) { + this.rebindSessionPageId(sessionId, owner.pageId) + } + this.readyChannels.add(sessionId) + return + } + + const sessionOwner: SessionOwner = { + route: owner.route, + source, + pageId: owner.pageId, + } + this.sessionOwnerMap.set(sessionId, sessionOwner) + this.addToPageSessionMap(sessionOwner, sessionId) + this.readyChannels.add(sessionId) + } + + /** + * 非空 sessionIds 直接使用指定集合;空数组表示按 source 全删。 + * + * gRPC 的按 source 全删不区分 route / pageId,因此这里也必须遍历反向索引, + * 收集所有页面中对应 source 的 session。否则在 B 页全删后只会停止 B, + * 同 source 的 A 页会话仍会继续运行。 + */ + private resolveSessionIds(params: { + sessionIds?: string[] + sources: AISource[] + route: YakitRouteType + pageId: string + }): string[] { + const { sessionIds, sources } = params + if (sessionIds?.length) return [...sessionIds] + + const sourceSet = new Set(sources) + const ids: string[] = [] + for (const [sessionId, owner] of this.sessionOwnerMap) { + if (sourceSet.has(owner.source)) ids.push(sessionId) + } + return ids + } + + /** 该 PageKey 下所有 source 的 session 并集 */ + private resolvePageSessionIds(route: YakitRouteType, pageId: string): string[] { + const sourceMap = this.pageSessionMap.get(makePageKey(route, pageId)) + if (!sourceMap) return [] + const ids: string[] = [] + for (const sessionSet of sourceMap.values()) { + for (const id of sessionSet) { + ids.push(id) + } + } + return ids + } + + /** + * 按 source + route 查询当前索引中的 sessionId 集合(跨该 route 下所有 pageId) + */ + public getSessionIdsBySourceAndRoute(source: AISource, route: YakitRouteType): string[] { + const ids: string[] = [] + for (const [sessionId, owner] of this.sessionOwnerMap) { + if (owner.source === source && owner.route === route) { + ids.push(sessionId) + } + } + return ids + } + + /** 从传入的 sessionId 集合中筛出 store.execute === true 的会话 */ + public filterExecutingSessionIds(sessionIds: string[]): string[] { + return sessionIds.filter((sessionId) => this.getSessionExecute(sessionId)) + } + + /** + * 只读查询 session 是否在执行中;无内存池时返回 false,不会 ensureSession 造空池 + */ + public getSessionExecute(sessionId: string): boolean { + return this.storePool.get(sessionId)?.getState().execute === true + } + + /** + * 同 route 下换绑 pageId:更新 sessionOwnerMap.pageId,从旧 PageKey 摘除、写入新 PageKey + * route / source 不变;newPageId 与旧相同或 session 已 dispose 则 no-op + */ + public rebindSessionPageId(sessionId: string, newPageId: string) { + const owner = this.sessionOwnerMap.get(sessionId) + if (!owner || owner.pageId === newPageId) return + + this.removeFromPageSessionMap(owner, sessionId) + owner.pageId = newPageId + this.addToPageSessionMap(owner, sessionId) + } + // #endregion + + /** 存放已建立连接的会话session集合 */ + private readyChannels = new Set() + /** 会话是否仍占坑(已 start 且尚未 end,含 cancel 等待 end 的窗口) */ + public isSessionReady(sessionId: string) { + return this.readyChannels.has(sessionId) + } + /** + * 待卸池的 session:forceClose 后保留监听与业务池,等 end / 兜底超时再 teardown + * value 为 dispose 时的 deletePersist 标记 + */ + private pendingDisposeSessions = new Map() + + private requestPool = new Map() + private storePool = new Map>() + private rawDataPool = new Map() + private metaPool = new Map() + /** 获取对应会话的所有数据集 */ + public ensureSession(sessionId: string) { + if (!this.storePool.has(sessionId)) { + this.storePool.set( + sessionId, + createChatStore({ + onRenderStructureChange: () => this.markSessionRenderDirty(sessionId), + }), + ) + this.rawDataPool.set(sessionId, genAIAgentChatData()) + this.requestPool.set(sessionId, cloneDeep(AIAgentSettingDefault)) + this.metaPool.set(sessionId, genAIAgentChatMetaData()) + } + return { + request: this.requestPool.get(sessionId)!, + store: this.storePool.get(sessionId)!, + rawData: this.rawDataPool.get(sessionId)!, + meta: this.metaPool.get(sessionId)!, + } + } + /** + * 更新指定会话的配置参数 + * + * Source 字段连接会话时锁死,后续不允许热更新 + */ + public updateSessionConfig(sessionId: string, config: Partial>) { + const { request } = this.ensureSession(sessionId) + const { Source: _omit, ...rest } = config as AIStartParams + Object.assign(request, rest) + } + + private activeShowSession: string = '' + /** 设置当前展示的会话 Session */ + public setActiveShowSession(sessionId: string) { + this.activeShowSession = sessionId + } + /** 判断指定会话是否当前正在展示 */ + public isActiveShowSession(sessionId: string) { + return this.activeShowSession === sessionId + } + + // #region IndexedDB 持久化门面(薄封装 aiChatPersistStore,错误兜底不抛穿 UI) + /** + * sessionRender 写串行链:同一 session 的渲染树写排队执行。 + * 作用有二: + * 1. 保证 hydrate 快照写与随后的流式 flush 写不并发交叠(后写覆盖先写语义成立) + * 2. teardownDisposedSession 删除 IDB 前 drainRenderWrites 排干在飞写, + * 避免 delete 后迟到的 put 又写回孤儿行 + */ + private renderWriteChains = new Map>() + private enqueueRenderWrite(sessionId: string, task: () => Promise): Promise { + const next = (this.renderWriteChains.get(sessionId) || Promise.resolve()).then(task, task) + this.renderWriteChains.set(sessionId, next) + next.finally(() => { + if (this.renderWriteChains.get(sessionId) === next) { + this.renderWriteChains.delete(sessionId) + } + }) + return next + } + /** 排干该 session 所有在飞的渲染树写;resolve 时链已排空 */ + private drainRenderWrites(sessionId: string): Promise { + return this.renderWriteChains.get(sessionId)?.catch(() => {}) || Promise.resolve() + } + + /** 从 sessionOwnerMap 取 source,兜底 'ai' */ + private resolvePersistSource(sessionId: string): AISource { + return this.sessionOwnerMap.get(sessionId)?.source || AISourceEnum.aiAgent + } + + /** 保存会话渲染树(element)和grpcOffset */ + private persistSetSessionRender( + sessionId: string, + content: SessionRenderContent, + grpcOffset?: number, + ): Promise { + // 同步计算 offset / source 并闭包捕获,避免 enqueue 后 session 被 teardown 导致取不到 + const offset = grpcOffset ?? this.rawDataPool.get(sessionId)?.grpcOffset ?? 0 + const source = this.resolvePersistSource(sessionId) + return this.enqueueRenderWrite(sessionId, () => + aiChatPersistStore.setSessionRender(sessionId, source, content, offset).catch(() => { + // 持久化失败不打断主流程 + }), + ) + } + /** 获取会话渲染树和grpcOffset */ + private async persistGetSessionRender(sessionId: string) { + try { + return await aiChatPersistStore.getSessionRender(sessionId, this.resolvePersistSource(sessionId)) + } catch { + return undefined + } + } + + /** 按 token 获取会话消息内容 */ + private async persistGetSessionContent(sessionId: string, token: string) { + try { + return await aiChatPersistStore.getSessionContent(sessionId, token) + } catch { + return undefined + } + } + /** 按 token 列表批量获取会话消息内容 */ + async persistGetSessionContents(sessionId: string, tokens: string[]) { + try { + return await aiChatPersistStore.getSessionContents(sessionId, tokens) + } catch { + return [] + } + } + + /** 按 token 列表批量获取会话参考资料(按落库时间正序) */ + public async getSessionReferenceMaterials(sessionId: string, tokens: string[]) { + try { + return await aiChatPersistStore.getSessionReferences(sessionId, tokens) + } catch { + return [] + } + } + + /** 按 source 清除该来源下所有 session 的持久化数据 */ + public async persistDeleteBySource(source: AISource) { + try { + await aiChatPersistStore.deletePersistBySource(source) + } catch { + // 持久化失败不打断主流程 + } + } + + /** + * 将渲染树写入 chatStore,并批量灌回首屏 contents。 + * @param content 优先用 start 时暂存的树;未传则再读 IDB 整行 + * + * 注意:必须先把 contents 灌进 Map,再 hydrate 渲染树。 + * UI(StaticChatContent 等)靠 renderNum 订阅,rawData.contents 原地 set 不会触发重渲染; + * 若先 hydrate,组件会在 contents 仍空时读一次并卡住空白。 + * 任务规划树不走本方法,由 handleSessionStartSuccess 的 PLAN_EXEC_TASKS sync 拉取。 + */ + private async loadSessionRenderToMemory(sessionId: string, content?: SessionRenderContent) { + let tree = content + if (!tree) { + const row = await this.persistGetSessionRender(sessionId) + tree = row?.content + } + if (!hasSessionRenderTree(tree)) return false + + const { store, rawData } = this.ensureSession(sessionId) + + const tokens = collectTopLevelContentTokens(tree!, ChatMultiSessionController.INITIAL_CONTENT_TOP_COUNT) + if (tokens.length) { + const rows = await this.persistGetSessionContents(sessionId, tokens) + for (const row of rows) { + rawData.contents.set(row.token, row.content) + } + } + + store.getState().hydrateRenderTree(tree!) + return true + } + + /** 仅删除内存 contents 中的条目,不删渲染树 / IDB */ + public removeContentsFromMemory(sessionId: string, tokens: string[]) { + // const { rawData } = this.ensureSession(sessionId) + // for (const token of tokens) { + // rawData.contents.delete(token) + // } + } + // #endregion + + // #region 渲染树-element debounce 落库 IDB + /** 渲染树(element相关数据) debounce 定时器 */ + private renderPersistTimers = new Map>() + + /** 取消该 session 的渲染树 debounce(不写 IDB) */ + private clearSessionRenderPersistTimer(sessionId: string) { + const timer = this.renderPersistTimers.get(sessionId) + if (timer) { + clearTimeout(timer) + this.renderPersistTimers.delete(sessionId) + } + } + + /** chatStore.dispatchStreamingNode触发后防抖3s,无新变更再写入 sessionRender */ + private markSessionRenderDirty(sessionId: string) { + // 已进入卸池流程:禁止再入内存,避免覆盖 dispose 时已 flush 的 IDB + if (this.pendingDisposeSessions.has(sessionId)) return + + this.clearSessionRenderPersistTimer(sessionId) + const timer = setTimeout(() => { + // 到期后统一走 flush:摘 timer、清 dirty、写 IDB(外部强制 flush 也走同一套) + this.flushSessionRender(sessionId) + }, ChatMultiSessionController.RENDER_PERSIST_DEBOUNCE_MS) + this.renderPersistTimers.set(sessionId, timer) + } + + /** 立即刷写渲染树快照并清除 dirty/timer */ + private flushSessionRender(sessionId: string) { + this.clearSessionRenderPersistTimer(sessionId) + + const store = this.storePool.get(sessionId) + const rawData = this.rawDataPool.get(sessionId) + if (!store || !rawData) return + const state = store.getState() + const content: SessionRenderContent = { + items: { ...state.items }, + groups: { ...state.groups }, + tasks: { ...state.tasks }, + casualElements: [...state.casualChat.elements], + taskElements: [...state.taskChat.elements], + } + void this.persistSetSessionRender(sessionId, content, rawData.grpcOffset) + } + // #endregion + + /** + * 无 UserQuery 建连进入恢复态:initLoading 为 true, + * 待 hydrate / recovery_history 结束后再关 + */ + private sessionRestoreLoading = new Set() + + /** + * cancel 后等待 session-end 的兜底定时器:超时则手动走 handleSessionEnd(摘监听 + 收尾) + * 避免 end 丢失导致监听泄漏 / onEnd 永不触发 + */ + private sessionEndFallbackTimers = new Map>() + /** 等待 session-end 的调用方;同一会话可被多个流程同时等待 */ + private sessionEndWaiters = new Map void>>() + + /** 唤醒等待指定 session-end 的所有调用方 */ + private resolveSessionEndWaiters(sessionId: string) { + const waiters = this.sessionEndWaiters.get(sessionId) + if (!waiters) return + this.sessionEndWaiters.delete(sessionId) + waiters.forEach((resolve) => resolve()) + } + + /** + * 停止仍在执行的会话,并等待真实 session-end 或 fallback 完成。 + * 无执行态会话时立即完成;所有 cancel 并发发出,等待上限不叠加。 + */ + public async stopExecutingSessionsAndWait(sessionIds: string[]): Promise { + const executingSessionIds = [...new Set(this.filterExecutingSessionIds(sessionIds))] + if (!executingSessionIds.length) return + + const waitForEnd = executingSessionIds.map( + (sessionId) => + new Promise((resolve) => { + let waiters = this.sessionEndWaiters.get(sessionId) + if (!waiters) { + waiters = new Set() + this.sessionEndWaiters.set(sessionId, waiters) + } + waiters.add(resolve) + }), + ) + + this.forceCloseSession({ sessionIds: executingSessionIds }) + await Promise.all(waitForEnd) + } + + /** 取消已有的 session-end 兜底定时器 */ + private clearSessionEndFallback(sessionId: string) { + const timer = this.sessionEndFallbackTimers.get(sessionId) + if (!timer) return + clearTimeout(timer) + this.sessionEndFallbackTimers.delete(sessionId) + } + + /** + * 建立会话时,获取grpc库中最新数据ID和IDB里的数据 + * 对齐offset,设置出最新的grpcOffset数据 + * 将IDB里渲染树(element)暂存, 供 pong 后 hydrate。 + * 不依赖会话 gRPC 已连通。 + */ + private async prepareSessionPersistBeforeStart(sessionId: string) { + const { rawData, meta } = this.ensureSession(sessionId) + + try { + const [eventRes, row] = await Promise.all([ + grpcQueryAIEvent( + { + Filter: { SessionID: sessionId }, + Pagination: { Page: 1, Limit: 1, OrderBy: 'created_at', Order: 'desc' }, + }, + true, + ).catch(() => ({ Events: [] as AIOutputEvent[] })), + this.persistGetSessionRender(sessionId), + ]) + + const latestId = eventRes?.Events?.[0]?.ID ?? 0 + const final = row?.grpcOffset !== undefined && row.grpcOffset !== null ? row.grpcOffset : latestId + rawData.grpcOffset = final + + if (hasSessionRenderTree(row?.content)) { + meta.pendingSessionRender = row!.content + } else { + meta.pendingSessionRender = undefined + } + } catch { + rawData.grpcOffset = rawData.grpcOffset || 0 + meta.pendingSessionRender = undefined + } + } + + /** + * 建立指定 session 连接(新会话首问 / 打开历史 / 无问侧重连 共用)。 + * 调用方应先用 isSessionReady 判重并挂好 IPC 监听,再调本方法(prepare 异步,invoke 晚于监听)。 + * - 有 UserQuery:立刻上屏首问,pong 后发问;无树时不强制 recovery_history + * - 无 UserQuery:视为恢复态,置 initLoading,pong 后 hydrate 或发 recovery_history + * @returns 是否真正发起了建连 + */ + public handleStartSession(requestParams: AIChatIPCStartParams, cb?: (sessionId: string) => void): boolean { + const { token: sessionId, params, route, pageId } = requestParams + if (this.readyChannels.has(sessionId)) { + yakitNotify('warning', '会话已经存在,请勿重复建立!') + return false + } + + this.registerSessionChannel(sessionId, { + route, + pageId, + source: params.Params?.Source, + }) + + const { request, store, rawData, meta } = this.ensureSession(sessionId) + const userQuery = (params.Params?.UserQuery || '').trim() + + // 恢复态:遮罩防止 hydrate / recovery 期间误点(UI 订阅 store.initLoading) + if (userQuery) { + store.getState().updateState({ execute: true, casualTitle: '发送问题,开启会话...' }) + } else { + store.getState().updateState({ execute: true, initLoading: true, casualTitle: '加载会话中...' }) + this.sessionRestoreLoading.add(sessionId) + } + + this.setActiveShowSession(sessionId) + + Object.assign(request, params.Params) + if (userQuery) { + // 判断建立grpc连接时是否附带问题 + // 如有,需要剥离出来,在grpc建立成功后再执行 + const chatID = uuidv4() + + const AttachedResourceInfos = params.AttachedResourceInfo || [] + AttachedResourceInfos.push({ + Key: AttachedResourceKeyEnum.CONTEXT_PROVIDER_KEY_DEFAULT, + Type: AttachedResourceTypeEnum.USER_FREE_INPUT_UUID, + Value: chatID, + }) + + meta.createChatQuestion = { + IsFreeInput: true, + FreeInput: userQuery, + AttachedResourceInfo: AttachedResourceInfos, + FocusModeLoop: params.FocusModeLoop, + } + + // 用户问了问题后,立即显示到UI上 + // 问题对应的re_act_task_id先由前端生成,并发送给后端 + // 后续生成re_act_task_id时,会把前端生成的uuid替换为后端生成的re_act_task_id + const chatData: AIChatQSData = { + id: chatID, + chatType: 'reAct', + type: AIChatQSDataTypeEnum.QUESTION, + Timestamp: moment().unix(), + data: userQuery, + AIService: '', + AIModelName: '', + // showQS为了UI渲染方便,重新构建的字段 + extraValue: { showQS: userQuery }, + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + }, + }) + } + meta.onLinkSuccess = cb + + // 读 IDB + 查最新事件 id(不依赖本会话流),完成后再 IPC start + void this.prepareSessionPersistBeforeStart(sessionId).finally(() => { + ipcRenderer.invoke('start-ai-re-act', sessionId, params) + + // 建立会话连接时,在主进程进行了一次ping请求 + // 如果五秒没有返回pong消息,则再次进行ping请求 + if (meta.pingTimer) clearInterval(meta.pingTimer) + meta.pingTimer = setInterval(() => { + meta.pingSyncID = uuidv4() + this.requestMessage(sessionId, { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PING, + SyncID: meta.pingSyncID, + }) + }, ChatMultiSessionController.PING_POLLING_INTERVAL) + }) + return true + } + + /** 主动向grpc发送请求 */ + public handleSendMessage(payload: AIChatSendParams) { + // console.log('handleSendMessage', payload) + try { + const { token, type, params, optionValue } = payload + if (!this.readyChannels.has(token)) { + if (!this.isActiveShowSession(token)) return + yakitNotify('warning', '会话不存在,无法发送消息') + return + } + + const { store, rawData, meta } = this.ensureSession(token) + + // 向上加载历史(recovery_history)进行中时禁止发送消息,避免与 gRPC 查询并发导致后端表死锁 + if (store.getState().grpcLoadMoreLoading) { + yakitNotify('warning', '历史消息加载中,请稍后再发送') + return + } + + if (params.IsFreeInput) { + const { casualLoading, currentCasualTaskID, taskStatus } = store.getState() + // 如果自由对话引起了任务规划,那么自由对话其实是空闲状态 + const isCasualIdle = + casualLoading && currentCasualTaskID && taskStatus.taskID && currentCasualTaskID === taskStatus.taskID + + if (!casualLoading || isCasualIdle) { + // 自由对话没有问题进行中时,才改变loading的title + store.getState().updateState({ casualTitle: '等待回复中...' }) + + const chatID = uuidv4() + const AttachedResourceInfos = params.AttachedResourceInfo || [] + AttachedResourceInfos.push({ + Key: AttachedResourceKeyEnum.CONTEXT_PROVIDER_KEY_DEFAULT, + Type: AttachedResourceTypeEnum.USER_FREE_INPUT_UUID, + Value: chatID, + }) + params.AttachedResourceInfo = AttachedResourceInfos + const chatData: AIChatQSData = { + id: chatID, + chatType: 'reAct', + type: AIChatQSDataTypeEnum.QUESTION, + Timestamp: moment().unix(), + data: params.FreeInput || '', + AIService: '', + AIModelName: '', + // showQS为了UI渲染方便,重新构建的字段 + extraValue: { showQS: params.FreeInput || '' }, + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(token, chatData) + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + }, + }) + } + + // 因为有用户问题发送,所以注册 获取问题队列轮询器 + if (!meta.queuePollingTimer) { + meta.queuePollingEmptyCount = 0 + meta.queuePollingTimer = setInterval(() => { + this.requestMessage(token, { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, + }) + }, 5000) + } + } + + // 记录发送请求里的syncId-标识开始处理中 + if (params.IsSyncMessage && params.SyncID) { + meta.syncIDMap.set(params.SyncID, true) + store.getState().updateStateCount('syncIDUpdate') + } + + switch (type) { + case 'casual': + if (params.IsInteractiveMessage && params.InteractiveId) { + const isExist = store.getState().currentCasualReview.includes(params.InteractiveId) + const review = rawData.contents.get(params.InteractiveId) + if (!isExist || !review) { + yakitNotify('error', '未获取到 review 信息, 操作无效') + return + } + + switch (review.type) { + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + // 非执行任务组的tool_review,并且review模式不是yolo,才能展示到UI上供用户主动操作 + // 用户操作后,review结果不会展示到UI上,所以需要删除该review的所有数据 + rawData.contents.delete(review.id) + store.getState().updateCasualReview(review.id, 'remove') + store.getState().deleteElementNode({ + chatType: 'reAct', + token: review.id, + kind: 'item', + taskID: review.TaskId || undefined, + onDelContent: (mapKey) => { + rawData.contents.delete(mapKey) + }, + }) + break + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + // review操作后正常展示在UI上 + review.data.selected = params.InteractiveJSONInput + review.data.optionValue = optionValue + store.getState().updateCasualReview(params.InteractiveId, 'remove') + store.getState().incrementNodeVersion(review.id, 'item') + persistIndependentItem(token, review) + break + default: + break + } + } + break + case 'task': + if (params.IsInteractiveMessage && params.InteractiveId) { + const isExist = store.getState().currentPlanReviewToken.token === params.InteractiveId + const review = rawData.contents.get(params.InteractiveId) + if (!isExist || !review) { + yakitNotify('error', '未获取到 review 信息, 操作无效') + return + } + + store.getState().updateState({ currentPlanReviewToken: { token: '', renderNum: 0 } }) + switch (review.type) { + case AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP: + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + // 任务规划的task_review和tool_review会在自动执行continue操作,不会在UI上展示 + // 如果能进入该逻辑,说明有问题 + console.error(`未知错误[handleSendMessage]: ${JSON.stringify(payload)}`) + break + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + // review操作后正常展示在UI上 + review.data.selected = params.InteractiveJSONInput + review.data.optionValue = optionValue + persistIndependentItem(token, review) + store.getState().dispatchStreamingNode({ + chatType: 'task', + parentTaskId: review.TaskId, + node: { + token: review.id, + kind: 'item', + type: review.type, + }, + }) + break + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: + review.data.selected = params.InteractiveJSONInput + review.data.optionValue = optionValue + persistIndependentItem(token, review) + if (optionValue === 'continue') { + const tasks = review.data + const plans = genExecTasks(tasks.plans.root_task) + store.getState().updatePlanTree({ + task_tree: cloneDeep(plans), + root_task_name: tasks.plans.root_task.name, + }) + } + store.getState().dispatchStreamingNode({ + chatType: 'task', + parentTaskId: review.TaskId, + node: { + token: review.id, + kind: 'item', + type: review.type, + }, + }) + break + default: + break + } + } + break + + default: + break + } + + this.requestMessage(token, params) + } catch (error) { + console.error('handleSendMessage error', error) + } + } + /** 向连接中的会话发送请求 */ + private requestMessage(sessionId: string, request: AIInputEvent) { + // console.log('requestMessage', sessionId, request) + ipcRenderer.invoke('send-ai-re-act', sessionId, request) + } + + /** 发 recovery_history 拉更旧事件(grpcOffset 为起点,向前回溯 RECOVERY_HISTORY_LIMIT 条) */ + public requestRecoveryHistory(sessionId: string) { + const { store, rawData } = this.ensureSession(sessionId) + const initLoading = store.getState().initLoading + const grpcLoadMoreLoading = store.getState().grpcLoadMoreLoading + if (!initLoading && grpcLoadMoreLoading) return + if (!initLoading && !grpcLoadMoreLoading) store.getState().updateState({ grpcLoadMoreLoading: true }) + this.requestMessage(sessionId, { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_HISTORY, + SyncJsonInput: JSON.stringify({ + start_id: rawData.grpcOffset, + limit: ChatMultiSessionController.RECOVERY_HISTORY_LIMIT, + }), + }) + } + + /** timeline 历史单次拉取条数 */ + private static readonly TIMELINE_PAGE_LIMIT = 200 + + /** + * 拉取 timeline 历史(grpcQueryAIEvent 按 NodeId=timeline_item 过滤,BeforeId 游标分页)。 + * 拉回后 reverse 为时间正序,前插到 store.reActTimelines;更新 timelineBeforeId 游标。 + * @returns 是否还有更旧历史(Events.length === LIMIT) + */ + public async loadTimelineHistory(sessionId: string): Promise { + const { rawData, store } = this.ensureSession(sessionId) + // 置 loading(驱动 TimelineCard 的 YakitSpin);与 finally 一致用 store.getState() 取最新 + store.getState().updateState({ + requestHistoryState: { ...store.getState().requestHistoryState, timelinesLoading: true }, + }) + try { + const request: AIEventQueryRequest = { + Filter: { SessionID: sessionId, NodeId: ['timeline_item'] }, + Pagination: { + Page: 1, + Limit: ChatMultiSessionController.TIMELINE_PAGE_LIMIT, + OrderBy: 'created_at', + Order: 'desc', + }, + } + if (rawData.timelineBeforeId > 0) { + request.Pagination!.BeforeId = rawData.timelineBeforeId + } + const { Events, Total } = await grpcQueryAIEvent(request, true) + if (Number(Total) === 0) { + // 已到最旧,置尽头标记,避免 hasMoreTimeline 误判导致无限空查询 + rawData.timelineNoMore = true + return false + } + + // 更新游标为最后一条(最旧)的 ID + rawData.timelineBeforeId = Number(Events[Events.length - 1].ID) + // 解析为 TimelineItem,reverse 为时间正序(旧→新) + const timelineItems: AIAgentGrpcApi.TimelineItem[] = Events.map((item) => { + const ipcContent = Uint8ArrayToString(item.Content) || '' + return JSON.parse(ipcContent) as AIAgentGrpcApi.TimelineItem + }).reverse() + // 前插合并去重:在 store set 回调里拿最新 state 合并, + // 避免 await 期间实时流推入的新数据被 stale state 覆盖丢失 + store.getState().setReActTimelines(timelineItems) + const hasMore = Events.length === ChatMultiSessionController.TIMELINE_PAGE_LIMIT + // 没拉满一页说明已到尽头,置标记 + if (!hasMore) rawData.timelineNoMore = true + return hasMore + } catch { + return false + } finally { + const curState = store.getState() + store.getState().updateState({ + requestHistoryState: { ...curState.requestHistoryState, timelinesLoading: false }, + }) + } + } + + /** 是否还有更旧 timeline 历史可加载(用 timelineNoMore 标记,避免无限空查询) */ + public hasMoreTimeline(sessionId: string): boolean { + const { rawData } = this.ensureSession(sessionId) + return !rawData.timelineNoMore + } + + /** + * 拉取文件系统历史(grpcQueryAIEvent 按 EventType=filesystem_pin_* 过滤,Limit=-1 全量)。 + * 按 path 去重合并到 store.grpcFolders。无分页。 + */ + public async loadFileSystemHistory(sessionId: string) { + const { store } = this.ensureSession(sessionId) + try { + const request: AIEventQueryRequest = { + Filter: { SessionID: sessionId, EventType: ['filesystem_pin_directory', 'filesystem_pin_filename'] }, + Pagination: { Page: 1, Limit: -1, OrderBy: 'created_at', Order: 'desc' }, + } + const { Events, Total } = await grpcQueryAIEvent(request, true) + if (Total === 0) return + + const files: AIFileSystemPin[] = Events.map((item) => { + const ipcContent = Uint8ArrayToString(item.Content) || '' + const { path } = JSON.parse(ipcContent) as AIAgentGrpcApi.FileSystemPin + return { path, isFolder: item.Type === 'filesystem_pin_directory' } + }) + // 本批次去重 + const filterFiles: AIFileSystemPin[] = [...new Map(files.map((item) => [item.path, item])).values()] + // 合并去重交给 store(在 set 回调里拿最新 state,避免 stale state 丢失实时数据) + store.getState().setGrpcFolders(filterFiles) + } catch { + // 持久化失败不打断主流程 + } + } + + /** 会话建立成功后, 需要做的额外操作 */ + private handleSessionStartSuccess(sessionId: string) { + const { store, meta } = this.ensureSession(sessionId) + + // 获取任务规划历史任务树 + this.requestMessage(sessionId, { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS, + }) + + // 获取最新记忆列表数据, 并注册轮询定时器 + this.requestMessage(sessionId, { IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_MEMORY_CONTEXT }) + if (meta.memoryPollingTimer) clearInterval(meta.memoryPollingTimer) + meta.memoryPollingTimer = setInterval(() => { + this.requestMessage(sessionId, { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_MEMORY_CONTEXT, + }) + }, 5000) + + // 如果任务规划运行态有数据,则置空 + store.getState().updateState({ taskStatus: cloneDeep(DefaultTaskPlanStatus) }) + + // 拉取 timeline 历史(首批)+ 文件系统历史(全量),不阻塞建连主流程 + void this.loadTimelineHistory(sessionId) + void this.loadFileSystemHistory(sessionId) + } + + /** 关闭恢复态 loading(hydrate 完成或 recovery 结束时调用) */ + private finishSessionRestoreLoading(sessionId: string) { + if (!this.sessionRestoreLoading.has(sessionId)) return + this.sessionRestoreLoading.delete(sessionId) + const store = this.storePool.get(sessionId) + store?.getState().updateState({ initLoading: false }) + } + + /** + * pong 后:消费暂存树或发 recovery_history。 + * @param needRecoveryHistory 无首问建连(恢复态)时为 true:空树则向后端拉历史 + */ + private async restoreSessionAfterPong(sessionId: string, needRecoveryHistory: boolean) { + const { store, rawData, meta } = this.ensureSession(sessionId) + const pending = meta.pendingSessionRender + meta.pendingSessionRender = undefined + + try { + if (hasSessionRenderTree(pending)) { + await this.loadSessionRenderToMemory(sessionId, pending) + const state = store.getState() + const content: SessionRenderContent = { + items: { ...state.items }, + groups: { ...state.groups }, + tasks: { ...state.tasks }, + casualElements: [...state.casualChat.elements], + taskElements: [...state.taskChat.elements], + } + await this.persistSetSessionRender(sessionId, content, rawData.grpcOffset) + this.finishSessionRestoreLoading(sessionId) + } else if (needRecoveryHistory) { + // grpcOffset 为 0:无历史游标可续,不发 recovery_history + if (!rawData.grpcOffset) { + const state = store.getState() + const content: SessionRenderContent = { + items: { ...state.items }, + groups: { ...state.groups }, + tasks: { ...state.tasks }, + casualElements: [...state.casualChat.elements], + taskElements: [...state.taskChat.elements], + } + await this.persistSetSessionRender(sessionId, content, rawData.grpcOffset) + this.finishSessionRestoreLoading(sessionId) + } else { + // 保持 initLoading,等 recovery_history 再关,避免 UI 提前可点 + this.requestRecoveryHistory(sessionId) + } + } else { + // 带首问的新会话:用当前 store 快照(可能已有首问)+ offset + const state = store.getState() + const content: SessionRenderContent = { + items: { ...state.items }, + groups: { ...state.groups }, + tasks: { ...state.tasks }, + casualElements: [...state.casualChat.elements], + taskElements: [...state.taskChat.elements], + } + await this.persistSetSessionRender(sessionId, content, rawData.grpcOffset) + } + } catch { + this.finishSessionRestoreLoading(sessionId) + } + } + + /** 💥 核心替换:接管原 useChatIPC 里的巨型数据分发逻辑! */ + public handleGrpcOutputEvent(sessionId: string, res: AIOutputEvent) { + try { + if (!this.readyChannels.has(sessionId)) return + + let ipcContent = Uint8ArrayToString(res.Content) || '' + // console.log('handleGrpcOutputEvent--', sessionId, '\n', res, '\n', ipcContent) + + const { store, rawData, request, meta } = this.ensureSession(sessionId) + + // 标识同步ID已处理 + if (res.SyncID && meta.syncIDMap.has(res.SyncID)) { + meta.syncIDMap.delete(res.SyncID) + store.getState().updateStateCount('syncIDUpdate') + } + + // const mirrorToLogWindow = () => { + // aiAgentLogEmitter.dispatch({ + // session: sessionId, + // type: 'log', + // Timestamp: res.Timestamp, + // log: { level: 'log', message: ipcContent }, + // }) + // } + + if (res.Type === 'pong') { + // 如果返回的pong没有值,但是pingSyncID有值,说明该条消息已经过期 + if (!res.SyncID && meta.pingSyncID) return + // 如果返回的pong有值,但是和pingSyncID不一样,说明该条消息已经过期 + if (res.SyncID && res.SyncID !== meta.pingSyncID) return + // 该条消息有效,不需要在轮询ping请求了 + if (meta.pingTimer) clearInterval(meta.pingTimer) + meta.pingTimer = null + meta.pingSyncID = '' + + if (meta.createChatQuestion) { + this.requestMessage(sessionId, meta.createChatQuestion) + meta.createChatQuestion = undefined + store.getState().updateState({ casualTitle: '等待回复中...' }) + + // 因为有用户问题发送,所以注册 获取问题队列轮询器 + if (meta.queuePollingTimer) clearInterval(meta.queuePollingTimer) + meta.queuePollingEmptyCount = 0 + meta.queuePollingTimer = setInterval(() => { + this.requestMessage(sessionId, { + IsSyncMessage: true, + SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO, + }) + }, 5000) + + void this.restoreSessionAfterPong(sessionId, false).finally(() => { + this.handleSessionStartSuccess(sessionId) + meta.onLinkSuccess?.(sessionId) + meta.onLinkSuccess = undefined + }) + } else { + void this.restoreSessionAfterPong(sessionId, true).finally(() => { + this.handleSessionStartSuccess(sessionId) + meta.onLinkSuccess?.(sessionId) + meta.onLinkSuccess = undefined + }) + } + return + } + + if (res.Type === 'structured' && res.NodeId === 'recovery_history') { + try { + const recoveryHistory = JSON.parse(ipcContent) as AIAgentGrpcApi.RecoveryHistory + if (typeof recoveryHistory.next_start_id === 'number') { + rawData.grpcOffset = recoveryHistory.next_start_id + const state = store.getState() + const content: SessionRenderContent = { + items: { ...state.items }, + groups: { ...state.groups }, + tasks: { ...state.tasks }, + casualElements: [...state.casualChat.elements], + taskElements: [...state.taskChat.elements], + } + void this.persistSetSessionRender(sessionId, content, rawData.grpcOffset) + } + } catch { + // ignore parse error + } + if (store.getState().grpcLoadMoreLoading) { + store.getState().updateState({ grpcLoadMoreLoading: false }) + } + // recovery 批次结束:关闭旧会话 UI/逻辑 loading + this.finishSessionRestoreLoading(sessionId) + return + } + + // 先解析业务 funcKey(对齐旧 useChatIPC:业务 NodeId 优先于纯日志) + let funcKey = res.Type + if ( + res.Type === 'structured' && + [ + 'session_title', + 'timeline_item', + 'react_task_enqueue', + 'react_task_dequeue', + 'queue_info', + 'react_task_status_changed', + 'status', + 'stream-finished', + 'capability_inventory', + 'react_task_created', + 'plan_exec_tasks', + ].includes(res.NodeId) + ) { + funcKey = res.NodeId + } else if (res.Type === 'api_request_failed' && res.NodeId === 'ai_call_failure') { + funcKey = res.NodeId + } else if (res.Type === 'report_finish' && res.NodeId === 'report-finish') { + funcKey = res.NodeId + } else if (res.Type === 'structured' && res.NodeId === 'system') { + try { + const data = JSON.parse(ipcContent) || '' + if (data && typeof data === 'object' && data?.type === 'push_task') { + funcKey = 'push_task' + } else if (data && typeof data === 'object' && data?.type === 'pop_task') { + funcKey = 'pop_task' + } + } catch { + // system 非合法 JSON 时保持 funcKey=structured + } + } else if (res.Type === 'perception' && res.NodeId === 'perception') { + funcKey = 'perception' + } else if (res.Type === 'current_task_todo_list_update' && res.NodeId === 'current_task_todo_list') { + funcKey = 'current_task_todo_list_update' + } else if (res.NodeId === 'session_snapshot') { + funcKey = res.NodeId + } else if (res.Type === 'detached_plan_require' && res.NodeId === 'detached-plan') { + funcKey = res.Type + } + + const handleFunc = grpcAIMessageHandlers[funcKey || ''] + + // 纯日志:structured + Log 结构 + 无业务 handler;不可无条件 JSON.parse(stream 等为纯文本) + if (!handleFunc && res.Type === 'structured') { + try { + const parsed = JSON.parse(ipcContent) + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as AIAgentGrpcApi.Log).level === 'string' && + typeof (parsed as AIAgentGrpcApi.Log).message === 'string' + ) { + aiAgentLogEmitter.dispatch({ + session: sessionId, + type: 'log', + Timestamp: res.Timestamp, + log: parsed as AIAgentGrpcApi.Log, + }) + return + } + } catch { + // 非合法 JSON / 非 Log 结构,走下方通用抄送 + } + } + + // 所有业务数据,均抄送一份到日志中 + // mirrorToLogWindow() + + if (handleFunc) { + handleFunc({ + sessionId, + res, + chatType: store.getState().taskStatus.coordinatorId === res.CoordinatorId ? 'task' : 'reAct', + store, + rawData, + request, + meta, + sendRequest: (request) => this.requestMessage(sessionId, request), + pushLog: (log) => { + if (res.IsSync) return + pushLogToOtherWindow({ sessionId: sessionId, Timestamp: res.Timestamp, ...log }) + }, + }) + } + } catch (error) { + console.error('handleGrpcOutputEvent error', error) + } + } + + /** 主动往列表里放入一条数据 */ + public pushDataToSession(sessionId: string, data: AIChatQSData) { + const { store, rawData } = this.ensureSession(sessionId) + rawData.contents.set(data.id, data) + persistIndependentItem(sessionId, data) + store.getState().dispatchStreamingNode({ + chatType: data.chatType, + parentTaskId: data.TaskId, + node: { + token: data.id, + kind: 'item', + type: data.type, + }, + }) + } + + /** + * 主动关闭展示给用户操作的review + * + * 一般来说,触发这个事件的情况,都是当前review数据无效了 + * 别的处理review数据事件,都由 handleSendMessage 进行处理了 + */ + public closeChatReview(sessionId: string, reviewToken: string) { + const { store, rawData } = this.ensureSession(sessionId) + const reviewDetail = rawData.contents.get(reviewToken) + if (!reviewDetail) { + yakitNotify('warning', '未获取到 review 信息, 操作无效') + return + } + + if (reviewDetail.chatType === 'reAct') { + rawData.contents.delete(reviewToken) + if ( + reviewDetail.type === AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE && + store.getState().currentPlanReviewToken.token === reviewDetail.id + ) { + // 该类型在任务规划的review弹窗显示,需要清空当前任务规划的review + store.getState().updateState({ currentPlanReviewToken: { token: '', renderNum: 0 } }) + } else { + store.getState().updateCasualReview(reviewToken, 'remove') + store.getState().deleteElementNode({ + chatType: 'reAct', + token: reviewDetail.id, + kind: 'item', + taskID: reviewDetail.TaskId || undefined, + onDelContent: (mapKey) => { + rawData.contents.delete(mapKey) + }, + }) + } + } else if (reviewDetail.chatType === 'task') { + const currentReview = store.getState().currentPlanReviewToken + if (!currentReview.token || currentReview.token !== reviewDetail.id) return + + // 不用调用deleteElementNode,因为能触发这个方法的地方,说明review还没有进入list列表中 + rawData.contents.delete(currentReview.token) + store.getState().updateState({ currentPlanReviewToken: { token: '', renderNum: 0 } }) + } + } + + /** 更新某一个指定的工具卡片内容(AIChatQSDataTypeEnum.TOOL_RESULT) */ + public updateToolResult(sessionId: string, mapToken: string, toolResult: Partial) { + const { store, rawData } = this.ensureSession(sessionId) + + const chatDetail = rawData.contents.get(mapToken) + if (!chatDetail || chatDetail.type !== AIChatQSDataTypeEnum.TOOL_RESULT) return + + Object.assign(chatDetail.data.tool, toolResult) + store.getState().incrementNodeVersion(chatDetail.id, 'item') + persistToolResultIfTerminal(sessionId, chatDetail) + } + + /** + * 删除内存数据(仅 deleteSessions / onPageUnload 调用) + * 1. 标记 pendingDispose + forceClose(cancel,保留 IPC 等 end) + * 2. 真实 session-end 或 5s 兜底后,再摘监听并清业务池与归属索引 + * @param deletePersist 是否同步删除 IDB。页面销毁只卸内存时应为 false,并先 flush 渲染树;显式删会话时为 true。 + */ + private disposeSessionMemory(sessionId: string, deletePersist = false) { + // 已收到 session-end 的会话无需再次 cancel;直接卸池,避免批量删除二次等待 fallback。 + if (!this.readyChannels.has(sessionId)) { + this.teardownDisposedSession(sessionId, deletePersist) + return + } + + if (deletePersist) { + // 显式删除:取消待写 debounce 即可,无需再刷进 IDB + this.clearSessionRenderPersistTimer(sessionId) + } else { + // 页面卸载:卸内存前先把渲染树刷进 IDB,保留可恢复数据 + this.flushSessionRender(sessionId) + } + + // 先标记 pending,再 forceClose:关停窗口内迟到的结构变更不再 arm dirty + this.pendingDisposeSessions.set(sessionId, deletePersist) + this.forceCloseSession({ sessionIds: [sessionId] }) + } + + /** end / 兜底超时后:摘池与归属索引(可选删 IDB) */ + private teardownDisposedSession(sessionId: string, deletePersist: boolean) { + // 卸池前清 debounce,避免空 ensureSession 后迟到 timer 把 IDB 盖成空树 + this.clearSessionRenderPersistTimer(sessionId) + this.sessionRestoreLoading.delete(sessionId) + + this.readyChannels.delete(sessionId) + + if (this.activeShowSession === sessionId) { + this.activeShowSession = '' + } + + this.requestPool.delete(sessionId) + this.storePool.delete(sessionId) + this.rawDataPool.delete(sessionId) + this.metaPool.delete(sessionId) + + const owner = this.sessionOwnerMap.get(sessionId) + if (owner) { + this.removeFromPageSessionMap(owner, sessionId) + this.sessionOwnerMap.delete(sessionId) + } + + if (deletePersist) { + // 先排干该 session 所有在飞的 IDB 写(render / content / reference), + // 再发 delete 事务,确保 delete 排在所有 put 之后,避免 delete 后迟到的 put 又写回孤儿行。 + // 内存池已在上文清空,此处只对 IDB 排干;队列本身在写完成 / finally 时自清。 + Promise.all([this.drainRenderWrites(sessionId), drainSessionContentWrites(sessionId)]) + .then(() => aiChatPersistStore.deleteSessionPersist(sessionId)) + .catch(() => {}) + } + // deletePersist=false(页面卸载)时不排干:disposeSessionMemory 已先 flush 渲染树, + // 该异步写靠闭包快照完成且 enqueueRenderWrite 的 finally 会自清链,写入正是「保留可恢复数据」所需; + // 此处再 drain 只会空等,且无 delete 要对齐顺序,故跳过。 + } + + /** 关闭会话的所有定时器 */ + private closeSessionTimers(meta: ReturnType['meta']) { + // 取消ping请求相关逻辑 + if (meta.pingTimer) clearInterval(meta.pingTimer) + meta.pingTimer = null + meta.pingSyncID = '' + // 清除通知消息消失的定时器 + if (meta.notifyMessageTimer) clearTimeout(meta.notifyMessageTimer) + meta.notifyMessageTimer = null + // 清除插件执行卡片处理的定时器 + if (meta.cardKVPaidTimer) clearTimeout(meta.cardKVPaidTimer) + meta.cardKVPaidTimer = null + // 清除获取最新问题队列的轮询器 + if (meta.queuePollingTimer) clearInterval(meta.queuePollingTimer) + meta.queuePollingTimer = null + meta.queuePollingEmptyCount = 0 + // 清除获取最新记忆库数据的轮询器 + if (meta.memoryPollingTimer) clearInterval(meta.memoryPollingTimer) + meta.memoryPollingTimer = null + } + + // 关闭ipc通道连接 + private closeIPCListeners(sessionId: string) { + ipcRenderer.removeAllListeners(`${sessionId}-data`) + ipcRenderer.removeAllListeners(`${sessionId}-end`) + ipcRenderer.removeAllListeners(`${sessionId}-error`) + } + + /** + * cancel 后武装 5s 兜底:若真实 -end 未到,手动走 handleSessionEnd + * 重复 cancel 会重置计时 + */ + private armSessionEndFallback(sessionId: string) { + this.clearSessionEndFallback(sessionId) + const timer = setTimeout(() => { + this.sessionEndFallbackTimers.delete(sessionId) + this.handleSessionEnd(sessionId) + }, ChatMultiSessionController.SESSION_END_FALLBACK_MS) + this.sessionEndFallbackTimers.set(sessionId, timer) + } + + // 监听 session-error 事件 + public handleSessionError(sessionId: string, error: any) { + // 暂无业务逻辑处理 + console.error('handleSessionError--', sessionId, error) + } + + // 监听 session-end 事件(含 cancel 后 5s 兜底合成) + public handleSessionEnd(sessionId: string, res?: any) { + this.clearSessionEndFallback(sessionId) + + // 先取出 onEnd:须在 teardown 之后再调,避免回调里重启时池已被卸掉 / 仍占坑 + let onEnd: (() => void) | undefined + + // 池仍在:走完整收尾;若已 teardown 则只保证摘监听 + if (this.storePool.has(sessionId)) { + const data = this.ensureSession(sessionId) + const { store, meta } = data + + this.closeSessionTimers(meta) + // 任务规划结束后的相关逻辑 + handleTaskPlanEnd({ ...data, sessionId }, true) + store.getState().updateState({ + execute: false, + casualLoading: false, + casualTitle: '会话已停止', + }) + this.readyChannels.delete(sessionId) + + onEnd = meta.onEnd + meta.onEnd = undefined + } + + this.closeIPCListeners(sessionId) + + const pendingDeletePersist = this.pendingDisposeSessions.get(sessionId) + // dispose 窗口内可能仍有结构/内容变更:卸池前再刷一次树(显式删 IDB 时无需写) + // flush 本身不受 pendingDispose 拦截(仅 markSessionRenderDirty 会拦截) + if (this.storePool.has(sessionId) && pendingDeletePersist !== true) { + this.flushSessionRender(sessionId) + } + + if (pendingDeletePersist !== undefined) { + this.pendingDisposeSessions.delete(sessionId) + this.teardownDisposedSession(sessionId, pendingDeletePersist) + } + + this.resolveSessionEndWaiters(sessionId) + onEnd?.() + } + + /** + * 关闭会话连接(停流) + * - cancel IPC、更新 execute;不立刻摘 IPC(等 session-end,或 5s 兜底合成 end) + * - 有 onEnd 时写入 meta,在 session-end / 兜底 移除监听前执行 + * - **不会**删除业务池与归属索引;关闭 ≠ 删除 + */ + public forceCloseSession(params: { sessionIds: string[]; onEnd?: () => void }) { + const { sessionIds, onEnd } = params + for (const session of sessionIds) { + const meta = this.metaPool.get(session) + if (meta && onEnd) { + meta.onEnd = onEnd + } + // 等真实 -end;超时则手动 handleSessionEnd,避免监听泄漏 / onEnd 挂死 + this.armSessionEndFallback(session) + ipcRenderer.invoke('cancel-ai-re-act', session).catch(() => {}) + const store = this.storePool.get(session) + if (store) { + store.getState().updateState({ execute: false, casualLoading: false, casualTitle: '会话关闭中...' }) + } + if (meta) this.closeSessionTimers(meta) + } + } + + /** 该 session 是否仍在内存业务池中(无池则多为仅 IDB 有数据的孤儿 session) */ + private hasSessionMemory(sessionId: string) { + return ( + this.storePool.has(sessionId) || + this.rawDataPool.has(sessionId) || + this.metaPool.has(sessionId) || + this.requestPool.has(sessionId) + ) + } + + /** + * 无内存池的 session:清理索引/监听后删 IDB,不走 forceClose 异步链 + * 写队列是模块级、可晚于内存池存活,删前仍需 drain,避免迟到 put 复活孤儿行 + */ + private deletePersistOnlySession(sessionId: string) { + this.clearSessionRenderPersistTimer(sessionId) + this.pendingDisposeSessions.delete(sessionId) + this.clearSessionEndFallback(sessionId) + this.closeIPCListeners(sessionId) + this.readyChannels.delete(sessionId) + this.sessionRestoreLoading.delete(sessionId) + + if (this.activeShowSession === sessionId) { + this.activeShowSession = '' + } + + const owner = this.sessionOwnerMap.get(sessionId) + if (owner) { + this.removeFromPageSessionMap(owner, sessionId) + this.sessionOwnerMap.delete(sessionId) + } + + Promise.all([this.drainRenderWrites(sessionId), drainSessionContentWrites(sessionId)]) + .then(() => aiChatPersistStore.deleteSessionPersist(sessionId)) + .catch(() => {}) + } + + /** + * 删除指定的 session(必须清除内存数据) + * - sessionIds 非空:删集合 + * - sessionIds 空数组:按 source 全删所有 route / page 下的 session + * - 内部会先 forceClose 再卸业务池与双索引,并同步删除该会话 IDB 三表 + * - grpc 删除由上层负责 + */ + public deleteSessions(params: { sessionIds: string[]; sources: AISource[]; route: YakitRouteType; pageId: string }) { + const { sessionIds, sources } = params + const isBulkDelete = !sessionIds?.length + const ids = this.resolveSessionIds(params) + + for (const sessionId of ids) { + if (this.hasSessionMemory(sessionId)) { + this.disposeSessionMemory(sessionId, true) + } else { + this.deletePersistOnlySession(sessionId) + } + } + + if (isBulkDelete) { + for (const source of sources) { + void this.persistDeleteBySource(source) + } + } + } + + /** + * 页面生命周期卸载:卸该「当前」page 下所有 source 的 session 内存(非仅 forceClose) + * 会 flush 渲染树后保留 IDB,供后续恢复;已 rebind 走的 session 不会被旧页清掉 + */ + public onPageUnload(route: YakitRouteType, pageId: string) { + const ids = this.resolvePageSessionIds(route, pageId) + for (const sessionId of ids) { + this.disposeSessionMemory(sessionId, false) + } + } +} + +export const globalSessionEngine = new ChatMultiSessionController() diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/AIAgentLogEmitter.streamBuffer.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/AIAgentLogEmitter.streamBuffer.test.ts new file mode 100644 index 0000000000..110b9a14d2 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/AIAgentLogEmitter.streamBuffer.test.ts @@ -0,0 +1,58 @@ +import './setupElectron' +import { describe, it, expect, beforeEach } from 'vitest' +import { AIAgentLogEmitter } from '../AIAgentLogEmitter' +import { ipcRendererMock, resetIpcMocks } from './setupElectron' + +describe('AIAgentLogEmitter stream buffer', () => { + let emitter: AIAgentLogEmitter + + beforeEach(() => { + resetIpcMocks() + emitter = new AIAgentLogEmitter() + }) + + it('F2: buffers stream chunks until end', () => { + emitter.dispatch({ + session: 's1', + type: 'stream', + Timestamp: 1, + stream: { NodeId: 'n1', EventUUID: 'e1', content: 'a', status: 'start' }, + }) + expect(ipcRendererMock.invoke).not.toHaveBeenCalled() + + emitter.dispatch({ + session: 's1', + type: 'stream', + Timestamp: 1, + stream: { NodeId: 'n1', EventUUID: 'e1', content: 'b', status: 'end' }, + }) + expect(ipcRendererMock.invoke).toHaveBeenCalledWith( + 'forward-ai-chat-log-data', + expect.objectContaining({ + message: 'ab', + isStream: true, + level: 'n1', + }), + ) + }) + + it('F3: clearSessionBuffer drops residual stream', () => { + emitter.dispatch({ + session: 's1', + type: 'stream', + Timestamp: 1, + stream: { NodeId: 'n1', EventUUID: 'e1', content: 'x', status: 'start' }, + }) + emitter.clearSessionBuffer('s1') + emitter.dispatch({ + session: 's1', + type: 'stream', + Timestamp: 1, + stream: { NodeId: 'n1', EventUUID: 'e1', content: 'y', status: 'end' }, + }) + expect(ipcRendererMock.invoke).toHaveBeenCalledWith( + 'forward-ai-chat-log-data', + expect.objectContaining({ message: 'y' }), + ) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/AIAgentLogEmitter.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/AIAgentLogEmitter.test.ts new file mode 100644 index 0000000000..6e45291d89 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/AIAgentLogEmitter.test.ts @@ -0,0 +1,38 @@ +import './setupElectron' +import { describe, it, expect, beforeEach } from 'vitest' +import { AIAgentLogEmitter } from '../AIAgentLogEmitter' +import { ipcRendererMock, resetIpcMocks } from './setupElectron' + +describe('AIAgentLogEmitter', () => { + let emitter: AIAgentLogEmitter + + beforeEach(() => { + resetIpcMocks() + emitter = new AIAgentLogEmitter() + }) + + it('F1: dispatch log invokes immediately', () => { + emitter.dispatch({ + session: 's1', + type: 'log', + Timestamp: 100, + log: { level: 'info', message: 'm' }, + }) + expect(ipcRendererMock.invoke).toHaveBeenCalledWith( + 'forward-ai-chat-log-data', + expect.objectContaining({ + sessionId: 's1', + level: 'info', + message: 'm', + }), + ) + }) + + it('F3: clearLogsWindow / closeLogsWindow', () => { + emitter.clearLogsWindow('s1') + expect(ipcRendererMock.invoke).toHaveBeenCalledWith('clear-ai-chat-log-data') + + emitter.closeLogsWindow('s1') + expect(ipcRendererMock.send).toHaveBeenCalledWith('close-ai-chat-window') + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/ChatMultiSessionController.pageIndex.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/ChatMultiSessionController.pageIndex.test.ts new file mode 100644 index 0000000000..0b7472c559 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/ChatMultiSessionController.pageIndex.test.ts @@ -0,0 +1,367 @@ +import './setupElectron' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ChatMultiSessionController } from '../ChatMultiSessionController' +import { YakitRoute } from '@/enums/yakitRoute' +import { ipcRendererMock, resetIpcMocks } from './setupElectron' +import { AITaskStatus } from '../grpcApi' +import { makeGrpcJsonRes } from './fixtures' + +vi.mock('@/utils/notification', () => ({ yakitNotify: vi.fn() })) +vi.mock('@/pages/ai-agent/grpc', () => ({ + grpcQueryAIEvent: vi.fn().mockResolvedValue({ Events: [] }), +})) +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), + persistToolResultIfTerminal: vi.fn(), + drainSessionContentWrites: vi.fn().mockResolvedValue([]), +})) +vi.mock('../persist/aiChatPersistStore', () => ({ + default: { + getSessionRender: vi.fn().mockResolvedValue(undefined), + setSessionRender: vi.fn().mockResolvedValue(undefined), + getSessionContents: vi.fn().mockResolvedValue([]), + getSessionReferences: vi.fn().mockResolvedValue([]), + deleteSessionPersist: vi.fn().mockResolvedValue(undefined), + deletePersistBySource: vi.fn().mockResolvedValue(undefined), + }, +})) +vi.mock('../AIAgentLogEmitter', () => ({ + aiAgentLogEmitter: { dispatch: vi.fn(), clearSessionBuffer: vi.fn() }, + AIAgentLogEmitter: class {}, +})) + +const startParams = (sessionId: string, pageId = 'page-1', userQuery = '') => ({ + token: sessionId, + route: YakitRoute.AI_Agent, + pageId, + params: { + Params: { + Source: 'ai', + UserQuery: userQuery, + }, + } as any, +}) + +describe('ChatMultiSessionController page index / ensureSession', () => { + let ctrl: ChatMultiSessionController + + beforeEach(() => { + resetIpcMocks() + vi.clearAllMocks() + ctrl = new ChatMultiSessionController() + }) + + it('A2/A3: ensureSession idempotent and active show', () => { + const a = ctrl.ensureSession('s1') + const b = ctrl.ensureSession('s1') + expect(a.store).toBe(b.store) + expect(ctrl.ensureSession('s2').store).not.toBe(a.store) + + ctrl.setActiveShowSession('s1') + expect(ctrl.isActiveShowSession('s1')).toBe(true) + expect(ctrl.isActiveShowSession('s2')).toBe(false) + }) + + it('A1/A4: start registers ready + rebind moves page', async () => { + expect(ctrl.handleStartSession(startParams('s-rebind', 'page-a'))).toBe(true) + expect(ctrl.isSessionReady('s-rebind')).toBe(true) + + ctrl.rebindSessionPageId('s-rebind', 'page-b') + // still ready after rebind + expect(ctrl.isSessionReady('s-rebind')).toBe(true) + + // unload old page should not dispose rebound session + ctrl.onPageUnload(YakitRoute.AI_Agent, 'page-a') + expect(ctrl.isSessionReady('s-rebind')).toBe(true) + + // wait microtasks from preparePersist + await Promise.resolve() + }) + + it('A5/A6: updateSessionConfig ignores Source; removeContentsFromMemory is callable', () => { + const { request, rawData } = ctrl.ensureSession('s-cfg') + request.Source = 'ai' + ctrl.updateSessionConfig('s-cfg', { ReviewPolicy: 'yolo', Source: 'im' } as any) + expect(request.ReviewPolicy).toBe('yolo') + expect(request.Source).toBe('ai') + + rawData.contents.set('t1', { id: 't1' } as any) + // 当前实现体已注释,调用应不抛;恢复删除逻辑后再断言 has===false + expect(() => ctrl.removeContentsFromMemory('s-cfg', ['t1'])).not.toThrow() + }) + + it('A22: getSessionExecute is read-only and does not create empty pool', () => { + expect(ctrl.getSessionExecute('ghost')).toBe(false) + // 只读查询不得 ensureSession 造池:再 ensure 才应新建 + expect(ctrl.filterExecutingSessionIds(['ghost'])).toEqual([]) + + ctrl.handleStartSession(startParams('s-exec')) + const { store } = ctrl.ensureSession('s-exec') + store.getState().updateState({ execute: true }) + expect(ctrl.getSessionExecute('s-exec')).toBe(true) + expect(ctrl.filterExecutingSessionIds(['s-exec', 'ghost'])).toEqual(['s-exec']) + + store.getState().updateState({ execute: false }) + expect(ctrl.getSessionExecute('s-exec')).toBe(false) + }) + + it('A23: getSessionIdsBySourceAndRoute crosses pageIds', () => { + ctrl.handleStartSession(startParams('s-a', 'page-a')) + ctrl.handleStartSession(startParams('s-b', 'page-b')) + ctrl.handleStartSession({ + ...startParams('s-im', 'page-a'), + params: { Params: { Source: 'im', UserQuery: '' } } as any, + }) + + const aiIds = ctrl.getSessionIdsBySourceAndRoute('ai', YakitRoute.AI_Agent).sort() + expect(aiIds).toEqual(['s-a', 's-b']) + expect(ctrl.getSessionIdsBySourceAndRoute('im', YakitRoute.AI_Agent)).toEqual(['s-im']) + }) +}) + +describe('ChatMultiSessionController session api / dispatch', () => { + let ctrl: ChatMultiSessionController + + beforeEach(() => { + resetIpcMocks() + ctrl = new ChatMultiSessionController() + ctrl.handleStartSession(startParams('s-api')) + }) + + it('A7: persist helpers callable', async () => { + await expect(ctrl.persistGetSessionContents('s-api', ['a'])).resolves.toBeDefined() + await expect(ctrl.getSessionReferenceMaterials('s-api', ['a'])).resolves.toBeDefined() + await expect(ctrl.persistDeleteBySource('ai')).resolves.toBeUndefined() + }) + + it('A8: handleGrpcOutputEvent unknown type no throw', () => { + expect(() => ctrl.handleGrpcOutputEvent('s-api', makeGrpcJsonRes('unknown_type_xyz', { a: 1 }))).not.toThrow() + }) + + it('A8: session_title via structured NodeId', () => { + ctrl.handleGrpcOutputEvent('s-api', makeGrpcJsonRes('structured', { title: 'T' }, { NodeId: 'session_title' })) + expect(ctrl.ensureSession('s-api').rawData.sessionTitle).toBe('T') + }) + + it('A9: pushDataToSession / closeChatReview / updateToolResult', () => { + const { store, rawData } = ctrl.ensureSession('s-api') + const data = { + id: 'q1', + type: 'question', + chatType: 'reAct', + Timestamp: 1, + AIService: '', + AIModelName: '', + data: 'hi', + } as any + ctrl.pushDataToSession('s-api', data) + expect(rawData.contents.get('q1')).toBeTruthy() + + rawData.contents.set('rev-1', { + id: 'rev-1', + type: 'tool_use_review_require', + chatType: 'reAct', + data: {}, + } as any) + store.getState().updateCasualReview('rev-1', 'add') + ctrl.closeChatReview('s-api', 'rev-1') + + rawData.contents.set('tool-1', { + id: 'tool-1', + type: 'tool_result', + chatType: 'reAct', + data: { tool: { status: 'default' } }, + } as any) + ctrl.updateToolResult('s-api', 'tool-1', { status: 'success' }) + expect((rawData.contents.get('tool-1') as any).data.tool.status).toBe('success') + }) +}) + +describe('ChatMultiSessionController lifecycle', () => { + let ctrl: ChatMultiSessionController + + beforeEach(() => { + resetIpcMocks() + vi.clearAllMocks() + vi.useFakeTimers() + ctrl = new ChatMultiSessionController() + ctrl.handleStartSession(startParams('s-life')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('A10: handleSessionEnd stops execute', () => { + const { store } = ctrl.ensureSession('s-life') + store.getState().updateState({ execute: true }) + ctrl.handleSessionEnd('s-life') + expect(store.getState().execute).toBe(false) + expect(store.getState().casualTitle).toBe('会话已停止') + expect(ctrl.isSessionReady('s-life')).toBe(false) + }) + + it('A11: forceClose arms fallback end', () => { + const onEnd = vi.fn() + ctrl.forceCloseSession({ sessionIds: ['s-life'], onEnd }) + expect(ipcRendererMock.invoke).toHaveBeenCalledWith('cancel-ai-re-act', 's-life') + vi.advanceTimersByTime(5000) + expect(onEnd).toHaveBeenCalled() + }) + + it('A12: deleteSessions by ids', async () => { + ctrl.deleteSessions({ + sessionIds: ['s-life'], + sources: ['ai'], + route: YakitRoute.AI_Agent, + pageId: 'page-1', + }) + expect(ipcRendererMock.invoke).toHaveBeenCalledWith('cancel-ai-re-act', 's-life') + // dispose 等 session-end / 5s 兜底后再卸 readyChannels + await vi.advanceTimersByTimeAsync(5000) + expect(ctrl.isSessionReady('s-life')).toBe(false) + }) + + it('A24: deleteSessions orphan path deletes IDB without cancel', async () => { + vi.useRealTimers() + const aiChatPersistStore = (await import('../persist/aiChatPersistStore')).default + const { drainSessionContentWrites } = await import('../persist/contentPersistHelper') + ;(drainSessionContentWrites as any).mockResolvedValue([]) + ;(aiChatPersistStore.deleteSessionPersist as any).mockResolvedValue(undefined) + + ctrl.deleteSessions({ + sessionIds: ['orphan-only'], + sources: ['ai'], + route: YakitRoute.AI_Agent, + pageId: 'page-1', + }) + expect(ipcRendererMock.invoke).not.toHaveBeenCalledWith('cancel-ai-re-act', 'orphan-only') + + // 孤儿路径:无 cancel,仅异步删 IDB;多刷几次微任务等 Promise.all.then + for (let i = 0; i < 5; i++) await Promise.resolve() + expect(aiChatPersistStore.deleteSessionPersist).toHaveBeenCalledWith('orphan-only') + }) + + it('A25: bulk deleteSessions by sources clears all pages and persistBySource', async () => { + const aiChatPersistStore = (await import('../persist/aiChatPersistStore')).default + ctrl.handleStartSession(startParams('s-bulk-a', 'page-a')) + ctrl.handleStartSession(startParams('s-bulk-b', 'page-b')) + + ctrl.deleteSessions({ + sessionIds: [], + sources: ['ai'], + route: YakitRoute.AI_Agent, + pageId: 'page-a', + }) + + expect(ipcRendererMock.invoke).toHaveBeenCalledWith('cancel-ai-re-act', 's-bulk-a') + expect(ipcRendererMock.invoke).toHaveBeenCalledWith('cancel-ai-re-act', 's-bulk-b') + expect(aiChatPersistStore.deletePersistBySource).toHaveBeenCalledWith('ai') + await vi.advanceTimersByTimeAsync(5000) + expect(ctrl.isSessionReady('s-bulk-a')).toBe(false) + expect(ctrl.isSessionReady('s-bulk-b')).toBe(false) + }) +}) + +describe('ChatMultiSessionController start / send / history', () => { + let ctrl: ChatMultiSessionController + + beforeEach(() => { + resetIpcMocks() + ctrl = new ChatMultiSessionController() + }) + + it('A13: duplicate start returns false', () => { + expect(ctrl.handleStartSession(startParams('s-dup'))).toBe(true) + expect(ctrl.handleStartSession(startParams('s-dup'))).toBe(false) + }) + + it('A14: no UserQuery enters restore loading', () => { + ctrl.handleStartSession(startParams('s-restore', 'page-1', '')) + expect(ctrl.ensureSession('s-restore').store.getState().initLoading).toBe(true) + }) + + it('A17: send without ready warns when active', () => { + ctrl.setActiveShowSession('ghost') + expect(() => + ctrl.handleSendMessage({ + token: 'ghost', + type: 'casual', + params: { IsFreeInput: true, FreeInput: 'hi' } as any, + }), + ).not.toThrow() + }) + + it('A19: requestRecoveryHistory invokes send', () => { + ctrl.handleStartSession(startParams('s-hist')) + ctrl.requestRecoveryHistory('s-hist') + expect(ipcRendererMock.invoke).toHaveBeenCalledWith( + 'send-ai-re-act', + 's-hist', + expect.objectContaining({ IsSyncMessage: true }), + ) + }) + + it('A20/A21: timeline / filesystem history callable', async () => { + ctrl.handleStartSession(startParams('s-tl')) + const { grpcQueryAIEvent } = await import('@/pages/ai-agent/grpc') + ;(grpcQueryAIEvent as any).mockResolvedValue({ Events: [] }) + await expect(ctrl.loadTimelineHistory('s-tl')).resolves.toBeTypeOf('boolean') + expect(ctrl.hasMoreTimeline('s-tl')).toBeTypeOf('boolean') + await expect(ctrl.loadFileSystemHistory('s-tl')).resolves.toBeUndefined() + }) +}) + +describe('ChatMultiSessionController restore / renderPersist / collect', () => { + let ctrl: ChatMultiSessionController + + beforeEach(() => { + resetIpcMocks() + vi.useFakeTimers() + ctrl = new ChatMultiSessionController() + ctrl.handleStartSession(startParams('s-rp')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('A15/A18: hydrate via ensureSession + structural dirty deferred', () => { + const { store } = ctrl.ensureSession('s-rp') + store.getState().hydrateRenderTree({ + items: { a: { kind: 'item', token: 'a', type: 'thought', renderNum: 0, nodeId: '' } as any }, + groups: {}, + tasks: {}, + casualElements: [{ kind: 'item', token: 'a', chatType: 'reAct', isHistory: false }], + taskElements: [], + }) + expect(store.getState().items.a).toBeTruthy() + }) + + it('A16: structure change schedules persist flush debounce', async () => { + const aiChatPersistStore = (await import('../persist/aiChatPersistStore')).default + const { store } = ctrl.ensureSession('s-rp') + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'n1', kind: 'item', type: 'thought' }, + }) + vi.advanceTimersByTime(3000) + await Promise.resolve() + expect(aiChatPersistStore.setSessionRender).toHaveBeenCalled() + }) + + it('A10b: session end with processing taskStatus', () => { + const { store } = ctrl.ensureSession('s-rp') + store.getState().updateState({ + taskStatus: { + plan: 'p', + task: 't', + taskID: 't1', + status: AITaskStatus.inProgress, + coordinatorId: 'c1', + }, + }) + ctrl.handleSessionEnd('s-rp') + expect(store.getState().execute).toBe(false) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiChatPersistStore.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiChatPersistStore.test.ts new file mode 100644 index 0000000000..734256f153 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiChatPersistStore.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import aiChatPersistStore from '../persist/aiChatPersistStore' + +const openMock = vi.fn() +const setSessionRender = vi.fn() +const getSessionRender = vi.fn() +const setSessionContent = vi.fn() +const getSessionContent = vi.fn() +const deleteSessionPersist = vi.fn() +const deletePersistBySource = vi.fn() +const close = vi.fn() + +vi.mock('../persist/aiChatPersistStore', () => ({ + default: { + open: (...args: any[]) => openMock(...args), + close: (...args: any[]) => close(...args), + setSessionRender: (...args: any[]) => setSessionRender(...args), + getSessionRender: (...args: any[]) => getSessionRender(...args), + setSessionContent: (...args: any[]) => setSessionContent(...args), + getSessionContent: (...args: any[]) => getSessionContent(...args), + deleteSessionPersist: (...args: any[]) => deleteSessionPersist(...args), + deletePersistBySource: (...args: any[]) => deletePersistBySource(...args), + }, +})) + +describe('aiChatPersistStore API surface (stubbed)', () => { + beforeEach(() => { + vi.clearAllMocks() + openMock.mockResolvedValue({}) + setSessionRender.mockResolvedValue(undefined) + getSessionRender.mockResolvedValue(undefined) + setSessionContent.mockResolvedValue(undefined) + getSessionContent.mockResolvedValue(undefined) + deleteSessionPersist.mockResolvedValue(undefined) + deletePersistBySource.mockResolvedValue(undefined) + close.mockResolvedValue(undefined) + }) + + it('E5: CRUD methods exist and are callable', async () => { + await aiChatPersistStore.open() + await aiChatPersistStore.setSessionRender('s1', 'ai', { items: {} } as any, 0) + await aiChatPersistStore.getSessionRender('s1', 'ai') + await aiChatPersistStore.setSessionContent('s1', 't1', () => ({ id: 't1' }) as any) + await aiChatPersistStore.getSessionContent('s1', 't1') + await aiChatPersistStore.deleteSessionPersist('s1') + await aiChatPersistStore.close() + + expect(openMock).toHaveBeenCalled() + expect(setSessionRender).toHaveBeenCalled() + expect(getSessionRender).toHaveBeenCalled() + expect(setSessionContent).toHaveBeenCalled() + expect(getSessionContent).toHaveBeenCalled() + expect(deleteSessionPersist).toHaveBeenCalledWith('s1') + expect(close).toHaveBeenCalled() + }) + + it('E6: deletePersistBySource', async () => { + await aiChatPersistStore.deletePersistBySource('ai') + expect(deletePersistBySource).toHaveBeenCalledWith('ai') + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiOther.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiOther.handlers.test.ts new file mode 100644 index 0000000000..b2d7cc6866 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiOther.handlers.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest' +import { aiOtherDataHandlers } from '../grpcStreamHandler/aiOther' +import { DefaultMemoryList } from '../defaultConstant' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), + persistToolResultIfTerminal: vi.fn(), + upsertSessionContent: vi.fn(), +})) + +describe('aiOther other handlers', () => { + it('D3: session_title updates rawData', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('structured', { title: 'hello-title' }, { NodeId: 'session_title' }), + }) + aiOtherDataHandlers.session_title(req) + expect(req.rawData.sessionTitle).toBe('hello-title') + }) + + it('D3: memory_context merges for reAct', () => { + const lists = { + ...DefaultMemoryList, + memories: [{ id: 'm1' }], + total_memories: 1, + memory_pool_limit: 10, + total_size: 1, + } + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('memory_context', lists), + chatType: 'reAct', + }) + aiOtherDataHandlers.memory_context(req) + expect(req.meta.casualMemoryList.total_memories).toBe(1) + }) + + it('D3: filesystem pin updates folders', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('filesystem_pin_directory', { path: '/tmp/x' }), + }) + aiOtherDataHandlers.filesystem_pin_directory(req) + expect(req.store.getState().grpcFolders.some((f) => f.path === '/tmp/x')).toBe(true) + }) + + it('D3: timeline_item appends', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('structured', { id: 9, content: 't' }, { NodeId: 'timeline_item' }), + }) + aiOtherDataHandlers.timeline_item(req) + expect(req.store.getState().reActTimelines.some((t) => t.id === 9)).toBe(true) + }) + + it('D3: notify sets message', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('notify', { type: 'info', content: 'n1', duration_ms: 0 }), + }) + aiOtherDataHandlers.notify(req) + expect(req.store.getState().notifyMessage?.content).toBe('n1') + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiOther.taskPlan.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiOther.taskPlan.test.ts new file mode 100644 index 0000000000..5f271309b0 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiOther.taskPlan.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { aiOtherDataHandlers } from '../grpcStreamHandler/aiOther' +import { AITaskStatus } from '../grpcApi' +import { DefaultTaskPlanEndGate } from '../defaultConstant' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), + persistToolResultIfTerminal: vi.fn(), + upsertSessionContent: vi.fn(), + drainSessionContentWrites: vi.fn(), +})) + +describe('aiOther task plan gate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const startPayload = { + coordinator_id: 'coord-1', + 're-act_task': 'task-1', + } + + it('D2: start resets gate and sets processing', () => { + const sendRequest = vi.fn() + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('start_plan_and_execution', startPayload), + sendRequest, + chatType: 'reAct', + }) + req.meta.taskPlanEndGate = { endReceived: true, pendingStatus: 'completed' } + aiOtherDataHandlers.start_plan_and_execution(req) + expect(req.meta.taskPlanEndGate).toEqual(DefaultTaskPlanEndGate) + expect(req.store.getState().taskStatus).toMatchObject({ + taskID: 'task-1', + coordinatorId: 'coord-1', + status: AITaskStatus.inProgress, + }) + expect(sendRequest).toHaveBeenCalled() + }) + + it('D2: end then change settles', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('end_plan_and_execution', startPayload), + chatType: 'reAct', + }) + req.store.getState().updateState({ + taskStatus: { + plan: '加载中...', + task: '加载中...', + taskID: 'task-1', + status: AITaskStatus.inProgress, + coordinatorId: 'coord-1', + }, + cancelTaskLoading: true, + }) + + aiOtherDataHandlers.end_plan_and_execution(req) + expect(req.store.getState().taskStatus.status).toBe(AITaskStatus.inProgress) + expect(req.meta.taskPlanEndGate.endReceived).toBe(true) + + const changeReq = { + ...req, + res: makeGrpcJsonRes( + 'structured', + { react_task_id: 'task-1', react_task_now_status: 'completed' }, + { NodeId: 'react_task_status_changed' }, + ), + chatType: 'reAct' as const, + } + aiOtherDataHandlers.react_task_status_changed(changeReq) + expect(req.store.getState().taskStatus.status).toBe('completed') + expect(req.store.getState().cancelTaskLoading).toBe(false) + expect(req.meta.taskPlanEndGate).toEqual(DefaultTaskPlanEndGate) + }) + + it('D2: change then end settles (unordered)', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes( + 'structured', + { react_task_id: 'task-1', react_task_now_status: 'aborted' }, + { NodeId: 'react_task_status_changed' }, + ), + chatType: 'reAct', + }) + req.store.getState().updateState({ + taskStatus: { + plan: 'p', + task: 't', + taskID: 'task-1', + status: AITaskStatus.inProgress, + coordinatorId: 'coord-1', + }, + }) + + aiOtherDataHandlers.react_task_status_changed(req) + expect(req.store.getState().taskStatus.status).toBe(AITaskStatus.inProgress) + expect(req.meta.taskPlanEndGate.pendingStatus).toBe('aborted') + + const endReq = { + ...req, + res: makeGrpcJsonRes('end_plan_and_execution', startPayload), + } + aiOtherDataHandlers.end_plan_and_execution(endReq) + expect(req.store.getState().taskStatus.status).toBe('aborted') + }) + + it('D2: plan updates task tree when chatType=task', () => { + const root = { + root_task: { + index: '0', + name: 'root', + goal: '', + progress: '', + task_id: 'root', + semantic_identifier: 'root', + depends_on: [], + subtasks: [ + { + index: '1', + name: 'leaf', + goal: '', + progress: '', + task_id: 'leaf-1', + semantic_identifier: 'L', + depends_on: [], + subtasks: [], + }, + ], + }, + } + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('plan', root), + chatType: 'task', + }) + aiOtherDataHandlers.plan(req) + expect(req.store.getState().taskChat.plan.root_task_name).toBe('root') + expect(req.store.getState().taskChat.plan.task_tree.some((t) => t.task_id === 'leaf-1')).toBe(true) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiPerf.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiPerf.handlers.test.ts new file mode 100644 index 0000000000..33a2e0e5d6 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiPerf.handlers.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { aiPerfDataHandlers } from '../grpcStreamHandler/aiPerf' +import { AIModelTypeEnum } from '@/pages/ai-agent/defaultConstant' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' + +describe('aiPerf handlers', () => { + it('D9: consumption updates rawData', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('consumption', { + input_consumption: 10, + output_consumption: 20, + cache_hit_token: 1, + tier_consumption: {}, + }), + }) + aiPerfDataHandlers.consumption(req) + expect(req.rawData.aiPerfData.consumption.input_consumption).toBe(10) + expect(req.rawData.aiPerfData.consumption.output_consumption).toBe(20) + }) + + it('D9: pressure / first / total cost append', () => { + const pressureReq = makeHandlerRequest({ + res: makeGrpcJsonRes('pressure', { + model_tier: AIModelTypeEnum.TierLightweight, + current_pressure: 1, + }), + }) + aiPerfDataHandlers.pressure(pressureReq) + expect(pressureReq.rawData.aiPerfData.pressure[AIModelTypeEnum.TierLightweight].length).toBe(1) + + const firstReq = makeHandlerRequest({ + res: makeGrpcJsonRes('ai_first_byte_cost_ms', { + model_tier: AIModelTypeEnum.TierLightweight, + cost_ms: 5, + }), + }) + aiPerfDataHandlers.ai_first_byte_cost_ms(firstReq) + expect(firstReq.rawData.aiPerfData.firstCost[AIModelTypeEnum.TierLightweight].length).toBe(1) + + const totalReq = makeHandlerRequest({ + res: makeGrpcJsonRes('ai_total_cost_ms', { + model_tier: AIModelTypeEnum.TierLightweight, + cost_ms: 9, + }), + }) + aiPerfDataHandlers.ai_total_cost_ms(totalReq) + expect(totalReq.rawData.aiPerfData.totalCost[AIModelTypeEnum.TierLightweight].length).toBe(1) + + expect(typeof aiPerfDataHandlers.prompt_profile).toBe('function') + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiReview.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiReview.handlers.test.ts new file mode 100644 index 0000000000..66386fef70 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiReview.handlers.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest' +import { aiReviewDataHandlers } from '../grpcStreamHandler/aiReview' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), +})) + +describe('aiReview handlers', () => { + it('D7: all review handlers are functions', () => { + const keys = [ + 'plan_review_require', + 'plan_task_analysis', + 'task_review_require', + 'tool_use_review_require', + 'require_user_interactive', + 'exec_aiforge_review_require', + 'ai_review_start', + 'ai_review_countdown', + 'ai_review_end', + 'review_release', + 'detached_plan_require', + ] as const + for (const key of keys) { + expect(typeof aiReviewDataHandlers[key]).toBe('function') + } + }) + + it('D7: tool_use_review_require does not throw with minimal payload', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('tool_use_review_require', { + id: 'rev-1', + selectors: [{ value: 'continue', prompt: 'ok' }], + }), + }) + expect(() => aiReviewDataHandlers.tool_use_review_require(req)).not.toThrow() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiSingleItem.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiSingleItem.handlers.test.ts new file mode 100644 index 0000000000..b161b89721 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiSingleItem.handlers.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { aiSingleItemDataHandlers } from '../grpcStreamHandler/aiSingleItem' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' +import { AIChatQSDataTypeEnum } from '../aiRender' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), +})) + +describe('aiSingleItem handlers', () => { + it('D5: thought inserts content and tree node', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('thought', { thought: 'thinking...' }), + }) + aiSingleItemDataHandlers.thought(req) + const thoughts = [...req.rawData.contents.values()].filter((c) => c.type === AIChatQSDataTypeEnum.THOUGHT) + expect(thoughts).toHaveLength(1) + expect(req.store.getState().casualChat.elements.length).toBeGreaterThan(0) + }) + + it('D5: result inserts when not after_stream', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('result', { result: 'ok', after_stream: false }), + }) + aiSingleItemDataHandlers.result(req) + const results = [...req.rawData.contents.values()].filter((c) => c.type === AIChatQSDataTypeEnum.RESULT) + expect(results.length).toBeGreaterThan(0) + }) + + it('D5: map registers push/pop/fail handlers', () => { + for (const key of [ + 'fail_react_task', + 'tool_call_decision', + 'fail_plan_and_execution', + 'ai_call_failure', + 'push_task', + 'pop_task', + 'report-finish', + ] as const) { + expect(typeof aiSingleItemDataHandlers[key]).toBe('function') + } + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiStream.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiStream.handlers.test.ts new file mode 100644 index 0000000000..12dad42aed --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiStream.handlers.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { aiStreamDataHandlers } from '../grpcStreamHandler/aiStream' +import { makeGrpcJsonRes, makeHandlerRequest, makeGrpcRes } from './fixtures' +import { AIChatQSDataTypeEnum } from '../aiRender' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), + persistToolResultIfTerminal: vi.fn(), + upsertSessionContent: vi.fn(), + setSessionReferencePersist: vi.fn(), +})) + +vi.mock('../AIAgentLogEmitter', () => ({ + aiAgentLogEmitter: { dispatch: vi.fn() }, + AIAgentLogEmitter: class {}, +})) + +describe('aiStream handlers', () => { + it('D4: stream_start creates STREAM content', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes( + 'stream_start', + { event_writer_id: 'ew-1' }, + { NodeId: 're-act-loop-thought', EventUUID: 'eu-1' }, + ), + }) + aiStreamDataHandlers.stream_start(req) + const found = [...req.rawData.contents.values()].find((c) => c.type === AIChatQSDataTypeEnum.STREAM) + expect(found).toBeTruthy() + }) + + it('D4: stream-finished ends stream by event_writer_id', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes( + 'structured', + { event_writer_id: 'ew-1', node_id: 're-act-loop-thought', is_reason: false, is_system: false }, + { NodeId: 'stream-finished' }, + ), + }) + req.rawData.contents.set('ew-1', { + id: 'ew-1', + type: AIChatQSDataTypeEnum.STREAM, + chatType: 'reAct', + Timestamp: 1, + AIService: '', + AIModelName: '', + data: { + NodeId: 're-act-loop-thought', + EventUUID: 'ew-1', + status: 'start', + content: 'hi', + }, + } as any) + aiStreamDataHandlers['stream-finished'](req) + const stream = req.rawData.contents.get('ew-1') as any + expect(stream.data.status).toBe('end') + }) + + it('D4: reference_material handler registered', () => { + expect(typeof aiStreamDataHandlers.reference_material).toBe('function') + }) + + it('D4: stream handler is registered', () => { + expect(typeof aiStreamDataHandlers.stream).toBe('function') + const req = makeHandlerRequest({ + res: makeGrpcRes({ + Type: 'stream', + NodeId: 're-act-loop-thought', + EventUUID: 'eu-1', + Content: new TextEncoder().encode('x'), + }), + }) + // without prior start may no-op; ensure no throw + expect(() => aiStreamDataHandlers.stream(req)).not.toThrow() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiTaskDetail.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiTaskDetail.handlers.test.ts new file mode 100644 index 0000000000..77254b1684 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiTaskDetail.handlers.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from 'vitest' +import { aiTaskDetailDataHandlers } from '../grpcStreamHandler/aiTaskDetail' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' +import { AIToDoListStatusEnum } from '@/pages/ai-agent/defaultConstant' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), +})) + +describe('aiTaskDetail handlers', () => { + it('D8: handler keys exist', () => { + for (const key of [ + 'capability_inventory', + 'perception', + 'current_task_todo_list_update', + 'session_snapshot', + ] as const) { + expect(typeof aiTaskDetailDataHandlers[key]).toBe('function') + } + }) + + it('D8: current_task_todo_list_update bumps todoListUpdate', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('current_task_todo_list_update', { + items: [{ id: '1', status: AIToDoListStatusEnum.Pending, scope_task_id: '' }], + }), + }) + const before = req.store.getState().casualChat.todoListUpdate + expect(() => aiTaskDetailDataHandlers.current_task_todo_list_update(req)).not.toThrow() + // may or may not bump depending on payload shape; ensure callable + expect(req.store.getState().casualChat.todoListUpdate).toBeGreaterThanOrEqual(before) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiToolResult.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiToolResult.handlers.test.ts new file mode 100644 index 0000000000..4fd3758922 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/aiToolResult.handlers.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest' +import { aiToolResultDataHandlers } from '../grpcStreamHandler/aiToolResult' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' +import { AIChatQSDataTypeEnum } from '../aiRender' + +vi.mock('../persist/contentPersistHelper', () => ({ + persistIndependentItem: vi.fn(), + persistToolResultIfTerminal: vi.fn(), + upsertSessionContent: vi.fn(), +})) + +describe('aiToolResult handlers', () => { + it('D6: tool_call_start creates TOOL_RESULT', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('tool_call_start', { + call_tool_id: 'call-9', + tool: { name: 'http', description: 'd' }, + start_time: 1, + start_time_ms: 1, + }), + }) + aiToolResultDataHandlers.tool_call_start(req) + const item = req.rawData.contents.get('call-9') + expect(item?.type).toBe(AIChatQSDataTypeEnum.TOOL_RESULT) + }) + + it('D6: tool_call_param updates params', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes('tool_call_param', { + call_tool_id: 'call-9', + params: { a: 1 }, + }), + }) + req.rawData.contents.set('call-9', { + id: 'call-9', + type: AIChatQSDataTypeEnum.TOOL_RESULT, + chatType: 'reAct', + Timestamp: 1, + AIService: '', + AIModelName: '', + data: { tool: { status: 'default' }, callToolId: 'call-9' }, + } as any) + aiToolResultDataHandlers.tool_call_param(req) + expect((req.rawData.contents.get('call-9') as any).data.tool.reviewParams).toEqual({ a: 1 }) + }) + + it('D6: handler keys registered', () => { + for (const key of Object.keys(aiToolResultDataHandlers)) { + expect(typeof (aiToolResultDataHandlers as any)[key]).toBe('function') + } + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.deleteElementNode.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.deleteElementNode.test.ts new file mode 100644 index 0000000000..3e37de9d7b --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.deleteElementNode.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' +import { createChatStore } from '../chatStore' + +describe('deleteElementNode', () => { + it('C4: removes item from elements and notifies content delete', () => { + const onRenderStructureChange = vi.fn() + const store = createChatStore({ onRenderStructureChange }) + const onDelContent = vi.fn() + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'del-me', kind: 'item', type: 'thought' }, + }) + onRenderStructureChange.mockClear() + + store.getState().deleteElementNode({ + token: 'del-me', + kind: 'item', + chatType: 'reAct', + onDelContent, + }) + expect(onDelContent).toHaveBeenCalledWith('del-me') + expect(store.getState().casualChat.elements.some((e) => e.token === 'del-me')).toBe(false) + expect(onRenderStructureChange).toHaveBeenCalledTimes(1) + }) + + it('C4: missing token is no-op and does not notify structure change', () => { + const onRenderStructureChange = vi.fn() + const store = createChatStore({ onRenderStructureChange }) + const onDelContent = vi.fn() + store.getState().deleteElementNode({ + token: 'absent', + kind: 'item', + chatType: 'reAct', + onDelContent, + }) + expect(onDelContent).not.toHaveBeenCalled() + expect(onRenderStructureChange).not.toHaveBeenCalled() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.dispatchStreamingNode.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.dispatchStreamingNode.test.ts new file mode 100644 index 0000000000..325016f869 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.dispatchStreamingNode.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { createChatStore } from '../chatStore' +import { AIChatQSDataTypeEnum } from '../aiRender' + +describe('dispatchStreamingNode', () => { + it('C3: appends item and mounts under task', () => { + const store = createChatStore() + store.getState().dispatchStreamingNode({ + chatType: 'task', + node: { token: 'task-1', kind: 'task', type: AIChatQSDataTypeEnum.TASK_NODE_GROUP }, + }) + expect(store.getState().tasks['task-1']).toBeTruthy() + expect(store.getState().taskChat.elements.some((e) => e.token === 'task-1')).toBe(true) + + store.getState().dispatchStreamingNode({ + chatType: 'task', + parentTaskId: 'task-1', + node: { token: 'child-1', kind: 'item', type: 'thought' }, + }) + expect(store.getState().tasks['task-1'].childrenTokens).toContain('child-1') + expect(store.getState().items['child-1']).toBeTruthy() + + // idempotent + store.getState().dispatchStreamingNode({ + chatType: 'task', + parentTaskId: 'task-1', + node: { token: 'child-1', kind: 'item', type: 'thought' }, + }) + expect(store.getState().tasks['task-1'].childrenTokens.filter((t) => t === 'child-1')).toHaveLength(1) + }) + + it('C3: history prepends', () => { + const store = createChatStore() + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'a', kind: 'item', type: 'thought' }, + }) + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'b', kind: 'item', type: 'thought', isHistory: true }, + }) + expect(store.getState().casualChat.elements.map((e) => e.token)).toEqual(['b', 'a']) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.replaceItemToken.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.replaceItemToken.test.ts new file mode 100644 index 0000000000..970e65b721 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.replaceItemToken.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest' +import { createChatStore } from '../chatStore' + +describe('replaceItemToken', () => { + it('C5: renames token in items and elements', () => { + const onRenderStructureChange = vi.fn() + const store = createChatStore({ onRenderStructureChange }) + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'old', kind: 'item', type: 'thought' }, + }) + onRenderStructureChange.mockClear() + + store.getState().replaceItemToken('old', 'new') + expect(store.getState().items['old']).toBeUndefined() + expect(store.getState().items['new']).toBeTruthy() + expect(store.getState().casualChat.elements.some((e) => e.token === 'new')).toBe(true) + expect(onRenderStructureChange).toHaveBeenCalledTimes(1) + }) + + it('C5: missing oldToken is no-op and does not notify structure change', () => { + const onRenderStructureChange = vi.fn() + const store = createChatStore({ onRenderStructureChange }) + store.getState().replaceItemToken('missing', 'new') + expect(store.getState().items['new']).toBeUndefined() + expect(onRenderStructureChange).not.toHaveBeenCalled() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.test.ts new file mode 100644 index 0000000000..f59e9e02e7 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/chatStore.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest' +import { createChatStore } from '../chatStore' +import { DefaultTaskPlanStatus, DefaultCurrentExecTaskTree } from '../defaultConstant' + +describe('chatStore basics', () => { + it('C1: initial state and updateState / updateTaskLoadingStatus', () => { + const store = createChatStore() + expect(store.getState().execute).toBe(false) + expect(store.getState().taskStatus).toEqual(DefaultTaskPlanStatus) + + store.getState().updateState({ execute: true, casualTitle: 'hi' }) + expect(store.getState().execute).toBe(true) + expect(store.getState().casualTitle).toBe('hi') + + store.getState().updateTaskLoadingStatus({ plan: 'p', status: 'processing' }) + expect(store.getState().taskStatus.plan).toBe('p') + expect(store.getState().taskStatus.status).toBe('processing') + }) + + it('C2: hydrateRenderTree', () => { + const store = createChatStore() + store.getState().hydrateRenderTree({ + items: { a: { kind: 'item', token: 'a', type: 'thought', renderNum: 1, nodeId: '' } as any }, + groups: {}, + tasks: {}, + casualElements: [{ kind: 'item', token: 'a', chatType: 'reAct', isHistory: false }], + taskElements: [], + }) + expect(store.getState().items.a.token).toBe('a') + expect(store.getState().casualChat.elements).toHaveLength(1) + }) + + it('C6: updatePlanTree / updateCasualReview / folders / timeline / http / risk', () => { + const store = createChatStore() + store.getState().updatePlanTree({ root_task_name: 'r', task_tree: [] }) + expect(store.getState().taskChat.plan.root_task_name).toBe('r') + + store.getState().updateCasualReview('rev-1', 'add') + expect(store.getState().currentCasualReview).toContain('rev-1') + store.getState().updateCasualReview('rev-1', 'remove') + expect(store.getState().currentCasualReview).not.toContain('rev-1') + + store.getState().updateFolders({ path: '/a', isFolder: true }) + store.getState().updateFolders({ path: '/a', isFolder: true }) + expect(store.getState().grpcFolders).toHaveLength(1) + + store.getState().setGrpcFolders([{ path: '/b', isFolder: false }]) + expect(store.getState().grpcFolders.some((f) => f.path === '/b')).toBe(true) + + store.getState().updateTimeLineItem({ id: 1 } as any) + store.getState().setReActTimelines([{ id: 1 } as any, { id: 2 } as any]) + expect(store.getState().reActTimelines.map((t) => t.id)).toEqual([2, 1]) + + store.getState().updateHttpData() + store.getState().updateRiskData() + expect(store.getState().httpTabShow).toBe(true) + expect(store.getState().riskTabShow).toBe(true) + + store.getState().updateCasualTodoList() + expect(store.getState().casualChat.todoListUpdate).toBe(1) + + expect(DefaultCurrentExecTaskTree.task_tree).toEqual([]) + }) + + it('C8: incrementNodeVersion / updateStateCount', () => { + const store = createChatStore() + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'i1', kind: 'item', type: 'thought' }, + }) + const { renderNum: prevNum } = store.getState().items.i1 + store.getState().incrementNodeVersion('i1', 'item') + expect(store.getState().items.i1.renderNum).toBe(prevNum + 1) + + const memBefore = store.getState().memoryListUpdate + store.getState().updateStateCount('memoryListUpdate') + expect(store.getState().memoryListUpdate).toBe(memBefore + 1) + }) +}) + +describe('chatStore onRenderStructureChange', () => { + it('C7: fires on structural dispatch', () => { + const onRenderStructureChange = vi.fn() + const store = createChatStore({ onRenderStructureChange }) + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { token: 'n1', kind: 'item', type: 'thought' }, + }) + expect(onRenderStructureChange).toHaveBeenCalled() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/contentPersistHelper.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/contentPersistHelper.test.ts new file mode 100644 index 0000000000..379bd2e5dc --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/contentPersistHelper.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + clonePersistableContent, + isToolResultTerminalStatus, + persistToolResultIfTerminal, + upsertSessionContent, + persistIndependentItem, + deletePersistedContent, + setSessionReferencePersist, + drainSessionContentWrites, +} from '../persist/contentPersistHelper' +import { AIChatQSDataTypeEnum } from '../aiRender' +import aiChatPersistStore from '../persist/aiChatPersistStore' + +vi.mock('../persist/aiChatPersistStore', () => { + return { + default: { + setSessionContent: vi.fn().mockResolvedValue(undefined), + deleteSessionContent: vi.fn().mockResolvedValue(undefined), + setSessionReference: vi.fn().mockResolvedValue(undefined), + }, + } +}) + +describe('contentPersistHelper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('E1: isToolResultTerminalStatus', () => { + expect(isToolResultTerminalStatus('success')).toBe(true) + expect(isToolResultTerminalStatus('failed')).toBe(true) + expect(isToolResultTerminalStatus('user_cancelled')).toBe(true) + expect(isToolResultTerminalStatus('default')).toBe(false) + expect(isToolResultTerminalStatus(undefined)).toBe(false) + }) + + it('E1: persistToolResultIfTerminal only writes terminal', async () => { + const nonTerminal = { + id: 't1', + type: AIChatQSDataTypeEnum.TOOL_RESULT, + data: { tool: { status: 'default' } }, + } as any + expect(persistToolResultIfTerminal('s1', nonTerminal)).toBeUndefined() + + const terminal = { + id: 't1', + type: AIChatQSDataTypeEnum.TOOL_RESULT, + data: { tool: { status: 'success' } }, + } as any + await persistToolResultIfTerminal('s1', terminal) + expect(aiChatPersistStore.setSessionContent).toHaveBeenCalled() + }) + + it('E3: clone / persistIndependent / delete', async () => { + const data = { + id: 'x1', + type: AIChatQSDataTypeEnum.THOUGHT, + data: 'hi', + } as any + const cloned = clonePersistableContent(data) + expect(cloned).toEqual(data) + expect(cloned).not.toBe(data) + + await persistIndependentItem('s1', data) + expect(aiChatPersistStore.setSessionContent).toHaveBeenCalledWith('s1', 'x1', expect.any(Function)) + + await deletePersistedContent('s1', 'x1') + expect(aiChatPersistStore.deleteSessionContent).toHaveBeenCalledWith('s1', 'x1') + }) + + it('E4: setSessionReferencePersist / drain', async () => { + await setSessionReferencePersist('s1', 'ref-1', { id: 'ref-1' } as any) + expect(aiChatPersistStore.setSessionReference).toHaveBeenCalled() + await expect(drainSessionContentWrites('s1')).resolves.toBeTruthy() + }) +}) + +describe('contentPersistHelper write queue', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('E2: serializes writes for same token', async () => { + const order: number[] = [] + ;(aiChatPersistStore.setSessionContent as any).mockImplementation(async () => { + order.push(1) + await Promise.resolve() + order.push(2) + }) + + const p1 = upsertSessionContent('s1', 'tok', { id: 'tok', type: AIChatQSDataTypeEnum.THOUGHT, data: 'a' } as any) + const p2 = upsertSessionContent('s1', 'tok', { id: 'tok', type: AIChatQSDataTypeEnum.THOUGHT, data: 'b' } as any) + await Promise.all([p1, p2]) + expect(order).toEqual([1, 2, 1, 2]) + }) + + it('E2: write chain self-clears after settle so drain is empty', async () => { + await upsertSessionContent('s-clear', 'tok', { + id: 'tok', + type: AIChatQSDataTypeEnum.THOUGHT, + data: 'a', + } as any) + // finally 自清后,再 drain 应立刻得到空数组(无悬挂链) + await expect(drainSessionContentWrites('s-clear')).resolves.toEqual([]) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/defaultConstant.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/defaultConstant.test.ts new file mode 100644 index 0000000000..cfc8fb9189 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/defaultConstant.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { convertNodeIdToVerbose, DefaultTaskPlanStatus, DefaultTaskPlanEndGate } from '../defaultConstant' +import { AITaskStatus } from '../grpcApi' + +describe('defaultConstant', () => { + it('B11: convertNodeIdToVerbose known and unknown', () => { + expect(convertNodeIdToVerbose('re-act-loop-thought')).toEqual({ + Zh: '思考', + En: '思考', + }) + expect(convertNodeIdToVerbose('unknown-node')).toEqual({ + Zh: 'unknown-node', + En: 'unknown-node', + }) + }) + + it('B12: DefaultTaskPlanStatus has no loading field', () => { + expect(DefaultTaskPlanStatus).toEqual({ + plan: '', + task: '', + taskID: '', + status: AITaskStatus.created, + coordinatorId: '', + }) + expect('loading' in DefaultTaskPlanStatus).toBe(false) + expect(DefaultTaskPlanEndGate).toEqual({ endReceived: false, pendingStatus: undefined }) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/ensureToolResultOnUI.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/ensureToolResultOnUI.test.ts new file mode 100644 index 0000000000..7cb80c94f4 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/ensureToolResultOnUI.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { ensureToolResultOnUI } from '../grpcStreamHandler/aiToolResult' +import { createTestSession, makeGrpcRes } from './fixtures' +import { AIChatQSDataTypeEnum } from '../aiRender' + +describe('ensureToolResultOnUI', () => { + it('B16: dispatches when missing, bumps when present', () => { + const { store } = createTestSession() + const res = makeGrpcRes({ Type: 'tool_call_done' }) + const toolResult = { + id: 'call-1', + type: AIChatQSDataTypeEnum.TOOL_RESULT, + chatType: 'reAct' as const, + Timestamp: 1, + AIService: '', + AIModelName: '', + data: { tool: { status: 'success' } }, + } as any + + ensureToolResultOnUI({ res, chatType: 'reAct', store }, toolResult) + expect(store.getState().items['call-1']).toBeTruthy() + expect(store.getState().casualChat.elements.some((e) => e.token === 'call-1')).toBe(true) + + const { renderNum: prevNum } = store.getState().items['call-1'] + ensureToolResultOnUI({ res, chatType: 'reAct', store }, toolResult) + expect(store.getState().items['call-1'].renderNum).toBe(prevNum + 1) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/fixtures.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/fixtures.ts new file mode 100644 index 0000000000..3158918dbf --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/fixtures.ts @@ -0,0 +1,175 @@ +import cloneDeep from 'lodash/cloneDeep' +import type { AIAgentChatData, AIAgentChatMetaData } from '@/pages/ai-agent/type/aiChat' +import { AIAgentSettingDefault, AIModelTypeEnum } from '@/pages/ai-agent/defaultConstant' +import { DefaultMemoryList, DefaultPlanItemDetailsData, DefaultTaskPlanEndGate } from '../defaultConstant' +import { createChatStore } from '../chatStore' +import type { AIMessageHandlerParams } from '../type' +import type { AIOutputEvent, AIStartParams } from '../grpcApi' +import type { ChatListRenderType } from '../aiRender' + +const emptyTierConsumption = { + cache_hit_token: 0, + input_consumption: 0, + output_consumption: 0, +} + +/** 与 Controller 内 genAIAgentChatData 对齐的最小会话 rawData */ +export const createTestRawData = (): AIAgentChatData => { + const defaultData: AIAgentChatData = { + httpFuzzRequest: undefined, + httpFlowFuzzStatus: undefined, + sessionTitle: '', + memoryList: DefaultMemoryList, + systemStream: '', + yaklangCodeChange: undefined, + grpcOffset: 0, + timelineBeforeId: 0, + timelineNoMore: false, + httpRunTimeIDs: [], + riskRunTimeIDs: [], + aiPerfData: { + consumption: { + cache_hit_token: 0, + input_consumption: 0, + output_consumption: 0, + consumption_uuid: '', + tier_consumption: { + [AIModelTypeEnum.TierIntelligent]: { ...emptyTierConsumption }, + [AIModelTypeEnum.TierLightweight]: { ...emptyTierConsumption }, + [AIModelTypeEnum.TierVision]: { ...emptyTierConsumption }, + }, + }, + pressure: { + [AIModelTypeEnum.TierIntelligent]: [], + [AIModelTypeEnum.TierLightweight]: [], + [AIModelTypeEnum.TierVision]: [], + }, + firstCost: { + [AIModelTypeEnum.TierIntelligent]: [], + [AIModelTypeEnum.TierLightweight]: [], + [AIModelTypeEnum.TierVision]: [], + }, + totalCost: { + [AIModelTypeEnum.TierIntelligent]: [], + [AIModelTypeEnum.TierLightweight]: [], + [AIModelTypeEnum.TierVision]: [], + }, + contextStats: { + prompt_bytes: 0, + prompt_tokens: 0, + data: { + times: [], + total_prompt_bytes: [], + total_prompt_tokens: [], + role_order: [], + role_labels: {}, + role_series: {}, + role_tokens: {}, + }, + }, + contextSections: { summary: new Map(), sections: [] }, + }, + casualChat: { + planDetails: DefaultPlanItemDetailsData, + planDetailsMap: new Map(), + }, + taskChat: { + planDetailsMap: new Map(), + }, + contents: new Map(), + } + return cloneDeep(defaultData) +} + +/** 与 Controller 内 genAIAgentChatMetaData 对齐 */ +export const createTestMeta = (): AIAgentChatMetaData => ({ + createChatQuestion: undefined, + onEnd: undefined, + pingSyncID: '', + pingTimer: null, + casualMemoryList: cloneDeep(DefaultMemoryList), + taskMemoryList: cloneDeep(DefaultMemoryList), + notifyMessageTimer: null, + currentTaskPlanActiveNode: new Set(), + taskPlanEndGate: cloneDeep(DefaultTaskPlanEndGate), + historyReviewReleaseID: {}, + currentPlanReviewExtraId: '', + planReviewExtraData: new Map(), + toolStderrStreamData: new Map(), + systemEventUUID: [], + cardKVPair: new Map(), + cardKVPaidTimer: null, + execFileRecordOrder: 1, + syncIDMap: new Map(), + queuePollingEmptyCount: 0, + queuePollingTimer: null, + memoryPollingTimer: null, + casualSubTaskIDs: new Set(), +}) + +export const createTestSession = (sessionId = 'test-session') => { + const store = createChatStore() + const rawData = createTestRawData() + const meta = createTestMeta() + const request = cloneDeep(AIAgentSettingDefault) as AIStartParams + return { sessionId, store, rawData, meta, request } +} + +export const makeGrpcRes = (partial: Partial & { Type: string }): AIOutputEvent => { + const { Content: partialContent, Type, ...rest } = partial + + return { + ID: 0, + CoordinatorId: '', + NodeId: '', + TaskIndex: '', + TaskId: '', + IsSystem: false, + IsStream: false, + IsReason: false, + IsJson: false, + IsResult: false, + IsSync: false, + DisableMarkdown: false, + SyncID: '', + EventUUID: 'evt-1', + StreamDelta: new Uint8Array(), + Timestamp: 1700000000, + CallToolID: '', + AIService: 'test-ai', + AIModelName: 'test-model', + ContentType: '', + NodeIdVerbose: { Zh: '', En: '' }, + ...rest, + Type, + Content: partialContent ?? new Uint8Array(), + } +} + +/** Content 为 JSON 对象时的便捷构造 */ +export const makeGrpcJsonRes = (type: string, content: unknown, extra: Partial = {}): AIOutputEvent => { + const { Content: _ignored, Type: _ignoredType, ...restExtra } = extra + return makeGrpcRes({ + ...restExtra, + Type: type, + Content: new TextEncoder().encode(JSON.stringify(content)), + }) +} + +export const makeHandlerRequest = ( + partial: Partial & { res: AIOutputEvent }, +): AIMessageHandlerParams => { + const base = createTestSession(partial.sessionId || 'handler-session') + return { + sessionId: base.sessionId, + store: base.store, + rawData: base.rawData, + meta: base.meta, + request: base.request, + chatType: (partial.chatType || 'reAct') as ChatListRenderType, + sendRequest: partial.sendRequest || (() => undefined), + pushLog: partial.pushLog || (() => undefined), + ...partial, + res: partial.res, + } +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getAIItemKind.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getAIItemKind.test.ts new file mode 100644 index 0000000000..699f3c9499 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getAIItemKind.test.ts @@ -0,0 +1,17 @@ +import './setupElectron' +import { describe, it, expect } from 'vitest' +import { getAIItemKind } from '../useAIItemKind' + +describe('getAIItemKind', () => { + it('B13: resolves item/group/task/null', () => { + const state = { + items: { i1: { kind: 'item', token: 'i1', type: 'thought', renderNum: 0, nodeId: '' } as any }, + groups: { g1: { kind: 'group', token: 'g1', type: 'stream', renderNum: 0, childrenTokens: [] } as any }, + tasks: { t1: { kind: 'task', token: 't1', type: 'task_node_group', renderNum: 0, childrenTokens: [] } as any }, + } + expect(getAIItemKind(state, 'i1')).toBe('item') + expect(getAIItemKind(state, 'g1')).toBe('group') + expect(getAIItemKind(state, 't1')).toBe('task') + expect(getAIItemKind(state, 'missing')).toBeNull() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getAIRecommendIconByType.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getAIRecommendIconByType.test.ts new file mode 100644 index 0000000000..985b86880a --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getAIRecommendIconByType.test.ts @@ -0,0 +1,25 @@ +import './setupElectron' +import { describe, it, expect, vi } from 'vitest' +import { getAIRecommendIconByType } from '../useGetAIMaterialsData' + +vi.mock('../../ai-agent/aiChatWelcome/icon', () => ({ + AIToolIcon: () => null, + HoverAIToolIcon: () => null, + AIForgeIcon: () => null, + HoverAIForgeIcon: () => null, + AIKnowledgeBaseIcon: () => null, + HoverAIKnowledgeBaseIcon: () => null, +})) + +vi.mock('@/pages/ai-agent/grpc', () => ({ + grpcGetRandomAIMaterials: vi.fn(), +})) + +describe('getAIRecommendIconByType', () => { + it('B15: returns icons for known types', () => { + expect(getAIRecommendIconByType('工具').icon).toBeTruthy() + expect(getAIRecommendIconByType('技能').icon).toBeTruthy() + expect(getAIRecommendIconByType('知识库').icon).toBeTruthy() + expect(getAIRecommendIconByType('其他').icon).toBeNull() + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getImageStoreKeyByAISource.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getImageStoreKeyByAISource.test.ts new file mode 100644 index 0000000000..3438be3192 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/getImageStoreKeyByAISource.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { getImageStoreKeyByAISource, AI_AGENT_HISTORY_AI_SOURCES } from '../useGetChatDataStoreKey' +import { AISourceEnum } from '../grpcApi' + +describe('getImageStoreKeyByAISource', () => { + it('B14: maps sources', () => { + expect(getImageStoreKeyByAISource(AISourceEnum.aiAgent)).toBe('aiChatDataStore') + expect(getImageStoreKeyByAISource(AISourceEnum.im)).toBe('aiChatDataStore') + expect(getImageStoreKeyByAISource(AISourceEnum.history)).toBe('histroyAiStore') + expect(getImageStoreKeyByAISource(AISourceEnum.flow)).toBe('FlowAiStore') + expect(getImageStoreKeyByAISource(AISourceEnum.yakRunner)).toBe('yakRunnerPageAiStore') + expect(getImageStoreKeyByAISource('unknown' as any)).toBe('unknown') + expect(AI_AGENT_HISTORY_AI_SOURCES).toEqual(['ai', 'im', '']) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/grpcAIOutputEventHandlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/grpcAIOutputEventHandlers.test.ts new file mode 100644 index 0000000000..ce511db729 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/grpcAIOutputEventHandlers.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { grpcAIMessageHandlers } from '../grpcStreamHandler/grpcAIOutputEventHandlers' +import { aiOtherDataHandlers } from '../grpcStreamHandler/aiOther' +import { aiStreamDataHandlers } from '../grpcStreamHandler/aiStream' +import { aiSingleItemDataHandlers } from '../grpcStreamHandler/aiSingleItem' +import { aiToolResultDataHandlers } from '../grpcStreamHandler/aiToolResult' +import { aiReviewDataHandlers } from '../grpcStreamHandler/aiReview' +import { aiTaskDetailDataHandlers } from '../grpcStreamHandler/aiTaskDetail' +import { aiPerfDataHandlers } from '../grpcStreamHandler/aiPerf' +import { aiYakExecResultDataHandlers } from '../grpcStreamHandler/yakExecResult' + +describe('grpcAIMessageHandlers', () => { + it('D1: aggregator includes all handler map keys', () => { + const expected = { + ...aiPerfDataHandlers, + ...aiOtherDataHandlers, + ...aiReviewDataHandlers, + ...aiToolResultDataHandlers, + ...aiSingleItemDataHandlers, + ...aiStreamDataHandlers, + ...aiYakExecResultDataHandlers, + ...aiTaskDetailDataHandlers, + } + for (const key of Object.keys(expected)) { + expect(typeof grpcAIMessageHandlers[key]).toBe('function') + } + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/setupElectron.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/setupElectron.ts new file mode 100644 index 0000000000..9901d714d1 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/setupElectron.ts @@ -0,0 +1,29 @@ +/** + * 在导入 ChatMultiSessionController / AIAgentLogEmitter 之前加载本文件, + * 避免顶层 window.require('electron') 在 jsdom 中抛错。 + */ +import { vi } from 'vitest' + +export const ipcRendererMock = { + on: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + send: vi.fn(), + invoke: vi.fn().mockResolvedValue(undefined), +} + +const electronStub = { ipcRenderer: ipcRendererMock } + +;(window as unknown as { require: (id: string) => unknown }).require = (id: string) => { + if (id === 'electron') return electronStub + throw new Error(`Unexpected require: ${id}`) +} + +export const resetIpcMocks = () => { + ipcRendererMock.on.mockClear() + ipcRendererMock.off.mockClear() + ipcRendererMock.removeAllListeners.mockClear() + ipcRendererMock.send.mockClear() + ipcRendererMock.invoke.mockClear() + ipcRendererMock.invoke.mockResolvedValue(undefined) +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/electronBridgeStub.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/electronBridgeStub.ts new file mode 100644 index 0000000000..14faf0c80e --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/electronBridgeStub.ts @@ -0,0 +1,49 @@ +/** Vitest stub: avoid requiring real window.yakitBridge at module load */ +const noop = () => undefined +const asyncNoop = async () => undefined + +const makeNs = () => + new Proxy( + {}, + { + get: () => asyncNoop, + }, + ) + +export const yakitApp = makeNs() +export const yakitTheme = makeNs() +export const yakitSystem = makeNs() +export const yakitNetwork = makeNs() +export const yakitShell = makeNs() +export const yakitReverse = makeNs() +export const yakitRisk = makeNs() +export const yakitAsset = makeNs() +export const yakitHTTPFlow = makeNs() +export const yakitHost = makeNs() +export const yakitWindow = makeNs() +export const yakitWindowControls = makeNs() +export const yakitChildWindow = makeNs() +export const yakitAuxWindow = makeNs() +export const yakitDialog = makeNs() +export const yakitLogs = { + openEngineLog: noop, + openRenderLog: noop, + openPrintLog: noop, + printLog: noop, +} +export const yakitEditorTools = makeNs() +export const yakitPerf = makeNs() +export const yakitCache = makeNs() +export const yakitClipboard = makeNs() +export const yakitProfile = makeNs() +export const yakitAuth = makeNs() +export const yakitRelease = makeNs() +export const yakitEngine = makeNs() +export const yakitUpload = makeNs() +export const yakitExporter = makeNs() +export const yakitExtractor = makeNs() +export const yakitProcessEnv = makeNs() +export const yakitPlugin = makeNs() +export const yakitScript = makeNs() +export const yakitMcp = makeNs() +export const yakitDuplex = makeNs() diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18nStub.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18nStub.ts new file mode 100644 index 0000000000..72babf8afe --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18nStub.ts @@ -0,0 +1,10 @@ +/** Minimal i18n stub for unit tests */ +const i18n = { + getFixedT: () => (key: string) => key, + t: (key: string) => key, + language: 'zh', + changeLanguage: async () => undefined, + use: () => i18n, + init: async () => undefined, +} +export default i18n diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18next-resources-to-backend.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18next-resources-to-backend.ts new file mode 100644 index 0000000000..662d04e4f2 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18next-resources-to-backend.ts @@ -0,0 +1,19 @@ +/** + * Vitest stub for i18next-resources-to-backend. + * Must return a module with `.type === 'backend'` for i18n.use(). + */ +export default function resourcesToBackend(_load: (lng: string, ns: string) => Promise) { + return { + type: 'backend' as const, + init() { + // no-op + }, + read(language: string, namespace: string, callback: (err: unknown, data: unknown) => void) { + try { + callback(null, {}) + } catch (e) { + callback(e, null) + } + }, + } +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/styleStub.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/styleStub.ts new file mode 100644 index 0000000000..ef25030b96 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/styleStub.ts @@ -0,0 +1,2 @@ +/** Vitest stub for scss/css modules */ +export default {} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.dialogueTransform.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.dialogueTransform.test.ts new file mode 100644 index 0000000000..d79424f726 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.dialogueTransform.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { indexedDBDataToReActChatRenderItem as toReActChatItems, getTreeDataIds, toDialogueData } from '../utils' +import type { DialogueRecord } from '@/pages/ai-agent/store/type' + +describe('dialogue transform helpers', () => { + it('B9: indexedDBDataToReActChatRenderItem', () => { + const data: DialogueRecord[] = [ + { + token: 'g1', + type: 'stream', + kind: 'group', + isGroup: true, + children: JSON.stringify([{ token: 'c1' }]), + sessionId: 's', + cacheOrder: 0, + } as any, + { + token: 'i1', + type: 'thought', + kind: 'item', + isGroup: false, + children: '[]', + sessionId: 's', + cacheOrder: 1, + } as any, + ] + const items = toReActChatItems('reAct', data) + expect(items[0]).toMatchObject({ token: 'g1', isGroup: true, isCached: true }) + expect(items[1]).toMatchObject({ token: 'i1', isGroup: false, kind: 'item' }) + }) + + it('B9: getTreeDataIds flattens tokens', () => { + const tree: DialogueRecord[] = [ + { + token: 'a', + children: JSON.stringify([{ token: 'b', children: '[]' }]), + } as any, + ] + expect(getTreeDataIds(tree)).toEqual(['a', 'b']) + }) + + it('B9: toDialogueData', () => { + const rows = toDialogueData( + [ + { token: 't1', type: 'thought', kind: 'item', children: [] } as any, + { token: 'g1', type: 'stream', kind: 'group', children: ['c1'] } as any, + ], + 'sid', + ) + expect(rows[0]).toMatchObject({ token: 't1', isGroup: false, sessionId: 'sid', cacheOrder: 0 }) + expect(rows[1].isGroup).toBe(true) + expect(JSON.parse(rows[1].children)).toEqual(['c1']) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.handleTodoListData.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.handleTodoListData.test.ts new file mode 100644 index 0000000000..66934ede0c --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.handleTodoListData.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { handleTodoListData } from '../utils' +import { AIToDoListStatusEnum } from '@/pages/ai-agent/defaultConstant' + +describe('handleTodoListData', () => { + it('B8: filters by scope and counts stats', () => { + const items = [ + { id: '1', status: AIToDoListStatusEnum.Pending, scope_task_id: '' }, + { id: '2', status: AIToDoListStatusEnum.Doing, scope_task_id: 't1' }, + { id: '3', status: AIToDoListStatusEnum.Done, scope_task_id: 't1' }, + { id: '4', status: AIToDoListStatusEnum.Skipped, scope_task_id: 't2' }, + ] as any[] + + const scoped = handleTodoListData(items, 't1') + expect(scoped.items.map((i) => i.id)).toEqual(['2', '3']) + expect(scoped.stats).toMatchObject({ doing: 1, done: 1, pending: 0, skipped: 0, deleted: 0 }) + expect(scoped.uuid).toBeTruthy() + + const root = handleTodoListData(items, '') + expect(root.items.map((i) => i.id)).toEqual(['1']) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.pushLog.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.pushLog.test.ts new file mode 100644 index 0000000000..267ff868f1 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.pushLog.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { pushLogToOtherWindow } from '../utils' + +const { dispatch } = vi.hoisted(() => ({ + dispatch: vi.fn(), +})) + +vi.mock('../AIAgentLogEmitter', () => ({ + aiAgentLogEmitter: { dispatch }, + AIAgentLogEmitter: class {}, +})) + +describe('pushLogToOtherWindow', () => { + beforeEach(() => { + dispatch.mockClear() + }) + + it('B10: forwards log to emitter', () => { + pushLogToOtherWindow({ + sessionId: 's1', + Timestamp: 99, + level: 'info', + message: 'hello', + }) + expect(dispatch).toHaveBeenCalledWith({ + session: 's1', + type: 'log', + Timestamp: 99, + log: { level: 'info', message: 'hello' }, + }) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.streamNodeId.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.streamNodeId.test.ts new file mode 100644 index 0000000000..045842091c --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.streamNodeId.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { isToolStdoutStream, isToolStderrStream, isToolExecStream } from '../utils' + +describe('stream node id helpers', () => { + it('B6: stdout / stderr / exec', () => { + expect(isToolStdoutStream('tool-foo-stdout')).toBe(true) + expect(isToolStdoutStream('tool-foo-stderr')).toBe(false) + expect(isToolStdoutStream('')).toBe(false) + + expect(isToolStderrStream('tool-foo-stderr')).toBe(true) + expect(isToolStderrStream('tool-foo-stdout')).toBe(false) + + expect(isToolExecStream('call-tools')).toBe(true) + expect(isToolExecStream('tool-x-stdout')).toBe(true) + expect(isToolExecStream('other')).toBe(false) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.taskPlanEnd.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.taskPlanEnd.test.ts new file mode 100644 index 0000000000..f21d9dd8de --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.taskPlanEnd.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { AITaskStatus } from '../grpcApi' +import { trySettleTaskPlanEnd, handleTaskPlanEnd } from '../utils' +import { DefaultTaskPlanEndGate } from '../defaultConstant' +import { createTestSession } from './fixtures' +import { AIChatQSDataTypeEnum } from '../aiRender' + +describe('trySettleTaskPlanEnd / handleTaskPlanEnd', () => { + beforeEach(() => { + // no-op + }) + + it('B1: missing end keeps status unchanged', () => { + const { store, meta } = createTestSession() + store.getState().updateState({ + taskStatus: { + plan: 'x', + task: 'y', + taskID: 't1', + status: AITaskStatus.inProgress, + coordinatorId: 'c1', + }, + cancelTaskLoading: true, + }) + meta.taskPlanEndGate = { endReceived: false, pendingStatus: 'completed' } + trySettleTaskPlanEnd(store, meta) + expect(store.getState().taskStatus.status).toBe(AITaskStatus.inProgress) + expect(store.getState().cancelTaskLoading).toBe(true) + expect(meta.taskPlanEndGate.pendingStatus).toBe('completed') + }) + + it('B1: missing pending keeps status unchanged', () => { + const { store, meta } = createTestSession() + store.getState().updateState({ + taskStatus: { + plan: 'x', + task: 'y', + taskID: 't1', + status: AITaskStatus.inProgress, + coordinatorId: 'c1', + }, + }) + meta.taskPlanEndGate = { endReceived: true, pendingStatus: undefined } + trySettleTaskPlanEnd(store, meta) + expect(store.getState().taskStatus.status).toBe(AITaskStatus.inProgress) + }) + + it('B1: both sides settle status and reset gate', () => { + const { store, meta } = createTestSession() + store.getState().updateState({ + taskStatus: { + plan: 'x', + task: 'y', + taskID: 't1', + status: AITaskStatus.inProgress, + coordinatorId: 'c1', + }, + cancelTaskLoading: true, + }) + meta.taskPlanEndGate = { endReceived: true, pendingStatus: 'completed' } + trySettleTaskPlanEnd(store, meta) + expect(store.getState().taskStatus.status).toBe('completed') + expect(store.getState().cancelTaskLoading).toBe(false) + expect(meta.taskPlanEndGate).toEqual(DefaultTaskPlanEndGate) + }) + + it('B2: handleTaskPlanEnd sets endReceived and settles when pending exists', () => { + const session = createTestSession() + session.store.getState().updateState({ + taskStatus: { + plan: 'p', + task: 't', + taskID: 't1', + status: AITaskStatus.inProgress, + coordinatorId: 'c1', + }, + }) + session.meta.taskPlanEndGate = { endReceived: false, pendingStatus: 'aborted' } + handleTaskPlanEnd(session) + expect(session.store.getState().taskStatus.plan).toBe('已结束') + expect(session.store.getState().taskStatus.task).toBe('已结束') + expect(session.store.getState().taskStatus.taskID).toBe('t1') + expect(session.store.getState().taskStatus.status).toBe('aborted') + expect(session.meta.taskPlanEndGate).toEqual(DefaultTaskPlanEndGate) + }) + + it('B2: isChatEnd resets gate without settling', () => { + const session = createTestSession() + session.store.getState().updateState({ + taskStatus: { + plan: 'p', + task: 't', + taskID: 't1', + status: AITaskStatus.inProgress, + coordinatorId: 'c1', + }, + }) + session.meta.taskPlanEndGate = { endReceived: true, pendingStatus: 'completed' } + handleTaskPlanEnd(session, true) + expect(session.store.getState().taskStatus.status).toBe(AITaskStatus.inProgress) + expect(session.meta.taskPlanEndGate).toEqual(DefaultTaskPlanEndGate) + }) + + it('B2: marks active TASK_NODE_GROUP as error', () => { + const session = createTestSession() + const nodeId = 'plan-task-1' + session.meta.currentTaskPlanActiveNode.add(nodeId) + session.rawData.contents.set(nodeId, { + id: nodeId, + type: AIChatQSDataTypeEnum.TASK_NODE_GROUP, + chatType: 'task', + Timestamp: 1, + AIService: '', + AIModelName: '', + data: { status: AITaskStatus.inProgress }, + } as any) + session.store.getState().updatePlanTree({ + root_task_name: 'root', + task_tree: [{ task_id: 'a', progress: AITaskStatus.inProgress } as any], + }) + handleTaskPlanEnd(session) + const node = session.rawData.contents.get(nodeId) as any + expect(node.data.status).toBe(AITaskStatus.error) + expect(session.store.getState().taskChat.plan.task_tree[0].progress).toBe(AITaskStatus.error) + expect(session.meta.currentTaskPlanActiveNode.size).toBe(0) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.test.ts new file mode 100644 index 0000000000..afb26fc197 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/utils.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { generateTaskNodeDataID, genBaseAIChatData, genExecTasks, isAutoExecuteReviewContinue } from '../utils' +import type { AIAgentGrpcApi } from '../grpcApi' + +describe('utils core helpers', () => { + it('B3: generateTaskNodeDataID for reAct / task', () => { + expect( + generateTaskNodeDataID({ + chatType: 'reAct', + planID: 'p1', + taskID: 't1', + isExist: () => false, + }), + ).toBeUndefined() + + expect( + generateTaskNodeDataID({ + chatType: 'reAct', + planID: 'p1', + taskID: 't1', + isExist: (k) => k === 'p1-t1', + }), + ).toBe('p1-t1') + + expect( + generateTaskNodeDataID({ + chatType: 'task', + planID: 'p1', + taskID: 't1', + isExist: () => false, + }), + ).toBe('p1-default') + + expect( + generateTaskNodeDataID({ + chatType: 'task', + planID: 'p1', + taskID: 't1', + isExist: (k) => k === 'p1-t1', + }), + ).toBe('p1-t1') + }) + + it('B5: genBaseAIChatData copies service fields', () => { + const base = genBaseAIChatData({ + AIService: 'svc', + AIModelName: 'model', + Timestamp: 123, + } as any) + expect(base.AIService).toBe('svc') + expect(base.AIModelName).toBe('model') + expect(base.Timestamp).toBe(123) + expect(base.id).toBeTruthy() + }) + + it('B4: genExecTasks flattens tree and maps depends_on', () => { + const root: AIAgentGrpcApi.PlanTask = { + index: '0', + name: 'root', + goal: '', + progress: '', + task_id: 'root', + semantic_identifier: 'root', + depends_on: [], + subtasks: [ + { + index: '1', + name: 'a', + goal: '', + progress: '', + task_id: 'id-a', + semantic_identifier: 'A', + depends_on: [], + subtasks: [], + } as any, + { + index: '2', + name: 'b', + goal: '', + progress: '', + task_id: 'id-b', + semantic_identifier: 'B', + depends_on: ['A'], + subtasks: [], + } as any, + ], + } as any + const tasks = genExecTasks(root) + expect(tasks.map((t) => t.task_id)).toEqual(['id-a', 'id-b']) + expect(tasks[1].depends_on).toEqual(['id-a']) + }) + + it('B7: isAutoExecuteReviewContinue', () => { + expect(isAutoExecuteReviewContinue({ type: 'require_user_interactive' })).toBe(false) + expect(isAutoExecuteReviewContinue({ getFunc: () => ({ ReviewPolicy: 'yolo' }) as any })).toBe(true) + expect(isAutoExecuteReviewContinue({ getFunc: () => ({ ReviewPolicy: 'manual' }) as any })).toBe(false) + expect(isAutoExecuteReviewContinue({})).toBe(false) + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/yakExecResult.handlers.test.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/yakExecResult.handlers.test.ts new file mode 100644 index 0000000000..752e77eca0 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/yakExecResult.handlers.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { aiYakExecResultDataHandlers } from '../grpcStreamHandler/yakExecResult' +import { makeGrpcJsonRes, makeHandlerRequest } from './fixtures' + +describe('yakExecResult handlers', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('D10: status updates casualTitle for reAct', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes( + 'structured', + { key: 're-act-loading-status-key', value: 'working' }, + { NodeId: 'status', TaskId: 'q1' }, + ), + chatType: 'reAct', + }) + req.store.getState().updateState({ currentCasualTaskID: 'q1' }) + aiYakExecResultDataHandlers.status(req) + expect(req.store.getState().casualTitle).toBe('working') + }) + + it('D10: status updates plan title for task', () => { + const req = makeHandlerRequest({ + res: makeGrpcJsonRes( + 'structured', + { key: 'plan-executing-loading-status-key', value: 'planning' }, + { NodeId: 'status' }, + ), + chatType: 'task', + }) + aiYakExecResultDataHandlers.status(req) + expect(req.store.getState().taskStatus.plan).toBe('planning') + }) + + it('D10: yak_exec_result registered', () => { + expect(typeof aiYakExecResultDataHandlers.yak_exec_result).toBe('function') + }) +}) diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/aiRender.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/aiRender.ts index fefe930b66..2d4ae040c1 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/aiRender.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/aiRender.ts @@ -1,7 +1,163 @@ import type { StreamResult } from '@/hook/useHoldGRPCStream/useHoldGRPCStreamType' -import type { AIAgentGrpcApi, AIOutputEvent, AITaskStatusType, AIOutputI18n, AIInputEvent } from './grpcApi' -import type { AIChatIPCStartParams } from './type' +import type { AIAgentGrpcApi, AIOutputEvent, AITaskStatusType, AIOutputI18n } from './grpcApi' +import type { AIFileSystemPin, AIQuestionQueues, UseAIMessageDataState } from './type' +import { CustomPluginExecuteFormValue } from '@/pages/plugins/operator/localPluginExecuteDetailHeard/LocalPluginExecuteDetailHeardType' +// #region AI-Agent 非会话列表外的渲染数据 +/** 插件执行中的文件操作记录 */ +export interface AIYakExecFileRecord extends StreamResult.Log { + /** 前端主动对接口流输出的文件记录进行先后操作的记录 */ + order: number +} + +/** 任务规划-可执行任务的数据结构 */ +export interface AITaskInfoProps extends Omit { + /** 层级(代表在树里的第几层) */ + level: number + /** 是否是叶子任务节点 */ + isLeaf: boolean +} + +/** UI:待办清单卡片数据*/ +export interface TodoListCardData { + items: AIAgentGrpcApi.TodoListUpdateItem[] + stats: AIAgentGrpcApi.TodoListUpdateStats + /** UI定时刷新数据渲染,用于确定数据是否有更新 */ + uuid: string +} + +/** 用户输入框上的AI提示消息 */ +export interface AIInputNotifyMessage { + type: 'notify' | 'rate-limit' + content: string + label: AIOutputI18n +} + +export type ForgesAndSkillsDynamicItem = Omit & + Omit & { + category: 'forge' | 'skill' + } + +/** 任务树节点的详情数据 */ +export interface PlanItemDetailsData { + /** UI定时刷新数据渲染,用于确定数据是否有更新 */ + uuid: string + /** 任务id */ + taskId: string + todoList: TodoListCardData + tool: { + fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] + dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] + } + forges: { + fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] + dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicForgesItem[] + } + skills: { + fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] + dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicSkillsItem[] + } + plugins: { + fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] + dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] + } + mcp: { + fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] + dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] + } + /** 目前没有这个数据 */ + mcpServices: { + fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] + dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] + } + perception: AIAgentGrpcApi.PerceptionData + execution: AIAgentGrpcApi.SessionSnapshot['execution'] + backgroundProcesses: AIAgentGrpcApi.SessionSnapshot['background_processes'] +} +// #endregion + +// #region AI-Agent 用于触发会话列表的渲染数据 +/** 会话列表类型(任务规划|自由对话) */ +export type ChatListRenderType = 'reAct' | 'task' + +/** 响应式骨架模型 (纯净的字典与关系树) */ +export interface ReActChatBaseMeta { + token: string + type: AIChatQSDataType + renderNum: number // 精准驱动打字机刷新的原子计数器,历史记录中永远为 0 +} +export interface ReActChatItemMeta extends ReActChatBaseMeta { + kind: 'item' + /** 组的标识符 */ + nodeId: AIOutputEvent['NodeId'] +} +export interface ReActChatGroupMeta extends ReActChatBaseMeta { + kind: 'group' + /** 组的标识符 */ + nodeId: AIOutputEvent['NodeId'] + childrenTokens: string[] +} +export interface ReActChatTaskMeta extends ReActChatBaseMeta { + kind: 'task' + childrenTokens: string[] +} + +/** UI 视图渲染路标 (控制元素怎么画、要不要动画、数据从哪来) */ +export interface ReActChatRenderElement { + kind: 'item' | 'group' | 'task' + token: string + chatType: ChatListRenderType + /** 决定是否触发工具链折叠与渐入动画 */ + isHistory: boolean +} + +/** 会话渲染树快照(zustand items/groups/tasks + 两侧 elements) */ +export interface SessionRenderContent { + items: Record + groups: Record + tasks: Record + casualElements: ReActChatRenderElement[] + taskElements: ReActChatRenderElement[] +} + +/** 控制UI渲染的数据数组元素 */ +export interface ReActChatBaseInfo { + chatType: ChatListRenderType + token: string + /** 决定具体样式的组件路由 */ + type: AIChatQSDataType + /** 高频更新时精准触发单项重渲染的“版本号” */ + renderNum: number + + /** 是否是已加载的历史缓存数据(用于拦截重复写入 DB) */ + isCached?: boolean + /** 缓存数据里的顺序 */ + cacheOrder?: number +} + +/** 独立 UI 节点 */ +export interface ReActChatElement extends ReActChatBaseInfo { + kind: 'item' +} +/** stream合成组group的节点 */ +export interface ReActChatGroupElement extends ReActChatBaseInfo { + kind: 'group' + children: ReActChatElement[] +} + +/** 进入 task 容器内部的子节点类型集合 */ +export type ReActChatTaskElementSub = ReActChatElement | ReActChatGroupElement +/** 任务内的所有节点 */ +export interface ReActChatTaskElement extends ReActChatBaseInfo { + kind: 'task' + children: ReActChatTaskElementSub[] +} + +export type ReActChatRenderItem = ReActChatElement | ReActChatGroupElement | ReActChatTaskElement + +// #endregion + +// #region UI元素渲染的详细数据结构 /** 工具流式输出里的可选操作列表 */ export interface ToolStreamSelectors { callToolId: string @@ -25,7 +181,7 @@ export interface AIStreamOutput { export interface AIToolResult { type: 'create' | 'stream' | 'result' callToolId: string - /**工具名称 */ + /** 工具名称 */ toolName: string /** 工具显示名称(多语言) */ verboseName?: AIOutputI18n @@ -108,12 +264,6 @@ export type AIReviewType = | UIRequireUserInteractive | UIExecAIForgeReview -/** 插件执行中的文件操作记录 */ -export interface AIYakExecFileRecord extends StreamResult.Log { - /** 前端主动对接口流输出的文件记录进行先后操作的记录 */ - order: number -} - /** 工具执行结果的决策展示数据 */ export interface AIToolCallDecision extends Omit { i18n: AIOutputI18n @@ -159,56 +309,6 @@ export interface ReportFinishCardData { content: string } -/** UI:待办清单卡片数据*/ -export interface TodoListCardData { - items: AIAgentGrpcApi.TodoListUpdateItem[] - stats: AIAgentGrpcApi.TodoListUpdateStats - /** UI定时刷新数据渲染,用于确定数据是否有更新 */ - uuid: string -} - -export type ForgesAndSkillsDynamicItem = Omit & - Omit & { - category: 'forge' | 'skill' - } - -/** 任务树节点的详情数据 */ -export interface PlanItemDetailsData { - /** UI定时刷新数据渲染,用于确定数据是否有更新 */ - uuid: string - /** 任务id */ - taskId: string - todoList: TodoListCardData - tool: { - fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] - dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] - } - forges: { - fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] - dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicForgesItem[] - } - skills: { - fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] - dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicSkillsItem[] - } - plugins: { - fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] - dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] - } - mcp: { - fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] - dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] - } - /** 目前没有这个数据 */ - mcpServices: { - fixed: AIAgentGrpcApi.PlanItemDetailsFixedItem[] - dynamic: AIAgentGrpcApi.PlanItemDetailsDynamicToolItem[] - } - perception: AIAgentGrpcApi.PerceptionData - execution: AIAgentGrpcApi.SessionSnapshot['execution'] - backgroundProcesses: AIAgentGrpcApi.SessionSnapshot['background_processes'] -} - /** UI:发包统计卡片数据(由 http_flow_fuzz_status 事件驱动) */ export interface HttpFlowFuzzStatusCardData { fuzz_id: string @@ -220,7 +320,9 @@ export interface HttpFlowFuzzStatusCardData { /** working 推送的进度;finish 时保留最后一次 */ progress?: AIAgentGrpcApi.HttpFlowFuzzStatusProgress } +// #endregion +// #region chat 问答内容组件的类型集合(包括了类型推导) export enum AIChatQSDataTypeEnum { /**用户的自由输入 */ QUESTION = 'question', @@ -273,72 +375,43 @@ export enum AIChatQSDataTypeEnum { /** 任务规划-未标识组的默认组 */ TASK_DEFAULT_GROUP = 'task_default_group', } - export type AIChatQSDataType = `${AIChatQSDataTypeEnum}` -/** 控制UI渲染的数据数组元素 */ -export interface ReActChatBaseInfo { - chatType: 'reAct' | 'task' - token: string - type: AIChatQSDataType - /** 触发渲染的次数, 无实际逻辑意义 */ - renderNum: number - /** 是否是已缓存数据 */ - isCached?: boolean - /** 缓存数据里的顺序 */ - cacheOrder?: number -} - -/** 独立 UI 节点 */ -export interface ReActChatElement extends ReActChatBaseInfo { - kind: 'item' -} -/** stream合成组group的节点 */ -export interface ReActChatGroupElement extends ReActChatBaseInfo { - kind: 'group' - children: ReActChatElement[] -} - -/** 进入 task 容器内部的子节点类型集合 */ -export type ReActChatTaskElementSub = ReActChatElement | ReActChatGroupElement -/** 任务内的所有节点 */ -export interface ReActChatTaskElement extends ReActChatBaseInfo { - kind: 'task' - children: ReActChatTaskElementSub[] -} - -export type ReActChatRenderItem = ReActChatElement | ReActChatGroupElement | ReActChatTaskElement - -// #region chat 问答内容组件的类型集合(包括了类型推导) export interface AIChatQSDataBase { type: T data: U + /** id就是token */ id: string - chatType: ReActChatBaseInfo['chatType'] + chatType: ChatListRenderType AIService: AIOutputEvent['AIService'] AIModelName: AIOutputEvent['AIModelName'] Timestamp: AIOutputEvent['Timestamp'] - /** 节点信息所属的任务聚合组 key(parentTaskId-taskId) */ - taskId?: string + /** + * 节点信息所属的任务节点索引 + * 索引结构 + * 问题: 问题id-default + * 任务: 问题id-任务id + */ + TaskId?: AIOutputEvent['TaskId'] /** 前端专属数据,供前端逻辑和UI处理使用 */ - extraValue?: AIChatIPCStartParams['extraValue'] - /** 参考资料 */ - reference?: ChatReferenceMaterialPayload - /** 父集合组的key(如果被收集到集合组中, 则存在该字段) */ - parentGroupKey?: string + extraValue?: CustomPluginExecuteFormValue | Record + /** 参考资料 token 列表(正文与资料分离;payload 存 sessionReference 表) */ + reference?: string[] + /** 父集合组的key/token(如果被收集到集合组中, 则存在该字段) */ + parentGroupToken?: string } -type ChatQuestion = AIChatQSDataBase +type ChatQuestion = AIChatQSDataBase export type ChatStream = AIChatQSDataBase type ChatToolCallResult = AIChatQSDataBase type ChatToolCallParams = AIChatQSDataBase -export type ChatApiRequestFailed = AIChatQSDataBase< +type ChatApiRequestFailed = AIChatQSDataBase< AIChatQSDataTypeEnum.AI_API_REQUEST_FAILED, AIAgentGrpcApi.AIApiRequestFailedPayload > type ChatThought = AIChatQSDataBase type ChatResult = AIChatQSDataBase -type ChatToolResult = AIChatQSDataBase +export type ChatToolResult = AIChatQSDataBase type ChatPlanReviewRequire = AIChatQSDataBase type ChatDetachedPlanReviewRequire = AIChatQSDataBase type ChatTaskReviewRequire = AIChatQSDataBase @@ -349,7 +422,7 @@ type ChatRequireUserInteractive = AIChatQSDataBase< > type ChatExecAIForgeReview = AIChatQSDataBase export type ChatTaskNodeGroup = AIChatQSDataBase -export type ChatToolCallDecision = AIChatQSDataBase +type ChatToolCallDecision = AIChatQSDataBase type ChatPlanExecEnd = AIChatQSDataBase type ChatFailPlanAndExecution = AIChatQSDataBase type ChatFailReact = AIChatQSDataBase @@ -357,15 +430,18 @@ type ChatReferenceMaterial = AIChatQSDataBase< AIChatQSDataTypeEnum.Reference_Material, { NodeId: AIOutputEvent['NodeId']; NodeIdVerbose: AIOutputEvent['NodeIdVerbose'] } > -/** 用于渲染State定义使用, 无实际逻辑意义 */ -type ChatStreamGroup = AIChatQSDataBase -export type ChatUserManualIntervention = AIChatQSDataBase< +type ChatUserManualIntervention = AIChatQSDataBase< AIChatQSDataTypeEnum.USER_MANUAL_INTERVENTION, UserManualInterventionContext > + type ChatHttpFlowFuzzStatus = AIChatQSDataBase type ChatReportFinish = AIChatQSDataBase -export type ChatTaskDefaultGroup = AIChatQSDataBase +type ChatTaskDefaultGroup = AIChatQSDataBase +type ChatStreamGroup = AIChatQSDataBase< + AIChatQSDataTypeEnum.STREAM_GROUP, + { NodeId: AIOutputEvent['NodeId']; NodeIdVerbose: AIOutputEvent['NodeIdVerbose']; lastToken: string } +> export type AIChatQSData = | ChatQuestion @@ -386,11 +462,266 @@ export type AIChatQSData = | ChatFailReact | ChatToolCallResult | ChatReferenceMaterial - | ChatStreamGroup | ChatUserManualIntervention | ChatToolCallParams | ChatApiRequestFailed | ChatHttpFlowFuzzStatus | ChatReportFinish | ChatTaskDefaultGroup + | ChatStreamGroup +// #endregion + +// #region 状态机定义及其相关字段的定义 +/** 任务规划运行态:展示文案 + 当前活动任务身份/状态(是否在跑只看 status) */ +export interface TaskPlanStatus { + plan: string + task: string + /** 当前任务规划的 re_act_task_id,'' 表示无活动任务规划 */ + taskID: string + /** + * 当前任务规划状态,AITaskStatus.created 表示无; + * processing 表示执行中或等待 end∧change 齐套;终态表示已结束 + */ + status: AITaskStatusType + /** 当前任务规划的 coordinatorId,'' 表示无 */ + coordinatorId: string +} + +/** 当前正在执行的任务树 */ +export interface CurrentExecTaskTree { + task_tree: AIAgentGrpcApi.PlanHistory['task_tree'] + root_task_name: AIAgentGrpcApi.PlanHistory['root_task_name'] +} + +/** @name 数据状态机定义 */ +export interface ChatStoreState { + /** 会话执行状态 */ + execute: boolean + + /** aiChat.d.ts AIAgentChatData httpFuzzRequest */ + httpFuzzRequestUpdate: number + /** aiChat.d.ts AIAgentChatData httpFlowFuzzStatus */ + httpFlowFuzzStatusUpdate: number + /** + * 更新会话标题名 + * aiChat.d.ts AIAgentChatData sessionTitle + */ + sessionTitleUpdate: number + /** + * 记忆列表 + * aiChat.d.ts AIAgentChatData memoryList + */ + memoryListUpdate: number + /** + * 系统流信息(isSystem=true&type=stream) + * aiChat.d.ts AIAgentChatData systemStream + */ + updateSystemStream: number + /** + * yaklang_code_change 更新 + * aiChat.d.ts AIAgentChatData yaklangCodeChange + */ + yaklangCodeChangeUpdate: number + /** aiChat.d.ts AIAgentChatMetaData syncIDMap */ + syncIDUpdate: number + + /** 接口运行过程中的数据文件夹合集 */ + grpcFolders: AIFileSystemPin[] + /** 时间线 */ + reActTimelines: AIAgentGrpcApi.TimelineItem[] + + /** 流推送的提示文案(notify / rate-limit),展示时长由 duration 系列字段控制,到期自动清空 */ + notifyMessage: AIInputNotifyMessage | null + /** 任务规划历史数据-任务树 */ + planHistoryList: AIAgentGrpcApi.PlanHistoryList + /** 问题队列信息 */ + questionQueue: AIQuestionQueues + + /** 运行时产生http数据(是否显示tab,是否刷新数据) */ + httpTabShow: boolean + /** aiChat.d.ts AIAgentChatData httpRunTimeIDs */ + httpTabUpdate: number + /** 运行时产生risk数据(是否显示tab,是否刷新数据) */ + riskTabShow: boolean + /** aiChat.d.ts AIAgentChatData riskRunTimeIDs */ + riskTabUpdate: number + + // #region 会话列表相关数据 + /** 当前自由对话问题的re_act_task_id */ + currentCasualTaskID: string + /** 自由对话的loading 显示的文案 */ + casualTitle: string + /** 自由对话的是否进行中 */ + casualLoading: boolean + /** 场景状态(仅供自由对话[reAct])使用 */ + focusMode: string + /** UI是否显示中间的任务规划列表 */ + showPlanList: boolean + /** 任务规划运行态(展示文案 + 当前活动任务;是否在跑看 status) */ + taskStatus: TaskPlanStatus + + /** + * 自由对话的当前review(未操作) + * 通过token去rawData中contents里取数据,token是唯一的 + */ + currentCasualReview: string[] + /** + * 任务规划当前显示的review数据 + * 通过token去rawData中contents里取数据,token是唯一的 + */ + currentPlanReviewToken: { token: string; renderNum: number } + /** + * 当前review是plan时,异步数据的更新版本 + * aiChat.d.ts AIAgentChatMetaData planReviewExtraData + */ + currentPlanReviewExtraUpdate: number + + items: SessionRenderContent['items'] + groups: SessionRenderContent['groups'] + tasks: SessionRenderContent['tasks'] + + casualChat: { + elements: SessionRenderContent['casualElements'] + /** aiChat.d.ts AIAgentChatData casualChat['planDetails'] */ + todoListUpdate: number + } + taskChat: { + elements: SessionRenderContent['taskElements'] + plan: CurrentExecTaskTree + } + // #endregion + + /** UI上的头部的card横向滚动列表数据 */ + card: AIAgentGrpcApi.AIInfoCard[] + /** 工具卡片相关的文件数据 */ + execFileRecord: Map + /** 文件操作列表 */ + yakExecResultLogs: StreamResult.Log[] + + /** 切换session时的loading状态 */ + /** 切换/恢复会话 loading(供 UI 遮罩与禁用交互,防止加载期间误点) */ + initLoading: boolean + /** 非初始化的请求grpc数据 loading */ + grpcLoadMoreLoading: boolean + + /** 用户主动取消问题的loading状态(自由对话) */ + cancelCasualLoading: boolean + /** 用户主动取消问题的loading状态(任务规划) */ + cancelTaskLoading: boolean + + /** + * TODO - 有问题,需要调整 请求历史数据相关State + */ + requestHistoryState: UseAIMessageDataState + + /** 更新精准字段数据依赖的渲染版本号 */ + updateStateCount: ( + type: + | 'httpFuzzRequestUpdate' + | 'httpFlowFuzzStatusUpdate' + | 'sessionTitleUpdate' + | 'memoryListUpdate' + | 'updateSystemStream' + | 'yaklangCodeChangeUpdate' + | 'syncIDUpdate' + | 'currentPlanReviewExtraUpdate', + ) => void + + updateFolders: (info: AIFileSystemPin) => void + /** 批量替换 grpcFolders(历史恢复用) */ + setGrpcFolders: (folders: AIFileSystemPin[]) => void + updateTimeLineItem: (item: AIAgentGrpcApi.TimelineItem) => void + /** 批量替换 reActTimelines(历史恢复用) */ + setReActTimelines: (timelines: AIAgentGrpcApi.TimelineItem[]) => void + + /** 更新http数据 */ + updateHttpData: () => void + /** 更新risk数据 */ + updateRiskData: () => void + + /** 精准字段级数据修改(除去列表数据外的其他数据) */ + updateState: ( + partial: Partial< + Omit< + ChatStoreState, + | 'httpFuzzRequestUpdate' + | 'httpFlowFuzzStatusUpdate' + | 'sessionTitleUpdate' + | 'memoryListUpdate' + | 'updateSystemStream' + | 'yaklangCodeChangeUpdate' + | 'syncIDUpdate' + | 'grpcFolders' + | 'reActTimelines' + | 'httpTabShow' + | 'httpTabUpdate' + | 'riskTabShow' + | 'riskTabUpdate' + | 'currentCasualReview' + | 'currentPlanReviewExtraUpdate' + | 'items' + | 'groups' + | 'tasks' + | 'casualChat' + | 'taskChat' + > + >, + ) => void + + /** + * 用持久化渲染树快照整体替换 items/groups/tasks/elements + * 供 ChatMultiSessionController 从 IDB 恢复会话使用 + */ + hydrateRenderTree: (content: SessionRenderContent) => void + + updateTaskLoadingStatus: (status: Partial) => void + + /** 正在等待用户操作的reviewId列表 */ + updateCasualReview: (id: string, status: 'add' | 'remove') => void + + /** 更新自由对话列表的todoList,真实数据存放在内存池中 */ + updateCasualTodoList: () => void + updatePlanTree: (planTree: CurrentExecTaskTree) => void + + /** 更新 每个工具执行过程中-文件的操作记录 */ + updateExecFileRecord: (callToolID: string, info: StreamResult.Log, order: number) => void + + dispatchStreamingNode: (params: { + chatType: ChatListRenderType + /** 是否属于任务组的子节点 */ + parentTaskId?: string + node: { + kind: ReActChatRenderElement['kind'] + token: string + type: AIChatQSDataType + isHistory?: boolean + /** + * 专供合并成组的逻辑使用 + * + * 是否能合并成组的标识符-AIOutputEvent['NodeId'] + * 只有在AIOutputEvent['ContentType']为default时,才能合并成组,并且才会有值 + */ + nodeId?: AIOutputEvent['NodeId'] + /** + * 专供合并成组的逻辑使用 + * + * 比如 合并成组、添加到组中时,需要对非状态机里数据做的额外处理操作 + * @param groupToken 组token + * @param tokens 新添加到组中的token集合 + */ + groupExtra?: (groupToken: string, tokens: string[]) => void + } + }) => void + incrementNodeVersion: (token: string, kind: ReActChatRenderElement['kind']) => void + + deleteElementNode( + params: { + taskID?: string + groupID?: string + onDelContent: (mapKey: string) => void + } & Omit, + ) + + replaceItemToken(oldToken: string, newToken: string) +} // #endregion diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/chatStore.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/chatStore.ts new file mode 100644 index 0000000000..25a6e68f01 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/chatStore.ts @@ -0,0 +1,435 @@ +import { AIChatQSDataTypeEnum, CurrentExecTaskTree, type ChatStoreState } from './aiRender' +import { createStore } from 'zustand/vanilla' +import { immer } from 'zustand/middleware/immer' +import { enableMapSet } from 'immer' +import cloneDeep from 'lodash/cloneDeep' +import { + DefaultAIQuestionQueues, + DefaultCurrentExecTaskTree, + DefaultPlanHistoryList, + DefaultTaskPlanStatus, +} from './defaultConstant' +import { v4 as uuidv4 } from 'uuid' +// state 里有 Map(execFileRecord),Immer 操作 Map/Set 前必须加载 MapSet 插件 +enableMapSet() +export type CreateChatStoreOptions = { + /** 渲染树结构变更时回调(dispatch / delete / replaceItemToken),用于 dirty debounce 落库 */ + onRenderStructureChange?: () => void +} + +export const createChatStore = (options?: CreateChatStoreOptions) => { + const onRenderStructureChange = options?.onRenderStructureChange + return createStore()( + immer((set) => ({ + execute: false, + + httpFuzzRequestUpdate: 0, + httpFlowFuzzStatusUpdate: 0, + sessionTitleUpdate: 0, + memoryListUpdate: 0, + updateSystemStream: 0, + yaklangCodeChangeUpdate: 0, + syncIDUpdate: 0, + + grpcFolders: [], + reActTimelines: [], + + notifyMessage: null, + planHistoryList: cloneDeep(DefaultPlanHistoryList), + questionQueue: cloneDeep(DefaultAIQuestionQueues), + + httpTabShow: false, + httpTabUpdate: 0, + riskTabShow: false, + riskTabUpdate: 0, + + currentCasualTaskID: '', + casualTitle: '', + casualLoading: false, + focusMode: '', + showPlanList: false, + taskStatus: cloneDeep(DefaultTaskPlanStatus), + + currentCasualReview: [], + currentPlanReviewToken: { token: '', renderNum: 0 }, + currentPlanReviewExtraUpdate: 0, + + items: {}, + groups: {}, + tasks: {}, + + casualChat: { + elements: [], + todoListUpdate: 0, + }, + taskChat: { + elements: [], + plan: cloneDeep(DefaultCurrentExecTaskTree), + }, + + card: [], + execFileRecord: new Map(), + yakExecResultLogs: [], + + initLoading: false, + grpcLoadMoreLoading: false, + + cancelCasualLoading: false, + cancelTaskLoading: false, + requestHistoryState: { + initLoading: false, + casualLoadMoreLoading: false, + taskLoadMoreLoading: false, + saveLoading: false, + timelinesLoading: false, + }, + + updateStateCount: (type) => + set((state) => { + state[type] += 1 + }), + + updateFolders: (info) => + set((state) => { + const isExist = state.grpcFolders.find((item) => item.path === info.path) + if (!isExist) state.grpcFolders.push(info) + }), + /** + * 批量合并文件系统 pin 记录,按 path 去重。 + * 在 set 回调里拿最新 state 合并,避免 Controller 用 stale state 拼接。 + */ + setGrpcFolders: (folders) => + set((state) => { + state.grpcFolders = [...new Map([...folders, ...state.grpcFolders].map((item) => [item.path, item])).values()] + }), + updateTimeLineItem: (item) => + set((state) => { + state.reActTimelines.push(item) + }), + /** + * 批量前插 timeline 历史并按 id 去重。 + * 在 set 回调里拿最新 state 合并,避免 Controller 用 stale state 拼接导致丢失实时数据。 + */ + setReActTimelines: (timelines) => + set((state) => { + const existingIds = new Set(state.reActTimelines.map((t) => t.id)) + const deduped = timelines.filter((t) => !existingIds.has(t.id)) + state.reActTimelines = [...deduped, ...state.reActTimelines] + }), + + updateHttpData: () => { + set((state) => { + if (!state.httpTabShow) state.httpTabShow = true + state.httpTabUpdate += 1 + }) + }, + updateRiskData: () => { + set((state) => { + if (!state.riskTabShow) state.riskTabShow = true + state.riskTabUpdate += 1 + }) + }, + + updateState: (partial) => + set((state) => { + Object.assign(state, partial) + }), + + /** 用持久化渲染树快照整体替换 items/groups/tasks/elements(供 Controller 恢复会话) */ + hydrateRenderTree: (content) => + set((state) => { + state.items = content.items || {} + state.groups = content.groups || {} + state.tasks = content.tasks || {} + state.casualChat.elements = content.casualElements || [] + state.taskChat.elements = content.taskElements || [] + }), + + updateTaskLoadingStatus: (partial) => + set((state) => { + Object.assign(state.taskStatus, partial) + }), + + updateCasualReview: (id: string, status: 'add' | 'remove') => + set((state) => { + if (status === 'add' && !state.currentCasualReview.includes(id)) { + state.currentCasualReview.push(id) + } else if (status === 'remove' && state.currentCasualReview.includes(id)) { + state.currentCasualReview = state.currentCasualReview.filter((item) => item !== id) + } + }), + + updateCasualTodoList: () => { + set((state) => { + state.casualChat.todoListUpdate += 1 + }) + }, + updatePlanTree: (planTree: CurrentExecTaskTree) => + set((state) => { + state.taskChat.plan = planTree + }), + + updateExecFileRecord: (callToolID, info, order) => + set((state) => { + const keyName = callToolID || 'system' + const keyList = state.execFileRecord.get(keyName) || [] + keyList.push({ ...info, id: uuidv4(), order: order }) + state.execFileRecord.set(keyName, keyList) + }), + + dispatchStreamingNode: ({ chatType, parentTaskId, node }) => { + set((state) => { + const isHistory = node.isHistory ?? false + const direction = isHistory ? 'prepend' : 'append' + const elementRef = { kind: node.kind, token: node.token, chatType, isHistory } + const targetElements = chatType === 'reAct' ? state.casualChat.elements : state.taskChat.elements + + // 注册实体(group 由连续 stream item 碰撞自动生成,不支持手动注册) + if (node.kind === 'item' && !state.items[node.token]) { + state.items[node.token] = { + kind: 'item', + token: node.token, + type: node.type, + renderNum: 0, + nodeId: node.nodeId || '', + } + } else if (node.kind === 'task' && !state.tasks[node.token]) { + state.tasks[node.token] = { + kind: 'task', + token: node.token, + type: node.type, + renderNum: 0, + childrenTokens: [], + } + } + + /** 挂树幂等:同一 token 重复 dispatch 时跳过,避免 elements / childrenTokens 双插 */ + const isAlreadyInTree = () => { + /** stream 合并后 item 挂在 group.childrenTokens,不在 siblings 顶层列表里 */ + const isInGroupChildren = (tokens: string[] | undefined) => + !!tokens?.some((t) => state.groups[t]?.childrenTokens.includes(node.token)) + + if (parentTaskId) { + const children = state.tasks[parentTaskId]?.childrenTokens + if (!children) return false + if (children.includes(node.token)) return true + return isInGroupChildren(children) + } + + // 顶层 item / task / group + if (targetElements.some((el) => el.token === node.token)) return true + // 顶层 stream 组内的 item + return targetElements.some((el) => state.groups[el.token]?.childrenTokens.includes(node.token)) + } + if (isAlreadyInTree()) return + + if (targetElements.length === 0) { + if (direction === 'append') targetElements.push(elementRef) + else targetElements.unshift(elementRef) + return + } + + const parentChildren = parentTaskId ? state.tasks[parentTaskId]?.childrenTokens : undefined + if (parentTaskId && !parentChildren) return + + const siblingToken = parentChildren?.length + ? direction === 'append' + ? parentChildren.at(-1) + : parentChildren[0] + : parentTaskId + ? undefined + : direction === 'append' + ? targetElements.at(-1)?.token + : targetElements[0]?.token + + if (siblingToken) { + const siblingItem = state.items[siblingToken] + const siblingGroup = state.groups[siblingToken] + const isStreamItem = node.kind === 'item' && node.type === AIChatQSDataTypeEnum.STREAM && !!node.nodeId + + if (isStreamItem && siblingGroup?.kind === 'group' && siblingGroup.nodeId === node.nodeId) { + if (siblingGroup.childrenTokens.includes(node.token)) return + node.groupExtra?.(siblingGroup.token, [node.token]) + if (direction === 'append') siblingGroup.childrenTokens.push(node.token) + else siblingGroup.childrenTokens.unshift(node.token) + siblingGroup.renderNum += 1 + return + } + + if ( + isStreamItem && + siblingItem?.type === AIChatQSDataTypeEnum.STREAM && + siblingItem.nodeId === node.nodeId + ) { + const newGroupToken = `${node.nodeId}-${uuidv4()}` + const groupChildrenTokens = + direction === 'append' ? [siblingToken, node.token] : [node.token, siblingToken] + node.groupExtra?.(newGroupToken, groupChildrenTokens) + state.groups[newGroupToken] = { + kind: 'group', + token: newGroupToken, + type: AIChatQSDataTypeEnum.STREAM_GROUP, + renderNum: 1, + nodeId: node.nodeId, + childrenTokens: groupChildrenTokens, + } + const groupElementRef = { kind: 'group' as const, token: newGroupToken, chatType, isHistory } + if (parentChildren?.length) { + parentChildren[direction === 'append' ? parentChildren.length - 1 : 0] = newGroupToken + } else { + targetElements[direction === 'append' ? targetElements.length - 1 : 0] = groupElementRef + } + return + } + } + + if (parentChildren) { + if (direction === 'append') parentChildren.push(node.token) + else parentChildren.unshift(node.token) + return + } + + const lastEl = state.taskChat.elements.at(-1) + if ( + direction === 'append' && + chatType === 'task' && + node.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP && + lastEl?.kind === 'task' && + state.tasks[lastEl.token]?.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP + ) { + // 任务规划最新一个元素,一定是默认任务组,所以别的元素需要往前插入 + state.taskChat.elements.splice(state.taskChat.elements.length - 1, 0, elementRef) + } else { + if (direction === 'append') targetElements.push(elementRef) + else targetElements.unshift(elementRef) + } + }) + onRenderStructureChange?.() + }, + + /** 高频更新节点渲染 */ + incrementNodeVersion: (token, kind) => + set((state) => { + if (kind === 'item' && state.items[token]) state.items[token].renderNum += 1 + if (kind === 'group' && state.groups[token]) state.groups[token].renderNum += 1 + if (kind === 'task' && state.tasks[token]) state.tasks[token].renderNum += 1 + }), + + /** 删除指定token的节点,并将关联节点一并更新 */ + deleteElementNode: (params) => { + let deleted = false + set((state) => { + const { token, kind, chatType, taskID, groupID, onDelContent } = params + const exists = + (kind === 'item' && !!state.items[token]) || + (kind === 'group' && !!state.groups[token]) || + (kind === 'task' && !!state.tasks[token]) + if (!exists) return + + deleted = true + const removeChatElement = (targetToken: string) => { + const target = chatType === 'reAct' ? state.casualChat : state.taskChat + target.elements = target.elements.filter((item) => item.token !== targetToken) + } + + const removeFromChildrenTokens = ( + container: { childrenTokens: string[] } | undefined, + targetToken: string, + ) => { + if (container) { + container.childrenTokens = container.childrenTokens.filter((t) => t !== targetToken) + } + } + + /** 如果是group类型, 则清除整个group里的所有item数据 */ + const purgeGroup = (groupToken: string) => { + const group = state.groups[groupToken] + if (!group) return + group.childrenTokens.forEach(onDelContent) + delete state.groups[groupToken] + } + + const detachFromParentOrTopLevel = () => { + if (taskID) { + removeFromChildrenTokens(state.tasks[taskID], token) + } else { + removeChatElement(token) + } + } + + switch (kind) { + case 'item': + onDelContent(token) + if (groupID) { + removeFromChildrenTokens(state.groups[groupID], token) + } else if (taskID) { + removeFromChildrenTokens(state.tasks[taskID], token) + } else { + removeChatElement(token) + } + break + case 'group': + onDelContent(token) + purgeGroup(token) + detachFromParentOrTopLevel() + break + case 'task': { + onDelContent(token) + const task = state.tasks[token] + if (task) { + for (const childToken of task.childrenTokens) { + purgeGroup(childToken) + onDelContent(childToken) + } + } + delete state.tasks[token] + removeChatElement(token) + break + } + } + }) + if (deleted) onRenderStructureChange?.() + }, + + /** 将指定item的token换成新token,并同步更新 elements / childrenTokens 中的引用 */ + replaceItemToken: (oldToken, newToken) => { + let replaced = false + set((state) => { + if (!state.items[oldToken]) return + + replaced = true + // 同步 items 实体 + state.items[newToken] = state.items[oldToken] + state.items[newToken].token = newToken + state.items[newToken].renderNum += 1 + delete state.items[oldToken] + + // 同步 casualChat.elements 中的 token 引用 + for (const el of state.casualChat.elements) { + if (el.token === oldToken) el.token = newToken + } + + // 同步 taskChat.elements 中的 token 引用 + for (const el of state.taskChat.elements) { + if (el.token === oldToken) el.token = newToken + } + + // 同步 tasks[*].childrenTokens 中的 token 引用 + for (const task of Object.values(state.tasks)) { + if (task.childrenTokens.includes(oldToken)) { + task.childrenTokens = task.childrenTokens.map((t) => (t === oldToken ? newToken : t)) + } + } + + // 同步 groups[*].childrenTokens 中的 token 引用 + for (const group of Object.values(state.groups)) { + if (group.childrenTokens.includes(oldToken)) { + group.childrenTokens = group.childrenTokens.map((t) => (t === oldToken ? newToken : t)) + } + } + }) + if (replaced) onRenderStructureChange?.() + }, + })), + ) +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/defaultConstant.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/defaultConstant.ts index 916aafa519..816b000320 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/defaultConstant.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/defaultConstant.ts @@ -1,7 +1,14 @@ -import { cloneDeep } from 'lodash' -import type { AIToolResult, PlanItemDetailsData, TodoListCardData } from './aiRender' +import type { + AIToolResult, + CurrentExecTaskTree, + PlanItemDetailsData, + TaskPlanStatus, + TodoListCardData, +} from './aiRender' +import { AITaskStatus } from './grpcApi' import type { AIOutputI18n, AIAgentGrpcApi } from './grpcApi' -import type { AIQuestionQueues, PlanLoadingStatus, CurrentExecTaskTree } from './type' +import type { AIQuestionQueues } from './type' +import type { AIAgentChatMetaData } from '@/pages/ai-agent/type/aiChat' /** 工具执行结果-默认值 */ export const DefaultAIToolResult: AIToolResult = { @@ -130,11 +137,19 @@ export const DefaultMemoryList: AIAgentGrpcApi.MemoryEntryList = { }, } -/** 任务规划loading-默认值 */ -export const DefaultPlanLoadingStatus: PlanLoadingStatus = { - loading: false, - plan: '加载中...', - task: '加载中...', +/** 任务规划运行态-默认值 */ +export const DefaultTaskPlanStatus: TaskPlanStatus = { + plan: '', + task: '', + taskID: '', + status: AITaskStatus.created, + coordinatorId: '', +} + +/** end_plan_and_execution & react_task_status_changed 齐了才 settle status */ +export const DefaultTaskPlanEndGate: AIAgentChatMetaData['taskPlanEndGate'] = { + endReceived: false, + pendingStatus: undefined, } export const DefaultPlanHistoryList: AIAgentGrpcApi.PlanHistoryList = { diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcAIMessageHandlers.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcAIMessageHandlers.ts deleted file mode 100644 index bd5fc47277..0000000000 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcAIMessageHandlers.ts +++ /dev/null @@ -1,2478 +0,0 @@ -import type { AIMessageHandler, AIMessageHandlerParams, UpdateRenderDataParams } from './type' -import type { AIAgentGrpcApi, AIInputEvent, AIOutputEvent } from './grpcApi' -import type { - AIChatQSData, - AIChatQSDataType, - AIReviewType, - AIToolResult, - ChatStream, - HttpFlowFuzzStatusCardData, - ReportFinishCardData, - ReActChatBaseInfo, - ReActChatGroupElement, - ReActChatElement, - ReActChatRenderItem, - ReActChatTaskElement, - ReActChatTaskElementSub, - PlanItemDetailsData, -} from './aiRender' - -import { Uint8ArrayToString } from '@/utils/str' -import { - genBaseAIChatData, - generateTaskId, - genErrorLogData, - handleTodoListData, - isAutoExecuteReviewContinue, - isToolStderrStream, - isToolStdoutStream, -} from './utils' -import { AIChatQSDataTypeEnum } from './aiRender' -import { - AIReviewJudgeLevelMap, - AIStreamContentType, - convertNodeIdToVerbose, - DefaultAIToolResult, - DefaultPlanItemDetailsData, - DefaultToolResultSummary, -} from './defaultConstant' -import cloneDeep from 'lodash/cloneDeep' -import { v4 as uuidv4 } from 'uuid' -import isEmpty from 'lodash/isEmpty' -import isArray from 'lodash/isArray' -import type { AIChatData } from '@/pages/ai-agent/type/aiChat' -// #region Common Utils -/** 自由对话:是否为子 agent 任务(planDetailsMap 用子任务 ID;contents 用复合 ID) */ -const isCasualSubAgentTask = ( - chatStore: AIChatData, - res: AIOutputEvent, - getTaskId?: AIMessageHandlerParams['getTaskId'], -) => { - if (chatStore.casualChat.planDetailsMap.has(res.TaskId)) return true - const parentTaskId = getTaskId?.() - if (!parentTaskId || !res.TaskId) return false - return ( - chatStore.casualChat.contents.get(`${parentTaskId}-${res.TaskId}`)?.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP - ) -} - -type ApplyCasualPlanDetailsFields = ( - target: PlanItemDetailsData, - ctx: { isMainCasualTask: boolean; taskId: string }, -) => void - -/** 主任务 planDetails 为单例,新一轮提问后 taskId 必须覆盖;子 agent 走 planDetailsMap */ -const resolvePlanDetailsTaskId = (target: PlanItemDetailsData, ctx: { isMainCasualTask: boolean; taskId: string }) => { - target.taskId = ctx.isMainCasualTask ? ctx.taskId : target.taskId || ctx.taskId -} - -const updateCasualPlanItemDetails = ( - chatStore: AIChatData, - res: AIOutputEvent, - getTaskId: AIMessageHandlerParams['getTaskId'], - applyFields: ApplyCasualPlanDetailsFields, -) => { - if (!res.TaskId) return - const isSubAgentTask = isCasualSubAgentTask(chatStore, res, getTaskId) - const chatDetail = isSubAgentTask - ? chatStore.casualChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) - : chatStore.casualChat.planDetails || cloneDeep(DefaultPlanItemDetailsData) - applyFields(chatDetail, { isMainCasualTask: !isSubAgentTask, taskId: res.TaskId }) - if (isSubAgentTask) { - chatStore.casualChat.planDetailsMap.set(res.TaskId, chatDetail) - } else { - chatStore.casualChat.planDetails = chatDetail - } -} -/** grpc流数据转换成错误信息输出到日志中 */ -const handleErrorGRPCToLog: ( - /** 该条grpc流数据是历史数据 */ - isHistory: AIMessageHandlerParams['res']['IsSync'], - pushLog: AIMessageHandlerParams['pushLog'], - error: ReturnType, -) => void = (isHistory, pushLog, error) => { - if (isHistory) return - pushLog(error) -} - -/** - * 在 elements 树中查找并更新指定渲染项(含 Task 组 / stream 组嵌套) - * 只是更新renderNum,不包含新建逻辑 - */ -const bumpRenderItem = (list: ReActChatRenderItem[], info: UpdateRenderDataParams): boolean => { - for (const item of list) { - if (item.token === info.mapKey && item.type === info.type) { - item.renderNum += 1 - return true - } - if (item.kind === 'task') { - for (const child of item.children) { - if (child.token === info.mapKey && child.type === info.type) { - child.renderNum += 1 - item.renderNum += 1 - return true - } - if (child.kind === 'group') { - const sub = child.children.find((c) => c.token === info.mapKey && c.type === info.type) - if (sub) { - sub.renderNum += 1 - child.renderNum += 1 - item.renderNum += 1 - return true - } - } - } - } - if (item.kind === 'group') { - const sub = item.children.find((c) => c.token === info.mapKey && c.type === info.type) - if (sub) { - sub.renderNum += 1 - item.renderNum += 1 - return true - } - } - } - return false -} - -/** - * 将新渲染项追加到 elements(只包含普通节点 和 Task 任务组,单节点不会出现在 Stream 组内) - * 只是新建逻辑,不包含更新renderNum - */ -const appendRenderItem = ( - old: ReActChatRenderItem[], - element: ReActChatRenderItem, - isHistory: boolean, - options?: { taskId: AIChatQSData['taskId']; getContentMap: AIMessageHandlerParams['getContentMap'] }, -): ReActChatRenderItem[] => { - if (options?.taskId) { - const taskGroupKey = options?.taskId - const groupIndex = old.findIndex((item) => item.kind === 'task' && item.token === taskGroupKey) - if (groupIndex >= 0) { - const list = [...old] - const group = list[groupIndex] as ReActChatTaskElement - const children = ( - isHistory ? [element, ...group.children] : [...group.children, element] - ) as ReActChatTaskElementSub[] - list[groupIndex] = { ...group, children, renderNum: group.renderNum + 1 } - return list - } - } - return isHistory ? [element, ...old] : [...old, element] -} - -/** 更新UI-State变量数据(独立单条数据) */ -const handleUpdateUISingleState = ( - setElements: AIMessageHandlerParams['setElements'], - getContentMap: AIMessageHandlerParams['getContentMap'], - isHistory: AIMessageHandlerParams['res']['IsSync'], - info: UpdateRenderDataParams & { chatType: ReActChatBaseInfo['chatType'] }, -) => { - try { - setElements((old) => { - if (bumpRenderItem(old, info)) { - return [...old] - } - - const element: ReActChatRenderItem = { - chatType: info.chatType, - token: info.mapKey, - type: info.type, - kind: 'item', - renderNum: 1, - } - const chatDetail = getContentMap(info.mapKey) - if (!chatDetail || chatDetail.id !== info.mapKey) return old - return appendRenderItem(old, element, isHistory, { - taskId: chatDetail?.taskId, - getContentMap, - }) - }) - } catch {} -} - -/** 更新UI-State变量数据(组数据) */ -const handleUpdateUIGroupState: ( - /** group数据 */ - group: { mapKey: string; type: AIChatQSDataType }, - /** sub数据 */ - sub: { mapKey: string; type: AIChatQSDataType }, - setElement: AIMessageHandlerParams['setElements'], - /** 父 TaskId 集合组 token */ - taskNodeKey?: string, -) => void = (group, sub, setElement, taskNodeKey) => { - try { - setElement((old) => { - const scope: ReActChatRenderItem[] = taskNodeKey - ? (old.find((item) => item.kind === 'task' && item.token === taskNodeKey) as ReActChatTaskElement | undefined) - ?.children || [] - : old - - const find = scope.find((item) => item.token === group.mapKey && item.type === group.type) - if (find && find.kind === 'group') { - const subFind = find.children.find((item) => item.token === sub.mapKey && item.type === sub.type) - if (subFind) subFind.renderNum += 1 - find.renderNum += 1 - if (taskNodeKey) { - const taskGroup = old.find((item) => item.kind === 'task' && item.token === taskNodeKey) as - | ReActChatTaskElement - | undefined - if (taskGroup) taskGroup.renderNum += 1 - } - return [...old] - } - - return old - }) - } catch {} -} -// #endregion - -// #region 单项流数据转换为独立UI数据 -/** Type='thought' 问题的思考 */ -const handleThought: AIMessageHandler = (request) => { - const { res, info, setContentMap } = request - if (res.Type !== 'thought') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { thought } = (JSON.parse(ipcContent) as AIAgentGrpcApi.AIChatThought) || {} - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: AIChatQSDataTypeEnum.THOUGHT, - data: thought || '', - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='result' 问题一次性的结果输出 */ -const handleResult: AIMessageHandler = (request) => { - const { res, info, setContentMap } = request - if (res.Type !== 'result') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { result, after_stream } = (JSON.parse(ipcContent) as AIAgentGrpcApi.AIChatResult) || {} - if (after_stream) return - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: AIChatQSDataTypeEnum.THOUGHT, - data: result || '', - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='fail_react_task' ReAct任务(自由对话)崩溃的错误信息 */ -const handleFailReactTask: AIMessageHandler = (request) => { - const { res, info, setContentMap } = request - if (res.Type !== 'fail_react_task') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: AIChatQSDataTypeEnum.FAIL_REACT, - data: { - content: ipcContent, - NodeId: res.NodeId, - NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(res.NodeId), - }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='tool_call_decision' 工具决策 */ -const handleToolCallDecision: AIMessageHandler = (request) => { - const { res, info, setContentMap } = request - if (res.Type !== 'tool_call_decision') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ToolCallDecision - const i18n = data?.i18n || { zh: data.action, en: data.action } - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: AIChatQSDataTypeEnum.TOOL_CALL_DECISION, - data: { - ...data, - i18n: { - Zh: i18n.zh, - En: i18n.en, - }, - }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='fail_plan_and_execution' 任务规划崩溃的错误信息[在任务规划启动就崩溃时,出现在自由对话中] */ -const handleFailPlanAndExecution: AIMessageHandler = (request) => { - const { res, info, setContentMap } = request - if (res.Type !== 'fail_plan_and_execution') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: AIChatQSDataTypeEnum.FAIL_PLAN_AND_EXECUTION, - data: { - content: ipcContent, - NodeId: res.NodeId, - NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(res.NodeId), - }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='structured'&NodeId='react_task_dequeue' 生成用户问题到自由对话的UI上展示 */ -const handleReactTaskDequeue: AIMessageHandler = (request) => { - const { res, info, setContentMap, getContentMap, setElements, getChatDataStore } = request - if (res.Type !== 'structured' || res.NodeId !== 'react_task_dequeue') return - // 任务规划-该类型数据为无效数据 - if (info.chatType === 'task') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.QuestionQueueStatusChange - const chatData: AIChatQSData = { - id: res.TaskId || data.react_task_id, - chatType: 'reAct', - type: AIChatQSDataTypeEnum.QUESTION, - Timestamp: res.Timestamp, - data: { qs: data.react_task_input || '', setting: {} }, - AIService: '', - AIModelName: '', - // showQS为了UI渲染方便,重新构建的字段 - extraValue: { showQS: data.react_task_input || '' }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - - if (data.react_task_user_input_uuid) { - const qsDetail = getContentMap(data.react_task_user_input_uuid) - if (qsDetail && qsDetail.type === AIChatQSDataTypeEnum.QUESTION) { - // getChatDataStore()?.casualChat.contents.delete(data.react_task_user_input_uuid) - // setElements((old) => - // old.map((item) => { - // if (item.token === data.react_task_user_input_uuid) { - // return { ...item, token: data.react_task_id } - // } - // return item - // }), - // ) - return - } - } - - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='api_request_failed'&NodeId='ai_call_failure' 模型/API 请求失败 */ -const handleApiRequestFailed: AIMessageHandler = (request) => { - const { res, info, setContentMap } = request - if (res.Type !== 'api_request_failed' || res.NodeId !== 'ai_call_failure') return - // 历史数据无用-不处理 - if (res.IsSync) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIApiRequestFailedPayload - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: AIChatQSDataTypeEnum.AI_API_REQUEST_FAILED, - data, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** Type='http_flow_fuzz_status' 发包统计卡片:按 fuzz_id 维护一张 HTTP_FLOW_FUZZ_STATUS 卡片 */ -const handleHttpFlowFuzzStatus: AIMessageHandler = (request) => { - const { res, info, setContentMap, getContentMap, pushLog } = request - if (res.Type !== 'http_flow_fuzz_status') return - // 历史数据无用-不处理 - if (res.IsSync) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const payload = JSON.parse(ipcContent) as AIAgentGrpcApi.GetHttpFlowFuzzStatus - const { fuzz_id, runtime_id, reason, status } = payload - if (!fuzz_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type} 数据缺少 fuzz_id`)) - return - } - - const cardType = AIChatQSDataTypeEnum.HTTP_FLOW_FUZZ_STATUS - const existing = getContentMap(fuzz_id) - const isExistingCard = existing?.type === cardType - - // 引擎结束态没有对应卡片时直接丢弃,保留原行为 - if (status === 'finish' && !isExistingCard) return - - const nextData: HttpFlowFuzzStatusCardData = { - fuzz_id, - runtime_id, - reason, - engine_status: status, - // 仅 `working` 覆盖 progress;其它状态保留上一次(新建时默认 undefined) - progress: status === 'working' ? payload.progress : isExistingCard ? existing!.data.progress : undefined, - } - - if (isExistingCard) { - Object.assign(existing!.data, nextData) - } else { - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - id: fuzz_id, - chatType: info.chatType, - type: cardType, - data: nextData, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - setContentMap(fuzz_id, chatData) - } - - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: fuzz_id, - type: cardType, - chatType: info.chatType, - }) -} - -/** Type='report_finish' NodeId='report-finish' 报告生成完成:展示报告路径 */ -const handleReportFinish: AIMessageHandler = (request) => { - const { res, info, setContentMap, pushLog } = request - if (res.Type !== 'report_finish' || res.NodeId !== 'report-finish') return - - const ipcContent = Uint8ArrayToString(res.Content) || '{}' - - const parsed = JSON.parse(ipcContent) as AIAgentGrpcApi.ReportFinishPayload - let report_path = parsed?.report_path ?? '' - let title = parsed?.title ?? '' - let content = parsed?.summary_markdown ?? '' - - if (!report_path) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type} 数据缺少 report_path`)) - return - } - - const cardType = AIChatQSDataTypeEnum.REPORT_FINISH - const nextData: ReportFinishCardData = { reportPath: report_path, title, content } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - type: cardType, - data: nextData, - } - setContentMap(chatData.id, chatData) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: cardType, - chatType: info.chatType, - }) -} -/** Type='current_task_todo_list_update'&NodeId='current_task_todo_list' todolist */ -const handleCurrentTaskTodoListUpdate: AIMessageHandler = (request) => { - const { res, info, getChatDataStore, getTaskId, callback } = request - if (!res.TaskId) return - if (res.Type !== 'current_task_todo_list_update' || res.NodeId !== 'current_task_todo_list') return - - const chatStore = getChatDataStore?.() - if (!chatStore) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - // 更新待办清单卡片数据 - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.TodoListUpdate - if (isEmpty(data)) return - - const newData = handleTodoListData(data.items, data.task_id) - const applyTodoListFields: ApplyCasualPlanDetailsFields = (target, ctx) => { - target.uuid = uuidv4() - resolvePlanDetailsTaskId(target, ctx) - target.todoList = newData - } - - if (info.chatType === 'task') { - const oldData = chatStore.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) - applyTodoListFields(oldData, { isMainCasualTask: false, taskId: res.TaskId }) - chatStore.taskChat.planDetailsMap.set(res.TaskId, oldData) - } else if (info.chatType === 'reAct') { - updateCasualPlanItemDetails(chatStore, res, getTaskId, applyTodoListFields) - callback?.(res) - } -} -/** Type='structured'&NodeId='capability_inventory' 能力清单(tool/skills/forge/yak_plugin/mac) */ -const handleCapabilityInventory: AIMessageHandler = (request) => { - const { res, info, getChatDataStore, getTaskId } = request - if (!res.TaskId) return - if (res.Type !== 'structured' || res.NodeId !== 'capability_inventory') return - - const chatStore = getChatDataStore?.() - if (!chatStore) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const payload = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanItemDetails - if (isEmpty(payload)) return - const { fixed, dynamic } = payload - - const itemData: Pick = { - uuid: uuidv4(), - tool: { - fixed: [], - dynamic: [], - }, - forges: { - fixed: [], - dynamic: [], - }, - skills: { - fixed: [], - dynamic: [], - }, - plugins: { - fixed: [], - dynamic: [], - }, - mcp: { - fixed: [], - dynamic: [], - }, - } - - if (!!fixed?.tools) { - for (const item of fixed.tools) { - switch (item.category) { - case 'tool': - itemData.tool.fixed.push(item) - break - case 'yak_plugin': - itemData.plugins.fixed.push(item) - break - case 'mcp': - itemData.mcp.fixed.push(item) - break - default: - break - } - } - } - /** 暂时目前没有这个数据 */ - // if (!!fixed?.mcp_servers) { - // itemData.mcpServices.fixed = fixed.mcp_servers - // } - if (!!fixed?.forges) { - itemData.forges.fixed = fixed.forges - } - if (!!fixed?.skills) { - itemData.skills.fixed = fixed.skills - } - - if (!!dynamic?.tools) { - for (const item of dynamic.tools) { - switch (item.category) { - case 'tool': - itemData.tool.dynamic.push(item) - break - case 'yak_plugin': - itemData.plugins.dynamic.push(item) - break - case 'mcp': - itemData.mcp.dynamic.push(item) - break - default: - break - } - } - } - if (!!dynamic?.skills) { - itemData.skills.dynamic = dynamic.skills - } - if (!!dynamic?.forges) { - itemData.forges.dynamic = dynamic.forges - } - const applyCapabilityFields: ApplyCasualPlanDetailsFields = (target, ctx) => { - target.uuid = itemData.uuid - resolvePlanDetailsTaskId(target, ctx) - target.tool = itemData.tool - target.forges = itemData.forges - target.skills = itemData.skills - target.plugins = itemData.plugins - target.mcp = itemData.mcp - } - if (info.chatType === 'task') { - const oldData = chatStore.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) - applyCapabilityFields(oldData, { isMainCasualTask: false, taskId: res.TaskId }) - chatStore.taskChat.planDetailsMap.set(res.TaskId, oldData) - } else if (info.chatType === 'reAct') { - updateCasualPlanItemDetails(chatStore, res, getTaskId, applyCapabilityFields) - } -} -/** Type='perception'&NodeId='perception' 意图感知 */ -const handlePerception: AIMessageHandler = (request) => { - const { res, info, getChatDataStore, getTaskId } = request - if (!res.TaskId) return - if (res.Type !== 'perception' || res.NodeId !== 'perception') return - const chatStore = getChatDataStore?.() - if (!chatStore) return - const ipcContent = Uint8ArrayToString(res.Content) || '' - const perception = (JSON.parse(ipcContent) as AIAgentGrpcApi.PerceptionData) || {} - if (isEmpty(perception)) return - perception.summary = isArray(perception.summary) ? perception.summary.join(',') : perception.summary - const applyPerceptionFields: ApplyCasualPlanDetailsFields = (target, ctx) => { - target.uuid = uuidv4() - resolvePlanDetailsTaskId(target, ctx) - target.perception = perception - } - if (info.chatType === 'task') { - const oldData = chatStore.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) - applyPerceptionFields(oldData, { isMainCasualTask: false, taskId: res.TaskId }) - chatStore.taskChat.planDetailsMap.set(res.TaskId, oldData) - } else if (info.chatType === 'reAct') { - updateCasualPlanItemDetails(chatStore, res, getTaskId, applyPerceptionFields) - } -} - -const handleSessionSnapshot: AIMessageHandler = (request) => { - const { res, info, getChatDataStore, getTaskId } = request - if (!res.TaskId) return - if (res.NodeId !== 'session_snapshot') return - - const chatStore = getChatDataStore?.() - if (!chatStore) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const snapshot = (JSON.parse(ipcContent) as AIAgentGrpcApi.SessionSnapshot) || {} - if (isEmpty(snapshot)) return - const applySnapshotFields: ApplyCasualPlanDetailsFields = (target, ctx) => { - target.uuid = uuidv4() - resolvePlanDetailsTaskId(target, ctx) - target.execution = snapshot.execution - target.backgroundProcesses = snapshot.background_processes - } - if (info.chatType === 'task') { - const oldData = chatStore.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) - applySnapshotFields(oldData, { isMainCasualTask: false, taskId: res.TaskId }) - chatStore.taskChat.planDetailsMap.set(res.TaskId, oldData) - } else if (info.chatType === 'reAct') { - updateCasualPlanItemDetails(chatStore, res, getTaskId, applySnapshotFields) - } -} -// #endregion - -// #region stream数据相关逻辑 -/** - * - 存放 Type:stream NodeId:tool-xxx-stderr 的内容数据 - * - call_tool_id => {content:string uuid:string status:"start" | "end"} - * - 当stream-finished触发后,将内容全部设置到工具结果对象中的execError字段中 - * - 本NodeId和stream类型中的其他NodeId有一样的后端逻辑,但是前端需要将其区分出来 - */ -const ToolResultForStreamError: Map = new Map() - -/** Type='stream_start' stream类型数据初始化 */ -const handleStreamStart: AIMessageHandler = (request) => { - const { res, info, setContentMap, getContentMap, pushLog } = request - if (res.Type !== 'stream_start') return - // 属于日志数据的不进入UI展示 - if (res.IsSystem || res.IsReason) return - - const { CallToolID, NodeId } = res - if (!NodeId) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { event_writer_id } = JSON.parse(ipcContent) as { event_writer_id: string } - // event_writer_id为空 - if (!event_writer_id) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(NodeId: ${NodeId}), event_writer_id 为空`), - ) - return - } - - // tool-xxx-stdout 数据单独初始化逻辑 - if (isToolStdoutStream(NodeId)) { - if (!CallToolID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空`), - ) - return - } - let toolResult = getContentMap(CallToolID || '') - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `NodeID: ${NodeId} 的stream数据没有对应的工具结果(CallToolID: ${CallToolID})初始化`, - ), - ) - return - } - - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - toolResult.data.stream.EventUUID = event_writer_id - toolResult.data.type = 'stream' - setContentMap(event_writer_id, { - ...genBaseAIChatData(res), - id: event_writer_id, - chatType: info.chatType, - type: AIChatQSDataTypeEnum.STREAM, - data: { - NodeId, - NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(NodeId), - CallToolID, - EventUUID: event_writer_id, - status: 'start', - content: '', - ContentType: res.ContentType, - }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - }) - return - } - // tool-xxx-stderr 数据单独初始化逻辑 - if (isToolStderrStream(NodeId) && CallToolID) { - if (!CallToolID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空`), - ) - return - } - if (!ToolResultForStreamError.has(CallToolID)) { - ToolResultForStreamError.set(CallToolID, { - content: '', - uuid: event_writer_id, - status: 'start', - }) - } - return - } - - // 数据集合中对应的数据 - const streamData = getContentMap(event_writer_id) - - // 数据已存在,流数据输出顺序不对, 视为异常 - if (streamData) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `异常 ${res.Type} 类型, NodeId: ${NodeId}, eventuuid: (${event_writer_id}), 已存在对应的数据`, - ), - ) - return - } - - setContentMap(event_writer_id, { - ...genBaseAIChatData(res), - id: event_writer_id, - chatType: info.chatType, - type: AIChatQSDataTypeEnum.STREAM, - data: { - NodeId, - NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(NodeId), - CallToolID, - EventUUID: event_writer_id, - status: 'start', - content: '', - ContentType: res.ContentType, - }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - }) -} - -/** 将 task 容器内 children 写回顶层 list,并给renderNum加一 */ -const writeBackTaskGroupChildren = ( - list: ReActChatRenderItem[], - taskGroupIndex: number, - children: ReActChatTaskElementSub[], -): ReActChatRenderItem[] => { - const taskGroup = list[taskGroupIndex] as ReActChatTaskElement - list[taskGroupIndex] = { ...taskGroup, children, renderNum: taskGroup.renderNum + 1 } - return list -} - -/** stream数据初始化到UI上的逻辑处理 */ -const handleIsGroupDisplayForStream: ( - /** grpc流数据 */ - res: AIMessageHandlerParams['res'], - /** stream类型对应的详细数据 */ - streamDetail: ChatStream, - /** 当前渲染的数据列表 */ - data: ReActChatRenderItem[], - /** 获取详情数据映射的函数 */ - getContentMap: AIMessageHandlerParams['getContentMap'], -) => ReActChatRenderItem[] = (res, streamDetail, data, getContentMap) => { - const taskNodeKey = streamDetail.taskId - const list = [...data] - /** 任务节点的索引 */ - let taskNodeIndex = -1 - /** task 容器内操作的列表;未命中时为顶层 list */ - let targetList: ReActChatTaskElementSub[] | ReActChatRenderItem[] = list - - if (taskNodeKey) { - taskNodeIndex = list.findIndex((item) => item.kind === 'task' && item.token === taskNodeKey) - if (taskNodeIndex >= 0) { - const taskGroup = list[taskNodeIndex] as ReActChatTaskElement - targetList = [...taskGroup.children] - } - } - - const { ContentType, IsSync } = res - const element: ReActChatElement = { - chatType: streamDetail.chatType, - token: streamDetail.id, - type: streamDetail.type, - kind: 'item', - renderNum: 1, - } - - const commitIfInTaskGroup = (): ReActChatRenderItem[] | null => { - if (taskNodeIndex < 0) return null - return writeBackTaskGroupChildren(list, taskNodeIndex, targetList as ReActChatTaskElementSub[]) - } - - // 以下 判断stream数据已经渲染在UI上的逻辑处理 - const find = targetList.find((item) => item.token === element.token) - if (find) { - // 已经渲染到UI上, 是单个节点,或者是task节点下的单个节点/组数据的节点key命中 - if (find.kind === 'group') { - const subFind = find.children.find((item) => item.token === element.token && item.type === element.type) - if (subFind) subFind.renderNum += 1 - } - find.renderNum += 1 - return commitIfInTaskGroup() ?? [...targetList] - } - if (streamDetail && streamDetail.parentGroupKey) { - // 已经渲染到UI上, 不是组数据的key, 但是是组内数据, 找到组信息,并触发渲染更新 - const group = targetList.find( - (item) => item.token === streamDetail.parentGroupKey && item.type === AIChatQSDataTypeEnum.STREAM_GROUP, - ) - if (group && group.kind === 'group') { - const subFind = group.children.find((item) => item.token === element.token && item.type === element.type) - if (subFind) subFind.renderNum += 1 - group.renderNum += 1 - } - return commitIfInTaskGroup() ?? [...targetList] - } - - // 以下 stream数据没有渲染在UI上的逻辑处理 - if (ContentType !== AIStreamContentType.DEFAULT || !targetList.length) { - // 新增不可成组类型数据 - IsSync ? targetList.unshift(element) : targetList.push(element) - return commitIfInTaskGroup() ?? [...targetList] - } - - const active = IsSync ? targetList[0] : targetList[targetList.length - 1] - const activeDetail = getContentMap(active.token) - if (!activeDetail || activeDetail.type !== AIChatQSDataTypeEnum.STREAM) { - // UI详细数据没有或不是可成组类型,新增数据到UI上 - IsSync ? targetList.unshift(element) : targetList.push(element) - return commitIfInTaskGroup() ?? [...targetList] - } - - if (active.type === AIChatQSDataTypeEnum.STREAM && active.kind !== 'group') { - if (activeDetail.data.NodeId === streamDetail.data.NodeId) { - // 命中单项,准备整合成组数据,将原有单项的token当成组token - const groupInfo: ReActChatGroupElement = { - chatType: active.chatType, - token: active.token, - type: AIChatQSDataTypeEnum.STREAM_GROUP, - renderNum: 1, - kind: 'group', - children: [], - } - groupInfo.children = IsSync - ? [element, cloneDeep(active) as ReActChatElement] - : [cloneDeep(active) as ReActChatElement, element] - const arr = groupInfo.children.map((item) => item.token) - for (let el of arr) { - const info = getContentMap(el) - if (info) info.parentGroupKey = active.token - } - IsSync ? targetList.shift() : targetList.pop() - IsSync ? targetList.unshift(groupInfo) : targetList.push(groupInfo) - } else { - IsSync ? targetList.unshift(element) : targetList.push(element) - } - return commitIfInTaskGroup() ?? [...targetList] - } else if (active.type === AIChatQSDataTypeEnum.STREAM_GROUP && active.kind === 'group') { - if (activeDetail.data.NodeId === streamDetail.data.NodeId) { - // 命中组内数据,追加到组内 - streamDetail.parentGroupKey = active.token - IsSync ? active.children.unshift(element) : active.children.push(element) - active.renderNum += 1 - } else { - IsSync ? targetList.unshift(element) : targetList.push(element) - } - return commitIfInTaskGroup() ?? [...targetList] - } else { - IsSync ? targetList.unshift(element) : targetList.push(element) - return commitIfInTaskGroup() ?? [...targetList] - } -} - -/** Type='stream' stream数据 */ -const handleStream: AIMessageHandler = (request) => { - const { res, setElements, getContentMap, pushLog } = request - if (res.Type !== 'stream') return - // 属于日志数据的不进入UI展示 - if (res.IsSystem || res.IsReason) return - - const { CallToolID, EventUUID, NodeId } = res - if (!EventUUID || !NodeId) return - - const content = (Uint8ArrayToString(res.Content) || '') + (Uint8ArrayToString(res.StreamDelta) || '') - - // tool-xxx-stderr 数据单独处理逻辑 - if (isToolStderrStream(NodeId)) { - if (!CallToolID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空`), - ) - return - } - const errorResult = ToolResultForStreamError.get(CallToolID) - if (errorResult) errorResult.content += content - return - } - // tool-xxx-stdout 数据单独处理逻辑 - if (isToolStdoutStream(NodeId)) { - if (!CallToolID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空`), - ) - return - } - const toolResult = getContentMap(CallToolID) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || !toolResult.data.stream.EventUUID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `NodeID: ${NodeId} 的stream数据没有对应的工具结果(CallToolID: ${CallToolID})初始化`, - ), - ) - return - } - const toolForStreamData = getContentMap(toolResult.data.stream.EventUUID) - if (!toolForStreamData || toolForStreamData.type !== AIChatQSDataTypeEnum.STREAM) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `EventUUID: ${toolResult.data.stream.EventUUID} 的stream数据没有对应的初始化`), - ) - return - } - const isRender = !toolForStreamData.data.content - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - toolForStreamData.data.content += content - if (isRender) { - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } - return - } - - // 数据集合中对应的数据 - const streamData = getContentMap(EventUUID) - - // 数据不存在 - if (!streamData || streamData.type !== AIChatQSDataTypeEnum.STREAM) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `异常 stream 类型, NodeId: ${NodeId}, eventuuid: (${EventUUID})`), - ) - return - } - - const isRender = !streamData.data.content - // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - streamData.Timestamp = isRender ? res.Timestamp : streamData.Timestamp - streamData.data.content += content - - if (isRender) { - // 判断是否成为组UI数据展示 - setElements((old) => { - const list = handleIsGroupDisplayForStream(res, streamData, old, getContentMap) - return list - }) - } -} - -/** Type='structured'&NodeId='stream-finished' stream数据结束标识 */ -const handleStreamFinished: AIMessageHandler = (request) => { - const { res, setElements, getElements, getContentMap, pushLog } = request - if (res.Type !== 'structured' || res.NodeId !== 'stream-finished') return - - let ipcContent = Uint8ArrayToString(res.Content) || '' - const { event_writer_id, node_id, is_reason, is_system } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIStreamFinished - // 属于日志数据的不进入UI展示 - if (is_reason || is_system) return - if (!event_writer_id) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `stream-finished数据, event_writer_id 为空`), - ) - return - } - - const { CallToolID } = res - // tool-xxx-stderr 数据单独结束逻辑 - if (isToolStderrStream(node_id)) { - if (!CallToolID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.NodeId}数据(NodeId: ${node_id}), CallToolID 为空`), - ) - return - } - - const toolErrorResult = ToolResultForStreamError.get(CallToolID) - if (!toolErrorResult) return - - const toolResult = getContentMap(CallToolID) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - // 工具执行结果卡片UI没有展示时 - toolErrorResult.status = 'end' - } else { - const showUI = getElements().find((item) => item.token === toolResult.id && item.type === toolResult.type) - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - toolResult.data.tool.execError = toolErrorResult.content - if (showUI) { - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } - ToolResultForStreamError.delete(CallToolID) - } - return - } - // tool-xxx-stdout 数据单独结束逻辑 - if (isToolStdoutStream(node_id)) { - if (!CallToolID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.NodeId}数据(NodeId: ${node_id}), CallToolID 为空`), - ) - return - } - - const toolResult = getContentMap(res.CallToolID) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || !toolResult.data.stream.EventUUID) { - return - } - const toolForStreamData = getContentMap(toolResult.data.stream.EventUUID) - if (!toolForStreamData || toolForStreamData.type !== AIChatQSDataTypeEnum.STREAM) { - return - } - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - toolForStreamData.data.status = 'end' - const isShowAll = toolForStreamData.data.content.length > 25600 // 50KB大概字符数25600 - const displayContent = isShowAll - ? '...' + toolForStreamData.data.content.slice(-25600) + '...' - : toolForStreamData.data.content - toolResult.data.tool.toolStdoutContent = { content: displayContent, isShowAll } - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - return - } - - // 数据集合中对应的数据 - const streamData = getContentMap(event_writer_id) - // 数据不存在 不输出到日志,因为日志的流数据也有该类型数据 - if (!streamData || streamData.type !== AIChatQSDataTypeEnum.STREAM) return - - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - streamData.data.status = 'end' - if (streamData.parentGroupKey) { - handleUpdateUIGroupState( - { mapKey: streamData.parentGroupKey, type: AIChatQSDataTypeEnum.STREAM_GROUP }, - { mapKey: event_writer_id, type: AIChatQSDataTypeEnum.STREAM }, - setElements, - streamData.taskId, - ) - } else { - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: streamData.id, - type: streamData.type, - chatType: streamData.chatType, - }) - } -} - -/** Type='reference_material' 参考资料, 可独立或追加到别的类型数据中展示 */ -const handleReferenceMaterial: AIMessageHandler = (request) => { - const { res, info, setElements, setContentMap, getContentMap } = request - if (res.Type !== 'reference_material') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ReferenceMaterialPayload - - const chatData = getContentMap(data.event_uuid) - const toolResult = getContentMap(res.CallToolID || '') - if (chatData) { - // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - chatData.reference = (chatData.reference || []).concat([data]) - if (chatData.parentGroupKey) { - handleUpdateUIGroupState( - { mapKey: chatData.parentGroupKey, type: AIChatQSDataTypeEnum.STREAM_GROUP }, - { mapKey: chatData.id, type: chatData.type }, - setElements, - chatData.taskId, - ) - } else if (chatData.type === AIChatQSDataTypeEnum.STREAM) { - if (toolResult && isToolStdoutStream(chatData.data.NodeId)) { - // 特殊情况,更新stdout流对应的工具执行结果卡片UI - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } else { - setElements((old) => { - const list = handleIsGroupDisplayForStream( - { ...res, ContentType: chatData.data.ContentType }, - chatData, - old, - getContentMap, - ) - return list - }) - } - return - } else { - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } - } else if ( - toolResult && - toolResult.type === AIChatQSDataTypeEnum.TOOL_RESULT && - toolResult.data.stream.EventUUID === data.event_uuid - ) { - toolResult.reference = (toolResult.reference || []).concat([data]) - handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } - // else { - // const chatData: AIChatQSData = { - // ...genBaseAIChatData(res), - // id: data.event_uuid, - // chatType: info.chatType, - // type: AIChatQSDataTypeEnum.Reference_Material, - // data: { - // NodeId: res.NodeId, - // NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(res.NodeId), - // }, - // reference: [data], - // taskIndex: generateTaskId({ - // chatType: info.chatType, - // res, - // getCurrentTaskPlanID: request.getCurrentTaskPlanID, - // getContentMap: request.getContentMap, - // }), - // } - // setContentMap(chatData.id, chatData) - // handleUpdateUISingleState(setElements, getContentMap, res.IsSync, { - // mapKey: chatData.id, - // type: chatData.type, - // chatType: chatData.chatType, - // }) - // } -} -// #endregion - -// #region 工具执行结果卡牌 -/** Type='tool_call_start' 工具执行-开始标识(OK) */ -const handleToolCallStart: AIMessageHandler = (request) => { - const { res, info, setContentMap, pushLog } = request - if (res.Type !== 'tool_call_start') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, tool, start_time, start_time_ms } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult: AIToolResult = { - ...cloneDeep(DefaultAIToolResult), - callToolId: call_tool_id, - toolName: tool?.name || '-', - verboseName: tool?.verbose_name_i18n, - toolDescription: tool?.description || '', - startTime: start_time || 0, - startTimeMS: start_time_ms || 0, - } - - setContentMap(call_tool_id, { - ...genBaseAIChatData(res), - id: call_tool_id, - chatType: info.chatType, - type: AIChatQSDataTypeEnum.TOOL_RESULT, - data: toolResult, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - }) -} - -/** Type='tool_call_param' 工具执行-review参数信息 */ -const handleToolCallParam: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - if (res.Type !== 'tool_call_param') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, params } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCallParams - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - - toolResult.data.tool.reviewParams = cloneDeep(params) - - if (toolResult.data.type === 'result') { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } -} - -/** Type='tool_call_watcher' 工具执行中-可操作选项 */ -const handleToolCallWatcher: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - if (res.Type !== 'tool_call_watcher') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, id, selectors } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCallWatcher - - if (!call_tool_id || !id || !selectors || !selectors?.length) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id: ${call_tool_id || '为空'} | id: ${id || '为空'}`), - ) - return - } - - // 先获取工具结果数据,从里面拿到stream的EventUUID - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || !toolResult.data.stream.EventUUID) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - // 通过上面获取到的EventUUID,获取stream数据 - const toolForStreamData = getContentMap(toolResult.data.stream.EventUUID) - if (!toolForStreamData || toolForStreamData.type !== AIChatQSDataTypeEnum.STREAM) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `EventUUID: ${toolResult.data.stream.EventUUID} 的stream数据没有对应的初始化`), - ) - return - } - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - toolForStreamData.data.selectors = { - callToolId: call_tool_id, - InteractiveId: id, - selectors: selectors, - } - - if (toolResult.data.type === 'stream') { - // 历史数据-该类型不出发渲染更新 - if (res.IsSync) return - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } -} - -/** Type='tool_call_log_dir' 工具执行-工作目录路径 */ -const handleToolCallLogDir: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - if (res.Type !== 'tool_call_log_dir') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, dir_path } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCallDirPath - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - if (toolResult.data.tool.dirPath) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(call_tool_id:${call_tool_id}), dir_path已存在,不能重复设置`), - ) - return - } - - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - toolResult.data.tool.dirPath = dir_path || '' - if (toolResult.data.tool.status !== 'default') { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } -} - -/** 工具执行结果的处理逻辑 */ -const handleToolCallResult: ( - request: AIMessageHandlerParams, - status: 'user_cancelled' | 'success' | 'failed', -) => void = (request, status) => { - const { res, getContentMap, pushLog } = request - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, ...rest } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall - - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - - // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - // 设置工具执行的开始时间、结束时间和持续时间等数据 - toolResult.data.type = 'result' - toolResult.data.startTime = rest.start_time || 0 - toolResult.data.startTimeMS = rest.start_time_ms || 0 - toolResult.data.endTime = rest.end_time || 0 - toolResult.data.endTimeMS = rest.end_time_ms || 0 - toolResult.data.durationMS = rest.duration_ms || 0 - toolResult.data.durationSeconds = rest.duration_seconds || 0 - toolResult.data.tool.status = status - /** 触发这个函数说明状态一定不是 processing_params */ - toolResult.data.isProcessingParams = false - // 设置总结内容,没有就设置成获取中,有就使用获取到的内容 - toolResult.data.tool.summary = toolResult.data.tool.summary || DefaultToolResultSummary[status]?.wait || '' - // 设置执行结果错误数据内容(std_xxx_stderr) - const errorResult = ToolResultForStreamError.get(call_tool_id) - if (errorResult && errorResult.status === 'end') { - toolResult.data.tool.execError = errorResult.content - // error数据先出但未存在对应的工具执行结果,工具结果出现后直接使用并删除map中的缓存数据 - ToolResultForStreamError.delete(call_tool_id) - } - - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) -} - -/** Type='tool_call_user_cancel' 工具执行结果-用户取消 */ -const handleToolCallUserCancel: AIMessageHandler = (request) => { - if (request.res.Type !== 'tool_call_user_cancel') return - handleToolCallResult(request, 'user_cancelled') -} - -/** Type='tool_call_done' 工具执行结果-成功 */ -const handleToolCallDone: AIMessageHandler = (request) => { - if (request.res.Type !== 'tool_call_done') return - handleToolCallResult(request, 'success') -} - -/** Type='tool_call_error' 工具执行结果-失败 */ -const handleToolCallError: AIMessageHandler = (request) => { - if (request.res.Type !== 'tool_call_error') return - handleToolCallResult(request, 'failed') -} - -/** Type='tool_call_status' 工具执行结果-状态 */ -const handleToolCallStatus: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - if (res.Type !== 'tool_call_status') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, status } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall - if (status !== 'processing_params') return - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - - const isProcessingParams = status === 'processing_params' - if (toolResult.data.isProcessingParams === isProcessingParams) return - - toolResult.data.isProcessingParams = status === 'processing_params' - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) -} - -/** Type='tool_call_summary' 工具执行结果-总结 */ -const handleToolCallSummary: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - if (res.Type !== 'tool_call_summary') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, summary } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall - - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - - const statusInfo = toolResult.data.tool.status - const summaryContent = !summary || summary === 'null' ? '' : summary - // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - // 设置总结内容,没有就设置成默认的状态展示内容,有就使用获取到的内容 - toolResult.data.tool.summary = - statusInfo === 'user_cancelled' - ? '当前工具调用已被取消,会使用当前输出结果进行后续工作决策' - : summaryContent || DefaultToolResultSummary[toolResult.data.tool.status]?.result || '' - // 设置执行结果错误数据内容(std_xxx_stderr) - const errorResult = ToolResultForStreamError.get(call_tool_id) - if (errorResult && errorResult.status === 'end') { - toolResult.data.tool.execError = errorResult.content - // error数据先出但未存在对应的工具执行结果,工具结果出现后直接使用并删除map中的缓存数据 - ToolResultForStreamError.delete(call_tool_id) - } - if (statusInfo !== 'default') { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } -} - -/** Type='tool_call_reason' 工具调用理由 */ -const handleToolCallReason: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - if (res.Type !== 'tool_call_reason') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const { call_tool_id, reason } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall - - if (!call_tool_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, call_tool_id 为空`)) - return - } - - const toolResult = getContentMap(call_tool_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, - ), - ) - return - } - - toolResult.data.tool.reason = reason || '' - if (toolResult.data.type === 'stream' || toolResult.data.type === 'result') { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) - } -} - -/** 工具执行结果的流量数据计数逻辑 */ -const handleTrafficCount: AIMessageHandler = (request) => { - const { res, getContentMap, pushLog } = request - // 历史数据中的流量计数无效 - // if (res.IsSync) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.HTTPTrafficNotice & AIAgentGrpcApi.RiskTrafficNotice - - if (!data.runtime_id) { - handleErrorGRPCToLog(res.IsSync, pushLog, genErrorLogData(res.Timestamp, `${res.Type}数据, runtime_id 为空`)) - return - } - const toolResult = getContentMap(data.runtime_id) - if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据(call_tool_id:${data.runtime_id}), 没有对应的工具执行结果数据`), - ) - return - } - - let update = false - switch (res.Type) { - case 'yak_httpflow_count': - if (data.http_flow_count !== toolResult.data.httpFlowDataCount) { - toolResult.data.httpFlowDataCount = data.http_flow_count ?? 0 - update = true - } - break - case 'yak_risk_count': - if (data.risk_count !== toolResult.data.riskFlowDataCount) { - toolResult.data.riskFlowDataCount = data.risk_count ?? 0 - update = true - } - break - default: - break - } - if (!update) return - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: toolResult.id, - type: toolResult.type, - chatType: toolResult.chatType, - }) -} - -/** Type='yak_httpflow_count' 新增流量数据 */ -const handleYakHttpFlow: AIMessageHandler = (request) => { - if (request.res.Type !== 'yak_httpflow_count') return - handleTrafficCount(request) -} -/** Type='yak_risk_count' 新增风险数据 */ -const handleYakRisk: AIMessageHandler = (request) => { - if (request.res.Type !== 'yak_risk_count') return - handleTrafficCount(request) -} -// #endregion - -// #region review相关的处理逻辑 -/** 保存那些review_release先出现的历史review数据的id */ -let reviewReleaseID: Record = {} -/** 记录plan_review补充信息的唯一ID */ -let currentPlanReviewId = '' - -/** 自动提交 review continue 给后端,并清理本地缓存数据 */ -const handleAutoSubmitReviewContinue = (request: AIMessageHandlerParams, chatData: AIChatQSData) => { - const { review } = request - const input: AIInputEvent = { - IsInteractiveMessage: true, - InteractiveId: chatData.id, - InteractiveJSONInput: JSON.stringify({ suggestion: 'continue' }), - } - - review?.sendRequest?.(input) - review?.handleSetReview?.(undefined) -} - -/** Type='plan_review_require' plan-review */ -const handlePlanReview: AIMessageHandler = (request) => { - const { res, info, getRequest, setContentMap, pushLog, review } = request - if (res.Type !== 'plan_review_require') return - // 历史数据-grpc流数据在任务规划下无效,不处理 - if (res.IsSync && info.chatType === 'task') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanReviewRequire - if (!data?.id || !data?.plans || !data?.plans?.root_task || !data?.selectors || !data?.selectors?.length) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常: id:${data?.id || '-'}; selectors:${JSON.stringify(data?.selectors || '-')}; plans:${ - !!data?.plans?.root_task ? 'valid' : 'invalid' - } data`, - ), - ) - return - } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - id: data.id, - type: AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE, - data: { ...cloneDeep(data) }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - if (res.IsSync) { - // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 - const target = reviewReleaseID[data.id] - if (target) { - chatData.data.selected = JSON.stringify(target.params) - chatData.data.optionValue = target.params?.suggestion || 'continue' - } - setContentMap(chatData.id, cloneDeep(chatData)) - if (target) { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } - return - } - - // 实时数据处理逻辑 - const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: getRequest }) - if (isAuto) { - chatData.data.selected = JSON.stringify({ suggestion: 'continue' }) - chatData.data.optionValue = 'continue' - } - // 将数据存入hook里的缓存变量中 - review?.handleSetReview && review.handleSetReview(isAuto ? undefined : chatData) - if (info.chatType === 'task') { - // 该类型的实时数据只有任务规划才有 - if (isAuto) { - setContentMap(chatData.id, cloneDeep(chatData)) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - review?.handleReviewDataToUI && review.handleReviewDataToUI(cloneDeep(chatData)) - } else { - currentPlanReviewId = '' - review?.onReview && review.onReview(cloneDeep(chatData)) - } - } -} -/** Type='detached_plan_require' detached plan review */ -const handleDetachedPlanReview: AIMessageHandler = (request) => { - const { res, info, pushLog, review } = request - if (res.Type !== 'detached_plan_require' || res.NodeId !== 'detached-plan') return - - // 历史数据-grpc流数据在任务规划下无效,不处理 - if (res.IsSync) return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.DetachedPlanRequire - if (!data?.id || !data?.plans?.root_task || !data?.selectors?.length) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常: id:${data?.id || '-'}; selectors:${JSON.stringify(data?.selectors || '-')}; plans:${ - !!data?.plans?.root_task ? 'valid' : 'invalid' - } data`, - ), - ) - return - } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - id: data.id, - type: AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE, - data: { ...cloneDeep(data) }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - if (res.IsSync) return - - if (info.chatType === 'reAct') { - review?.onReview && review.onReview(cloneDeep(chatData)) - } -} -/** Type='plan_task_analysis' plan-review的补充信息 */ -const handlePlanReviewAnalysis: AIMessageHandler = (request) => { - const { res, info, getRequest, pushLog, review } = request - // 历史数据-该类型数据无用 - if (res.IsSync) return - if (res.Type !== 'plan_task_analysis') return - // 该类型数据只有任务规划才有 - if (info.chatType !== 'task') return - - const reviewDetail = review?.handleGetReview?.() - if (!reviewDetail || reviewDetail.type !== AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据异常: 未找到对应plan_review_require数据`), - ) - return - } - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanReviewRequireExtra - if ( - !data?.plans_id || - !data?.task_id || - !data?.keywords?.length || - (currentPlanReviewId && currentPlanReviewId !== data.plans_id) - ) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常: plans_id:${data?.plans_id || '-'};task_id:${data?.task_id || '-'};keywords:${JSON.stringify( - data?.keywords || '-', - )}`, - ), - ) - return - } - - if (!currentPlanReviewId) currentPlanReviewId = data.plans_id - const reviewInfo = reviewDetail.data - if (!reviewInfo.taskExtra) reviewInfo.taskExtra = new Map() - reviewInfo.taskExtra.set(data.task_id, data) - - const isAuto = isAutoExecuteReviewContinue({ getFunc: getRequest }) - if (!isAuto && review?.onReviewExtra) review.onReviewExtra(cloneDeep(data)) -} - -/** Type='task_review_require' task-review */ -const handleTaskReview: AIMessageHandler = (request) => { - const { res, info, getRequest, setContentMap, pushLog, review } = request - if (res.Type !== 'task_review_require') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.TaskReviewRequire - if (!data?.id || !data?.selectors || !data?.selectors?.length) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常: id:${data?.id || '-'}; selectors:${JSON.stringify(data?.selectors || '-')}`, - ), - ) - return - } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - id: data.id, - type: AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE, - data: { ...cloneDeep(data) }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - if (res.IsSync) { - // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 - // const target = reviewReleaseID[data.id] - // if (target) { - // chatData.data.selected = JSON.stringify(target.params) - // chatData.data.optionValue = target.params?.suggestion || 'continue' - // } - // setContentMap(chatData.id, cloneDeep(chatData)) - // if (target) { - // handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - // mapKey: chatData.id, - // type: chatData.type, - // chatType: chatData.chatType, - // }) - // } - return - } - - // 实时数据处理逻辑 - const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: getRequest }) - if (isAuto) { - chatData.data.selected = JSON.stringify({ suggestion: 'continue' }) - chatData.data.optionValue = 'continue' - } - if (info.chatType === 'task') { - if (isAuto) { - // setContentMap(chatData.id, cloneDeep(chatData)) - // handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - // mapKey: chatData.id, - // type: chatData.type, - // chatType: chatData.chatType, - // }) - } else { - handleAutoSubmitReviewContinue(request, chatData) - } - } else if (info.chatType === 'reAct') { - if (isAuto) return - // 将数据存入hook里的缓存变量中 - review?.handleSetReview && review.handleSetReview(isAuto ? undefined : chatData) - setContentMap(chatData.id, cloneDeep(chatData)) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } -} - -/** Type='tool_use_review_require' tool-review */ -const handleToolReview: AIMessageHandler = (request) => { - const { res, info, getRequest, setContentMap, pushLog, review } = request - if (res.Type !== 'tool_use_review_require') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ToolUseReviewRequire - if (!data?.id || !data?.selectors || !data?.selectors?.length) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常: id:${data?.id || '-'}; selectors:${JSON.stringify(data?.selectors || '-')}`, - ), - ) - return - } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - id: data.id, - type: AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE, - data: { ...cloneDeep(data) }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - if (res.IsSync) { - // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 - // const target = reviewReleaseID[data.id] - // if (target) { - // chatData.data.selected = JSON.stringify(target.params) - // chatData.data.optionValue = target.params?.suggestion || 'continue' - // } - // setContentMap(chatData.id, cloneDeep(chatData)) - // if (target) { - // handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - // mapKey: chatData.id, - // type: chatData.type, - // chatType: chatData.chatType, - // }) - // } - return - } - - // 实时数据处理逻辑 - const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: getRequest }) - if (isAuto) { - chatData.data.selected = JSON.stringify({ suggestion: 'continue' }) - chatData.data.optionValue = 'continue' - } - if (info.chatType === 'task') { - if (isAuto) { - // setContentMap(chatData.id, cloneDeep(chatData)) - // handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - // mapKey: chatData.id, - // type: chatData.type, - // chatType: chatData.chatType, - // }) - } else { - handleAutoSubmitReviewContinue(request, chatData) - } - } else if (info.chatType === 'reAct') { - if (isAuto) return - // 将数据存入hook里的缓存变量中 - review?.handleSetReview && review.handleSetReview(isAuto ? undefined : chatData) - setContentMap(chatData.id, cloneDeep(chatData)) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } -} - -/** Type='require_user_interactive' AI人机交互 */ -const handleUserInteractive: AIMessageHandler = (request) => { - const { res, info, setContentMap, pushLog, review } = request - if (res.Type !== 'require_user_interactive') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIReviewRequire - if (!data?.id) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据异常: id:${data?.id || '-'}`), - ) - return - } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - id: data.id, - type: AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE, - data: cloneDeep(data), - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - if (res.IsSync) { - // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 - const target = reviewReleaseID[data.id] - if (target) { - chatData.data.selected = JSON.stringify(target.params) - chatData.data.optionValue = target.params?.suggestion || 'continue' - } - setContentMap(chatData.id, cloneDeep(chatData)) - if (target) { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } - return - } - - // 将数据存入hook里的缓存变量中 - review?.handleSetReview && review.handleSetReview(chatData) - if (info.chatType === 'task') { - review?.onReview && review.onReview(cloneDeep(chatData)) - } else if (info.chatType === 'reAct') { - setContentMap(chatData.id, cloneDeep(chatData)) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } -} - -/** Type='exec_aiforge_review_require' 智能体review */ -const handleAIForgeReviewRequire: AIMessageHandler = (request) => { - const { res, info, getRequest, setContentMap, pushLog, review } = request - // 任务规划不存在该类型数据 - if (info.chatType === 'task') return - if (res.Type !== 'exec_aiforge_review_require') return - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ExecForgeReview - if (!data?.id || !data?.selectors || !data?.selectors?.length) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常: id:${data?.id || '-'}; selectors:${JSON.stringify(data?.selectors || '-')}`, - ), - ) - return - } - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: info.chatType, - id: data.id, - type: AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE, - data: { ...cloneDeep(data) }, - taskId: generateTaskId({ - chatType: info.chatType, - res, - getCurrentTaskPlanID: request.getCurrentTaskPlanID, - getTaskId: request.getTaskId, - getContentMap: request.getContentMap, - }), - } - if (res.IsSync) { - // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 - const target = reviewReleaseID[data.id] - if (target) { - chatData.data.selected = JSON.stringify(target.params) - chatData.data.optionValue = target.params?.suggestion || 'continue' - } - setContentMap(chatData.id, cloneDeep(chatData)) - if (target) { - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } - return - } - - // 实时数据处理逻辑 - const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: getRequest }) - if (isAuto) { - chatData.data.selected = JSON.stringify({ suggestion: 'continue' }) - chatData.data.optionValue = 'continue' - } - // 将数据存入hook里的缓存变量中 - review?.handleSetReview && review.handleSetReview(isAuto ? undefined : chatData) - setContentMap(chatData.id, cloneDeep(chatData)) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) -} - -/** AI对review信息的评分和自动化操作 */ -const handleAIReviewJudgement: AIMessageHandler = (request) => { - const { res, info, getRequest, getContentMap, pushLog, review } = request - // 历史数据不处理该类型数据 - if (res.IsSync) return - - const reviewDetail = review?.handleGetReview?.() - if (!reviewDetail) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据异常: 没有对应的review数据`), - ) - return - } - - const ipcContent = Uint8ArrayToString(res.Content) || '' - const score = JSON.parse(ipcContent) as AIAgentGrpcApi.AIReviewJudgement - if (!score?.interactive_id) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据异常: interactive_id:${score?.interactive_id || '-'}`), - ) - return - } - - const { interactive_id } = score - score.levelLabel = AIReviewJudgeLevelMap[score?.level || '']?.label || undefined - - if (info.chatType === 'task') { - if ( - reviewDetail.type !== AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE || - reviewDetail.data.id !== score.interactive_id - ) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据异常(interactive_id:${score?.interactive_id || '-'})未找到对应review`, - ), - ) - return - } - - if ( - !reviewDetail.data.aiReview || - (reviewDetail.data.aiReview && typeof reviewDetail.data.aiReview.seconds === 'undefined') - ) { - // aiReview 没有或者 aiReview 的 seconds 为空时可以赋值 - reviewDetail.data.aiReview = cloneDeep(score) - } - const isAuto = isAutoExecuteReviewContinue({ getFunc: getRequest }) - if (!isAuto && review?.onReview) review.onReview(cloneDeep(reviewDetail)) - } else if (info.chatType === 'reAct') { - if ( - reviewDetail.type === AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE || - reviewDetail.type === AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE - ) { - if ( - !reviewDetail.data.aiReview || - (reviewDetail.data.aiReview && typeof reviewDetail.data.aiReview.seconds === 'undefined') - ) { - reviewDetail.data.aiReview = cloneDeep(score) - } - } - - const chatData = getContentMap(interactive_id) - if ( - chatData && - (chatData.type === AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE || - chatData.type === AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE) - ) { - if ( - !chatData.data.aiReview || - (chatData.data.aiReview && typeof chatData.data.aiReview.seconds === 'undefined') - ) { - // aiReview 没有或者 aiReview 的 seconds 为空时可以赋值 - chatData.data.aiReview = cloneDeep(score) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - } - } else { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(interactive_id:${score?.interactive_id || '-'})未找到对应review`, - ), - ) - } - } -} -/** Type='ai_review_start' AI评分-开始 */ -const handleAIReviewStart: AIMessageHandler = (request) => { - if (request.res.Type !== 'ai_review_start') return - handleAIReviewJudgement(request) -} -/** Type='ai_review_countdown' AI评分-倒计时 */ -const handleAIReviewCountdown: AIMessageHandler = (request) => { - if (request.res.Type !== 'ai_review_countdown') return - handleAIReviewJudgement(request) -} -/** Type='ai_review_end' AI评分-结束 */ -const handleAIReviewEnd: AIMessageHandler = (request) => { - if (request.res.Type !== 'ai_review_end') return - handleAIReviewJudgement(request) -} - -/** Type='review_release' review释放通知 */ -const handleReviewRelease: AIMessageHandler = (request) => { - const { res, info, getRequest, setContentMap, getContentMap, pushLog, review } = request - if (res.Type !== 'review_release') return - const ipcContent = Uint8ArrayToString(res.Content) || '' - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ReviewRelease - if (!data?.id) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData(res.Timestamp, `${res.Type}数据异常: id:${data?.id || '-'}`), - ) - return - } - - if (res.IsSync) { - const reviewDetail = getContentMap(data.id) - if (!reviewDetail) { - reviewReleaseID[data.id] = data - return - } - switch (reviewDetail.type) { - case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: - case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: - case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: - case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: - case AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE: - case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: - reviewDetail.data.selected = JSON.stringify(data.params) - reviewDetail.data.optionValue = data.params?.suggestion || 'continue' - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: reviewDetail.id, - type: reviewDetail.type, - chatType: reviewDetail.chatType, - }) - return - } - } else { - const reviewDetail = review?.handleGetReview?.() - if (!reviewDetail) return - - const reviewInfo = cloneDeep(reviewDetail?.data) as AIReviewType - if (reviewInfo?.id !== data.id) { - handleErrorGRPCToLog( - res.IsSync, - pushLog, - genErrorLogData( - res.Timestamp, - `${res.Type}数据(id:${data?.id || '-'})和当前展示review数据(id:${reviewInfo?.id || '-'})不匹配`, - ), - ) - return - } - - reviewInfo.selected = JSON.stringify({ suggestion: 'continue' }) - reviewInfo.optionValue = 'continue' - const chatData: AIChatQSData = { - ...reviewDetail, - data: reviewInfo as any, - } - review?.handleSetReview && review.handleSetReview(undefined) - if (info.chatType === 'task') { - currentPlanReviewId = '' - // review自动释放后,还需进行的额外逻辑处理 - review?.handleReviewDataToUI && review.handleReviewDataToUI(cloneDeep(chatData)) - } - setContentMap(chatData.id, cloneDeep(chatData)) - handleUpdateUISingleState(request.setElements, request.getContentMap, res.IsSync, { - mapKey: chatData.id, - type: chatData.type, - chatType: chatData.chatType, - }) - - const isAuto = isAutoExecuteReviewContinue({ type: chatData.type, getFunc: getRequest }) - if (!isAuto) review?.onReviewRelease && review.onReviewRelease(data.id) - } -} -// #endregion - -/** 切换session会话后的重置逻辑 */ -export const handleResetForNewSession: () => void = () => { - ToolResultForStreamError.clear() - reviewReleaseID = {} - currentPlanReviewId = '' -} - -/** - * grpc流数据的各种类型处理逻辑集合 - * 该逻辑集合里的方法处理,没有使用try-catch拦截,因为在hook层进行了同一try-catch拦截 - * 注意!别的地方单独使用时,请自行加入try-catch拦截错误 - */ -export const grpcAIMessageHandlers: Record = { - thought: handleThought, - result: handleResult, - fail_react_task: handleFailReactTask, - tool_call_decision: handleToolCallDecision, - fail_plan_and_execution: handleFailPlanAndExecution, - tool_call_start: handleToolCallStart, - tool_call_param: handleToolCallParam, - tool_call_watcher: handleToolCallWatcher, - tool_call_log_dir: handleToolCallLogDir, - tool_call_user_cancel: handleToolCallUserCancel, - tool_call_done: handleToolCallDone, - tool_call_error: handleToolCallError, - tool_call_status: handleToolCallStatus, - tool_call_summary: handleToolCallSummary, - tool_call_reason: handleToolCallReason, - yak_httpflow_count: handleYakHttpFlow, - yak_risk_count: handleYakRisk, - stream_start: handleStreamStart, - stream: handleStream, - 'stream-finished': handleStreamFinished, - reference_material: handleReferenceMaterial, - plan_review_require: handlePlanReview, - detached_plan_require: handleDetachedPlanReview, - plan_task_analysis: handlePlanReviewAnalysis, - task_review_require: handleTaskReview, - tool_use_review_require: handleToolReview, - require_user_interactive: handleUserInteractive, - exec_aiforge_review_require: handleAIForgeReviewRequire, - ai_review_start: handleAIReviewStart, - ai_review_countdown: handleAIReviewCountdown, - ai_review_end: handleAIReviewEnd, - review_release: handleReviewRelease, - react_task_dequeue: handleReactTaskDequeue, - api_request_failed: handleApiRequestFailed, - http_flow_fuzz_status: handleHttpFlowFuzzStatus, - 'report-finish': handleReportFinish, - current_task_todo_list_update: handleCurrentTaskTodoListUpdate, - capability_inventory: handleCapabilityInventory, - perception: handlePerception, - session_snapshot: handleSessionSnapshot, -} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcApi.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcApi.ts index 34ff6034d7..4c6d7b494b 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcApi.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcApi.ts @@ -505,6 +505,8 @@ export declare namespace AIAgentGrpcApi { export interface PlanTask { /** 任务id */ task_id: string + /** 审阅树扁平化后的层级(仅前端使用) */ + level?: number /** 任务名 */ name: string /** 正文 */ @@ -878,7 +880,7 @@ export declare namespace AIAgentGrpcApi { /** 问题状态变化消息 */ export interface ReactTaskChanged { react_task_id: string - react_task_now_status: string + react_task_now_status: AITaskStatusType react_task_old_status: string } diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiOther.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiOther.ts new file mode 100644 index 0000000000..996bbf99fd --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiOther.ts @@ -0,0 +1,524 @@ +import type { AIMessageHandler } from '../type' +import { AIInputEventSyncTypeEnum, AITaskStatus, AITaskStatusType, type AIAgentGrpcApi } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { genBaseAIChatData, genExecTasks, handleTaskPlanEnd, trySettleTaskPlanEnd } from '../utils' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '../aiRender' +import cloneDeep from 'lodash/cloneDeep' +import { DefaultCurrentExecTaskTree, DefaultPlanItemDetailsData, DefaultTaskPlanEndGate } from '../defaultConstant' +import has from 'lodash/has' +import { + persistIndependentItem, + persistToolResultIfTerminal, + deletePersistedContent, +} from '../persist/contentPersistHelper' +import { ensureToolResultOnUI } from './aiToolResult' +import type { AIAgentChatMetaData } from '@/pages/ai-agent/type/aiChat' + +const handleHttpFuzzRequestChange: AIMessageHandler = (request) => { + const { res, store, rawData } = request + if (res.Type !== 'http_fuzz_request_change') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const httpFuzzRequest = JSON.parse(ipcContent) as AIAgentGrpcApi.HttpFuzzRequestChange + rawData.httpFuzzRequest = httpFuzzRequest + store.getState().updateStateCount('httpFuzzRequestUpdate') +} + +const handleHttpFlowFuzzStatus: AIMessageHandler = (request) => { + const { res, store, rawData } = request + if (res.Type !== 'http_flow_fuzz_status') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const httpFlowFuzzStatus = JSON.parse(ipcContent) as AIAgentGrpcApi.GetHttpFlowFuzzStatus + rawData.httpFlowFuzzStatus = httpFlowFuzzStatus + store.getState().updateStateCount('httpFlowFuzzStatusUpdate') +} + +const handleSessionTitle: AIMessageHandler = (request) => { + const { res, store, rawData } = request + if (res.Type !== 'structured' || res.NodeId !== 'session_title') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const nameInfo = JSON.parse(ipcContent) as { title: string } + if (!nameInfo || !nameInfo.title) { + request.pushLog({ level: 'error', message: `${res.NodeId}数据错误: ${ipcContent}` }) + return + } + rawData.sessionTitle = nameInfo.title + store.getState().updateStateCount('sessionTitleUpdate') +} + +const handleStartPlanAndExecution: AIMessageHandler = (requestInfo) => { + const { res, store, rawData, meta, sendRequest } = requestInfo + if (res.Type !== 'start_plan_and_execution') return + if (res.IsSync) return + + // 清空任务规划的todo-list数据 + rawData.taskChat.planDetailsMap.clear() + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const startInfo = JSON.parse(ipcContent) as AIAgentGrpcApi.AIStartPlanAndExecution + if (!startInfo.coordinator_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据, coordinator_id 为空` }) + return + } + + // 开始任务规划后,刷新历史任务树 + sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS }) + /** 获取最新任务树状态 */ + sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN }) + // 重置任务规划结束的中间状态 + meta.taskPlanEndGate = cloneDeep(DefaultTaskPlanEndGate) + const taskStatus = { + taskID: startInfo['re-act_task'], + status: AITaskStatus.inProgress, + // 取消任务规划需要的数据id + coordinatorId: startInfo.coordinator_id, + plan: '加载中...', + task: '加载中...', + } + // 初始化任务规划运行态数据和部分UI状态 + store.getState().updateState({ + taskStatus, + showPlanList: true, + cancelTaskLoading: false, + }) + // 重置当前任务树详情 + store.getState().updatePlanTree(cloneDeep(DefaultCurrentExecTaskTree)) + // 生成任务规划里的默认任务聚合组 + const taskID = `${taskStatus.taskID}-default` + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + id: taskID, + chatType: 'task', + type: AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP, + } as AIChatQSData + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: 'task', + node: { token: chatData.id, kind: 'task', type: chatData.type }, + }) +} +const handleEndPlanAndExecution: AIMessageHandler = (requestInfo) => { + const { res, store, rawData, meta } = requestInfo + if (res.Type !== 'end_plan_and_execution') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const startInfo = JSON.parse(ipcContent) as AIAgentGrpcApi.AIStartPlanAndExecution + if (!startInfo.coordinator_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据, coordinator_id 为空` }) + return + } + if (startInfo.coordinator_id === store.getState().taskStatus.coordinatorId) { + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: 'task', + type: AIChatQSDataTypeEnum.END_PLAN_AND_EXECUTION, + data: '', + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: 'task', + node: { token: chatData.id, kind: 'item', type: chatData.type }, + }) + handleTaskPlanEnd({ store, rawData, meta, request: requestInfo.request, sessionId: requestInfo.sessionId }) + } +} + +const handleMemoryContext: AIMessageHandler = (request) => { + const { res, chatType, store, rawData, meta } = request + if (res.Type !== 'memory_context') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const lists = JSON.parse(ipcContent) as AIAgentGrpcApi.MemoryEntryList + if (chatType === 'reAct') { + meta.casualMemoryList = lists + } else { + meta.taskMemoryList = lists + } + try { + const newMemoryEntryList: AIAgentGrpcApi.MemoryEntryList = { + memories: [...(meta.taskMemoryList.memories || []), ...(meta.casualMemoryList.memories || [])], + memory_pool_limit: + Number(meta.taskMemoryList.memory_pool_limit) + Number(meta.casualMemoryList.memory_pool_limit), + memory_session_id: meta.casualMemoryList.memory_session_id, + total_memories: Number(meta.taskMemoryList.total_memories) + Number(meta.casualMemoryList.total_memories), + total_size: Number(meta.taskMemoryList.total_size) + Number(meta.casualMemoryList.total_size), + score_overview: { + A_total: + Number(meta.taskMemoryList.score_overview.A_total) + Number(meta.casualMemoryList.score_overview.A_total), + C_total: + Number(meta.taskMemoryList.score_overview.C_total) + Number(meta.casualMemoryList.score_overview.C_total), + E_total: + Number(meta.taskMemoryList.score_overview.E_total) + Number(meta.casualMemoryList.score_overview.E_total), + + O_total: + Number(meta.taskMemoryList.score_overview.O_total) + Number(meta.casualMemoryList.score_overview.O_total), + P_total: + Number(meta.taskMemoryList.score_overview.P_total) + Number(meta.casualMemoryList.score_overview.P_total), + R_total: + Number(meta.taskMemoryList.score_overview.R_total) + Number(meta.casualMemoryList.score_overview.R_total), + T_total: + Number(meta.taskMemoryList.score_overview.T_total) + Number(meta.casualMemoryList.score_overview.T_total), + }, + } + rawData.memoryList = newMemoryEntryList + store.getState().updateStateCount('memoryListUpdate') + } catch (error) {} +} + +const handleFileSystemPin: AIMessageHandler = (request) => { + const { res, store } = request + if (res.Type !== 'filesystem_pin_directory' && res.Type !== 'filesystem_pin_filename') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { path } = JSON.parse(ipcContent) as AIAgentGrpcApi.FileSystemPin + store.getState().updateFolders({ path, isFolder: res.Type === 'filesystem_pin_directory' }) +} + +const handleTimelineItem: AIMessageHandler = (request) => { + const { res, store, meta } = request + if (res.Type !== 'structured' || res.NodeId !== 'timeline_item') return + if (res.IsSync) return + // 自由对话的成组子agent任务的timeline-item不展示 + if (meta.casualSubTaskIDs.has(res.TaskId)) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const timelineItem = JSON.parse(ipcContent) as AIAgentGrpcApi.TimelineItem + store.getState().updateTimeLineItem(timelineItem) +} + +const handleReactTaskEnqueue: AIMessageHandler = (request) => { + const { res, chatType, sendRequest } = request + if (res.Type !== 'structured' || res.NodeId !== 'react_task_enqueue') return + if (res.IsSync || chatType === 'task') return + + sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO }) +} +const handleReactTaskDequeue: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, sendRequest } = requestInfo + if (res.Type !== 'structured' || res.NodeId !== 'react_task_dequeue') return + if (chatType === 'task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.QuestionQueueStatusChange + + // 实时数据里,记录用户问题的状态和专注模式信息 + if (!res.IsSync) { + sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO }) + rawData.casualChat.planDetails = cloneDeep(DefaultPlanItemDetailsData) + store.getState().updateCasualTodoList() + store.getState().updateState({ + currentCasualTaskID: res.TaskId || data.react_task_id, + focusMode: data.focus_mode ? data.focus_mode : '', + casualLoading: true, + }) + } + + // 用户问题的UI回显 + const chatData: AIChatQSData = { + id: res.TaskId || data.react_task_id, + chatType: 'reAct', + type: AIChatQSDataTypeEnum.QUESTION, + Timestamp: res.Timestamp, + data: data.react_task_input || '', + AIService: '', + AIModelName: '', + // showQS为了UI渲染方便,重新构建的字段 + extraValue: { showQS: data.react_task_input || '' }, + } + rawData.contents.set(chatData.id, chatData) + + persistIndependentItem(requestInfo.sessionId, chatData) + if (data.react_task_user_input_uuid) { + const qsDetail = store.getState().items[data.react_task_user_input_uuid] + if (qsDetail && qsDetail.type === AIChatQSDataTypeEnum.QUESTION) { + rawData.contents.delete(data.react_task_user_input_uuid) + // 清掉前端临时 uuid 对应的 IDB 孤儿行 + deletePersistedContent(requestInfo.sessionId, data.react_task_user_input_uuid) + store.getState().replaceItemToken(data.react_task_user_input_uuid, chatData.id) + return + } + } + + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleNotify: AIMessageHandler = (request) => { + const { res, store, meta } = request + if (res.Type !== 'notify') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.Notify + if (meta.notifyMessageTimer !== null) { + clearTimeout(meta.notifyMessageTimer) + meta.notifyMessageTimer = null + } + const { type, content } = data + store.getState().updateState({ notifyMessage: { type, content, label: res.NodeIdVerbose } }) + + let durationMs = 0 + if (typeof data.duration_ms === 'number' && !Number.isNaN(data.duration_ms) && data.duration_ms > 0) { + durationMs = data.duration_ms + } else if ( + typeof data.duration_seconds === 'number' && + !Number.isNaN(data.duration_seconds) && + data.duration_seconds > 0 + ) { + durationMs = data.duration_seconds * 1000 + } else if (typeof data.duration === 'number' && !Number.isNaN(data.duration) && data.duration > 0) { + durationMs = data.duration * 1000 + } + if (durationMs > 0) { + meta.notifyMessageTimer = setTimeout(() => { + meta.notifyMessageTimer = null + store.getState().updateState({ notifyMessage: null }) + }, durationMs) + } +} + +const handlePlanExecTasks: AIMessageHandler = (request) => { + const { res, store } = request + if (res.Type !== 'structured' || res.NodeId !== 'plan_exec_tasks') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const list = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanHistoryList + try { + const arr = cloneDeep(list.records) + if (!arr || arr.length === 0) { + store.getState().updateState({ planHistoryList: { ...list } }) + return + } + const newArr = arr + .map((item) => { + // 因为后端给过来的task_progress是一个json的string类型数据 + item.task_progress = JSON.parse(item.task_progress as unknown as string) as AIAgentGrpcApi.PlanHistoryProgress + // 因为后端给过来的task_tree是一个json的string类型数据,所以需要转换成树形结构的数据,供UI展示使用 + const tree = JSON.parse(item.task_tree as unknown as string) as AIAgentGrpcApi.PlanTask + // 记录任务树根节点的名字,供UI展示使用 + item.root_task_name = tree.name + item.task_tree = genExecTasks(tree) + return item + }) + .filter((item) => item.task_progress.phase !== 'Completed') + store.getState().updateState({ planHistoryList: { ...list, records: newArr } }) + } catch (error) {} +} + +const handleQueueInfo: AIMessageHandler = (request) => { + const { res, chatType, store, meta } = request + if (res.Type !== 'structured' || res.NodeId !== 'queue_info') return + if (chatType === 'task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { tasks, total_tasks } = JSON.parse(ipcContent) as AIAgentGrpcApi.QuestionQueues + // 记录最新问题队列的数量,4次为空,则关闭轮询器 + if (tasks.length === 0) meta.queuePollingEmptyCount += 1 + else meta.queuePollingEmptyCount = 0 + if (meta.queuePollingEmptyCount > 3 && meta.queuePollingTimer) { + clearInterval(meta.queuePollingTimer) + meta.queuePollingTimer = null + } + + store.getState().updateState({ + questionQueue: { + total: total_tasks, + data: tasks ?? [], + }, + }) +} + +const handleReactTaskStatusChanged: AIMessageHandler = (request) => { + const { res, chatType, store, rawData, meta } = request + if (res.Type !== 'structured' || res.NodeId !== 'react_task_status_changed') return + if (res.IsSync || chatType === 'task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const info = JSON.parse(ipcContent) as AIAgentGrpcApi.ReactTaskChanged + + const react_task_id = info.react_task_id + if (['completed', 'aborted', 'skipped'].includes(info.react_task_now_status)) { + if (store.getState().currentCasualTaskID && store.getState().currentCasualTaskID === react_task_id) { + store.getState().updateState({ focusMode: '', cancelCasualLoading: false, casualLoading: false }) + } + if (store.getState().taskStatus.taskID === react_task_id) { + // 只推进中间状态;与 end 齐套后才落到 taskStatus.status + meta.taskPlanEndGate.pendingStatus = info.react_task_now_status as NonNullable< + AIAgentChatMetaData['taskPlanEndGate']['pendingStatus'] + > + trySettleTaskPlanEnd(store, meta) + } + } + // 更新自由对话-执行任务组的状态 + if (!store.getState().currentCasualTaskID || !react_task_id) return + const taskKey = `${store.getState().currentCasualTaskID}-${react_task_id}` + const taskDetail = rawData.contents.get(taskKey) + if (!taskDetail || taskDetail.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) return + taskDetail.data.status = info.react_task_now_status as AITaskStatusType + store.getState().incrementNodeVersion(taskDetail.id, 'task') + persistIndependentItem(request.sessionId, taskDetail) +} + +const handleTrafficCount: AIMessageHandler = (request) => { + const { res, chatType, store, rawData } = request + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.HTTPTrafficNotice & AIAgentGrpcApi.RiskTrafficNotice + if (!data.runtime_id) { + request.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + // 更新流量表和风险表数据, 历史数据不处理 + if (!res.IsSync) { + if (res.Type === 'yak_httpflow_count' && !rawData.httpRunTimeIDs.includes(data.runtime_id)) { + rawData.httpRunTimeIDs.push(data.runtime_id) + store.getState().updateHttpData() + } else if (res.Type === 'yak_risk_count' && !rawData.riskRunTimeIDs.includes(data.runtime_id)) { + rawData.riskRunTimeIDs.push(data.runtime_id) + store.getState().updateRiskData() + } + } + + // 更新工具执行结果卡片里的流量和风险数量 + const toolResult = rawData.contents.get(data.runtime_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || toolResult.chatType !== chatType) { + request.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${data.runtime_id})工具结果卡片数据不存在`, + }) + return + } + let update = false + switch (res.Type) { + case 'yak_httpflow_count': + if (data.http_flow_count !== toolResult.data.httpFlowDataCount) { + toolResult.data.httpFlowDataCount = data.http_flow_count ?? 0 + update = true + } + break + case 'yak_risk_count': + if (data.risk_count !== toolResult.data.riskFlowDataCount) { + toolResult.data.riskFlowDataCount = data.risk_count ?? 0 + update = true + } + break + default: + break + } + if (!update) return + ensureToolResultOnUI(request, toolResult) + // 流量/风险计数:仅工具已终态时追加写正文 + persistToolResultIfTerminal(request.sessionId, toolResult) +} + +const handlePlan: AIMessageHandler = (requestInfo) => { + const { res, chatType, store } = requestInfo + if (res.Type !== 'plan' || chatType === 'reAct') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const tasks = JSON.parse(ipcContent) as { root_task: AIAgentGrpcApi.PlanTask } + if (has(tasks, 'root_task')) { + const plans = genExecTasks(tasks.root_task) + store.getState().updatePlanTree({ task_tree: cloneDeep(plans), root_task_name: tasks.root_task.name }) + } else { + store.getState().updatePlanTree(cloneDeep(DefaultCurrentExecTaskTree)) + } +} + +const handleYaklangCodeChange: AIMessageHandler = (requestInfo) => { + const { res, rawData, store } = requestInfo + if (res.Type !== 'yaklang_code_change') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const yaklangCodeChange = JSON.parse(ipcContent) as AIAgentGrpcApi.YaklangCodeChange + rawData.yaklangCodeChange = yaklangCodeChange + store.getState().updateStateCount('yaklangCodeChangeUpdate') +} + +const handleReactTaskCreated: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'structured' || res.NodeId !== 'react_task_created') return + if (res.IsSync || chatType === 'task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const info = JSON.parse(ipcContent) as AIAgentGrpcApi.CasualCreated + + // 非聚合任务不处理 + if (!info.react_task_is_sub_agent) return + + if (!store.getState().currentCasualTaskID || !info.react_task_id) return + const taskKey = `${store.getState().currentCasualTaskID}-${info.react_task_id}` + meta.casualSubTaskIDs.add(info.react_task_id) + + const existing = rawData.contents.get(taskKey) + if (existing) return + + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + id: taskKey, + chatType: 'reAct', + type: AIChatQSDataTypeEnum.TASK_NODE_GROUP, + data: { + taskId: info.react_task_id, + taskName: info.react_task_name || info.react_user_input || info.react_task_id, + goal: info.react_user_input || '', + status: info.react_task_status, + }, + } as AIChatQSData + + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + // planDetailsMap / 子任务收集仍按后端子任务 ID(res.TaskId)索引 + if (rawData.casualChat.planDetailsMap.has(info.react_task_id)) { + rawData.casualChat.planDetailsMap.set(info.react_task_id, cloneDeep(DefaultPlanItemDetailsData)) + } + store.getState().dispatchStreamingNode({ + chatType: 'reAct', + node: { + token: chatData.id, + kind: 'task', + type: chatData.type, + }, + }) +} + +export const aiOtherDataHandlers = { + http_fuzz_request_change: handleHttpFuzzRequestChange, + http_flow_fuzz_status: handleHttpFlowFuzzStatus, + session_title: handleSessionTitle, + start_plan_and_execution: handleStartPlanAndExecution, + end_plan_and_execution: handleEndPlanAndExecution, + memory_context: handleMemoryContext, + filesystem_pin_directory: handleFileSystemPin, + filesystem_pin_filename: handleFileSystemPin, + timeline_item: handleTimelineItem, + react_task_enqueue: handleReactTaskEnqueue, + react_task_dequeue: handleReactTaskDequeue, + notify: handleNotify, + plan_exec_tasks: handlePlanExecTasks, + queue_info: handleQueueInfo, + react_task_status_changed: handleReactTaskStatusChanged, + yak_httpflow_count: handleTrafficCount, + yak_risk_count: handleTrafficCount, + plan: handlePlan, + yaklang_code_change: handleYaklangCodeChange, + react_task_created: handleReactTaskCreated, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiPerf.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiPerf.ts new file mode 100644 index 0000000000..f2885ad987 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiPerf.ts @@ -0,0 +1,220 @@ +import type { AIMessageHandler } from '../type' +import type { AIAgentGrpcApi } from '../grpcApi' +import type { AIContextStatsDetail } from '@/pages/ai-agent/type/aiChat' +import { Uint8ArrayToString } from '@/utils/str' + +const handleConsumption: AIMessageHandler = (request) => { + const { res, rawData } = request + if (res.Type !== 'consumption') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.Consumption + rawData.aiPerfData.consumption.input_consumption = data.input_consumption + rawData.aiPerfData.consumption.output_consumption = data.output_consumption + rawData.aiPerfData.consumption.cache_hit_token = data.cache_hit_token + rawData.aiPerfData.consumption.tier_consumption = { ...data.tier_consumption } +} + +const handlePressure: AIMessageHandler = (request) => { + const { res, rawData } = request + if (res.Type !== 'pressure') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.Pressure + const target = rawData.aiPerfData.pressure[data.model_tier] + if (target) { + target.push({ ...data, timestamp: Number(res.Timestamp) || 0 }) + } else { + rawData.aiPerfData.pressure[data.model_tier] = [{ ...data, timestamp: Number(res.Timestamp) || 0 }] + } +} + +const handleAIFirstByteCostMS: AIMessageHandler = (request) => { + const { res, rawData } = request + if (res.Type !== 'ai_first_byte_cost_ms') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIFirstCostMS + const target = rawData.aiPerfData.firstCost[data.model_tier] + if (target) { + target.push({ ...data, timestamp: Number(res.Timestamp) || 0 }) + } else { + rawData.aiPerfData.firstCost[data.model_tier] = [{ ...data, timestamp: Number(res.Timestamp) || 0 }] + } +} + +const handleAITotalCostMS: AIMessageHandler = (request) => { + const { res, rawData } = request + if (res.Type !== 'ai_total_cost_ms') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AITotalCostMS + const target = rawData.aiPerfData.totalCost[data.model_tier] + if (target) { + target.push({ ...data, timestamp: Number(res.Timestamp) || 0 }) + } else { + rawData.aiPerfData.totalCost[data.model_tier] = [{ ...data, timestamp: Number(res.Timestamp) || 0 }] + } +} + +const CONTEXT_STATS_SERIES_MAX = 50 +/** + * 首次建立 role_order 时,若存在这些 role_name 则按此顺序排在前面。 + * + * P1.1 之后 aireact 已经把老的 semi_dynamic 段彻底拆成 semi_dynamic_1 + * (Skills Context + CacheToolCall) 与 semi_dynamic_2 (Persistent + + * Schema + OutputExample) 两个独立 role;老的 semi_dynamic 已被移除, + * 不再保留兜底位。 + * + * 字节统计图 "从下往上" 的堆叠物理顺序固定为: + * high_static -> frozen_block -> semi_dynamic_1 -> semi_dynamic_2 -> + * timelineOpen -> dynamic + */ +const CONTEXT_STATS_ROLE_NAME_ORDER = [ + 'high_static', + 'frozen_block', + 'semi_dynamic_1', + 'semi_dynamic_2', + 'timelineOpen', + 'dynamic', +] as const +const trimContextStatsSeries = (d: AIContextStatsDetail['data']) => { + if (!Array.isArray(d.total_prompt_bytes)) d.total_prompt_bytes = [] + if (!Array.isArray(d.total_prompt_tokens)) d.total_prompt_tokens = [] + while (d.times.length > CONTEXT_STATS_SERIES_MAX) { + d.times.shift() + d.total_prompt_bytes.shift() + d.total_prompt_tokens.shift() + for (const name of d.role_order) { + d.role_series[name]?.shift() + d.role_tokens[name]?.shift() + } + } +} + +/** 递归上下文成分里的summary并归类到map对象后消除summary字段内容 */ +const handleSummarySectionsSummary = ( + sections: AIAgentGrpcApi.AIContextSections[], + summaryMap: Map, +) => { + for (let item of sections) { + summaryMap.set(item.key, item.summary || '') + delete item.summary + if (item.children) handleSummarySectionsSummary(item.children, summaryMap) + } +} + +const handlePromptProfile: AIMessageHandler = (request) => { + const { res, rawData } = request + if (res.Type !== 'prompt_profile') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ContextStatsSections + + // 上下文字节统计 + if (rawData.aiPerfData.contextStats) { + const d = rawData.aiPerfData.contextStats.data + const incomingRoles = Array.isArray(data.role_stats) ? data.role_stats : [] + const ts = Number(res.Timestamp) || 0 + + if (incomingRoles.length > 0) { + // 每个 turn 后端都会按 promptSectionRolesInOrder 推全集 6 个 role + // (P1.1: high_static / frozen_block / semi_dynamic_1 / semi_dynamic_2 / + // timelineOpen / dynamic), 即便 bytes 为 0 也会预填. 所以这里每次 + // 都用 incoming 重新 reconcile role_order, 既能让新出现的 role + // (如 semi_dynamic_1/2) 按 CONTEXT_STATS_ROLE_NAME_ORDER 物理顺序 + // 插入, 也能把 incoming 里彻底消失的老 role (如老的 'semi_dynamic') + // 自动 evict 掉, 避免历史会话残留的图例顺序污染. + const incomingMap = new Map() + for (const r of incomingRoles) { + if (!r.role_name || incomingMap.has(r.role_name)) continue + incomingMap.set(r.role_name, r) + } + + const preferred = new Set(CONTEXT_STATS_ROLE_NAME_ORDER) + const desiredOrder: string[] = [] + for (const name of CONTEXT_STATS_ROLE_NAME_ORDER) { + if (incomingMap.has(name)) desiredOrder.push(name) + } + for (const name of incomingMap.keys()) { + if (!preferred.has(name) && !desiredOrder.includes(name)) { + desiredOrder.push(name) + } + } + + const oldOrderSet = new Set(d.role_order) + const newOrderSet = new Set(desiredOrder) + + // 新出现的 role: 用 0 补齐已积累的 d.times 长度, 让历史时间轴上 + // 这条线从 0 起步, 而不是凭空错位 + for (const name of desiredOrder) { + const r = incomingMap.get(name) + if (!oldOrderSet.has(name)) { + d.role_labels[name] = r?.role_name_zh || name + d.role_series[name] = new Array(d.times.length).fill(0) + } else if (r?.role_name_zh) { + d.role_labels[name] = r.role_name_zh + } + } + // 老 role (如 'semi_dynamic') 在 incoming 中彻底消失 -> 释放 series + for (const name of d.role_order) { + if (!newOrderSet.has(name)) { + delete d.role_labels[name] + delete d.role_series[name] + } + } + + d.role_order = desiredOrder + } + + rawData.aiPerfData.contextStats.prompt_bytes = data.prompt_bytes ?? 0 + rawData.aiPerfData.contextStats.prompt_tokens = data.prompt_tokens ?? 0 + d.times.push(ts) + d.total_prompt_bytes.push(data.prompt_bytes ?? 0) + d.total_prompt_tokens.push(data.prompt_tokens ?? 0) + + if (d.role_order.length > 0) { + const map = new Map() + const tokenMap = new Map() + for (const r of incomingRoles) { + if (!d.role_order.includes(r.role_name)) continue + map.set(r.role_name, r.role_bytes ?? 0) + tokenMap.set(r.role_name, r.role_tokens ?? 0) + } + for (const name of d.role_order) { + if (!d.role_series[name]) d.role_series[name] = [] + d.role_series[name].push(map.get(name) ?? 0) + } + for (const name of d.role_order) { + if (!d.role_tokens[name]) d.role_tokens[name] = [] + d.role_tokens[name].push(tokenMap.get(name) ?? 0) + } + } + + trimContextStatsSeries(d) + } + + const sections = rawData.aiPerfData.contextSections + const sectionsData = data.sections || [] + if (sections) { + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + // 每次新的后端数据进来,需要重置上次数据逻辑处理的map对象,避免之前数据对当前数据处理造成影响 + const summaryMap = new Map() + handleSummarySectionsSummary(sectionsData, summaryMap) + sections.summary = summaryMap + sections.sections = sectionsData + } +} + +export const aiPerfDataHandlers = { + consumption: handleConsumption, + pressure: handlePressure, + ai_first_byte_cost_ms: handleAIFirstByteCostMS, + ai_total_cost_ms: handleAITotalCostMS, + prompt_profile: handlePromptProfile, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiReview.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiReview.ts new file mode 100644 index 0000000000..6ad7277a6c --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiReview.ts @@ -0,0 +1,624 @@ +import type { AIMessageHandler } from '../type' +import type { AIAgentGrpcApi, AIInputEvent } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { genBaseAIChatData, generateTaskNodeDataID, genExecTasks, isAutoExecuteReviewContinue } from '../utils' +import { type AIChatQSData, AIChatQSDataTypeEnum } from '../aiRender' +import cloneDeep from 'lodash/cloneDeep' +import { AIReviewJudgeLevelMap } from '../defaultConstant' +import { persistIndependentItem } from '../persist/contentPersistHelper' + +const handlePlanReviewRequire: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, request, meta } = requestInfo + if (res.Type !== 'plan_review_require') return + // 历史数据-grpc流数据在任务规划下无效,不处理 + if ((res.IsSync && chatType === 'task') || (!res.IsSync && chatType !== 'task')) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanReviewRequire + if (!data?.id || !data?.plans || !data?.plans?.root_task || !data?.selectors || !data?.selectors?.length) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const taskID = store.getState().taskStatus.taskID + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + id: data.id, + type: AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE, + data: { ...cloneDeep(data) }, + TaskId: taskID ? `${taskID}-default` : undefined, + } + if (res.IsSync) { + // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 + const target = meta.historyReviewReleaseID[data.id] + if (target) { + chatData.data.selected = JSON.stringify(target.params) + chatData.data.optionValue = target.params?.suggestion || 'continue' + } + rawData.contents.set(chatData.id, cloneDeep(chatData)) + // 仅已 release(会进渲染树)时落库;仅等待 release 的中间态不落库 + if (target) { + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: true, + }, + }) + } + return + } + + // 实时数据处理逻辑 + const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: () => request }) + if (isAuto) { + chatData.data.selected = JSON.stringify({ suggestion: 'continue' }) + chatData.data.optionValue = 'continue' + } + // 将数据存入hook里的缓存变量中 + rawData.contents.set(chatData.id, cloneDeep(chatData)) + // 该类型的实时数据只有任务规划才有 + if (isAuto) { + // auto:立刻进渲染树,此时落库 + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + }, + }) + const tasks = chatData.data + const plans = genExecTasks(tasks.plans.root_task) + store.getState().updatePlanTree({ + task_tree: cloneDeep(plans), + root_task_name: tasks.plans.root_task.name, + }) + } else { + // 仅弹窗展示,操作完成(release)后再落库 + store.getState().updateState({ currentPlanReviewToken: { token: chatData.id, renderNum: 0 } }) + } +} +const handlePlanTaskAnalysis: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, request, meta } = requestInfo + if (res.Type !== 'plan_task_analysis') return + // 历史数据-grpc流数据在任务规划下无效,不处理 + if (res.IsSync || chatType !== 'task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanReviewRequireExtra + if ( + !data?.plans_id || + !data?.task_id || + !data?.keywords?.length || + (meta.currentPlanReviewExtraId && meta.currentPlanReviewExtraId !== data.plans_id) + ) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + if (!store.getState().currentPlanReviewToken.token) return + const reviewDetail = rawData.contents.get(store.getState().currentPlanReviewToken.token) + if (!reviewDetail || reviewDetail.type !== AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: 未找到对应plan_review_require数据` }) + return + } + + if (!meta.currentPlanReviewExtraId) meta.currentPlanReviewExtraId = data.plans_id + const reviewInfo = reviewDetail.data + if (!reviewInfo.taskExtra) reviewInfo.taskExtra = new Map() + reviewInfo.taskExtra.set(data.task_id, data) + + const isAuto = isAutoExecuteReviewContinue({ getFunc: () => request }) + if (!isAuto) { + meta.planReviewExtraData.set(data.task_id, cloneDeep(data)) + store.getState().updateStateCount('currentPlanReviewExtraUpdate') + } +} + +const handleTaskReviewRequire: AIMessageHandler = (requestInfo) => { + const { res, chatType, request, sendRequest } = requestInfo + if (res.Type !== 'task_review_require') return + // 实时数据-没有task_review类型 + if (!res.IsSync && chatType !== 'task') return + // 历史数据-task-review数据不进行展示 + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.TaskReviewRequire + if (!data?.id || !data?.selectors || !data?.selectors?.length) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + // 实时数据处理逻辑 + const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: () => request }) + // 实时数据-(自由对话|任务规划)的 review 自动执行,并且不展示在UI上 + if (isAuto) return + + // 任务规划下,task_review在非yolo模式时,自动执行continue操作,并且不在UI上展示操作结果 + const info: AIInputEvent = { + IsInteractiveMessage: true, + InteractiveId: data.id, + InteractiveJSONInput: JSON.stringify({ suggestion: 'continue' }), + } + sendRequest(info) +} + +const handleToolReview: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, request, sendRequest } = requestInfo + if (res.Type !== 'tool_use_review_require') return + // 历史数据-tool-review数据不进行展示 + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ToolUseReviewRequire + if (!data?.id || !data?.selectors || !data?.selectors?.length) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + id: data.id, + type: AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE, + data: { ...cloneDeep(data) }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + + // 实时数据处理逻辑 + const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: () => request }) + // 实时数据-(自由对话|任务规划)的 review 自动执行,并且不展示在UI上 + if (isAuto) return + + if (chatType === 'task') { + // 任务规划下,tool_review在非yolo模式时,自动执行continue操作,并且不在UI上展示操作结果 + const info: AIInputEvent = { + IsInteractiveMessage: true, + InteractiveId: chatData.id, + InteractiveJSONInput: JSON.stringify({ suggestion: 'continue' }), + } + sendRequest(info) + } else { + const taskGroupDetail = rawData.contents.get(chatData.TaskId || '') + // 自由对话下,如果属于执行任务组里的task_review,在任何review模式下,后端都会自动执行continue操作,并且不在UI上展示操作结果 + // 非执行任务组的review,正常显示到UI上,根据review模式和用户主动操作,决定结果,并且操作后,也不在UI上展示结果 + if (!taskGroupDetail || taskGroupDetail.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) { + rawData.contents.set(chatData.id, cloneDeep(chatData)) + // 操作后会从列表删除,展示态不落库 + store.getState().updateCasualReview(chatData.id, 'add') + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + }, + }) + } + } +} + +const handleUserInteractive: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'require_user_interactive') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIReviewRequire + if (!data?.id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + id: data.id, + type: AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE, + data: cloneDeep(data), + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + // 历史数据 + if (res.IsSync) { + // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 + const target = meta.historyReviewReleaseID[data.id] + if (target) { + chatData.data.selected = JSON.stringify(target.params) + chatData.data.optionValue = target.params?.suggestion || 'continue' + } + rawData.contents.set(chatData.id, cloneDeep(chatData)) + if (target) { + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: true, + }, + }) + } + return + } + + // 将数据存入hook里的缓存变量中 + rawData.contents.set(chatData.id, cloneDeep(chatData)) + if (chatType === 'task') { + // 仅弹窗,操作完成后再落库 + store.getState().updateState({ currentPlanReviewToken: { token: chatData.id, renderNum: 0 } }) + } else if (chatType === 'reAct') { + // 立刻进渲染树,需要落库 + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().updateCasualReview(chatData.id, 'add') + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + }, + }) + } +} + +const handleAIForgeReviewRequire: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, request, meta } = requestInfo + if (res.Type !== 'exec_aiforge_review_require') return + // 任务规划不存在该类型数据 + if (chatType === 'task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ExecForgeReview + if (!data?.id || !data?.selectors || !data?.selectors?.length) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + id: data.id, + type: AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE, + data: { ...cloneDeep(data) }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + if (res.IsSync) { + // 历史review数据,直接存入map里,等待review_release出现后渲染到UI上 + const target = meta.historyReviewReleaseID[data.id] + if (target) { + chatData.data.selected = JSON.stringify(target.params) + chatData.data.optionValue = target.params?.suggestion || 'continue' + } + rawData.contents.set(chatData.id, cloneDeep(chatData)) + if (target) { + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: true, + }, + }) + } + return + } + + // 实时数据处理逻辑 + const isAuto = isAutoExecuteReviewContinue({ type: res.Type, getFunc: () => request }) + if (isAuto) { + chatData.data.selected = JSON.stringify({ suggestion: 'continue' }) + chatData.data.optionValue = 'continue' + } else { + store.getState().updateCasualReview(chatData.id, 'add') + } + // 将数据存入hook里的缓存变量中 + rawData.contents.set(chatData.id, cloneDeep(chatData)) + // 立刻进渲染树,需要落库 + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + }, + }) +} + +const handleAIReviewJudgement: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData } = requestInfo + if (!['ai_review_start', 'ai_review_countdown', 'ai_review_end'].includes(res.Type)) return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const score = JSON.parse(ipcContent) as AIAgentGrpcApi.AIReviewJudgement + if (!score?.interactive_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + const reviewDetail = rawData.contents.get(score.interactive_id) + if (!reviewDetail || reviewDetail.id !== score.interactive_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: 没有对应的review数据` }) + return + } + + score.levelLabel = AIReviewJudgeLevelMap[score?.level || '']?.label || undefined + + switch (reviewDetail.type) { + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + if ( + !reviewDetail.data.aiReview || + (reviewDetail.data.aiReview && typeof reviewDetail.data.aiReview.seconds === 'undefined') + ) { + // aiReview 没有或者 aiReview 的 seconds 为空时可以赋值 + reviewDetail.data.aiReview = cloneDeep(score) + } + if (chatType === 'reAct') { + store.getState().incrementNodeVersion(reviewDetail.id, 'item') + } else { + const reviewStore = store.getState().currentPlanReviewToken + reviewStore.renderNum += 1 + store.getState().updateState({ currentPlanReviewToken: { ...reviewStore } }) + } + break + + default: + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据异常(interactive_id:${score?.interactive_id || '-'})未找到对应review`, + }) + break + } +} + +const handleReviewRelease: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'review_release') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ReviewRelease + if (!data?.id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const reviewDetail = rawData.contents.get(data.id) + + // 历史数据 + if (res.IsSync) { + if (!reviewDetail) { + meta.historyReviewReleaseID[data.id] = data + return + } + switch (reviewDetail.type) { + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + reviewDetail.data.selected = JSON.stringify(data.params) + reviewDetail.data.optionValue = data.params?.suggestion || 'continue' + persistIndependentItem(requestInfo.sessionId, reviewDetail) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: reviewDetail.TaskId, + node: { + token: reviewDetail.id, + kind: 'item', + type: reviewDetail.type, + isHistory: true, + }, + }) + break + case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + // 历史数据-不展示,直接跳过处理逻辑 + break + default: + break + } + + return + } + + if (!reviewDetail) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据(id:${data?.id || '-'})没有对应的review数据` }) + return + } + // 任务规划下没有该review + const noTaskReview = + !store.getState().currentPlanReviewToken.token || store.getState().currentPlanReviewToken.token !== reviewDetail.id + // 自由对话下没有该review + const noCasualReview = !store.getState().currentCasualReview.includes(reviewDetail.id) + + // 实时数据 + switch (reviewDetail.type) { + case AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE: + if (noTaskReview) return + + reviewDetail.data.selected = JSON.stringify(data.params) + reviewDetail.data.optionValue = data.params?.suggestion || 'continue' + persistIndependentItem(requestInfo.sessionId, reviewDetail) + // 清空plan-review的异步拓展信息 + meta.currentPlanReviewExtraId = '' + meta.planReviewExtraData.clear() + // 生成执行的任务树并更新到UI上 + const tasks = reviewDetail.data + const plans = genExecTasks(tasks.plans.root_task) + store.getState().updatePlanTree({ + task_tree: cloneDeep(plans), + root_task_name: tasks.plans.root_task.name, + }) + // 将操作记录渲染到列表上 + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: reviewDetail.TaskId, + node: { + token: reviewDetail.id, + kind: 'item', + type: reviewDetail.type, + }, + }) + // 关闭review的弹窗 + if (chatType === 'task') store.getState().updateState({ currentPlanReviewToken: { token: '', renderNum: 0 } }) + break + case AIChatQSDataTypeEnum.EXEC_AIFORGE_REVIEW_REQUIRE: + if (chatType === 'reAct') { + if (noCasualReview) return + reviewDetail.data.selected = JSON.stringify(data.params) + reviewDetail.data.optionValue = data.params?.suggestion || 'continue' + store.getState().updateCasualReview(reviewDetail.id, 'remove') + store.getState().incrementNodeVersion(reviewDetail.id, 'item') + persistIndependentItem(requestInfo.sessionId, reviewDetail) + } + break + case AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE: + if (chatType === 'reAct') { + if (noTaskReview) return + reviewDetail.data.selected = JSON.stringify(data.params) + reviewDetail.data.optionValue = data.params?.suggestion || 'continue' + persistIndependentItem(requestInfo.sessionId, reviewDetail) + store.getState().updateState({ currentPlanReviewToken: { token: '', renderNum: 0 } }) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: reviewDetail.TaskId, + node: { + token: reviewDetail.id, + kind: 'item', + type: reviewDetail.type, + }, + }) + } + break + case AIChatQSDataTypeEnum.REQUIRE_USER_INTERACTIVE: + reviewDetail.data.selected = JSON.stringify(data.params) + reviewDetail.data.optionValue = data.params?.suggestion || 'continue' + if (chatType === 'reAct') { + if (noCasualReview) return + store.getState().updateCasualReview(reviewDetail.id, 'remove') + store.getState().incrementNodeVersion(reviewDetail.id, 'item') + persistIndependentItem(requestInfo.sessionId, reviewDetail) + } else { + if (noTaskReview) return + persistIndependentItem(requestInfo.sessionId, reviewDetail) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: reviewDetail.TaskId, + node: { + token: reviewDetail.id, + kind: 'item', + type: reviewDetail.type, + }, + }) + store.getState().updateState({ currentPlanReviewToken: { token: '', renderNum: 0 } }) + } + break + case AIChatQSDataTypeEnum.TASK_REVIEW_REQUIRE: + // 实时数据-不会有task_review_release出现,出现则舍弃无效数据 + break + case AIChatQSDataTypeEnum.TOOL_USE_REVIEW_REQUIRE: + // 实时数据-任务规划-tool_review都会自动执行,不展示到UI上 + // 自由对话里,没有在执行任务组的tool_review会展示到UI上,需要处理数据并在UI上删除掉 + rawData.contents.delete(reviewDetail.id) + store.getState().updateCasualReview(reviewDetail.id, 'remove') + store.getState().deleteElementNode({ + chatType: chatType, + token: reviewDetail.id, + kind: 'item', + taskID: reviewDetail.TaskId || undefined, + onDelContent: (mapKey) => { + rawData.contents.delete(mapKey) + }, + }) + break + default: + break + } +} + +const handleDetachedPlanReview: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData } = requestInfo + if (res.Type !== 'detached_plan_require' || res.NodeId !== 'detached-plan') return + + // 历史数据-grpc流数据在任务规划下无效,不处理 + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.DetachedPlanRequire + if (!data?.id || !data?.plans?.root_task || !data?.selectors?.length) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const reviewDetail = rawData.contents.get(data.id) + if (reviewDetail) { + if (reviewDetail.type !== AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE) return + reviewDetail.data = { ...data } + // 弹窗展示态不落库,操作完成(release)后再写 + const reviewStore = store.getState().currentPlanReviewToken + reviewStore.renderNum += 1 + store.getState().updateState({ currentPlanReviewToken: { ...reviewStore } }) + } else { + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + id: data.id, + type: AIChatQSDataTypeEnum.DETACHED_PLAN_REQUIRE, + data: { ...cloneDeep(data) }, + TaskId: generateTaskNodeDataID({ + chatType: chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, cloneDeep(chatData)) + if (chatType === 'reAct') { + store.getState().updateState({ currentPlanReviewToken: { token: chatData.id, renderNum: 0 } }) + } + } +} + +export const aiReviewDataHandlers = { + plan_review_require: handlePlanReviewRequire, + plan_task_analysis: handlePlanTaskAnalysis, + task_review_require: handleTaskReviewRequire, + tool_use_review_require: handleToolReview, + require_user_interactive: handleUserInteractive, + exec_aiforge_review_require: handleAIForgeReviewRequire, + ai_review_start: handleAIReviewJudgement, + ai_review_countdown: handleAIReviewJudgement, + ai_review_end: handleAIReviewJudgement, + review_release: handleReviewRelease, + detached_plan_require: handleDetachedPlanReview, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiSingleItem.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiSingleItem.ts new file mode 100644 index 0000000000..ebcb2496d5 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiSingleItem.ts @@ -0,0 +1,423 @@ +import type { AIMessageHandler } from '../type' +import { AIInputEventSyncTypeEnum, AITaskStatus, type AIAgentGrpcApi } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { genBaseAIChatData, generateTaskNodeDataID } from '../utils' +import { type AIChatQSData, AIChatQSDataTypeEnum, type ReportFinishCardData } from '../aiRender' +import { convertNodeIdToVerbose } from '../defaultConstant' +import cloneDeep from 'lodash/cloneDeep' +import { persistIndependentItem } from '../persist/contentPersistHelper' + +const handleThought: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'thought') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { thought } = (JSON.parse(ipcContent) as AIAgentGrpcApi.AIChatThought) || {} + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: AIChatQSDataTypeEnum.THOUGHT, + data: thought || '', + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleResult: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'result') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { result, after_stream } = (JSON.parse(ipcContent) as AIAgentGrpcApi.AIChatResult) || {} + if (after_stream) return + + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: AIChatQSDataTypeEnum.RESULT, + data: result || '', + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleFailReactTask: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'fail_react_task') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: AIChatQSDataTypeEnum.FAIL_REACT, + data: { + content: ipcContent, + NodeId: res.NodeId, + NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(res.NodeId), + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleToolCallDecision: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'tool_call_decision') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ToolCallDecision + const i18n = data?.i18n || { zh: data.action, en: data.action } + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: AIChatQSDataTypeEnum.TOOL_CALL_DECISION, + data: { + ...data, + i18n: { + Zh: i18n.zh, + En: i18n.en, + }, + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleFailPlanAndExecution: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'fail_plan_and_execution') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: AIChatQSDataTypeEnum.FAIL_PLAN_AND_EXECUTION, + data: { + content: ipcContent, + NodeId: res.NodeId, + NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(res.NodeId), + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleApiRequestFailed: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'api_request_failed' || res.NodeId !== 'ai_call_failure') return + // 历史数据无用-不处理 + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIApiRequestFailedPayload + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: AIChatQSDataTypeEnum.AI_API_REQUEST_FAILED, + data, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handleHttpFlowFuzzStatus: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'http_flow_fuzz_status') return + // 历史数据无用-不处理 + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const payload = JSON.parse(ipcContent) as AIAgentGrpcApi.GetHttpFlowFuzzStatus + const { fuzz_id, runtime_id, reason, status } = payload + if (!fuzz_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + const chatDetail = rawData.contents.get(fuzz_id) + + if (chatDetail && chatDetail.type === AIChatQSDataTypeEnum.HTTP_FLOW_FUZZ_STATUS) { + chatDetail.data.runtime_id = runtime_id + chatDetail.data.reason = reason + chatDetail.data.engine_status = status + chatDetail.data.progress = status === 'working' ? payload.progress : chatDetail.data.progress + store.getState().incrementNodeVersion(chatDetail.id, 'item') + persistIndependentItem(requestInfo.sessionId, chatDetail) + } else { + // 引擎结束态没有对应卡片时直接丢弃,保留原行为 + if (status === 'finish') return + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + id: fuzz_id, + chatType: chatType, + type: AIChatQSDataTypeEnum.HTTP_FLOW_FUZZ_STATUS, + data: { + fuzz_id, + runtime_id, + reason, + engine_status: status, + // 仅 `working` 覆盖 progress;其它状态保留上一次(新建时默认 undefined) + progress: status === 'working' ? payload.progress : undefined, + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) + } +} + +const handleReportFinish: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'report_finish' || res.NodeId !== 'report-finish') return + + const ipcContent = Uint8ArrayToString(res.Content) || '{}' + + const parsed = JSON.parse(ipcContent) as AIAgentGrpcApi.ReportFinishPayload + let report_path = parsed?.report_path ?? '' + let title = parsed?.title ?? '' + let content = parsed?.summary_markdown ?? '' + + if (!report_path) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据缺少 report_path` }) + return + } + + const cardType = AIChatQSDataTypeEnum.REPORT_FINISH + const nextData: ReportFinishCardData = { reportPath: report_path, title, content } + + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + chatType: chatType, + type: cardType, + data: nextData, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + } + rawData.contents.set(chatData.id, chatData) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handlePushTask: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'structured' || res.NodeId !== 'system') return + // 历史数据和自由对话数据不处理 + if (res.IsSync || chatType === 'reAct') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) || '' + if (!data || typeof data !== 'object' || data?.type !== 'push_task') return + + const info = data as AIAgentGrpcApi.ChangeTask + const newPlanTree = cloneDeep(store.getState().taskChat.plan) + newPlanTree.task_tree = newPlanTree.task_tree.map((item) => { + if (item.task_id === info.task.task_id) item.progress = AITaskStatus.inProgress + return item + }) + store.getState().updatePlanTree(newPlanTree) + + const taskId = store.getState().taskStatus.taskID + if (!taskId || !info.task.task_id) return + const taskID = `${taskId}-${info.task.task_id}` + const chatDetail = rawData.contents.get(taskID) + if (chatDetail) { + requestInfo.pushLog({ level: 'error', message: `${info.task.task_id}-push_task数据已存在` }) + return + } + const chatData: AIChatQSData = { + ...genBaseAIChatData(res), + id: taskID, + chatType: 'task', + type: AIChatQSDataTypeEnum.TASK_NODE_GROUP, + data: { + taskId: info.task.task_id, + taskName: info.task.name, + goal: info.task.goal, + status: info.task.task_status || AITaskStatus.inProgress, + }, + } + rawData.contents.set(chatData.id, chatData) + meta.currentTaskPlanActiveNode.add(chatData.id) + persistIndependentItem(requestInfo.sessionId, chatData) + store.getState().dispatchStreamingNode({ + chatType: chatType, + node: { + token: chatData.id, + kind: 'task', + type: chatData.type, + isHistory: res.IsSync, + }, + }) +} + +const handlePopTask: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta, sendRequest } = requestInfo + if (res.Type !== 'structured' || res.NodeId !== 'system') return + // 历史数据和自由对话数据不处理 + if (res.IsSync || chatType === 'reAct') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) || '' + if (!data || typeof data !== 'object' || data?.type !== 'pop_task') return + + const info = data as AIAgentGrpcApi.ChangeTask + const newPlanTree = cloneDeep(store.getState().taskChat.plan) + newPlanTree.task_tree = newPlanTree.task_tree.map((item) => { + if (item.task_id === info.task.task_id) item.progress = info.task.task_status + return item + }) + store.getState().updatePlanTree(newPlanTree) + + const taskId = store.getState().taskStatus.taskID + if (!taskId || !info.task.task_id) return + const taskID = `${taskId}-${info.task.task_id}` + const chatDetail = rawData.contents.get(taskID) + if (!chatDetail || chatDetail.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) { + requestInfo.pushLog({ level: 'error', message: `${info.task.task_id}-pop_task数据不存在` }) + return + } + meta.currentTaskPlanActiveNode.delete(chatDetail.id) + chatDetail.data.status = info.task.task_status + store.getState().incrementNodeVersion(chatDetail.id, 'task') + persistIndependentItem(requestInfo.sessionId, chatDetail) + // 更新任务树状态 + sendRequest && sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN }) +} + +/** 单条grpc流数据对应一个独立的UI数据 */ +export const aiSingleItemDataHandlers = { + thought: handleThought, + result: handleResult, + fail_react_task: handleFailReactTask, + tool_call_decision: handleToolCallDecision, + fail_plan_and_execution: handleFailPlanAndExecution, + ai_call_failure: handleApiRequestFailed, + http_flow_fuzz_status: handleHttpFlowFuzzStatus, + 'report-finish': handleReportFinish, + push_task: handlePushTask, + pop_task: handlePopTask, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiStream.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiStream.ts new file mode 100644 index 0000000000..f9cce5bfe8 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiStream.ts @@ -0,0 +1,511 @@ +import type { AIMessageHandler, AIMessageHandlerParams } from '../type' +import type { AIAgentGrpcApi } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { genBaseAIChatData, generateTaskNodeDataID, isToolStderrStream, isToolStdoutStream } from '../utils' +import { AIChatQSDataTypeEnum, type AIChatQSData } from '../aiRender' +import { AIStreamContentType, convertNodeIdToVerbose } from '../defaultConstant' +import { aiAgentLogEmitter } from '../AIAgentLogEmitter' +import { v4 as uuidv4 } from 'uuid' +import { + persistIndependentItem, + persistToolResultIfTerminal, + setSessionReferencePersist, + upsertSessionContent, +} from '../persist/contentPersistHelper' +import { ensureToolResultOnUI } from './aiToolResult' + +/** 生成stream_group组数据 */ +const genStreamGroupData = ( + params: { + group: string + tokens: string[] + } & AIMessageHandlerParams, +) => { + const { group, tokens, sessionId, res, chatType, rawData, store, meta } = params + + const groupDetail = rawData.contents.get(group) + // 设置组数据详情 + if (groupDetail && groupDetail.type === AIChatQSDataTypeEnum.STREAM_GROUP) { + groupDetail.data.lastToken = tokens[tokens.length - 1] + persistIndependentItem(sessionId, groupDetail) + } else { + const chatData: AIChatQSData = { + id: group, + chatType: chatType, + type: AIChatQSDataTypeEnum.STREAM_GROUP, + data: { + NodeId: res.NodeId, + NodeIdVerbose: res.NodeIdVerbose, + lastToken: tokens[tokens.length - 1], + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + AIService: '', + AIModelName: '', + Timestamp: res.Timestamp, + } + rawData.contents.set(group, chatData) + persistIndependentItem(sessionId, chatData) + } + + tokens.forEach((mapKey) => { + const mapValue = rawData.contents.get(mapKey) + if (!mapValue) return + mapValue.parentGroupToken = group + // 流已结束后才入组:需补写 parentGroupToken + if (mapValue.type === AIChatQSDataTypeEnum.STREAM && mapValue.data.status === 'end') { + upsertSessionContent(sessionId, mapValue.id, mapValue) + } + }) +} + +const handleStreamStart: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'stream_start') return + + if (res.IsSystem || res.IsReason) return + + const { CallToolID, NodeId } = res + if (!NodeId) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { event_writer_id } = JSON.parse(ipcContent) as { event_writer_id: string } + // event_writer_id为空 + if (!event_writer_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常: ${ipcContent}` }) + return + } + + // tool-xxx-stderr 数据单独初始化逻辑 + if (isToolStderrStream(NodeId) && CallToolID) { + if (!CallToolID) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空` }) + return + } + if (!meta.toolStderrStreamData.has(CallToolID)) { + meta.toolStderrStreamData.set(CallToolID, { + content: '', + uuid: event_writer_id, + status: 'start', + }) + } + return + } + // tool-xxx-stdout 数据单独初始化逻辑 + if (isToolStdoutStream(NodeId)) { + if (!CallToolID) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空` }) + return + } + let toolResult = rawData.contents.get(CallToolID) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `NodeID: ${NodeId} 的stream数据没有对应的工具结果(CallToolID: ${CallToolID})初始化`, + }) + return + } + + rawData.contents.set(event_writer_id, { + ...genBaseAIChatData(res), + id: event_writer_id, + chatType: chatType, + type: AIChatQSDataTypeEnum.STREAM, + data: { + NodeId, + NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(NodeId), + CallToolID, + EventUUID: event_writer_id, + status: 'start', + content: '', + ContentType: res.ContentType, + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + }) + toolResult.data.stream.EventUUID = event_writer_id + toolResult.data.type = 'stream' + // stdout 流开始:工具卡片首次上树(create 阶段故意不上屏) + if (!res.IsSync) ensureToolResultOnUI(requestInfo, toolResult) + return + } + + // 数据集合中对应的数据 + const streamData = rawData.contents.get(event_writer_id) + + // 数据已存在,流数据输出顺序不对, 视为异常 + if (streamData) { + requestInfo.pushLog({ + level: 'error', + message: `Stream-NodeId: ${NodeId}, EventUUID: (${event_writer_id}), 已存在对应的数据`, + }) + return + } + + rawData.contents.set(event_writer_id, { + ...genBaseAIChatData(res), + id: event_writer_id, + chatType: chatType, + type: AIChatQSDataTypeEnum.STREAM, + data: { + NodeId, + NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(NodeId), + CallToolID, + EventUUID: event_writer_id, + status: 'start', + content: '', + ContentType: res.ContentType, + }, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + }) +} + +const handleStream: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData, meta } = requestInfo + if (res.Type !== 'stream') return + // 历史数据-(系统信息|推理信息)数据,不处理 + if (res.IsSync && (res.IsSystem || res.IsReason)) return + + const { CallToolID, EventUUID, NodeId } = res + const content = (Uint8ArrayToString(res.Content) || '') + (Uint8ArrayToString(res.StreamDelta) || '') + if (!EventUUID || !NodeId) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据缺失: EventUUID(${EventUUID}) NodeId(${NodeId})` }) + return + } + + if (res.IsSystem) { + // 实时数据-系统信息 + const lastUUID = meta.systemEventUUID[meta.systemEventUUID.length - 1] + if (lastUUID) { + if (lastUUID === EventUUID) { + // 由UI进行定时获取渲染(打字机效果) + rawData.systemStream += content + } else { + if (meta.systemEventUUID.includes(EventUUID)) return + meta.systemEventUUID.push(EventUUID) + rawData.systemStream = content + } + } else { + meta.systemEventUUID.push(EventUUID) + rawData.systemStream = content + } + store.getState().updateStateCount('updateSystemStream') + return + } + if (res.IsReason) { + // 实时数据-推理信息-系统信息输出到日志中 + aiAgentLogEmitter.dispatch({ + session: requestInfo.sessionId, + type: 'stream', + Timestamp: res.Timestamp, + stream: { + NodeId: res.NodeId, + EventUUID: res.EventUUID, + content: (Uint8ArrayToString(res.Content) || '') + (Uint8ArrayToString(res.StreamDelta) || ''), + status: 'start', + }, + }) + return + } + + // tool-xxx-stderr 数据单独处理逻辑 + if (isToolStderrStream(NodeId)) { + if (!CallToolID) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空` }) + return + } + const errorResult = meta.toolStderrStreamData.get(CallToolID) + if (errorResult) errorResult.content += content + return + } + // tool-xxx-stdout 数据单独处理逻辑 + if (isToolStdoutStream(NodeId)) { + if (!CallToolID) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据(NodeId: ${NodeId}), CallToolID 为空` }) + return + } + const toolResult = rawData.contents.get(CallToolID) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || !toolResult.data.stream.EventUUID) { + requestInfo.pushLog({ + level: 'error', + message: `Stream-NodeID: ${NodeId} 数据没有对应的工具结果(CallToolID: ${CallToolID})初始化`, + }) + return + } + const toolForStreamData = rawData.contents.get(toolResult.data.stream.EventUUID) + if (!toolForStreamData || toolForStreamData.type !== AIChatQSDataTypeEnum.STREAM) { + requestInfo.pushLog({ + level: 'error', + message: `Stream-EventUUID: ${toolResult.data.stream.EventUUID} 数据没有对应的初始化`, + }) + return + } + const isRender = !toolForStreamData.data.content + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + toolForStreamData.data.content += content + if (isRender) { + // 先确定是UI定时拿数据还是流数据触发渲染,还有就是,UI已经渲染出来工具卡片了吗 + store.getState().incrementNodeVersion(toolForStreamData.id, 'item') + } + return + } + + // 数据集合中对应的数据 + const streamData = rawData.contents.get(EventUUID) + + // 数据不存在 + if (!streamData || streamData.type !== AIChatQSDataTypeEnum.STREAM) { + requestInfo.pushLog({ level: 'error', message: `Stream-NodeId: ${NodeId}, EventUUID: (${EventUUID}) 数据未初始化` }) + return + } + + const isRender = !streamData.data.content + // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + streamData.Timestamp = isRender ? res.Timestamp : streamData.Timestamp + streamData.data.content += content + + if (isRender) { + // 判断是否成为组UI数据展示 + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: streamData.TaskId, + node: { + token: streamData.id, + kind: 'item', + type: streamData.type, + isHistory: res.IsSync, + nodeId: res.ContentType === AIStreamContentType.DEFAULT ? res.NodeId : undefined, + groupExtra: (group: string, tokens: string[]) => { + genStreamGroupData({ group, tokens, ...requestInfo }) + }, + }, + }) + } +} + +const handleStreamFinished: AIMessageHandler = (requestInfo) => { + const { res, store, rawData, meta } = requestInfo + if (res.Type !== 'structured' || res.NodeId !== 'stream-finished') return + // 历史数据-(系统信息|推理信息)数据,不处理 + if (res.IsSync && (res.IsSystem || res.IsReason)) return + + let ipcContent = Uint8ArrayToString(res.Content) || '' + const { event_writer_id, node_id, is_reason, is_system } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIStreamFinished + if (!event_writer_id) { + requestInfo.pushLog({ level: 'error', message: `stream-finished数据, event_writer_id 为空` }) + return + } + + // 实时数据-系统信息(不需要结束处理, 由下一个自动顶替) + if (is_system) return + // 实时数据-推理信息-系统信息输出到日志中 + if (is_reason) { + aiAgentLogEmitter.dispatch({ + session: requestInfo.sessionId, + type: 'stream', + Timestamp: res.Timestamp, + stream: { + NodeId: node_id, + EventUUID: event_writer_id, + content: '', + status: 'end', + }, + }) + return + } + + const { CallToolID } = res + // tool-xxx-stderr 数据单独结束逻辑 + if (isToolStderrStream(node_id)) { + if (!CallToolID) { + requestInfo.pushLog({ level: 'error', message: `数据(NodeId: ${node_id}), CallToolID 为空` }) + return + } + + const toolErrorResult = meta.toolStderrStreamData.get(CallToolID) + if (!toolErrorResult) { + requestInfo.pushLog({ level: 'error', message: `NodeId(${node_id})&CallToolID(${CallToolID}) 数据没有初始化` }) + return + } + + const toolResult = rawData.contents.get(CallToolID) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + // 工具执行结果卡片UI没有展示时 + toolErrorResult.status = 'end' + } else { + const showUI = store.getState().items[toolResult.id] + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + toolResult.data.tool.execError = toolErrorResult.content + if (showUI) store.getState().incrementNodeVersion(toolResult.id, 'item') + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) + meta.toolStderrStreamData.delete(CallToolID) + } + return + } + // tool-xxx-stdout 数据单独结束逻辑 + if (isToolStdoutStream(node_id)) { + if (!CallToolID) { + requestInfo.pushLog({ level: 'error', message: `(NodeId: ${node_id})的数据, CallToolID 为空` }) + return + } + + const toolResult = rawData.contents.get(res.CallToolID) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || !toolResult.data.stream.EventUUID) { + return + } + const toolForStreamData = rawData.contents.get(toolResult.data.stream.EventUUID) + if (!toolForStreamData || toolForStreamData.type !== AIChatQSDataTypeEnum.STREAM) { + return + } + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + toolForStreamData.data.status = 'end' + const isShowAll = toolForStreamData.data.content.length > 25600 // 50KB大概字符数25600 + const displayContent = isShowAll + ? '...' + toolForStreamData.data.content.slice(-25600) + '...' + : toolForStreamData.data.content + toolResult.data.tool.toolStdoutContent = { content: displayContent, isShowAll } + ensureToolResultOnUI(requestInfo, toolResult) + // stdout 流结束:落库该 STREAM;若工具已终态则同步刷新 TOOL_RESULT + upsertSessionContent(requestInfo.sessionId, toolForStreamData.id, toolForStreamData) + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) + return + } + + // 数据集合中对应的数据 + const streamData = rawData.contents.get(event_writer_id) + // 数据不存在 不输出到日志,因为日志的流数据也有该类型数据 + if (!streamData || streamData.type !== AIChatQSDataTypeEnum.STREAM) return + + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + streamData.data.status = 'end' + store.getState().incrementNodeVersion(streamData.id, 'item') + upsertSessionContent(requestInfo.sessionId, streamData.id, streamData) +} + +const handleReferenceMaterial: AIMessageHandler = (requestInfo) => { + const { sessionId, res, chatType, store, rawData } = requestInfo + if (res.Type !== 'reference_material') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ReferenceMaterialPayload + + const chatData = rawData.contents.get(data.event_uuid) + const toolResult = rawData.contents.get(res.CallToolID || '') + + // 收数时自动生成 refToken,立刻落表3;内存只挂 token,不存完整 payload + const refToken = uuidv4() + setSessionReferencePersist(sessionId, refToken, data) + + if (chatData) { + chatData.reference = [...(chatData.reference || []), refToken] + // STREAM:仅 status=end 后才追加写正文;未 end 只挂内存,等 finished 一并落库 + // 非 STREAM:有内存则立刻 upsert(独立单条晚到参考资料) + const shouldUpsertContent = chatData.type === AIChatQSDataTypeEnum.STREAM ? chatData.data.status === 'end' : true + if (shouldUpsertContent) { + upsertSessionContent(sessionId, chatData.id, chatData) + } + if (store.getState().items[chatData.id]) { + // 属于item元素,已经在UI上渲染了 + store.getState().incrementNodeVersion(chatData.id, 'item') + } else if (store.getState().groups[chatData.id]) { + // 属于group元素,已经在UI上渲染了 + store.getState().incrementNodeVersion(chatData.id, 'group') + } else if (store.getState().tasks[chatData.id]) { + // 属于task元素,已经在UI上渲染了 + store.getState().incrementNodeVersion(chatData.id, 'task') + } else if (chatData.type === AIChatQSDataTypeEnum.STREAM) { + // 属于stream类型数据,但未在UI上渲染 + if ( + toolResult && + toolResult.type === AIChatQSDataTypeEnum.TOOL_RESULT && + isToolStdoutStream(chatData.data.NodeId) + ) { + // 特殊情况,更新stdout流对应的工具执行结果卡片UI(未挂树则首次挂树) + ensureToolResultOnUI(requestInfo, toolResult) + } else { + // 触发UI渲染 + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + nodeId: chatData.data.ContentType === AIStreamContentType.DEFAULT ? chatData.data.NodeId : undefined, + groupExtra: (group: string, tokens: string[]) => { + genStreamGroupData({ group, tokens, ...requestInfo }) + }, + }, + }) + } + return + } else { + // 其余类型,触发UI渲染 + store.getState().dispatchStreamingNode({ + chatType: chatType, + parentTaskId: chatData.TaskId, + node: { + token: chatData.id, + kind: 'item', + type: chatData.type, + isHistory: res.IsSync, + }, + }) + } + } else { + // 数据不存在,直接生成一个参考资料的UI元素 + // 现阶段直接注释,因为UI还没有写入专门的参考资料元素 + // const chatData: AIChatQSData = { + // ...genBaseAIChatData(res), + // id: refToken, + // chatType: chatType, + // type: AIChatQSDataTypeEnum.Reference_Material, + // data: { + // NodeId: res.NodeId, + // NodeIdVerbose: res.NodeIdVerbose || convertNodeIdToVerbose(res.NodeId), + // }, + // reference: [refToken], + // TaskId: generateTaskNodeDataID({ + // chatType, + // planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : meta.currentTaskPlanID?.taskID, + // taskID: res.TaskId, + // isExist: (key) => rawData.contents.has(key), + // }), + // } + // rawData.contents.set(chatData.id, chatData) + // persistIndependentItem(requestInfo.sessionId, chatData) + // store.getState().dispatchStreamingNode({ + // chatType: chatType, + // parentTaskId: chatData.TaskId, + // node: { + // token: chatData.id, + // kind: 'item', + // type: chatData.type, + // isHistory: res.IsSync, + // }, + // }) + } +} +// #endregion + +/** stream数据相关处理逻辑集合 */ +export const aiStreamDataHandlers = { + stream_start: handleStreamStart, + stream: handleStream, + 'stream-finished': handleStreamFinished, + reference_material: handleReferenceMaterial, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiTaskDetail.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiTaskDetail.ts new file mode 100644 index 0000000000..0bbd0fca6e --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiTaskDetail.ts @@ -0,0 +1,251 @@ +import type { AIMessageHandler, AIMessageHandlerParams } from '../type' +import type { AIAgentGrpcApi, AIOutputEvent } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { handleTodoListData } from '../utils' +import cloneDeep from 'lodash/cloneDeep' +import isArray from 'lodash/isArray' +import isEmpty from 'lodash/isEmpty' +import { v4 as uuidv4 } from 'uuid' +import { AIChatQSDataTypeEnum, type PlanItemDetailsData } from '../aiRender' +import { DefaultPlanItemDetailsData } from '../defaultConstant' + +/** 自由对话:是否为子 agent 任务(planDetailsMap 用子任务 ID;contents 用复合 ID) */ +const isCasualSubAgentTask = ( + rawData: AIMessageHandlerParams['rawData'], + store: AIMessageHandlerParams['store'], + res: AIOutputEvent, +) => { + if (rawData.casualChat.planDetailsMap.has(res.TaskId)) return true + + if (!store.getState().currentCasualTaskID || !res.TaskId) return false + return ( + rawData.contents.get(`${store.getState().currentCasualTaskID}-${res.TaskId}`)?.type === + AIChatQSDataTypeEnum.TASK_NODE_GROUP + ) +} + +const handleCapabilityInventory: AIMessageHandler = (requestInfo) => { + const { res, chatType, rawData, store } = requestInfo + if (res.Type !== 'structured' || res.NodeId !== 'capability_inventory') return + if (!res.TaskId) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const payload = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanItemDetails + if (isEmpty(payload)) return + + const { fixed, dynamic } = payload + const itemData: Pick = { + uuid: uuidv4(), + tool: { + fixed: [], + dynamic: [], + }, + forges: { + fixed: [], + dynamic: [], + }, + skills: { + fixed: [], + dynamic: [], + }, + plugins: { + fixed: [], + dynamic: [], + }, + mcp: { + fixed: [], + dynamic: [], + }, + } + if (!!fixed?.tools) { + for (const item of fixed.tools) { + switch (item.category) { + case 'tool': + itemData.tool.fixed.push(item) + break + case 'yak_plugin': + itemData.plugins.fixed.push(item) + break + case 'mcp': + itemData.mcp.fixed.push(item) + break + default: + break + } + } + } + /** 暂时目前没有这个数据 */ + // if (!!fixed?.mcp_servers) { + // itemData.mcpServices.fixed = fixed.mcp_servers + // } + if (!!fixed?.forges) { + itemData.forges.fixed = fixed.forges + } + if (!!fixed?.skills) { + itemData.skills.fixed = fixed.skills + } + + if (!!dynamic?.tools) { + for (const item of dynamic.tools) { + switch (item.category) { + case 'tool': + itemData.tool.dynamic.push(item) + break + case 'yak_plugin': + itemData.plugins.dynamic.push(item) + break + case 'mcp': + itemData.mcp.dynamic.push(item) + break + default: + break + } + } + } + if (!!dynamic?.skills) { + itemData.skills.dynamic = dynamic.skills + } + if (!!dynamic?.forges) { + itemData.forges.dynamic = dynamic.forges + } + + // 只能通过字段重新赋值的方式修改,不能解构赋值 + const applyCapabilityFields = (target: PlanItemDetailsData) => { + target.uuid = itemData.uuid + target.taskId = target.taskId || res.TaskId + target.tool = itemData.tool + target.forges = itemData.forges + target.skills = itemData.skills + target.plugins = itemData.plugins + target.mcp = itemData.mcp + } + + if (chatType === 'task') { + const oldData = rawData.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + applyCapabilityFields(oldData) + rawData.taskChat.planDetailsMap.set(res.TaskId, oldData) + } else if (chatType === 'reAct') { + const isSubAgentTask = isCasualSubAgentTask(rawData, store, res) + const chatDetail = isSubAgentTask + ? rawData.casualChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + : rawData.casualChat?.planDetails || cloneDeep(DefaultPlanItemDetailsData) + applyCapabilityFields(chatDetail) + if (isSubAgentTask) { + rawData.casualChat.planDetailsMap.set(res.TaskId, chatDetail) + } else { + rawData.casualChat.planDetails = chatDetail + } + } +} + +const handlePerception: AIMessageHandler = (requestInfo) => { + const { res, chatType, rawData, store } = requestInfo + if (res.Type !== 'perception' || res.NodeId !== 'perception') return + if (!res.TaskId) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const perception = (JSON.parse(ipcContent) as AIAgentGrpcApi.PerceptionData) || {} + if (isEmpty(perception)) return + + perception.summary = isArray(perception.summary) ? perception.summary.join(',') : perception.summary + const applyPerceptionFields = (target: PlanItemDetailsData) => { + target.uuid = uuidv4() + target.taskId = target.taskId || res.TaskId + target.perception = perception + } + + if (chatType === 'task') { + const oldData = rawData.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + applyPerceptionFields(oldData) + rawData.taskChat.planDetailsMap.set(res.TaskId, oldData) + } else if (chatType === 'reAct') { + const isSubAgentTask = isCasualSubAgentTask(rawData, store, res) + const chatDetail = isSubAgentTask + ? rawData.casualChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + : rawData.casualChat?.planDetails || cloneDeep(DefaultPlanItemDetailsData) + applyPerceptionFields(chatDetail) + if (isSubAgentTask) { + rawData.casualChat.planDetailsMap.set(res.TaskId, chatDetail) + } else { + rawData.casualChat.planDetails = chatDetail + } + } +} + +const handleCurrentTaskTodoListUpdate: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData } = requestInfo + if (res.Type !== 'current_task_todo_list_update' || res.NodeId !== 'current_task_todo_list') return + if (!res.TaskId) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + // 更新待办清单卡片数据 + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.TodoListUpdate + if (isEmpty(data)) return + + const newData = handleTodoListData(data.items, data.task_id) + const applyTodoListFields = (target: PlanItemDetailsData) => { + target.uuid = uuidv4() + target.taskId = target.taskId || res.TaskId + target.todoList = newData + } + + if (chatType === 'task') { + const oldData = rawData.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + applyTodoListFields(oldData) + rawData.taskChat.planDetailsMap.set(res.TaskId, oldData) + } else if (chatType === 'reAct') { + const isSubAgentTask = isCasualSubAgentTask(rawData, store, res) + const chatDetail = isSubAgentTask + ? rawData.casualChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + : rawData.casualChat?.planDetails + if (!chatDetail) return + applyTodoListFields(chatDetail) + if (isSubAgentTask) { + rawData.casualChat.planDetailsMap.set(res.TaskId, chatDetail) + } else { + rawData.casualChat.planDetails = chatDetail + } + store.getState().updateCasualTodoList() + } +} + +const handleSessionSnapshot: AIMessageHandler = (requestInfo) => { + const { res, chatType, rawData, store } = requestInfo + if (res.NodeId !== 'session_snapshot') return + if (!res.TaskId) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const snapshot = (JSON.parse(ipcContent) as AIAgentGrpcApi.SessionSnapshot) || {} + if (isEmpty(snapshot)) return + const applySnapshotFields = (target: PlanItemDetailsData) => { + target.uuid = uuidv4() + target.taskId = target.taskId || res.TaskId + target.execution = snapshot.execution + target.backgroundProcesses = snapshot.background_processes + } + + if (chatType === 'task') { + const oldData = rawData.taskChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + applySnapshotFields(oldData) + rawData.taskChat.planDetailsMap.set(res.TaskId, oldData) + } else if (chatType === 'reAct') { + const isSubAgentTask = isCasualSubAgentTask(rawData, store, res) + const chatDetail = isSubAgentTask + ? rawData.casualChat.planDetailsMap.get(res.TaskId) || cloneDeep(DefaultPlanItemDetailsData) + : rawData.casualChat?.planDetails || cloneDeep(DefaultPlanItemDetailsData) + applySnapshotFields(chatDetail) + if (isSubAgentTask) { + rawData.casualChat.planDetailsMap.set(res.TaskId, chatDetail) + } else { + rawData.casualChat.planDetails = chatDetail + } + } +} + +/** 单条grpc流数据对应一个独立的UI数据 */ +export const aiTaskDetailDataHandlers = { + capability_inventory: handleCapabilityInventory, + perception: handlePerception, + current_task_todo_list_update: handleCurrentTaskTodoListUpdate, + session_snapshot: handleSessionSnapshot, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiToolResult.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiToolResult.ts new file mode 100644 index 0000000000..5982c8e8a4 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/aiToolResult.ts @@ -0,0 +1,329 @@ +import type { AIMessageHandler, AIMessageHandlerParams } from '../type' +import type { AIAgentGrpcApi } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { genBaseAIChatData, generateTaskNodeDataID } from '../utils' +import { AIChatQSDataTypeEnum, type AIChatQSData, type AIToolResult } from '../aiRender' +import cloneDeep from 'lodash/cloneDeep' +import { DefaultAIToolResult, DefaultToolResultSummary } from '../defaultConstant' +import { persistToolResultIfTerminal, upsertSessionContent } from '../persist/contentPersistHelper' + +/** + * 工具卡片首次上树用 dispatchStreamingNode;已在 items 里则只 bump renderNum。 + * 全文件此前只调 incrementNodeVersion,未挂树时 UI 永远不出现。 + */ +export const ensureToolResultOnUI = ( + requestInfo: Pick, + toolResult: Extract, +) => { + const { res, chatType, store } = requestInfo + if (store.getState().items[toolResult.id]) { + store.getState().incrementNodeVersion(toolResult.id, 'item') + return + } + store.getState().dispatchStreamingNode({ + chatType, + parentTaskId: toolResult.TaskId, + node: { + token: toolResult.id, + kind: 'item', + type: toolResult.type, + isHistory: res.IsSync, + }, + }) +} + +const handleToolCallStart: AIMessageHandler = (requestInfo) => { + const { res, chatType, store, rawData } = requestInfo + if (res.Type !== 'tool_call_start') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, tool, start_time, start_time_ms } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult: AIToolResult = { + ...cloneDeep(DefaultAIToolResult), + callToolId: call_tool_id, + toolName: tool?.name || '-', + verboseName: tool?.verbose_name_i18n, + toolDescription: tool?.description || '', + startTime: start_time || 0, + startTimeMS: start_time_ms || 0, + } + + rawData.contents.set(call_tool_id, { + ...genBaseAIChatData(res), + id: call_tool_id, + chatType: chatType, + type: AIChatQSDataTypeEnum.TOOL_RESULT, + data: toolResult, + TaskId: generateTaskNodeDataID({ + chatType, + planID: chatType === 'reAct' ? store.getState().currentCasualTaskID : store.getState().taskStatus.taskID, + taskID: res.TaskId, + isExist: (key) => rawData.contents.has(key), + }), + }) +} + +const handleToolCallParam: AIMessageHandler = (requestInfo) => { + const { res, rawData } = requestInfo + if (res.Type !== 'tool_call_param') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, params } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCallParams + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + + toolResult.data.tool.reviewParams = cloneDeep(params) + // 晚到 param(已 result):刷新已上树卡片;未上树则首次挂树 + if (toolResult.data.type === 'result') ensureToolResultOnUI(requestInfo, toolResult) + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) +} + +const handleToolCallWatcher: AIMessageHandler = (requestInfo) => { + const { res, rawData } = requestInfo + if (res.Type !== 'tool_call_watcher') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, id, selectors } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCallWatcher + if (!call_tool_id || !id || !selectors || !selectors?.length) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + // 先获取工具结果数据,从里面拿到stream的EventUUID + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT || !toolResult.data.stream.EventUUID) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + // 通过上面获取到的EventUUID,获取stream数据 + const toolForStreamData = rawData.contents.get(toolResult.data.stream.EventUUID) + if (!toolForStreamData || toolForStreamData.type !== AIChatQSDataTypeEnum.STREAM) { + requestInfo.pushLog({ + level: 'error', + message: `EventUUID: ${toolResult.data.stream.EventUUID} 的stream数据没有对应的初始化`, + }) + return + } + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + toolForStreamData.data.selectors = { + callToolId: call_tool_id, + InteractiveId: id, + selectors: selectors, + } + + if (toolResult.data.type === 'stream') { + // 历史数据-该类型不出发渲染更新 + if (res.IsSync) return + ensureToolResultOnUI(requestInfo, toolResult) + } +} + +const handleToolCallLogDir: AIMessageHandler = (requestInfo) => { + const { res, rawData } = requestInfo + if (res.Type !== 'tool_call_log_dir') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, dir_path } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCallDirPath + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + if (toolResult.data.tool.dirPath) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), dir_path已存在,不能重复设置`, + }) + return + } + + // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + toolResult.data.tool.dirPath = dir_path || '' + if (toolResult.data.tool.status !== 'default') ensureToolResultOnUI(requestInfo, toolResult) + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) +} + +const handleToolCallResult: AIMessageHandler = (requestInfo) => { + const { res, rawData, meta } = requestInfo + if (!['tool_call_user_cancel', 'tool_call_done', 'tool_call_error'].includes(res.Type)) return + const status = + { tool_call_user_cancel: 'user_cancelled', tool_call_done: 'success', tool_call_error: 'failed' }[res.Type] || + 'default' + if (status === 'default') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, ...rest } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + + // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + // 设置工具执行的开始时间、结束时间和持续时间等数据 + toolResult.data.type = 'result' + toolResult.data.startTime = rest.start_time || 0 + toolResult.data.startTimeMS = rest.start_time_ms || 0 + toolResult.data.endTime = rest.end_time || 0 + toolResult.data.endTimeMS = rest.end_time_ms || 0 + toolResult.data.durationMS = rest.duration_ms || 0 + toolResult.data.durationSeconds = rest.duration_seconds || 0 + toolResult.data.tool.status = status as 'user_cancelled' | 'success' | 'failed' | 'default' + /** 触发这个函数说明状态一定不是 processing_params */ + toolResult.data.isProcessingParams = false + + // 设置总结内容,没有就设置成获取中,有就使用获取到的内容 + toolResult.data.tool.summary = toolResult.data.tool.summary || DefaultToolResultSummary[status]?.wait || '' + // 设置执行结果错误数据内容(std_xxx_stderr) + const errorResult = meta.toolStderrStreamData.get(call_tool_id) + if (errorResult && errorResult.status === 'end') { + toolResult.data.tool.execError = errorResult.content + // error数据先出但未存在对应的工具执行结果,工具结果出现后直接使用并删除map中的缓存数据 + meta.toolStderrStreamData.delete(call_tool_id) + } + + ensureToolResultOnUI(requestInfo, toolResult) + upsertSessionContent(requestInfo.sessionId, toolResult.id, toolResult) +} + +const handleToolCallSummary: AIMessageHandler = (requestInfo) => { + const { res, rawData, meta } = requestInfo + if (res.Type !== 'tool_call_summary') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, summary } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + + const statusInfo = toolResult.data.tool.status + const summaryContent = !summary || summary === 'null' ? '' : summary + // 下面的设置: 是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 + // 设置总结内容,没有就设置成默认的状态展示内容,有就使用获取到的内容 + toolResult.data.tool.summary = + statusInfo === 'user_cancelled' + ? '当前工具调用已被取消,会使用当前输出结果进行后续工作决策' + : summaryContent || DefaultToolResultSummary[toolResult.data.tool.status]?.result || '' + // 设置执行结果错误数据内容(std_xxx_stderr) + const errorResult = meta.toolStderrStreamData.get(call_tool_id) + if (errorResult && errorResult.status === 'end') { + toolResult.data.tool.execError = errorResult.content + // error数据先出但未存在对应的工具执行结果,工具结果出现后直接使用并删除map中的缓存数据 + meta.toolStderrStreamData.delete(call_tool_id) + } + if (statusInfo !== 'default') { + ensureToolResultOnUI(requestInfo, toolResult) + } + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) +} + +const handleToolCallStatus: AIMessageHandler = (requestInfo) => { + const { res, rawData } = requestInfo + if (res.Type !== 'tool_call_status') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, status } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + + const isProcessingParams = status === 'processing_params' + if (toolResult.data.isProcessingParams === isProcessingParams) return + + toolResult.data.isProcessingParams = status === 'processing_params' + ensureToolResultOnUI(requestInfo, toolResult) + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) +} + +const handleToolCallReason: AIMessageHandler = (requestInfo) => { + const { res, rawData } = requestInfo + if (res.Type !== 'tool_call_reason') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const { call_tool_id, reason } = JSON.parse(ipcContent) as AIAgentGrpcApi.AIToolCall + if (!call_tool_id) { + requestInfo.pushLog({ level: 'error', message: `${res.Type}数据异常, ${ipcContent}` }) + return + } + + const toolResult = rawData.contents.get(call_tool_id) + if (!toolResult || toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) { + requestInfo.pushLog({ + level: 'error', + message: `${res.Type}数据(call_tool_id:${call_tool_id}), 没有对应的tool_call_start类型初始化`, + }) + return + } + + toolResult.data.tool.reason = reason || '' + if (toolResult.data.type !== 'create') ensureToolResultOnUI(requestInfo, toolResult) + persistToolResultIfTerminal(requestInfo.sessionId, toolResult) +} + +export const aiToolResultDataHandlers = { + tool_call_start: handleToolCallStart, + tool_call_param: handleToolCallParam, + tool_call_watcher: handleToolCallWatcher, + tool_call_log_dir: handleToolCallLogDir, + tool_call_user_cancel: handleToolCallResult, + tool_call_done: handleToolCallResult, + tool_call_error: handleToolCallResult, + tool_call_summary: handleToolCallSummary, + tool_call_status: handleToolCallStatus, + tool_call_reason: handleToolCallReason, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/grpcAIOutputEventHandlers.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/grpcAIOutputEventHandlers.ts new file mode 100644 index 0000000000..dcaee10006 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/grpcAIOutputEventHandlers.ts @@ -0,0 +1,25 @@ +import type { AIMessageHandler } from '../type' +import { aiPerfDataHandlers } from './aiPerf' +import { aiOtherDataHandlers } from './aiOther' +import { aiReviewDataHandlers } from './aiReview' +import { aiToolResultDataHandlers } from './aiToolResult' +import { aiSingleItemDataHandlers } from './aiSingleItem' +import { aiStreamDataHandlers } from './aiStream' +import { aiYakExecResultDataHandlers } from './yakExecResult' +import { aiTaskDetailDataHandlers } from './aiTaskDetail' + +/** + * grpc流数据的各种类型处理逻辑集合 + * 该逻辑集合里的方法处理,没有使用try-catch拦截,因为在hook层进行了同一try-catch拦截 + * 注意!别的地方单独使用时,请自行加入try-catch拦截错误 + */ +export const grpcAIMessageHandlers: Record = { + ...aiPerfDataHandlers, + ...aiOtherDataHandlers, + ...aiReviewDataHandlers, + ...aiToolResultDataHandlers, + ...aiSingleItemDataHandlers, + ...aiStreamDataHandlers, + ...aiYakExecResultDataHandlers, + ...aiTaskDetailDataHandlers, +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/yakExecResult.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/yakExecResult.ts new file mode 100644 index 0000000000..adeb0115a0 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/grpcStreamHandler/yakExecResult.ts @@ -0,0 +1,109 @@ +import type { AIMessageHandler, AIMessageHandlerParams } from '../type' +import type { AIAgentGrpcApi } from '../grpcApi' +import { Uint8ArrayToString } from '@/utils/str' +import { checkStreamValidity, convertCardInfo } from '@/hook/useHoldGRPCStream/useHoldGRPCStream' +import type { StreamResult } from '@/hook/useHoldGRPCStream/useHoldGRPCStreamType' + +const handleStatus: AIMessageHandler = (request) => { + const { res, chatType, store, meta } = request + if (res.Type !== 'structured' || res.NodeId !== 'status') return + if (res.IsSync) return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as { key: string; value: string } + if (data.key === 're-act-loading-status-key') { + if (chatType === 'task') { + // 任务规划-loading展示标题 + store.getState().updateTaskLoadingStatus({ task: data.value || '加载中...' }) + } else { + // 只展示自由对话问题的相关title + if (res.TaskId !== store.getState().currentCasualTaskID) return + // 自由对话-loading展示标题 + store.getState().updateState({ casualTitle: data.value }) + } + } else if (data.key === 'plan-executing-loading-status-key') { + if (chatType === 'task') { + // 任务规划-loading展示标题 + store.getState().updateTaskLoadingStatus({ plan: data.value || '加载中...' }) + } + } else { + const originData = meta.cardKVPair.get(data.key) + if (originData && originData.Timestamp > res.Timestamp) return + + meta.cardKVPair.set(data.key, { + Id: data.key, + Data: data.value, + Timestamp: res.Timestamp, + Tags: [], + }) + + if (meta.cardKVPaidTimer) return + meta.cardKVPaidTimer = setTimeout(() => { + const cacheCard: AIAgentGrpcApi.AIInfoCard[] = convertCardInfo(meta.cardKVPair) + store.getState().updateState({ card: cacheCard }) + meta.cardKVPaidTimer = null + }, 500) + } +} + +const handleCard: (value: AIAgentGrpcApi.AICardMessage, requestInfo: AIMessageHandlerParams) => void = ( + value, + requestInfo, +) => { + const { store, meta } = requestInfo + + const logData = value.content as StreamResult.Log + const checkInfo: AIAgentGrpcApi.AICard = checkStreamValidity(value.content as StreamResult.Log) + if (!checkInfo) return + const { id, data, tags } = checkInfo + const { timestamp } = logData + const originData = meta.cardKVPair.get(id) + if (originData && originData.Timestamp > timestamp) { + return + } + meta.cardKVPair.set(id, { + Id: id, + Data: data, + Timestamp: timestamp, + Tags: Array.isArray(tags) ? tags : [], + }) + + if (meta.cardKVPaidTimer) return + meta.cardKVPaidTimer = setTimeout(() => { + const cacheCard: AIAgentGrpcApi.AIInfoCard[] = convertCardInfo(meta.cardKVPair) + store.getState().updateState({ card: cacheCard }) + meta.cardKVPaidTimer = null + }, 500) +} + +const handleYakExecResult: AIMessageHandler = (requestInfo) => { + const { res, store, meta } = requestInfo + if (res.Type !== 'yak_exec_result') return + + const ipcContent = Uint8ArrayToString(res.Content) || '' + const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIPluginExecResult + + if (!data?.IsMessage) return + const message = data?.Message || '' + const obj: AIAgentGrpcApi.AICardMessage = JSON.parse(Buffer.from(message, 'base64').toString('utf8')) + + if (obj.type !== 'log') return + const content = obj.content as StreamResult.Log + switch (content.level) { + case 'feature-status-card-data': + handleCard(obj, requestInfo) + break + case 'file': + meta.execFileRecordOrder += 1 + store.getState().updateExecFileRecord(res.CallToolID, content, meta.execFileRecordOrder) + break + default: + break + } +} + +/** 插件执行过程的卡片数据和文件操作记录处理逻辑 */ +export const aiYakExecResultDataHandlers = { + status: handleStatus, + yak_exec_result: handleYakExecResult, +} as const diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/aiChatPersistStore.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/aiChatPersistStore.ts new file mode 100644 index 0000000000..18ccc0f135 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/aiChatPersistStore.ts @@ -0,0 +1,353 @@ +import type { AISource, AIAgentGrpcApi } from '../grpcApi' +import type { AIChatQSData, SessionRenderContent } from '../aiRender' +import { + DB_NAME, + DB_VERSION, + INDEX_BY_SESSION_ID, + INDEX_BY_SOURCE, + SESSION_CONTENT_STORE, + SESSION_REFERENCE_STORE, + SESSION_RENDER_STORE, +} from './constants' +import type { + SessionContentUpdater, + SessionReferenceItem, + SessionReferenceRecord, + SessionRenderRecord, + SessionContentRecord, + SessionContentItem, +} from './type' + +/** + * AI 会话独立持久化 Store(IndexedDB) + * - 三表:sessionRender / sessionContent / sessionReference + * - 直接存结构化对象(structured clone),不做整包 JSON.stringify + * - 单例 lazy-open:缓存 dbPromise,读写内 await open() 兜底 + */ +class AIChatPersistStore { + private dbPromise: Promise | null = null + + /** + * 打开(或复用)数据库连接;首次调用时建库建表。 + */ + open(): Promise { + if (!this.dbPromise) { + this.dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION) + req.onerror = () => reject(req.error) + req.onsuccess = () => resolve(req.result) + req.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + // 表1:会话渲染树,主键 [sessionId, source];bySource 便于按来源批量查/删 + if (!db.objectStoreNames.contains(SESSION_RENDER_STORE)) { + const renderStore = db.createObjectStore(SESSION_RENDER_STORE, { + keyPath: ['sessionId', 'source'], + }) + renderStore.createIndex(INDEX_BY_SOURCE, 'source', { unique: false }) + } + + // 表2:会话正文,主键 [sessionId, token];按 session 批量删用 bySessionId + if (!db.objectStoreNames.contains(SESSION_CONTENT_STORE)) { + const contentStore = db.createObjectStore(SESSION_CONTENT_STORE, { + keyPath: ['sessionId', 'token'], + }) + contentStore.createIndex(INDEX_BY_SESSION_ID, 'sessionId', { unique: false }) + } + + // 表3:参考资料,主键 [sessionId, token];按 session 批量删用 bySessionId + if (!db.objectStoreNames.contains(SESSION_REFERENCE_STORE)) { + const refStore = db.createObjectStore(SESSION_REFERENCE_STORE, { + keyPath: ['sessionId', 'token'], + }) + refStore.createIndex(INDEX_BY_SESSION_ID, 'sessionId', { unique: false }) + } + } + }) + } + return this.dbPromise + } + + /** 关闭数据库连接并清空缓存的 Promise */ + async close(): Promise { + if (!this.dbPromise) return + const db = await this.dbPromise + db.close() + this.dbPromise = null + } + + /** 写入/覆盖会话渲染快照(content + grpcOffset 同写) */ + async setSessionRender( + sessionId: string, + source: AISource, + content: SessionRenderContent, + grpcOffset: number, + ): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_RENDER_STORE, 'readwrite') + const store = tx.objectStore(SESSION_RENDER_STORE) + const record: SessionRenderRecord = { sessionId, source, content, grpcOffset } + const req = store.put(record) + req.onsuccess = () => resolve() + req.onerror = () => reject(req.error) + }) + } + + /** 读取会话渲染整行(含 grpcOffset) */ + async getSessionRender(sessionId: string, source: AISource): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_RENDER_STORE, 'readonly') + const store = tx.objectStore(SESSION_RENDER_STORE) + const req = store.get([sessionId, source]) + req.onsuccess = () => { + resolve(req.result as SessionRenderRecord | undefined) + } + req.onerror = () => reject(req.error) + }) + } + + /** 写入/覆盖单条参考资料(token 由调用方在收数时 uuidv4 生成;写入时记录 createdAt) */ + async setSessionReference( + sessionId: string, + token: string, + content: AIAgentGrpcApi.ReferenceMaterialPayload, + ): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_REFERENCE_STORE, 'readwrite') + const store = tx.objectStore(SESSION_REFERENCE_STORE) + const record: SessionReferenceRecord = { + sessionId, + token, + createdAt: Date.now(), + content, + } + const req = store.put(record) + req.onsuccess = () => resolve() + req.onerror = () => reject(req.error) + }) + } + + /** + * 按 token 列表批量取参考资料;缺的跳过,返回按存入时间 createdAt 正序。 + * 主键并行 get(适合一次最多约几十个 token,避免 getAll 整 session 过读)。 + */ + async getSessionReferences(sessionId: string, tokens: string[]): Promise { + if (!tokens.length) return [] + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_REFERENCE_STORE, 'readonly') + const store = tx.objectStore(SESSION_REFERENCE_STORE) + const result: SessionReferenceItem[] = [] + let pending = tokens.length + let failed = false + + tokens.forEach((token) => { + const req = store.get([sessionId, token]) + req.onsuccess = () => { + if (failed) return + const row = req.result as SessionReferenceRecord | undefined + if (row) { + result.push({ + token: row.token, + createdAt: row.createdAt ?? 0, + content: row.content, + }) + } + pending -= 1 + if (pending === 0) { + result.sort((a, b) => a.createdAt - b.createdAt) + resolve(result) + } + } + req.onerror = () => { + if (failed) return + failed = true + reject(req.error) + } + }) + }) + } + + /** 读取单条会话正文 */ + async getSessionContent(sessionId: string, token: string): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_CONTENT_STORE, 'readonly') + const store = tx.objectStore(SESSION_CONTENT_STORE) + const req = store.get([sessionId, token]) + req.onsuccess = () => { + const row = req.result as SessionContentRecord | undefined + resolve(row?.content) + } + req.onerror = () => reject(req.error) + }) + } + + /** 删除单条会话正文(如 QUESTION 前端 uuid 被后端 id 替换后清孤儿行) */ + async deleteSessionContent(sessionId: string, token: string): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_CONTENT_STORE, 'readwrite') + const store = tx.objectStore(SESSION_CONTENT_STORE) + const req = store.delete([sessionId, token]) + req.onsuccess = () => resolve() + req.onerror = () => reject(req.error) + }) + } + + /** + * 按 token 列表批量取会话正文;缺的跳过,返回顺序尽量跟入参 tokens 一致。 + * 主键并行 get(适合一次最多约几十个 token)。 + */ + async getSessionContents(sessionId: string, tokens: string[]): Promise { + if (!tokens.length) return [] + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_CONTENT_STORE, 'readonly') + const store = tx.objectStore(SESSION_CONTENT_STORE) + const result: Array = new Array(tokens.length) + let pending = tokens.length + let failed = false + + tokens.forEach((token, index) => { + const req = store.get([sessionId, token]) + req.onsuccess = () => { + if (failed) return + const row = req.result as SessionContentRecord | undefined + if (row) { + result[index] = { token: row.token, content: row.content } + } + pending -= 1 + if (pending === 0) { + resolve(result.filter((item): item is SessionContentItem => !!item)) + } + } + req.onerror = () => { + if (failed) return + failed = true + reject(req.error) + } + }) + }) + } + + /** + * 同事务 get → updater → put,写入/更新会话正文。 + * updater 收到旧值(可能 undefined),必须返回完整 AIChatQSData。 + */ + async setSessionContent(sessionId: string, token: string, updater: SessionContentUpdater): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction(SESSION_CONTENT_STORE, 'readwrite') + const store = tx.objectStore(SESSION_CONTENT_STORE) + const getReq = store.get([sessionId, token]) + + getReq.onsuccess = () => { + const oldRow = getReq.result as SessionContentRecord | undefined + try { + const next = updater(oldRow?.content) + const record: SessionContentRecord = { sessionId, token, content: next } + const putReq = store.put(record) + putReq.onsuccess = () => resolve(next) + putReq.onerror = () => reject(putReq.error) + } catch (err) { + reject(err) + } + } + getReq.onerror = () => reject(getReq.error) + }) + } + + /** + * 清除指定 session 在三表中的全部记录。 + * - sessionRender:复合主键前缀无法直接 range 删,用游标扫 sessionId + * - sessionContent / sessionReference:走 bySessionId 索引 + */ + async deleteSessionPersist(sessionId: string): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction([SESSION_RENDER_STORE, SESSION_CONTENT_STORE, SESSION_REFERENCE_STORE], 'readwrite') + + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + + // 表1:复合主键 [sessionId, source],用前缀范围删除该 session 全部 source + const renderStore = tx.objectStore(SESSION_RENDER_STORE) + const renderRange = IDBKeyRange.bound([sessionId, ''], [sessionId, '\uffff']) + const renderKeysReq = renderStore.getAllKeys(renderRange) + renderKeysReq.onsuccess = () => { + for (const key of renderKeysReq.result) { + renderStore.delete(key) + } + } + + // 表2 / 表3:索引批量删 + const deleteBySessionIndex = (storeName: string) => { + const store = tx.objectStore(storeName) + const index = store.index(INDEX_BY_SESSION_ID) + const keysReq = index.getAllKeys(IDBKeyRange.only(sessionId)) + keysReq.onsuccess = () => { + for (const key of keysReq.result) { + store.delete(key) + } + } + } + deleteBySessionIndex(SESSION_CONTENT_STORE) + deleteBySessionIndex(SESSION_REFERENCE_STORE) + }) + } + + /** + * 按 source 删除该来源下所有 session 在三表中的全部数据。 + * - 用 sessionRender.bySource 索引直接取该 source 的主键列表(不必全表扫) + * - 删渲染行后,再按 sessionId 清 sessionContent / sessionReference + */ + async deletePersistBySource(source: AISource): Promise { + const db = await this.open() + return new Promise((resolve, reject) => { + const tx = db.transaction([SESSION_RENDER_STORE, SESSION_CONTENT_STORE, SESSION_REFERENCE_STORE], 'readwrite') + + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + + const renderStore = tx.objectStore(SESSION_RENDER_STORE) + const sourceIndex = renderStore.index(INDEX_BY_SOURCE) + + // 等价 SQL: SELECT primaryKey FROM sessionRender WHERE source = ? + const keysReq = sourceIndex.getAllKeys(IDBKeyRange.only(source)) + keysReq.onsuccess = () => { + const keys = keysReq.result as Array<[string, AISource]> + const sessionIds = new Set() + + for (const key of keys) { + sessionIds.add(key[0]) + renderStore.delete(key) + } + + const deleteBySessionIndex = (storeName: string, sessionId: string) => { + const store = tx.objectStore(storeName) + const index = store.index(INDEX_BY_SESSION_ID) + const sessionKeysReq = index.getAllKeys(IDBKeyRange.only(sessionId)) + sessionKeysReq.onsuccess = () => { + for (const key of sessionKeysReq.result) { + store.delete(key) + } + } + } + + for (const sessionId of sessionIds) { + deleteBySessionIndex(SESSION_CONTENT_STORE, sessionId) + deleteBySessionIndex(SESSION_REFERENCE_STORE, sessionId) + } + } + keysReq.onerror = () => reject(keysReq.error) + }) + } +} + +const aiChatPersistStore = new AIChatPersistStore() + +export default aiChatPersistStore diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/constants.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/constants.ts new file mode 100644 index 0000000000..0d34bb8a6f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/constants.ts @@ -0,0 +1,20 @@ +/** AI 会话持久化 IndexedDB 库名(与旧 aiChatMessageDB 隔离) */ +export const DB_NAME = 'aiChatPersistDB' + +/** 数据库版本号(升版本会触发 onupgradeneeded,用于加表/加索引) */ +export const DB_VERSION = 1 + +/** 会话渲染树表:items / groups / tasks / 列表元素 */ +export const SESSION_RENDER_STORE = 'sessionRender' + +/** 会话正文表:整条 AIChatQSData(reference 仅为 token 列表) */ +export const SESSION_CONTENT_STORE = 'sessionContent' + +/** 会话参考资料表:单条 ReferenceMaterialPayload */ +export const SESSION_REFERENCE_STORE = 'sessionReference' + +/** 按 sessionId 批量列出/删除用的索引名(建在 sessionContent / sessionReference) */ +export const INDEX_BY_SESSION_ID = 'bySessionId' + +/** 按 source 查询/删除用的索引名(建在 sessionRender) */ +export const INDEX_BY_SOURCE = 'bySource' diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/contentPersistHelper.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/contentPersistHelper.ts new file mode 100644 index 0000000000..0949c8dff0 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/contentPersistHelper.ts @@ -0,0 +1,118 @@ +import cloneDeep from 'lodash/cloneDeep' +import { AIChatQSDataTypeEnum, type AIChatQSData } from '../aiRender' +import type { AIAgentGrpcApi } from '../grpcApi' +import aiChatPersistStore from './aiChatPersistStore' +import type { SessionContentUpdater } from './type' + +/** + * 同一 sessionId::token 的串行写队列,避免异步 put 未完成又来更新导致丢写。 + * 同时承载 sessionContent 与 sessionReference 两类写(主键结构一致 [sessionId, token]), + * drainSessionContentWrites 按 sessionId 前缀一并排干。 + */ +const contentWriteChains = new Map>() + +const contentKey = (sessionId: string, token: string) => `${sessionId}::${token}` + +function enqueueContentWrite(sessionId: string, token: string, task: () => Promise): Promise { + const key = contentKey(sessionId, token) + const next = (contentWriteChains.get(key) || Promise.resolve()).then(task, task) + contentWriteChains.set(key, next) + next.finally(() => { + if (contentWriteChains.get(key) === next) { + contentWriteChains.delete(key) + } + }) + return next +} + +/** 生成可 structured-clone 的正文快照 */ +export const clonePersistableContent = (data: AIChatQSData): AIChatQSData => { + return cloneDeep(data) +} + +/** + * 写入/覆盖会话正文(入队串行)。 + * next 为完整对象时直接 put;为 updater 时走同事务 get→update→put。 + */ +export const upsertSessionContent = ( + sessionId: string, + token: string, + next: AIChatQSData | SessionContentUpdater, +): Promise => { + return enqueueContentWrite(sessionId, token, async () => { + try { + if (typeof next === 'function') { + await aiChatPersistStore.setSessionContent(sessionId, token, next) + } else { + const snapshot = clonePersistableContent(next) + await aiChatPersistStore.setSessionContent(sessionId, token, () => snapshot) + } + } catch { + // 持久化失败不打断主流程 + } + }) +} + +/** 独立单条首次/更新落库(薄封装,便于各 handler 统一调用) */ +export const persistIndependentItem = (sessionId: string, data: AIChatQSData): Promise => { + return upsertSessionContent(sessionId, data.id, data) +} + +/** + * 删除已落库正文(走同 token 串行队列,避免未完成的 put 在 delete 后又写回孤儿行)。 + * 典型场景:QUESTION 前端 uuid → 后端 taskId 替换。 + */ +export const deletePersistedContent = (sessionId: string, token: string): Promise => { + return enqueueContentWrite(sessionId, token, async () => { + try { + await aiChatPersistStore.deleteSessionContent(sessionId, token) + } catch { + // 持久化失败不打断主流程 + } + }) +} + +/** TOOL_RESULT 终态:success / failed / user_cancelled */ +export const isToolResultTerminalStatus = (status: string | undefined): boolean => { + return status === 'success' || status === 'failed' || status === 'user_cancelled' +} + +/** 工具已终态时追加写正文;未终态不落库 */ +export const persistToolResultIfTerminal = (sessionId: string, toolResult: AIChatQSData): Promise | void => { + if (toolResult.type !== AIChatQSDataTypeEnum.TOOL_RESULT) return + if (!isToolResultTerminalStatus(toolResult.data.tool.status)) return + return upsertSessionContent(sessionId, toolResult.id, toolResult) +} + +/** + * 写入/覆盖参考资料(入队串行,纳入 session 排干)。 + * 复用 contentWriteChains(key=sessionId::refToken),drainSessionContentWrites 按 sessionId 前缀一并排干。 + */ +export const setSessionReferencePersist = ( + sessionId: string, + refToken: string, + data: AIAgentGrpcApi.ReferenceMaterialPayload, +): Promise => { + return enqueueContentWrite(sessionId, refToken, async () => { + try { + await aiChatPersistStore.setSessionReference(sessionId, refToken, data) + } catch { + // 持久化失败不打断主流程 + } + }) +} + +/** + * 排干某 session 所有在飞的正文/参考资料写,resolve 时该 session 的 token 写队列已排空。 + * 整 session 删除前调用,确保 delete 事务排在所有 put 之后,避免 delete 后迟到的 put 又写回孤儿行。 + */ +export const drainSessionContentWrites = (sessionId: string): Promise => { + const prefix = `${sessionId}::` + const chains: Promise[] = [] + for (const [key, chain] of contentWriteChains) { + if (key.startsWith(prefix)) { + chains.push(chain.catch(() => {})) + } + } + return Promise.all(chains) +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/type.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/type.ts new file mode 100644 index 0000000000..e60c939e2f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/persist/type.ts @@ -0,0 +1,43 @@ +import type { AISource, AIAgentGrpcApi } from '../grpcApi' +import type { AIChatQSData, SessionRenderContent } from '../aiRender' + +/** sessionRender 表一行记录 */ +export interface SessionRenderRecord { + sessionId: string + source: AISource + content: SessionRenderContent + /** 与后端事件游标对齐;写树时一并 put */ + grpcOffset?: number +} + +/** sessionContent 表一行记录(content 内 reference 仅为 token 列表) */ +export interface SessionContentRecord { + sessionId: string + token: string + content: AIChatQSData +} + +/** getSessionContents 单条返回 */ +export interface SessionContentItem { + token: string + content: AIChatQSData +} + +/** sessionReference 表一行记录 */ +export interface SessionReferenceRecord { + sessionId: string + token: string + /** 存入时间(毫秒时间戳),取数时按此正序 */ + createdAt: number + content: AIAgentGrpcApi.ReferenceMaterialPayload +} + +/** getSessionReferences 单条返回 */ +export interface SessionReferenceItem { + token: string + createdAt: number + content: AIAgentGrpcApi.ReferenceMaterialPayload +} + +/** setSessionContent 的 updater:同事务内 get → update → put */ +export type SessionContentUpdater = (old: AIChatQSData | undefined) => AIChatQSData diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/type.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/type.ts index 24ab97f588..2b70d9ed9a 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/type.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/type.ts @@ -1,28 +1,20 @@ -import type { StreamResult } from '@/hook/useHoldGRPCStream/useHoldGRPCStreamType' import type { AIChatQSData, AIChatQSDataType, + AIInputNotifyMessage, AIStreamOutput, - AIYakExecFileRecord, - ReActChatBaseInfo, + ChatListRenderType, ReActChatRenderItem, - TodoListCardData, + TaskPlanStatus, } from './aiRender' import type { Dispatch, SetStateAction } from 'react' import type { Domain } from '@/pages/ai-agent/store/constants' -import type { - AIAgentGrpcApi, - AIInputEvent, - AIOutputEvent, - AIOutputI18n, - AISource, - AIStartParams, - AITaskStatusType, -} from './grpcApi' +import type { AIAgentGrpcApi, AIInputEvent, AIOutputEvent, AISource, AIStartParams, AITaskStatusType } from './grpcApi' import type { AIAgentSetting } from '@/pages/ai-agent/aiAgentType' -import type { CustomPluginExecuteFormValue } from '@/pages/plugins/operator/localPluginExecuteDetailHeard/LocalPluginExecuteDetailHeardType' import type { AIChatData } from '@/pages/ai-agent/type/aiChat' import type { ChatDataStore } from '@/pages/ai-agent/store/ChatDataStore' +import { ChatMultiSessionController } from './ChatMultiSessionController' +import type { YakitRouteType } from '@/enums/yakitRoute' // #region 公共 hooks 事件 export interface UseHookBaseParams { @@ -35,12 +27,7 @@ interface UseHookBaseEvents { handleSetData: (res: AIOutputEvent) => void handleResetData: () => void } -export type handleSendFunc = (params: { - request: AIInputEvent - optionValue?: string - extraValue?: AIChatIPCStartParams['extraValue'] - cb?: () => void -}) => void +export type handleSendFunc = (params: { request: AIInputEvent; optionValue?: string; cb?: () => void }) => void interface UseHookStateFunc { getContentMap: (token: string) => AIChatQSData | undefined @@ -56,95 +43,6 @@ export interface UpdateRenderDataParams { } // #endregion -// #region useAIPerfData相关定义 -export interface UseAIPerfDataParams extends UseHookBaseParams {} - -export interface UseAIPerfDataEvents { - handleSetData: (res: AIOutputEvent) => void -} -// #endregion - -// #region useYakExecResult相关定义 -export interface UseYakExecResultParams extends UseHookBaseParams {} - -export interface UseYakExecResultState { - card: AIAgentGrpcApi.AIInfoCard[] - execFileRecord: Map - yakExecResultLogs: StreamResult.Log[] -} -export interface UseYakExecResultEvents extends UseHookBaseEvents { - /** 设置UI展示的列表数据 */ - handleSetYakResult: (newData: UseYakExecResultState) => void -} -// #endregion - -// #region useCasualChat相关定义 -export interface UseCasualChatParams extends UseHookBaseParams { - /** 获取流接口请求参数 */ - getRequest: () => AIAgentSetting | undefined - /** 获取当前自由对话父任务 ID */ - getCurrentCasualTaskID: () => string - /** review 触发回调事件 */ - onReview?: (data: AIChatQSData) => void - /** 触发 review-release 后的回调事件 */ - onReviewRelease: (id: string) => void - /** 收集自由对话里的子任务-taskID */ - onSubTaskID: (taskID: string) => void -} - -export interface UseCasualChatState { - elements: ReActChatRenderItem[] - toolListRenderNumber: number -} -export interface UseCasualChatEvents extends UseHookBaseEvents, UseHookStateFunc { - handleSend: handleSendFunc - /** 用户手动介入逻辑 */ - handleUserManualIntervention: (chatInfo: AIChatQSData) => void - /** 清空todo-list数据 */ - resetPlanDetailsForNewTask: (newTaskId: string) => void -} -// #endregion - -// #region useTaskChat相关定义 -export interface UseTaskChatParams extends UseHookBaseParams { - /** 获取流接口请求参数 */ - getRequest: () => AIAgentSetting | undefined - /** 获取当前任务规划的问题ID信息 */ - getCurrentTaskPlanID: () => TaskChatTaskInfo | undefined - /** review 触发回调事件 */ - onReview?: (data: AIChatQSData) => void - /** plan_review 补充数据 */ - onReviewExtra?: (data: AIAgentGrpcApi.PlanReviewRequireExtra) => void - /** 触发 review-release 后的回调事件 */ - onReviewRelease?: (id: string) => void - /** 向接口发送消息 */ - sendRequest?: (request: AIInputEvent) => void -} - -/** 当前正在执行的任务树 */ -export interface CurrentExecTaskTree { - task_tree: AIAgentGrpcApi.PlanHistory['task_tree'] - root_task_name: AIAgentGrpcApi.PlanHistory['root_task_name'] -} - -export interface UseTaskChatState { - /** 正在执行的任务列表 */ - plan: CurrentExecTaskTree - elements: ReActChatRenderItem[] -} -export interface UseTaskChatEvents extends UseHookBaseEvents, UseHookStateFunc { - handleSend: handleSendFunc - /** grpc接口关闭后的后续处理逻辑 */ - handleCloseGrpc: () => void - /** 当前任务规划结束-触发UI展示结束标识 */ - handlePlanExecEnd: (res: AIOutputEvent) => void - /** 用户手动介入逻辑 */ - handleUserManualIntervention: (chatInfo: AIChatQSData) => void - /** 清空当前任务树 */ - handleResetPlanTree: () => void -} -// #endregion - // #region useChatIPC相关定义 /** 会话类型 */ export type ChatIPCSendType = 'casual' | 'task' | '' @@ -212,13 +110,6 @@ export interface AIQuestionQueues { data: AIAgentGrpcApi.QuestionQueueItem[] } -/** 任务规划-loading状态信息 */ -export interface PlanLoadingStatus { - loading: boolean - plan: string - task: string -} - export interface UseChatIPCState { /** 流执行状态 */ execute: boolean @@ -226,12 +117,10 @@ export interface UseChatIPCState { httpRunTimeIDs: string[] /** 运行时产生risk数据的run_time_id合集 */ riskRunTimeIDs: string[] - /** 插件输出的卡片数据 */ - yakExecResult: UseYakExecResultState /** 自由对话相关数据 */ - casualChat: UseCasualChatState + casualChat: any /** 任务规划相关数据 */ - taskChat: UseTaskChatState + taskChat: any /** 接口运行过程中的数据文件夹合集 */ grpcFolders: AIFileSystemPin[] /** 问题队列信息 */ @@ -241,8 +130,8 @@ export interface UseChatIPCState { /** 记忆列表 */ memoryList: AIAgentGrpcApi.MemoryEntryList - /** 任务规划的loading状态信息 */ - taskStatus: PlanLoadingStatus + /** 任务规划运行态(loading 文案 + 当前活动任务) */ + taskStatus: TaskPlanStatus /** 自由对话的loading 显示的文案 */ casualTitle: string /** 自由对话的是否进行中 */ @@ -250,8 +139,8 @@ export interface UseChatIPCState { /** 场景状态(仅供自由对话[reAct])使用 */ focusMode: string - /** 切换session时的loading状态 */ - switchLoading: boolean + /** 切换/恢复会话 loading(UI 遮罩与禁用交互;无 UserQuery 建连时置 true,hydrate/recovery 结束后 false) */ + initLoading: boolean /** 任务规划历史数据-任务树 */ planHistoryList: AIAgentGrpcApi.PlanHistoryList /** 用户主动取消问题的loading状态(自由对话) */ @@ -259,7 +148,7 @@ export interface UseChatIPCState { /** 用户主动取消问题的loading状态(任务规划) */ cancelTaskLoading: boolean /** 流推送的提示文案(notify / rate-limit),展示时长由 duration 系列字段控制,到期自动清空 */ - notifyMessage: { type: 'notify' | 'rate-limit'; content: string; label: AIOutputI18n } | null + notifyMessage: AIInputNotifyMessage | null /** 请求历史数据相关State */ requestHistoryState: UseAIMessageDataState } @@ -268,8 +157,10 @@ export interface UseChatIPCState { export interface AIChatIPCStartParams { token: string params: AIInputEvent - /** 供前端处理逻辑和UI的额外参数 */ - extraValue?: CustomPluginExecuteFormValue | Record + /** 会话归属路由(不可变) */ + route: YakitRouteType + /** 会话初始归属 pageId(后续可 rebind) */ + pageId: string } /** 执行流途中发送消息的参数 */ @@ -278,7 +169,6 @@ export interface AIChatSendParams { type: ChatIPCSendType params: AIInputEvent optionValue?: string - extraValue?: AIChatIPCStartParams['extraValue'] } /** 任务规划的taskID和状态 */ @@ -287,50 +177,6 @@ export interface TaskChatTaskInfo { status: AITaskStatusType coordinatorId: AIOutputEvent['CoordinatorId'] } - -export interface UseChatIPCEvents { - /** 获取当前执行接口流的唯一标识符 */ - fetchToken: () => string - /** 获取当前执行接口流的请求参数 */ - fetchAIRequest: () => AIStartParams | undefined - /** 获取当前执行的自由对话问题(这个问题可能引起的是任务规划)对应的任务ID */ - fetchCurrentCasualTaskID: () => string - /** 获取当前执行任务规划的问题详情 */ - fetchCurrentTaskPlanID: () => TaskChatTaskInfo | undefined - /** 获取当前外界传入的数据类实例 */ - fetchChatDataStore: () => UseChatIPCParams['cacheDataStore'] - /** 切换历史会话展示 */ - onSwitchChat: (session?: string, isCreate?: boolean) => void - /** 开始执行接口流 */ - onStart: (params: AIChatIPCStartParams, cb?: () => void) => void - /** 向执行中的接口流主动输入信息 */ - onSend: (params: AIChatSendParams) => void - /** 主动结束正在执行中的接口流 */ - onClose: ( - token: string, - option?: { - tip: () => void - }, - ) => void - /** 重置所有数据 */ - onReset: () => void - /** 取消任务规划当前的Review */ - handleTaskReviewRelease: (id: string) => void - /** 删除会话操作的关联逻辑 */ - onDelChats: (session: string[]) => void - /** 用户主动取消问题的loading状态变换 */ - handleCancelLoadingChange: (type: ReActChatBaseInfo['chatType'], status: boolean) => void - /** 清空指定变量数据 */ - handleResetTarget: (target: 'memoryList') => void - /** 用户手动干预的执行事件 */ - handleUserManualIntervention: (chatInfo: AIChatQSData) => void - /** 加载更多历史数据 */ - handleLoadMoreHistory: (chatType: HistoryChatType) => void - /** 是否还有更多历史数据 */ - handleHasMoreHistory: (type: HistoryChatType) => boolean - /** 清除当前任务规划的ID信息 */ - resetCurrentTaskPlanID: () => void -} // #endregion // #region useAIChatLog相关定义 @@ -358,42 +204,13 @@ export interface UseAIChatLogEvents { // #endregion // #region AI-Agent相关grpc流数据处理逻辑 -export interface AIMessageHandlerParams extends UseHookStateFunc { +export interface AIMessageHandlerParams extends ReturnType { + sessionId: string /** grpc流原始数据 */ res: AIOutputEvent - /** 处理数据的额外补充数据 */ - info: { - chatType: ReActChatRenderItem['chatType'] - } - /** 获取当前任务规划的问题ID信息 */ - getCurrentTaskPlanID?: () => TaskChatTaskInfo | undefined - /** 获取流接口请求参数 */ - getRequest: () => AIAgentSetting | undefined - /** 将数据推送到日志集合中 */ - pushLog: UseHookBaseParams['pushLog'] - /** review 类型相关变量和方法 */ - review?: { - /** 设置review数据 */ - handleSetReview?: (newReview: AIChatQSData | undefined) => void - /** 获取review数据 */ - handleGetReview?: () => AIChatQSData | undefined - /** review 触发回调事件 */ - onReview?: UseTaskChatParams['onReview'] - /** plan_review 补充数据 */ - onReviewExtra?: UseTaskChatParams['onReviewExtra'] - /** 触发 review-release 后的回调事件 */ - onReviewRelease?: (id: string) => void - /** 将 review 数据处理成需要展示的UI数据 */ - handleReviewDataToUI?: (reviewInfo: AIChatQSData) => void - /** 向进行中的 grpc 流发送交互消息 */ - sendRequest?: UseTaskChatParams['sendRequest'] - } - /** store数据 */ - getChatDataStore: UseHookBaseParams['getChatDataStore'] - /** 回调方法 */ - callback?: (data: AIOutputEvent) => void - /** 当前自由对话父任务 ID(任务规划请用 getCurrentTaskPlanID) */ - getTaskId?: () => string + chatType: ChatListRenderType + sendRequest: (request: AIInputEvent) => void + pushLog: (log: AIAgentGrpcApi.Log) => void } export type AIMessageHandler = (params: AIMessageHandlerParams) => void // #endregion @@ -404,10 +221,7 @@ export type loadMoreType = keyof AIChatData['beforeID'] export interface AIMessageDataProps { type: Domain getChatStore: UseHookBaseParams['getChatDataStore'] - setContentMap: ( - chatType: ReActChatBaseInfo['chatType'], - ...args: Parameters - ) => void + setContentMap: (chatType: ChatListRenderType, ...args: Parameters) => void setCasualElements: UseHookStateFunc['setElements'] setTaskElements: UseHookStateFunc['setElements'] grpcLoadMore?: (request: { limit: number; start_id?: number }) => void @@ -433,7 +247,7 @@ export interface UseAIMessageDataState { timelinesLoading: boolean } -export type HistoryChatType = ReActChatBaseInfo['chatType'] | 'timelines' +export type HistoryChatType = ChatListRenderType | 'timelines' export interface UseAIMessageDataEvents { /** 给UI使用的hasMore获取方法 */ diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useAIItemKind.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useAIItemKind.ts new file mode 100644 index 0000000000..75708c373a --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useAIItemKind.ts @@ -0,0 +1,31 @@ +import { useMemoizedFn } from 'ahooks' +import { useCurrentStore } from './useCurrentDataBySession' +import { ReActChatItemMeta, ReActChatGroupMeta, ReActChatTaskMeta } from './aiRender' + +function useAIItemKind() { + const store = useCurrentStore() + + const getKind = useMemoizedFn((token: string) => { + const state = store.getState() + return getAIItemKind(state, token) + }) + return getKind +} + +export default useAIItemKind + +export type AIItemKind = 'item' | 'group' | 'task' + +export function getAIItemKind( + state: { + items: Record + groups: Record + tasks: Record + }, + token: string, +): AIItemKind | null { + if (state.items[token]) return 'item' + if (state.groups[token]) return 'group' + if (state.tasks[token]) return 'task' + return null +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useAIPerfData.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useAIPerfData.ts deleted file mode 100644 index d4bc5fa4ef..0000000000 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useAIPerfData.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { useCreation, useMemoizedFn } from 'ahooks' -import { Uint8ArrayToString } from '@/utils/str' -import type { AIContextStatsDetail } from '@/pages/ai-agent/type/aiChat' -import { AIChatLogData, UseAIPerfDataEvents, UseAIPerfDataParams } from './type' -import { handleGrpcDataPushLog } from './utils' -import { AIAgentGrpcApi, AIOutputEvent } from './grpcApi' - -const CONTEXT_STATS_SERIES_MAX = 50 - -/** - * 首次建立 role_order 时,若存在这些 role_name 则按此顺序排在前面。 - * - * P1.1 之后 aireact 已经把老的 semi_dynamic 段彻底拆成 semi_dynamic_1 - * (Skills Context + CacheToolCall) 与 semi_dynamic_2 (Persistent + - * Schema + OutputExample) 两个独立 role;老的 semi_dynamic 已被移除, - * 不再保留兜底位。 - * - * 字节统计图 "从下往上" 的堆叠物理顺序固定为: - * high_static -> frozen_block -> semi_dynamic_1 -> semi_dynamic_2 -> - * timelineOpen -> dynamic - */ -const CONTEXT_STATS_ROLE_NAME_ORDER = [ - 'high_static', - 'frozen_block', - 'semi_dynamic_1', - 'semi_dynamic_2', - 'timelineOpen', - 'dynamic', -] as const - -const trimContextStatsSeries = (d: AIContextStatsDetail['data']) => { - if (!Array.isArray(d.total_prompt_bytes)) d.total_prompt_bytes = [] - if (!Array.isArray(d.total_prompt_tokens)) d.total_prompt_tokens = [] - while (d.times.length > CONTEXT_STATS_SERIES_MAX) { - d.times.shift() - d.total_prompt_bytes.shift() - d.total_prompt_tokens.shift() - for (const name of d.role_order) { - d.role_series[name]?.shift() - d.role_tokens[name]?.shift() - } - } -} - -/** 递归上下文成分里的summary并归类到map对象后消除summary字段内容 */ -const handleSummarySectionsSummary = ( - sections: AIAgentGrpcApi.AIContextSections[], - summaryMap: Map, -) => { - for (let item of sections) { - summaryMap.set(item.key, item.summary || '') - delete item.summary - if (item.children) handleSummarySectionsSummary(item.children, summaryMap) - } -} - -// 属于该 hook 处理数据的类型 -export const UseAIPerfDataTypes = [ - 'consumption', - 'pressure', - 'ai_first_byte_cost_ms', - 'ai_total_cost_ms', - 'prompt_profile', -] - -function useAIPerfData(params?: UseAIPerfDataParams): UseAIPerfDataEvents - -/** 提供 AI 硬件相关性能数据 */ -function useAIPerfData(params?: UseAIPerfDataParams) { - const { pushLog, getChatDataStore } = params || {} - - const handlePushLog = useMemoizedFn((log: AIChatLogData) => { - pushLog?.(log) - }) - - const handleSetData = useMemoizedFn((res: AIOutputEvent) => { - try { - let ipcContent = Uint8ArrayToString(res.Content) || '' - - if (res.Type === 'consumption') { - // 消耗Token - // 因为可能存在多个 ai 并发输出,所以这里的 token 量是一个集合 - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.Consumption - const consumption = getChatDataStore?.()?.aiPerfData?.consumption - if (consumption) { - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - consumption.input_consumption = data.input_consumption - consumption.output_consumption = data.output_consumption - consumption.cache_hit_token = data.cache_hit_token - consumption.tier_consumption = { ...data.tier_consumption } - } - return - } - - if (res.Type === 'pressure') { - // 上下文压力 - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.Pressure - const pressure = getChatDataStore?.()?.aiPerfData?.pressure - if (pressure) { - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - const target = pressure[data.model_tier] - if (!target) { - pressure[data.model_tier] = [{ ...data, timestamp: Number(res.Timestamp) || 0 }] - } else { - target.push({ ...data, timestamp: Number(res.Timestamp) || 0 }) - // if (target.length > 100) target.shift() - } - } - return - } - - if (res.Type === 'ai_first_byte_cost_ms') { - // 首字符响应耗时 - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIFirstCostMS - const firstCost = getChatDataStore?.()?.aiPerfData?.firstCost - if (firstCost) { - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - const target = firstCost[data.model_tier] - if (!target) { - firstCost[data.model_tier] = [{ ...data, timestamp: Number(res.Timestamp) || 0 }] - } else { - target.push({ ...data, timestamp: Number(res.Timestamp) || 0 }) - // if (target.length > 100) target.shift() - } - } - return - } - - if (res.Type === 'ai_total_cost_ms') { - // 总对话耗时 - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AITotalCostMS - const totalCost = getChatDataStore?.()?.aiPerfData?.totalCost - if (totalCost) { - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - const target = totalCost[data.model_tier] - if (!target) { - totalCost[data.model_tier] = [{ ...data, timestamp: Number(res.Timestamp) || 0 }] - } else { - target.push({ ...data, timestamp: Number(res.Timestamp) || 0 }) - // if (target.length > 100) target.shift() - } - } - return - } - - if (res.Type === 'prompt_profile') { - // 上下文字节统计 & 上下文成分(源头:AI 流式输出事件 prompt_profile,经 useChatIPC → handleSetData) - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ContextStatsSections - const stats = getChatDataStore?.()?.aiPerfData?.contextStats - if (stats) { - const d = stats.data - const incomingRoles = Array.isArray(data.role_stats) ? data.role_stats : [] - const ts = Number(res.Timestamp) || 0 - - if (incomingRoles.length > 0) { - // 每个 turn 后端都会按 promptSectionRolesInOrder 推全集 6 个 role - // (P1.1: high_static / frozen_block / semi_dynamic_1 / semi_dynamic_2 / - // timelineOpen / dynamic), 即便 bytes 为 0 也会预填. 所以这里每次 - // 都用 incoming 重新 reconcile role_order, 既能让新出现的 role - // (如 semi_dynamic_1/2) 按 CONTEXT_STATS_ROLE_NAME_ORDER 物理顺序 - // 插入, 也能把 incoming 里彻底消失的老 role (如老的 'semi_dynamic') - // 自动 evict 掉, 避免历史会话残留的图例顺序污染. - const incomingMap = new Map() - for (const r of incomingRoles) { - if (!r.role_name || incomingMap.has(r.role_name)) continue - incomingMap.set(r.role_name, r) - } - - const preferred = new Set(CONTEXT_STATS_ROLE_NAME_ORDER) - const desiredOrder: string[] = [] - for (const name of CONTEXT_STATS_ROLE_NAME_ORDER) { - if (incomingMap.has(name)) desiredOrder.push(name) - } - for (const name of incomingMap.keys()) { - if (!preferred.has(name) && !desiredOrder.includes(name)) { - desiredOrder.push(name) - } - } - - const oldOrderSet = new Set(d.role_order) - const newOrderSet = new Set(desiredOrder) - - // 新出现的 role: 用 0 补齐已积累的 d.times 长度, 让历史时间轴上 - // 这条线从 0 起步, 而不是凭空错位 - for (const name of desiredOrder) { - const r = incomingMap.get(name) - if (!oldOrderSet.has(name)) { - d.role_labels[name] = r?.role_name_zh || name - d.role_series[name] = new Array(d.times.length).fill(0) - } else if (r?.role_name_zh) { - d.role_labels[name] = r.role_name_zh - } - } - // 老 role (如 'semi_dynamic') 在 incoming 中彻底消失 -> 释放 series - for (const name of d.role_order) { - if (!newOrderSet.has(name)) { - delete d.role_labels[name] - delete d.role_series[name] - } - } - - d.role_order = desiredOrder - } - - stats.prompt_bytes = data.prompt_bytes ?? 0 - stats.prompt_tokens = data.prompt_tokens ?? 0 - d.times.push(ts) - d.total_prompt_bytes.push(data.prompt_bytes ?? 0) - d.total_prompt_tokens.push(data.prompt_tokens ?? 0) - - if (d.role_order.length > 0) { - const map = new Map() - const tokenMap = new Map() - for (const r of incomingRoles) { - if (!d.role_order.includes(r.role_name)) continue - map.set(r.role_name, r.role_bytes ?? 0) - tokenMap.set(r.role_name, r.role_tokens ?? 0) - } - for (const name of d.role_order) { - if (!d.role_series[name]) d.role_series[name] = [] - d.role_series[name].push(map.get(name) ?? 0) - } - for (const name of d.role_order) { - if (!d.role_tokens[name]) d.role_tokens[name] = [] - d.role_tokens[name].push(tokenMap.get(name) ?? 0) - } - } - - trimContextStatsSeries(d) - } - - const sections = getChatDataStore?.()?.aiPerfData?.contextSections - const sectionsData = data.sections || [] - if (sections) { - // 这里是直接使用引用设置的值,所以不需要在使用setContentMap设置回去 - // 每次新的后端数据进来,需要重置上次数据逻辑处理的map对象,避免之前数据对当前数据处理造成影响 - const summaryMap = new Map() - handleSummarySectionsSummary(sectionsData, summaryMap) - sections.summary = summaryMap - sections.sections = sectionsData - } - } - } catch (error) { - handleGrpcDataPushLog({ info: res, pushLog: handlePushLog }) - } - }) - - const events: UseAIPerfDataEvents = useCreation(() => { - return { handleSetData } - }, []) - - return events -} - -export default useAIPerfData diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useCasualChat.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useCasualChat.ts deleted file mode 100644 index ebb364f82f..0000000000 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useCasualChat.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { - AIChatLogData, - handleSendFunc, - UseCasualChatEvents, - UseCasualChatParams, - UseCasualChatState, -} from './type' -import type { AIChatQSData, AIReviewType, ReActChatRenderItem } from './aiRender' -import type { AIAgentGrpcApi, AIOutputEvent, AITaskStatusType } from './grpcApi' -import { useRef, useState } from 'react' -import { useCreation, useMemoizedFn } from 'ahooks' -import { Uint8ArrayToString } from '@/utils/str' -import cloneDeep from 'lodash/cloneDeep' -import { genBaseAIChatData, handleGrpcDataPushLog } from './utils' -import { AIChatQSDataTypeEnum } from './aiRender' -import { yakitNotify } from '@/utils/notification' -import useGetSetState from '@/pages/pluginHub/hooks/useGetSetState' -import { grpcAIMessageHandlers } from './grpcAIMessageHandlers' -import { DefaultTodoListCardData, DefaultPlanItemDetailsData } from './defaultConstant' - -function useCasualChat(params: UseCasualChatParams): [UseCasualChatState, UseCasualChatEvents] - -function useCasualChat(params: UseCasualChatParams) { - const { pushLog, getChatDataStore, getRequest, getCurrentCasualTaskID, onReview, onReviewRelease, onSubTaskID } = - params || {} - - const handlePushLog = useMemoizedFn((logInfo: AIChatLogData) => { - pushLog && pushLog(logInfo) - }) - - const [elements, setElements, getElements] = useGetSetState([]) - - const getCasualChat = useMemoizedFn(() => { - return getChatDataStore?.()?.casualChat - }) - const [toolListRenderNumber, setToolListRenderNumber] = useState(0) - /** 新一轮自由对话出队时,重置主任务详情(避免 taskId 仍指向上一次提问) */ - const resetPlanDetailsForNewTask = useMemoizedFn((newTaskId: string) => { - const chatDetail = getCasualChat() - if (!chatDetail) return - chatDetail.planDetails = { - ...cloneDeep(DefaultPlanItemDetailsData), - taskId: newTaskId, - } - setToolListRenderNumber((old) => old + 1) - }) - const getContentMap = useMemoizedFn((mapKey: string) => { - const contentMap = getChatDataStore?.()?.casualChat?.contents - if (!contentMap) return undefined - return contentMap.get(mapKey) - }) - const setContentMap = useMemoizedFn((mapKey: string, value: AIChatQSData) => { - const contentMap = getChatDataStore?.()?.casualChat?.contents - contentMap && contentMap.set(mapKey, value) - }) - - /** 子 agent 任务创建时生成聚合卡片 */ - const handleReactTaskCreated = useMemoizedFn((res: AIOutputEvent, info: AIAgentGrpcApi.CasualCreated) => { - if (!info.react_task_is_sub_agent) return - - const ownTaskId = info.react_task_id - const taskId = getCurrentCasualTaskID?.() - if (!taskId || !ownTaskId) return - const taskKey = `${taskId}-${ownTaskId}` - onSubTaskID?.(ownTaskId) - - const existing = getContentMap(taskKey) - if (existing) return - - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - id: taskKey, - chatType: 'reAct', - type: AIChatQSDataTypeEnum.TASK_NODE_GROUP, - data: { - taskId: ownTaskId, - taskName: info.react_task_name || info.react_user_input || ownTaskId, - goal: info.react_user_input || '', - status: info.react_task_status, - }, - } as AIChatQSData - - setContentMap(chatData.id, chatData) - const chatStore = getChatDataStore?.() - // planDetailsMap / 子任务收集仍按后端子任务 ID(res.TaskId)索引 - if (chatStore && !chatStore.casualChat.planDetailsMap.has(ownTaskId)) { - chatStore.casualChat.planDetailsMap.set(ownTaskId, cloneDeep(DefaultPlanItemDetailsData)) - } - setElements((old) => { - const exists = old.some((item) => item.token === chatData.id && item.type === chatData.type) - if (exists) return old - return [ - ...old, - { token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'reAct', kind: 'task', children: [] }, - ] - }) - }) - - /** 子 agent 任务状态变更时更新聚合卡片 */ - const handleReactTaskStatusChanged = useMemoizedFn((res: AIOutputEvent, info: AIAgentGrpcApi.ReactTaskChanged) => { - const taskId = getCurrentCasualTaskID?.() - const ownTaskId = res.TaskId || info.react_task_id - const taskKey = taskId && ownTaskId ? `${taskId}-${ownTaskId}` : '' - if (!taskKey) return - - const existing = getContentMap(taskKey) - if (!existing || existing.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) return - - existing.data.status = info.react_task_now_status as AITaskStatusType - setContentMap(taskKey, existing) - setElements((old) => - old.map((item) => { - if (item.token === taskKey && item.type === existing.type) { - return { ...item, renderNum: item.renderNum + 1 } - } - return item - }), - ) - }) - - // #region review数据-hook缓存数据 - const review = useRef() - const handleGetReview = useMemoizedFn(() => { - return review.current - }) - const handleSetReview = useMemoizedFn((newReview: AIChatQSData | undefined) => { - review.current = cloneDeep(newReview) - }) - // #endregion - - // 处理数据方法 - const handleSetData = useMemoizedFn((res: AIOutputEvent) => { - try { - const ipcContent = Uint8ArrayToString(res.Content) || '' - - if (res.Type === 'structured' && res.NodeId === 'react_task_created') { - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.CasualCreated - handleReactTaskCreated(res, data) - return - } - - if (res.Type === 'structured' && res.NodeId === 'react_task_status_changed') { - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.ReactTaskChanged - handleReactTaskStatusChanged(res, data) - return - } - - let funcKey = res.Type - if (res.Type === 'report_finish' && res.NodeId === 'report-finish') { - funcKey = res.NodeId - } - if (res.Type === 'structured' && res.NodeId === 'stream-finished') { - // stream数据结束标识 - funcKey = res.NodeId - } else if (res.Type === 'structured' && res.NodeId === 'react_task_dequeue') { - // 用户问题开始执行标识 - funcKey = res.NodeId - } else if (res.Type === 'api_request_failed' && res.NodeId === 'ai_call_failure') { - funcKey = res.Type - } else if (res.Type === 'structured' && res.NodeId === 'capability_inventory') { - funcKey = res.NodeId - } else if (res.Type === 'perception' && res.NodeId === 'perception') { - funcKey = res.Type - } else if (res.Type === 'current_task_todo_list_update' && res.NodeId === 'current_task_todo_list') { - funcKey = res.Type - } else if (res.NodeId === 'session_snapshot') { - funcKey = res.NodeId - } - const handleFunc = grpcAIMessageHandlers[funcKey || ''] - if (handleFunc) { - handleFunc({ - res, - info: { chatType: 'reAct' }, - getRequest, - setElements, - getElements, - setContentMap, - getContentMap, - pushLog: handlePushLog, - review: { - handleGetReview, - handleSetReview, - onReview, - onReviewRelease, - }, - getTaskId: getCurrentCasualTaskID, - getChatDataStore, - callback: (data) => { - if (data.Type === 'current_task_todo_list_update' && data.NodeId === 'current_task_todo_list') { - setToolListRenderNumber((old) => old + 1) - } - }, - }) - return - } - - // 未识别类型全部归档到日志处理 - handleGrpcDataPushLog({ info: res, pushLog: handlePushLog }) - } catch (error) { - handleGrpcDataPushLog({ - info: res, - pushLog: handlePushLog, - }) - } - }) - - // 用户问题或review的主动操作 - const handleSend: handleSendFunc = useMemoizedFn(({ request, optionValue, cb }) => { - try { - const { IsInteractiveMessage, InteractiveId } = request - if (IsInteractiveMessage && InteractiveId) { - if (!review.current || (review.current.data as AIReviewType)?.id !== InteractiveId) { - yakitNotify('error', '未获取到 review 信息, 操作无效') - return - } - - ;(review.current.data as AIReviewType).selected = request.InteractiveJSONInput - ;(review.current.data as AIReviewType).optionValue = optionValue - - const chatData = cloneDeep(review.current) - review.current = undefined - setContentMap(chatData.id, chatData) - setElements((old) => { - const newArr = old.map((item) => { - if (chatData.taskId && item.token === chatData.taskId && item.kind === 'task') { - const newChild = item.children.map((subItem) => { - if (subItem.token === chatData.id && subItem.type === chatData.type) { - return { ...subItem, renderNum: subItem.renderNum + 1 } - } - return subItem - }) - return { ...item, children: newChild, renderNum: item.renderNum + 1 } - } else if (item.token === chatData.id && item.type === chatData.type) { - return { ...item, renderNum: item.renderNum + 1 } - } else { - return item - } - }) - return newArr - }) - } - cb && cb() - } catch (error) {} - }) - - const handleResetData = useMemoizedFn(() => { - review.current = undefined - getChatDataStore?.()?.casualChat.planDetailsMap.clear() - setElements([]) - setToolListRenderNumber(0) - }) - - /** 用户手动介入逻辑 */ - const handleUserManualIntervention = useMemoizedFn((chatInfo: AIChatQSData) => { - try { - setContentMap(chatInfo.id, cloneDeep(chatInfo)) - setElements((old) => [ - ...old, - { token: chatInfo.id, type: chatInfo.type, renderNum: 1, chatType: 'reAct', kind: 'item' }, - ]) - } catch (error) { - yakitNotify('error', `用户手动干预操作失败: ${error}`) - } - }) - - const state: UseCasualChatState = useCreation(() => { - return { elements, toolListRenderNumber } - }, [elements, toolListRenderNumber]) - - const events: UseCasualChatEvents = useCreation(() => { - return { - handleSetData, - handleResetData, - handleSend, - getContentMap, - setContentMap, - setElements: setElements, - getElements: getElements, - handleUserManualIntervention, - resetPlanDetailsForNewTask, - } - }, []) - - return [state, events] as const -} - -export default useCasualChat diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useChatIPC.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useChatIPC.ts index 6a7b1ef55b..30ba6b9b32 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useChatIPC.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useChatIPC.ts @@ -1,1419 +1,59 @@ -import type { - AIChatIPCNotifyMessage, - AIChatIPCStartParams, - AIChatSendParams, - AIFileSystemPin, - AIMessageDataProps, - AIQuestionQueues, - HistoryChatType, - PlanLoadingStatus, - TaskChatTaskInfo, - UseCasualChatEvents, - UseChatIPCEvents, - UseChatIPCParams, - UseChatIPCState, - UseHookBaseParams, - UseTaskChatEvents, -} from './type' -import type { - AIAgentGrpcApi, - AIEventQueryRequest, - AIInputEvent, - AIOutputEvent, - AIOutputI18n, - AIStartParams, -} from './grpcApi' -import type { AIChatData } from '@/pages/ai-agent/type/aiChat' -import type { DeepPartial } from '@/pages/ai-agent/store/ChatDataStore' -import { AIChatQSDataTypeEnum, type AIChatQSData, type ReActChatBaseInfo } from './aiRender' - -import { useEffect, useRef, useState } from 'react' +// useChatIPC.ts +import { useEffect } from 'react' +import { globalSessionEngine } from './ChatMultiSessionController' +import type { AIChatSendParams } from './type' +import { useMemoizedFn } from 'ahooks' +import type { UseChatIPCStartParams } from '@/pages/ai-agent/useContext/AIAgentContext' +import type { YakitRouteType } from '@/enums/yakitRoute' import { yakitNotify } from '@/utils/notification' -import { useCreation, useInterval, useMemoizedFn, useThrottleFn } from 'ahooks' -import { Uint8ArrayToString } from '@/utils/str' -import useGetSetState from '@/pages/pluginHub/hooks/useGetSetState' -import useAIPerfData, { UseAIPerfDataTypes } from './useAIPerfData' -import useCasualChat from './useCasualChat' -import useYakExecResult, { UseYakExecResultTypes } from './useYakExecResult' -import useTaskChat from './useTaskChat' -import { genBaseAIChatData, genErrorLogData, genExecTasks, handleGrpcDataPushLog } from './utils' -import { AITaskStatus, AIInputEventSyncTypeEnum } from './grpcApi' -import useAIChatLog from './useAIChatLog' -import cloneDeep from 'lodash/cloneDeep' -import { - convertNodeIdToVerbose, - DefaultAIQuestionQueues, - DefaultMemoryList, - DefaultPlanHistoryList, - DefaultPlanLoadingStatus, -} from './defaultConstant' -import useThrottleState from '@/hook/useThrottleState' -import { aiSystemStreamStore } from '@/store/aiSystemStream' -import { grpcQueryAIEvent } from '@/pages/ai-agent/grpc' -import useAINodeLabel from './useAINodeLabel' -import { formatAIAgentSetting } from '@/pages/ai-agent/utils' -import { handleResetForNewSession } from './grpcAIMessageHandlers' -import useAIMessageData from './useAIMessageData' -import { getDomainFromAISource } from './useGetChatDataStoreKey' -import { v4 as uuidv4 } from 'uuid' -import moment from 'moment' -import { AttachedResourceKeyEnum, AttachedResourceTypeEnum } from '@/pages/ai-agent/defaultConstant' - const { ipcRenderer } = window.require('electron') -function useChatIPC(params?: UseChatIPCParams): [UseChatIPCState, UseChatIPCEvents] - -function useChatIPC(params?: UseChatIPCParams) { - const { - autoConnect, - cacheDataStore, - setSessionChatName, - onTaskStart, - onTaskReview, - onTaskReviewExtra, - onReviewRelease, - onEnd, - onSyncIDChange, - getSetting, - onHttpFuzzRequestChange, - onGetHttpFlowFuzzStatus, - onYaklangCodeChange, - aiSource = 'ai', - } = params || {} - - const { getLabelByParams } = useAINodeLabel() - - // #region 全局公共方法集合 - /** 自由对话(ReAct)-review 信息的自动释放 */ - const handleCasualReviewRelease = useMemoizedFn((id: string) => { - onReviewRelease && onReviewRelease('casual', id) - }) - // 任务规划-review 信息的自动释放 - const handleTaskReviewRelease = useMemoizedFn((id: string) => { - onReviewRelease && onReviewRelease('task', id) - }) - - /** 消息通知提醒弹框 */ - const handleNotifyMessage = useMemoizedFn((message: AIChatIPCNotifyMessage) => { - const { NodeIdVerbose, Content } = message - const verbose = getLabelByParams(NodeIdVerbose) - yakitNotify('info', { - message: verbose, - description: Content, - }) - }) - - /** 向进行中的grpc流接口发送请求 */ - const sendRequest = useMemoizedFn((request: AIInputEvent) => { - if (!chatID.current) return - // console.log('send-ai-re-act---\n', chatID.current, request) - ipcRenderer.invoke('send-ai-re-act', chatID.current, request) - }) - - /** 获取当前会话数据集类实例 */ - const fetchChatDataStore = useMemoizedFn(() => { - return cacheDataStore - }) - // #endregion - - // #region 全局状态变量 - /** 通信的唯一标识符 */ - const chatID = useRef('') - const fetchToken = useMemoizedFn(() => { - return chatID.current - }) - - /** 启动流接口的请求参数 */ - const aiRequest = useRef() - const fetchAIRequest = useMemoizedFn(() => { - return cloneDeep(aiRequest.current) - }) - const handleResetAIRequest = useMemoizedFn(() => { - aiRequest.current = undefined - }) - - /** 建立grpc连接的初次问题数据 */ - const firstQS = useRef() - /** ping请求的计时器 */ - const pingTimerRef = useRef | null>(null) - /** ping请求的syncID - * 初次ping请求没有id,因为初次在主进程进行的 - * 后续两次ping都有对应的syncID,在该文件进行的 +export function useChatIPC(route: YakitRouteType, pageId: string) { + /** + * isSessionReady 已连则直接返回(不动已有监听)→ 用入参 token 挂监听 → handleStartSession + * prepare 异步,invoke 晚于本同步栈挂监听,不会丢流;token 不依赖 React 闭包里的 SessionID */ - const pingSyncID = useRef('') - const handlePingTimer = useMemoizedFn(() => { - if (pingTimerRef.current) clearInterval(pingTimerRef.current) - pingTimerRef.current = setInterval(() => { - pingSyncID.current = uuidv4() - sendRequest({ - IsSyncMessage: true, - SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PING, - SyncID: pingSyncID.current, - }) - }, 5000) - }) - const resetPingTimer = useMemoizedFn(() => { - if (pingTimerRef.current) clearInterval(pingTimerRef.current) - pingTimerRef.current = null - pingSyncID.current = '' - }) - - /** 获取全部聊天数据 */ - const getChatDataStore: UseHookBaseParams['getChatDataStore'] = useMemoizedFn(() => { - if (!chatID.current) return - return cacheDataStore?.get(chatID.current) - }) - - // 通信的状态 - const [execute, setExecute, getExecute] = useGetSetState(false) - // #endregion - - // #region 接口更新的(文件|文件夹)数据集合 - const [grpcFolders, setGrpcFolders] = useState([]) - const handleSetGrpcFolders = useMemoizedFn((info: AIFileSystemPin) => { - setGrpcFolders((old) => { - const isExist = old.find((item) => item.path === info.path) - if (!!isExist) return old - return [...old, info] - }) - }) - - const handleResetGrpcFile = useMemoizedFn(() => { - setGrpcFolders([]) - }) - // #endregion - - // #region grpc流里所有的runtimeIDs集合 - // http数据的run_time_id合集 - const [httpRunTimeIDs, setHttpRunTimeIDs] = useState([]) - // risk数据的run_time_id合集 - const [riskRunTimeIDs, setRiskRunTimeIDs] = useState([]) - - const handleResetRunTimeIDs = useMemoizedFn(() => { - setHttpRunTimeIDs([]) - setRiskRunTimeIDs([]) - }) - // #endregion - - // #region 问题队列相关逻辑 - // 问题队列(自由对话专属)[todo: 后续存在任务规划的问题队列后,需要放入对应的hook中进行处理和储存] - const [questionQueue, setQuestionQueue] = useState(cloneDeep(DefaultAIQuestionQueues)) - - const handleResetQuestionQueue = useMemoizedFn(() => { - setQuestionQueue(cloneDeep(DefaultAIQuestionQueues)) - }) - // #endregion - - // #region 实时记忆列表相关逻辑 - const reactMemorys = useRef(cloneDeep(DefaultMemoryList)) - const taskMemorys = useRef(cloneDeep(DefaultMemoryList)) - const [memoryList, setMemoryList] = useState(cloneDeep(DefaultMemoryList)) - - const handleResetMemoryList = useMemoizedFn(() => { - reactMemorys.current = cloneDeep(DefaultMemoryList) - taskMemorys.current = cloneDeep(DefaultMemoryList) - setMemoryList(cloneDeep(DefaultMemoryList)) - }) - // #endregion - - // #region 时间线相关逻辑 - // 实时时间线 - const [reActTimelines, setReActTimelines] = useThrottleState([], { wait: 100 }) - - const handleResetReActTimelines = useMemoizedFn(() => { - setReActTimelines(() => []) - }) - // #endregion - - // #region 系统信息流展示相关逻辑 - /** 记录都存在过的系统信息uuid, 只展示最新的一条系统信息 */ - const handleSetSystemStream = useMemoizedFn((uuid: string, content: string) => { - aiSystemStreamStore.appendChunk(uuid, content) - }) - const handleResetSystemStream = useMemoizedFn(() => { - aiSystemStreamStore.reset() - }) - // #endregion - - // #region 场景状态相关逻辑 - const [focusMode, setFocusMode] = useState('') - const handleFocusModeChange = useMemoizedFn((mode: string) => { - setFocusMode(mode) - }) - - const handleResetFocusMode = useMemoizedFn(() => { - setFocusMode('') - }) - // #endregion - - // #region 通知消息相关逻辑 - const [notifyMessage, setNotifyMessage] = useState(null) - const notifyMessageTimerRef = useRef | null>(null) - const handleSetNotifyMessage = useMemoizedFn((raw: AIAgentGrpcApi.Notify, label: AIOutputI18n) => { - if (notifyMessageTimerRef.current !== null) { - clearTimeout(notifyMessageTimerRef.current) - notifyMessageTimerRef.current = null - } - const { type, content } = raw - setNotifyMessage({ type, content, label }) - - let durationMs = 0 - if (typeof raw.duration_ms === 'number' && !Number.isNaN(raw.duration_ms) && raw.duration_ms > 0) { - durationMs = raw.duration_ms - } else if ( - typeof raw.duration_seconds === 'number' && - !Number.isNaN(raw.duration_seconds) && - raw.duration_seconds > 0 - ) { - durationMs = raw.duration_seconds * 1000 - } else if (typeof raw.duration === 'number' && !Number.isNaN(raw.duration) && raw.duration > 0) { - durationMs = raw.duration * 1000 - } - if (durationMs > 0) { - notifyMessageTimerRef.current = setTimeout(() => { - notifyMessageTimerRef.current = null - setNotifyMessage(null) - }, durationMs) - } - }) - const handleResetNotifyMessage = useMemoizedFn(() => { - if (notifyMessageTimerRef.current !== null) { - clearTimeout(notifyMessageTimerRef.current) - notifyMessageTimerRef.current = null - } - setNotifyMessage(null) - }) - // #endregion - - // #region 历史任务规划列表相关逻辑 - const [planHistoryList, setPlanHistoryList] = useState( - cloneDeep(DefaultPlanHistoryList), - ) - const handlePlanHistoryListChange = useMemoizedFn((list: AIAgentGrpcApi.PlanHistoryList) => { - try { - const arr = cloneDeep(list.records) - if (!arr || arr.length === 0) { - setPlanHistoryList({ ...list }) - return - } - const newArr = arr - .map((item) => { - // 因为后端给过来的task_progress是一个json的string类型数据 - item.task_progress = JSON.parse(item.task_progress as unknown as string) as AIAgentGrpcApi.PlanHistoryProgress - // 因为后端给过来的task_tree是一个json的string类型数据,所以需要转换成树形结构的数据,供UI展示使用 - const tree = JSON.parse(item.task_tree as unknown as string) as AIAgentGrpcApi.PlanTask - // 记录任务虎根节点的名字,供UI展示使用 - item.root_task_name = tree.name - item.task_tree = genExecTasks(tree) - return item - }) - .filter((item) => item.task_progress.phase !== 'Completed') - setPlanHistoryList({ ...list, records: newArr }) - } catch (error) {} - }) - const handleResetPlanHistoryList = useMemoizedFn(() => { - setPlanHistoryList(cloneDeep(DefaultPlanHistoryList)) - }) - // #endregion - - // #region 单次流执行时的输出展示数据 - // 日志 - const logEvents = useAIChatLog() - - // AI性能相关数据和逻辑 - const aiPerfDataEvent = useAIPerfData({ - pushLog: logEvents.pushLog, - getChatDataStore, - }) - // 执行过程中插件输出的卡片 - const [yakExecResult, yakExecResultEvent] = useYakExecResult({ - pushLog: logEvents.pushLog, - getChatDataStore, - }) - // #endregion - - // #region 自由对话(ReAct)相关变量和hook - /** 当前执行问题的task_id */ - const currentCasualTaskID = useRef('') - const fetchCurrentCasualTaskID = useMemoizedFn(() => { - return currentCasualTaskID.current - }) - - /** 记录所有自由对话的子任务-taskID */ - const casualSubTaskIDs = useRef>(new Set()) - - /** 用户主动关闭当前问题的loading状态(自由对话) */ - const [cancelCasualLoading, setCancelCasualLoading] = useState(false) - - /** 自由对话(ReAct)的loading状态 */ - /** 自由对话loading状态中的显示文案 */ - const [casualTitle, setCasualTitle] = useState('') - /** 自由对话是否在进行中 */ - const [casualLoading, setCasualLoading] = useState(false) - const handleResetCasualStatus = useMemoizedFn(() => { - setCasualLoading(false) - setCasualTitle('') - return - }) - - const [casualChat, casualChatEvent] = useCasualChat({ - pushLog: logEvents.pushLog, - getChatDataStore, - getRequest: fetchAIRequest, - getCurrentCasualTaskID: fetchCurrentCasualTaskID, - onReview: onTaskReview, - onReviewRelease: handleCasualReviewRelease, - onSubTaskID: (taskID) => { - casualSubTaskIDs.current.add(taskID) - }, - }) - // #endregion - - // #region 任务规划相关变量和hook - /** 任务规划对应的问题信息, 供UI使用,因为任务结束后,该变量不会清空 */ - const currentTaskPlanID = useRef() - const fetchCurrentTaskPlanID = useMemoizedFn(() => { - return currentTaskPlanID.current - }) - const resetCurrentTaskPlanID = useMemoizedFn(() => { - currentTaskPlanID.current = undefined - }) - - /** 用户主动(关闭/恢复)当前问题的loading状态(任务规划) */ - const [cancelTaskLoading, setCancelTaskLoading] = useState(false) - - /** 任务规划的loading状态 */ - const [taskStatus, setTaskStatus] = useState(cloneDeep(DefaultPlanLoadingStatus)) - const handleResetTaskStatus = useMemoizedFn(() => { - setTaskStatus(cloneDeep(DefaultPlanLoadingStatus)) - }) - - const [taskChat, taskChatEvent] = useTaskChat({ - pushLog: logEvents.pushLog, - getChatDataStore, - getRequest: fetchAIRequest, - getCurrentTaskPlanID: fetchCurrentTaskPlanID, - onReview: onTaskReview, - onReviewExtra: onTaskReviewExtra, - onReviewRelease: handleTaskReviewRelease, - sendRequest: sendRequest, - }) - // #endregion - - /** 向对应列表(自由对话|任务规划)里的map设置数据 */ - const handleSetContentMap: AIMessageDataProps['setContentMap'] = useMemoizedFn((chatType, token, content) => { - if (chatType === 'reAct') { - casualChatEvent.setContentMap(token, content) - } else if (chatType === 'task') { - taskChatEvent.setContentMap(token, content) - } - }) - - // #region 历史数据的请求hook - const [requestState, requestEvents] = useAIMessageData({ - type: getDomainFromAISource(aiSource), - getChatStore: getChatDataStore, - setContentMap: handleSetContentMap, - setCasualElements: casualChatEvent.setElements, - setTaskElements: taskChatEvent.setElements, - grpcLoadMore: (request) => { - sendRequest({ - IsSyncMessage: true, - SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_RECOVERY_HISTORY, - SyncJsonInput: JSON.stringify(request), - }) - }, - setGrpcFiles: setGrpcFolders, - setTimelines: setReActTimelines, - }) - - /** 请求更多数据加载 */ - const handleLoadMore: UseChatIPCEvents['handleLoadMoreHistory'] = useMemoizedFn((chatType: HistoryChatType) => { - if (!chatID.current) return - return requestEvents.handleLoadMore(chatID.current, chatType) - }) - /** 是否还有更多历史数据 */ - const handleHasMore: UseChatIPCEvents['handleHasMoreHistory'] = useMemoizedFn((chatType: HistoryChatType) => { - if (!chatID.current) return false - return requestEvents.handleHasMore(chatType) - }) - // #endregion - - /** 用户主动取消问题的loading状态变换 */ - const handleCancelLoadingChange = useMemoizedFn((type: ReActChatBaseInfo['chatType'], status: boolean) => { - if (type === 'reAct') { - setCancelCasualLoading(status) - } else { - setCancelTaskLoading(status) - } - }) - - // #region 问题和问题队列相关逻辑 - /** 更新问题队列状态 */ - const handleTriggerQuestionQueueRequest = useThrottleFn( - () => { - sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO }) - }, - { wait: 50, leading: false }, - ).run - - // 问题队列清空操作-进行通知逻辑 - const handleReActTaskCleared = useMemoizedFn((res: AIOutputEvent) => { - try { - const { Type, NodeId, NodeIdVerbose, Timestamp } = res - handleNotifyMessage({ - Type, - NodeId, - NodeIdVerbose, - Timestamp, - Content: '已清空所有任务队列数据', - }) - } catch (error) { - handleGrpcDataPushLog({ - info: res, - pushLog: logEvents.pushLog, - }) - } - }) - // #endregion - - // #region review事件相关方法 - /** review 界面选项触发事件 */ - const onSend = useMemoizedFn(({ token, type, params, optionValue, extraValue }: AIChatSendParams) => { - try { - if (!getExecute()) { - yakitNotify('warning', 'AI 未执行任务,无法发送选项') - return - } - if (!chatID.current || chatID.current !== token) { - yakitNotify('warning', '该选项非本次 AI 执行的回答选项') - return - } - - if (params.IsConfigHotpatch) { - aiRequest.current = { ...(aiRequest.current || {}), ...(params.Params || {}) } - } - if (params.IsFreeInput) { - setCasualTitle('等待回复中...') - // 如果自由对话引起了任务规划,那么自由对话其实是空闲状态 - const isCasualIdle = casualLoading && currentCasualTaskID.current === currentTaskPlanID.current?.taskID - if (!casualLoading || isCasualIdle) { - const chatID = uuidv4() - const AttachedResourceInfos = params.AttachedResourceInfo || [] - AttachedResourceInfos.push({ - Key: AttachedResourceKeyEnum.CONTEXT_PROVIDER_KEY_DEFAULT, - Type: AttachedResourceTypeEnum.USER_FREE_INPUT_UUID, - Value: chatID, - }) - params.AttachedResourceInfo = AttachedResourceInfos - const chatData: AIChatQSData = { - id: chatID, - chatType: 'reAct', - type: AIChatQSDataTypeEnum.QUESTION, - Timestamp: moment().unix(), - data: { qs: params.FreeInput || '', setting: {} }, - AIService: '', - AIModelName: '', - // showQS为了UI渲染方便,重新构建的字段 - extraValue: { showQS: params.FreeInput || '' }, - } - casualChatEvent.setContentMap(chatData.id, chatData) - casualChatEvent.setElements((old) => { - return [ - ...old, - { token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'reAct', kind: 'task', children: [] }, - ] - }) - } - } - - switch (type) { - case 'casual': - case 'task': - const events: UseCasualChatEvents | UseTaskChatEvents = type === 'casual' ? casualChatEvent : taskChatEvent - events.handleSend({ - request: params, - optionValue, - extraValue, - cb: () => { - sendRequest(params) - }, - }) - break - - default: - sendRequest(params) - break - } - } catch (error) {} - }) - // #endregion - - // #region 外界进行删除会话数据操作时的重置逻辑 - const delChats = useRef([]) - const onDelChats = useMemoizedFn(async (session: string[]) => { - const filterSessions = session.filter((item) => !delChats.current.includes(item)) - delChats.current.push(...filterSessions) - - let failedSessions: string[] = [] - let err: any = null - for (let item of filterSessions) { - try { - cacheDataStore?.remove(item) - } catch (error) { - failedSessions.push(item) - err = error - } - } - await requestEvents.handleDeleteSession(filterSessions) - if (failedSessions.length > 0 && !!err) { - yakitNotify('error', `删除会话(${failedSessions.join(',')})失败: ${err}`) - } - }) - // #endregion - - /** grpc接口流断开瞬间, 需要将状态相关变量进行重置 */ - const handleResetGrpcStatus = useMemoizedFn(() => { - resetPingTimer() - taskChatEvent.handleCloseGrpc() - setExecute(false) - setCasualLoading(false) - handleResetTaskStatus() - }) - - /** 流接口开始前需要重置的一些状态 */ - const handleResetBeforeStart = useMemoizedFn(() => { - // 清空场景 - handleResetFocusMode() - // 清空自由对话相关的ID - currentCasualTaskID.current = '' - // 清空任务规划相关的ID - resetCurrentTaskPlanID() - taskChatEvent.handleResetPlanTree() - }) - - /** 重置所有数据 */ - const onReset = useMemoizedFn(() => { - chatID.current = '' - handleResetAIRequest() - setExecute(false) - handleResetGrpcFile() - handleResetRunTimeIDs() - handleResetQuestionQueue() - handleResetMemoryList() - handleResetReActTimelines() - handleResetSystemStream() - handleResetFocusMode() - handleResetNotifyMessage() - handleResetPlanHistoryList() - currentCasualTaskID.current = '' - casualSubTaskIDs.current.clear() - handleResetCasualStatus() - resetCurrentTaskPlanID() - handleResetTaskStatus() - - setCancelCasualLoading(false) - setCancelTaskLoading(false) - yakExecResultEvent.handleResetData() - casualChatEvent.handleResetData() - taskChatEvent.handleResetData() - - // 清除类型处理方法库里的临时数据 - handleResetForNewSession() - // 重置历史数据请求 - requestEvents.handleReset() - }) - - /** 建立会话连接后需要同步的数据 */ - const handleSyncDataAfterConnect = useMemoizedFn(() => { - // 获取任务规划历史任务树 - sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS }) - }) - - /** 需要轮询获取最新的数据请求 */ - const handleStartSyncDataInterval = useMemoizedFn(() => { - // 获取最新问题队列数据 - sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_QUEUE_INFO }) - // 获取最新记忆列表数据 - sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_MEMORY_CONTEXT }) - }) - - /** 保存state类型的数据 */ - const saveStateDataOfEnd = useMemoizedFn((session: string) => { - if (delChats.current.includes(session)) { - // 该session对应的会话数据实例已被删除 - delChats.current = delChats.current.filter((item) => item !== session) - return - } - - const answer: DeepPartial = { - httpRunTimeIDs: cloneDeep(httpRunTimeIDs), - riskRunTimeIDs: cloneDeep(riskRunTimeIDs), - yakExecResult: cloneDeep(yakExecResult), - casualChat: cloneDeep(casualChat), - taskChat: cloneDeep(taskChat), - grpcFolders: cloneDeep(grpcFolders), - reActTimelines: cloneDeep(reActTimelines), - } - try { - cacheDataStore?.updater(session, answer) - const store = getChatDataStore() - if (!store) return - requestEvents.handleSave(session, { - casualElements: store.casualChat.elements, - taskElements: store.taskChat.elements, - casualContentMap: store.casualChat.contents, - taskContentMap: store.taskChat.contents, - }) - } catch {} - }) - - /** 获取对应session会话的流数据最新ID, 用于获取历史数据的偏移量 */ - const handleGetLatestID = useMemoizedFn(async (session: string) => { - let lastID = 0 - try { - const request: AIEventQueryRequest = { - Filter: { - SessionID: session, - }, - Pagination: { Page: 1, Limit: 1, OrderBy: 'created_at', Order: 'desc' }, - } - const { Events } = await grpcQueryAIEvent(request) - if (Events.length) lastID = Number(Events[0].ID) || 0 - } catch { - } finally { - const chatStore = getChatDataStore() - if (chatStore) chatStore.beforeID.chatID = lastID - return lastID - } - }) - - const onStart = useMemoizedFn(async (args: AIChatIPCStartParams, cb?: () => void) => { - const { token, params } = args - - if (getExecute()) { - yakitNotify('warning', 'useChatIPC AI任务正在执行中,请稍后再试!') + const onStart = useMemoizedFn(({ token, params, onSuccess }: UseChatIPCStartParams) => { + if (globalSessionEngine.isSessionReady(token)) { + yakitNotify('warning', '会话已经存在,请勿重复建立!') return } - const isInit = chatID.current !== token - if (isInit) { - onReset() - try { - cacheDataStore?.create(token) - } catch (error) {} - } - handleResetBeforeStart() - chatID.current = token - - /** 先设置等待文案再设置为执行中 */ - setCasualTitle('发送问题,开启会话...') - setExecute(true) - - aiRequest.current = params.Params - - const nextID = await handleGetLatestID(token) - - ipcRenderer.on(`${token}-data`, (e, res: AIOutputEvent) => { - try { - if (res.Type === 'pong') { - // 如果返回的pong没有值,但是pingSyncID有值,说明该条消息已经过期 - if (!res.SyncID && pingSyncID.current) return - // 如果返回的pong有值,但是和pingSyncID不一样,说明该条消息已经过期 - if (res.SyncID && res.SyncID !== pingSyncID.current) return - // 该条消息有效,不需要在轮询ping请求了 - resetPingTimer() - // pong类型消息是用来检测grpc连接是否成功的,不需要展示在界面上 - // 以及把需要在grpc连接后的操作进行触发的地方 - if (firstQS.current) { - sendRequest(firstQS.current) - firstQS.current = undefined - setCasualTitle('等待回复中...') - } else { - // 如果建立流时,已经初始化过,则不在进行历史初始化 - // 场景: 删除记忆库,会断开流,如果用户在当前会话继续问问题,就会建立新的流,这时候就不需要再进行一次历史数据的初始化了 - if (isInit) requestEvents.handleLoadInit(token, nextID) - } - handleSyncDataAfterConnect() - handleStartSyncDataInterval() - cb?.() - return - } - - if (res.SyncID) { - onSyncIDChange?.(res.SyncID) - } - - let ipcContent = Uint8ArrayToString(res.Content) || '' - // console.log('ipcContent', res, `------${res.TaskId}`, ipcContent) - if (res.Type === 'structured' && res.NodeId === 'recovery_history') { - const recoveryHistory = JSON.parse(ipcContent) as AIAgentGrpcApi.RecoveryHistory - const chatStore = getChatDataStore() - if (chatStore) chatStore.beforeID.chatID = recoveryHistory.next_start_id - requestEvents.handleGrpcLoadMore(recoveryHistory) - return - } - - if (res.Type === 'yak_httpflow_count') { - // 产生一条http流量数据时的通知 - // 不能在这个if里return,因为这个数据在工具卡片中还要进行计数逻辑使用 - const httpNotice = JSON.parse(ipcContent) as AIAgentGrpcApi.HTTPTrafficNotice - if (!res.IsSync) { - setHttpRunTimeIDs((old) => { - if (old.includes(httpNotice.runtime_id)) return old - return [...old, httpNotice.runtime_id] - }) - } - } - - if (res.Type === 'yak_risk_count') { - // 产生一条risk流量数据时的通知 - // 不能在这个if里return,因为这个数据在工具卡片中还要进行计数逻辑使用 - const riskNotice = JSON.parse(ipcContent) as AIAgentGrpcApi.RiskTrafficNotice - if (!res.IsSync) { - setRiskRunTimeIDs((old) => { - if (old.includes(riskNotice.runtime_id)) return old - return [...old, riskNotice.runtime_id] - }) - } - } - - if (res.Type === 'http_fuzz_request_change') { - if (res.IsSync) return - // http_fuzz_request_change - const httpFuzzRequest = JSON.parse(ipcContent) as AIAgentGrpcApi.HttpFuzzRequestChange - onHttpFuzzRequestChange?.(httpFuzzRequest) - return - } - - if (res.Type === 'http_flow_fuzz_status') { - const httpFlowFuzzStatus = JSON.parse(ipcContent) as AIAgentGrpcApi.GetHttpFlowFuzzStatus - onGetHttpFlowFuzzStatus?.(httpFlowFuzzStatus) - } - - if (res.Type === 'yaklang_code_change') { - if (res.IsSync) return - const yaklangCodeChange = JSON.parse(ipcContent) as AIAgentGrpcApi.YaklangCodeChange - onYaklangCodeChange?.(yaklangCodeChange) - return - } - - if (res.Type === 'structured' && res.NodeId === 'session_title') { - if (res.IsSync) return - // 生成会话的名称 - const nameInfo = JSON.parse(ipcContent) as { title: string } - if (nameInfo && nameInfo.title && !!setSessionChatName) setSessionChatName(chatID.current, nameInfo.title) - return - } - - if (res.Type === 'start_plan_and_execution') { - if (res.IsSync) return - /** 清空任务规划的todolist详情 */ - getChatDataStore()?.taskChat.planDetailsMap.clear() - // 触发任务规划,并传出任务规划流的标识 coordinator_id - const startInfo = JSON.parse(ipcContent) as AIAgentGrpcApi.AIStartPlanAndExecution - if (startInfo.coordinator_id && currentTaskPlanID.current?.coordinatorId !== startInfo.coordinator_id) { - // 设置任务规划对应的问题ID, 并清除自由对话(ReAct)的loading状态 - currentTaskPlanID.current = { - taskID: startInfo['re-act_task'], - status: AITaskStatus.inProgress, - // 取消任务规划需要的数据id - coordinatorId: startInfo.coordinator_id, - } - // 开始任务规划后,刷新历史任务树 - sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN_EXEC_TASKS }) - // 任务规划的loading开始置为true - setTaskStatus(() => ({ loading: true, plan: '加载中...', task: '加载中...' })) - // 触发任务规划UI展示的回调 - onTaskStart && onTaskStart() - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - id: `${currentTaskPlanID.current.taskID}-unknown`, - chatType: 'task', - type: AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP, - } as AIChatQSData - getChatDataStore?.()?.taskChat?.contents.set(chatData.id, chatData) - taskChatEvent.setElements((old) => { - const exists = old.some((item) => item.token === chatData.id && item.type === chatData.type) - if (exists) return old - return [ - ...old, - { token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'task', kind: 'task', children: [] }, - ] - }) - } - /** 获取最新任务树状态 */ - sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN }) - /** 恢复任务规划的时候,这个指令执行成功后,在这里取消loading */ - setCancelTaskLoading(false) - return - } - if (res.Type === 'end_plan_and_execution') { - if (res.IsSync) return - // 结束任务规划,并传出任务规划流的标识 coordinator_id - const startInfo = JSON.parse(ipcContent) as AIAgentGrpcApi.AIStartPlanAndExecution - if (startInfo.coordinator_id && currentTaskPlanID.current?.coordinatorId === startInfo.coordinator_id) { - taskChatEvent.handlePlanExecEnd(res) - /**先修改任务状态loading,再改变任务树的状态 */ - handleResetTaskStatus() - taskChatEvent.handleCloseGrpc() - } - return - } - - if (res.Type === 'memory_context') { - // 实时记忆列表 - const lists = JSON.parse(ipcContent) as AIAgentGrpcApi.MemoryEntryList - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) { - taskMemorys.current = lists - } else { - reactMemorys.current = lists - } - try { - const newMemoryEntryList: AIAgentGrpcApi.MemoryEntryList = { - memories: [...(taskMemorys.current.memories || []), ...(reactMemorys.current.memories || [])], - memory_pool_limit: - Number(taskMemorys.current.memory_pool_limit) + Number(reactMemorys.current.memory_pool_limit), - memory_session_id: reactMemorys.current.memory_session_id, - total_memories: Number(taskMemorys.current.total_memories) + Number(reactMemorys.current.total_memories), - total_size: Number(taskMemorys.current.total_size) + Number(reactMemorys.current.total_size), - score_overview: { - A_total: - Number(taskMemorys.current.score_overview.A_total) + - Number(reactMemorys.current.score_overview.A_total), - C_total: - Number(taskMemorys.current.score_overview.C_total) + - Number(reactMemorys.current.score_overview.C_total), - E_total: - Number(taskMemorys.current.score_overview.E_total) + - Number(reactMemorys.current.score_overview.E_total), - - O_total: - Number(taskMemorys.current.score_overview.O_total) + - Number(reactMemorys.current.score_overview.O_total), - P_total: - Number(taskMemorys.current.score_overview.P_total) + - Number(reactMemorys.current.score_overview.P_total), - R_total: - Number(taskMemorys.current.score_overview.R_total) + - Number(reactMemorys.current.score_overview.R_total), - T_total: - Number(taskMemorys.current.score_overview.T_total) + - Number(reactMemorys.current.score_overview.T_total), - }, - } - setMemoryList(newMemoryEntryList) - } catch (error) {} - - return - } - - if (['filesystem_pin_directory', 'filesystem_pin_filename'].includes(res.Type)) { - if (res.IsSync) return - // 会话在本地缓存数据的(文件夹/文件)路径-更新就通知[不区分自由对话和任务规划] - const { path } = JSON.parse(ipcContent) as AIAgentGrpcApi.FileSystemPin - handleSetGrpcFolders({ path, isFolder: res.Type === 'filesystem_pin_directory' }) - return - } - - if (res.Type === 'structured' && res.NodeId === 'react_task_enqueue') { - if (res.IsSync) return - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) return - handleTriggerQuestionQueueRequest() - } - if (res.Type === 'structured' && res.NodeId === 'react_task_dequeue') { - // 自由对话里的问题出队消息 - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) return - - if (!res.IsSync) { - handleTriggerQuestionQueueRequest() - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.QuestionQueueStatusChange - const newCasualTaskId = res.TaskId || data.react_task_id - currentCasualTaskID.current = newCasualTaskId - casualChatEvent.resetPlanDetailsForNewTask(newCasualTaskId) - if (data.focus_mode) { - // 记录场景状态 - handleFocusModeChange(data.focus_mode) - } else { - // 非场景状态 - handleResetFocusMode() - } - setCasualLoading(true) - } - // 不能return,因为自由对话的hook要进行问题的UI渲染逻辑处理 - } - if (res.Type === 'structured' && res.NodeId === 'react_task_cleared') { - // 自由对话里的问题队列清空消息 - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) return - // 问题队列清空操作 - handleReActTaskCleared(res) - return - } - - if (res.Type === 'notify') { - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.Notify - handleSetNotifyMessage(data, res.NodeIdVerbose) - return - } - - if (res.Type === 'structured' && res.NodeId === 'plan_exec_tasks') { - // 任务规划历史数据列表 - const list = JSON.parse(ipcContent) as AIAgentGrpcApi.PlanHistoryList - handlePlanHistoryListChange(list) - return - } - - if (UseAIPerfDataTypes.includes(res.Type)) { - if (res.IsSync) return - // AI性能数据处理 - aiPerfDataEvent.handleSetData(res) - return - } - - if (UseYakExecResultTypes.includes(res.Type)) { - if (res.IsSync) return - // 执行过程中插件输出的卡片 - yakExecResultEvent.handleSetData(res) - return - } - - if (res.Type === 'structured' && res.NodeId === 'queue_info') { - // 因为问题队列也分自由对话和任务规划队列,所以需要先屏蔽处理任务规划的队列信息 - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) return - // 问题队列信息由chatIPC-hook进行收集 - const { tasks, total_tasks } = JSON.parse(ipcContent) as AIAgentGrpcApi.QuestionQueues - setQuestionQueue({ - total: total_tasks, - data: tasks ?? [], - }) - return - } - - if (res.Type === 'structured' && res.NodeId === 'timeline_item') { - if (res.IsSync) return - // 自由对话子任务的time-line-item不展示 - if (casualSubTaskIDs.current.has(res.TaskId)) return - /* 实时时间线单条 */ - const timelineItem = JSON.parse(ipcContent) as AIAgentGrpcApi.TimelineItem - setReActTimelines((old) => [...old, timelineItem]) - return - } - - if (res.Type === 'structured') { - const obj = JSON.parse(ipcContent) || '' - - if (obj?.level) { - // 执行日志信息 - const data = obj as AIAgentGrpcApi.Log - logEvents.pushLog({ - type: 'log', - Timestamp: res.Timestamp, - data: data, - }) - } else if (res.NodeId === 'react_task_status_changed') { - if (res.IsSync) return - // 只负责获取自由对话的任务状态 - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) return - /* 问题的状态变化 */ - - const params = JSON.parse(ipcContent) as AIAgentGrpcApi.ReactTaskChanged - const { react_task_now_status } = params - const react_task_id = res.TaskId || params.react_task_id - if (['completed', 'aborted', 'skipped'].includes(react_task_now_status)) { - if (currentCasualTaskID.current && currentCasualTaskID.current === react_task_id) { - setCancelCasualLoading(false) - setCasualLoading(false) - } - if (currentCasualTaskID.current === react_task_id) handleResetFocusMode() - if (currentTaskPlanID.current?.taskID === react_task_id) { - currentTaskPlanID.current.status = react_task_now_status as AITaskStatus - setCancelTaskLoading(false) - } - } - casualChatEvent.handleSetData(res) - return - } else if (res.NodeId === 'status') { - if (res.IsSync) return - const data = JSON.parse(ipcContent) as { key: string; value: string } - if (data.key === 're-act-loading-status-key') { - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) { - // 任务规划-loading展示标题 - setTaskStatus((old) => { - if (old.loading) { - return { ...old, task: data.value || '加载中...' } - } - return old - }) - } else { - if (res.TaskId !== currentCasualTaskID.current) return - // 自由对话-loading展示标题 - setCasualTitle(data.value) - } - } else if (data.key === 'plan-executing-loading-status-key') { - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) { - // 任务规划-loading展示标题 - setTaskStatus((old) => { - if (old.loading) { - return { ...old, plan: data.value || '加载中...' } - } - return old - }) - } - } else { - // 执行状态卡片处理 - yakExecResultEvent.handleSetData(res) - } - } else { - // 因为流数据有日志类型,所以都放入日志逻辑过滤一遍 - if (res.NodeId === 'stream-finished') { - const { event_writer_id, is_reason, is_system } = JSON.parse( - ipcContent, - ) as AIAgentGrpcApi.AIStreamFinished - if (!event_writer_id) { - logEvents.pushLog(genErrorLogData(res.Timestamp, `stream-finished数据异常, event_writer_id缺失`)) - return - } - logEvents.sendStreamLog(event_writer_id) - - // 非stream数据不需要进行后续的流结束处理逻辑 - if (is_reason || is_system) return - } - - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) { - taskChatEvent.handleSetData(res) - } else { - casualChatEvent.handleSetData(res) - } - } - return - } - - if (res.Type === 'stream') { - if (res.IsSystem || res.IsReason) { - const { CallToolID, NodeId, NodeIdVerbose, EventUUID, StreamDelta, ContentType } = res - if (!NodeId || !EventUUID) return - let ipcStreamDelta = Uint8ArrayToString(StreamDelta) || '' - const content = ipcContent + ipcStreamDelta - logEvents.pushLog({ - type: 'stream', - Timestamp: res.Timestamp, - data: { - CallToolID, - NodeId, - NodeIdVerbose: NodeIdVerbose || convertNodeIdToVerbose(NodeId), - EventUUID, - status: 'start', - content: content, - ContentType, - }, - }) - - // 输出实时系统信息流 - if (res.IsSystem) handleSetSystemStream(EventUUID, content) - return - } - - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) { - taskChatEvent.handleSetData(res) - } else { - casualChatEvent.handleSetData(res) - } - return - } - - // 自由对话和任务规划共用的类型 - if (currentTaskPlanID.current?.coordinatorId === res.CoordinatorId) { - taskChatEvent.handleSetData(res) - } else { - casualChatEvent.handleSetData(res) - } - return - } catch (error) { - handleGrpcDataPushLog({ info: res, pushLog: logEvents.pushLog }) - } + ipcRenderer.removeAllListeners(`${token}-data`) + ipcRenderer.removeAllListeners(`${token}-error`) + ipcRenderer.removeAllListeners(`${token}-end`) + ipcRenderer.on(`${token}-data`, (e, res: any) => { + globalSessionEngine.handleGrpcOutputEvent(token, res) + }) + ipcRenderer.on(`${token}-error`, (e, res: any) => { + globalSessionEngine.handleSessionError(token, res) }) ipcRenderer.on(`${token}-end`, (e, res: any) => { - // console.log("end", res) - handleResetGrpcStatus() - saveStateDataOfEnd(token) - setCasualTitle('会话已停止') - onEnd && onEnd() - if (endAfterSession.current) { - handleSwitchSessionData(endAfterSession.current) - } - - ipcRenderer.invoke('cancel-ai-re-act', token).catch(() => {}) - ipcRenderer.removeAllListeners(`${token}-data`) - ipcRenderer.removeAllListeners(`${token}-end`) - ipcRenderer.removeAllListeners(`${token}-error`) + globalSessionEngine.handleSessionEnd(token, res) }) - // ipcRenderer.on(`${token}-error`, (e, err: any) => { - // console.log('error', err) - // yakitNotify('error', `AI执行失败: ${err}`) - // }) - // console.log('start-ai-re-act', token, params) - - if (params.Params?.UserQuery) { - // 判断建立grpc连接时是否附带问题 - // 如有,需要剥离出来,在grpc建立成功后再执行 - - const chatID = uuidv4() - const AttachedResourceInfos = params.AttachedResourceInfo || [] - AttachedResourceInfos.push({ - Key: AttachedResourceKeyEnum.CONTEXT_PROVIDER_KEY_DEFAULT, - Type: AttachedResourceTypeEnum.USER_FREE_INPUT_UUID, - Value: chatID, - }) - firstQS.current = { - IsFreeInput: true, - FreeInput: params.Params.UserQuery, - AttachedResourceInfo: AttachedResourceInfos, - FocusModeLoop: params.FocusModeLoop, - } - // 用户问了问题后,立即显示到UI上 - // 问题对应的re_act_task_id先由前端生成,并发送给后端 - // 后续生成re_act_task_id时,会把前端生成的uuid替换为后端生成的re_act_task_id - const chatData: AIChatQSData = { - id: chatID, - chatType: 'reAct', - type: AIChatQSDataTypeEnum.QUESTION, - Timestamp: moment().unix(), - data: { qs: firstQS.current.FreeInput || '', setting: {} }, - AIService: '', - AIModelName: '', - // showQS为了UI渲染方便,重新构建的字段 - extraValue: { showQS: firstQS.current.FreeInput || '' }, - } - casualChatEvent.setContentMap(chatData.id, chatData) - casualChatEvent.setElements((old) => { - return [ - ...old, - { token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'reAct', kind: 'task', children: [] }, - ] - }) - } - ipcRenderer.invoke('start-ai-re-act', token, params) - - // 建立会话连接时,在主进程进行了一次ping请求 - // 如果五秒没有返回pong消息,则再次进行ping请求 - handlePingTimer() + globalSessionEngine.handleStartSession({ token, params, route, pageId }, onSuccess) }) - /** - * 切换session会话的数据 - * @param isCreate - * 该参数主要识别,切换是欢迎页切换到历史会话,还是欢迎页直接新建会话的情况 - * 新建会话不触发自动连接逻辑,否则UI条用的连接逻辑直接被拦截失效 - */ - const handleSwitchSessionData = useMemoizedFn((session: string, isCreate?: boolean) => { - if (!session) { - setTimeout(() => { - setSwitchLoading(false) - }, 200) - return - } - - onReset() - - if (session === 'clear') { - setTimeout(() => { - setSwitchLoading(false) - }, 200) - endAfterSession.current = '' - chatID.current = '' - cacheDataStore?.clear() - return - } - - const chatData = cacheDataStore?.get(session) - if (chatData) { - // 后续只有当前会话的数据存放在类实例中 - // 切换会话时,会自动把类实例的数据clear掉,所以这个判断下面代码不可能执行 - chatID.current = session - setGrpcFolders(chatData.grpcFolders || []) - setHttpRunTimeIDs(chatData.httpRunTimeIDs || []) - setRiskRunTimeIDs(chatData.riskRunTimeIDs || []) - setReActTimelines(() => chatData.reActTimelines || []) - yakExecResultEvent.handleSetYakResult(chatData.yakExecResult || {}) - casualChatEvent.setElements(chatData.casualChat?.elements || []) - taskChatEvent.setElements(chatData.taskChat?.elements || []) - } else { - cacheDataStore?.clear() - } - endAfterSession.current = '' - setSwitchLoading(false) - if (autoConnect && getSetting && !getExecute()) { - if (isCreate) return - onStart({ - token: session, - params: { - IsStart: true, - Params: { - ...formatAIAgentSetting(getSetting()), - UserQuery: '', - TimelineSessionID: session, - CoordinatorId: '', - Sequence: 1, - }, - }, - }) - } + const onSend = useMemoizedFn((payload: AIChatSendParams) => { + globalSessionEngine.handleSendMessage(payload) }) - const [switchLoading, setSwitchLoading] = useState(false) - /** - * 标记session会话切换后,是否设置新的session - * @return clear 代表清空数据并不设置数据 - * @return session 代表清空数据并设置新session对应的数据 - */ - const endAfterSession = useRef('') - const onSwitchChat: UseChatIPCEvents['onSwitchChat'] = useMemoizedFn((session, isCreate) => { - if (!chatID.current && getExecute()) { - yakitNotify('warning', 'AI异常, 未记录session却处于执行状态, 请关闭AI页面重试!') - return - } - if (!chatID.current && !session) return - if (session && chatID.current && chatID.current === session) return - - setSwitchLoading(true) - if (getExecute()) { - endAfterSession.current = session || 'clear' - // 这里使用chatID是因为session是替换chatID的新值,所以需要先取消旧session的会话 - onClose(chatID.current) - } else { - endAfterSession.current = '' - // 直接切换数据逻辑 - handleSwitchSessionData(session || 'clear', isCreate) - } + const onClose = useMemoizedFn((sessionIds: string[], onEnd?: () => void) => { + globalSessionEngine.forceCloseSession({ sessionIds, onEnd }) }) - const onClose = useMemoizedFn((token: string, option?: { tip: () => void }) => { - ipcRenderer.invoke('cancel-ai-re-act', token).catch(() => {}) - if (option?.tip) { - option.tip() - } else { - // yakitNotify("info", "useChatIPC AI 任务已取消") - } + /** 将指定 session 换绑到本 hook 入参 pageId(pageId 为定值) */ + const onUpdatePageId = useMemoizedFn((sessionId: string) => { + globalSessionEngine.rebindSessionPageId(sessionId, pageId) }) - useInterval( - () => { - handleStartSyncDataInterval() - }, - execute ? 5000 : undefined, - ) - + // 组件卸载时拔插头,清理闭环 useEffect(() => { return () => { - if (getExecute() && chatID.current) { - onClose(chatID.current) - } - // 多个接口流不会清空,只在页面卸载时触发清空并关闭页面 - logEvents.cancelLogsWin() + globalSessionEngine.onPageUnload(route, pageId) } }, []) - /** 清空指定变量数据 */ - const handleResetTarget = useMemoizedFn((target: 'memoryList') => { - switch (target) { - case 'memoryList': - handleResetMemoryList() - break - - default: - break - } - }) - - /** 用户手动创建内容的执行方法 */ - const handleUserManualIntervention = useMemoizedFn((chatInfo: AIChatQSData) => { - try { - if (chatInfo.chatType === 'reAct') { - casualChatEvent.handleUserManualIntervention(chatInfo) - } - if (chatInfo.chatType === 'task') { - taskChatEvent.handleUserManualIntervention(chatInfo) - } - } catch (error) { - yakitNotify('error', `用户手动干预操作失败: ${error}`) - } - }) - - const state: UseChatIPCState = useCreation(() => { - return { - execute, - httpRunTimeIDs, - riskRunTimeIDs, - yakExecResult, - casualChat, - taskChat, - grpcFolders, - questionQueue, - reActTimelines, - memoryList, - - taskStatus, - casualTitle, - casualLoading, - - focusMode, - switchLoading, - planHistoryList, - cancelCasualLoading, - cancelTaskLoading, - notifyMessage, - requestHistoryState: requestState, - } - }, [ - execute, - httpRunTimeIDs, - riskRunTimeIDs, - yakExecResult, - casualChat, - taskChat, - grpcFolders, - questionQueue, - reActTimelines, - memoryList, - - taskStatus, - casualTitle, - casualLoading, - - focusMode, - switchLoading, - planHistoryList, - cancelCasualLoading, - cancelTaskLoading, - notifyMessage, - requestState, - ]) - - const event: UseChatIPCEvents = useCreation(() => { - return { - fetchToken, - fetchAIRequest, - fetchCurrentCasualTaskID, - fetchCurrentTaskPlanID, - fetchChatDataStore, - onSwitchChat, - onStart, - onSend, - onClose, - onReset, - handleTaskReviewRelease, - onDelChats, - handleCancelLoadingChange, - handleResetTarget, - handleUserManualIntervention, - handleLoadMoreHistory: handleLoadMore, - handleHasMoreHistory: handleHasMore, - resetCurrentTaskPlanID, - } - }, []) - - return [state, event] as const + return { onStart, onSend, onClose, onUpdatePageId } } - -export default useChatIPC diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useCurrentDataBySession.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useCurrentDataBySession.ts new file mode 100644 index 0000000000..85f87c5373 --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useCurrentDataBySession.ts @@ -0,0 +1,43 @@ +import { globalSessionEngine } from './ChatMultiSessionController' +import useCurrentSessionId from './useCurrentSessionId' + +/** + * @description 获取当前会话的数据 + */ +function useCurrentDataBySession() { + const sessionId = useCurrentSessionId() + return globalSessionEngine.ensureSession(sessionId) +} + +/** + * 获取当前数据的 store + */ + +export function useCurrentStore() { + const data = useCurrentDataBySession() + return data.store +} + +/** + * 获取当前数据的 rawData + */ +export function useCurrentRawData() { + const data = useCurrentDataBySession() + return data.rawData +} + +/** + * 获取当前数据的 meta + */ +export function useCurrentMeta() { + const data = useCurrentDataBySession() + return data.meta +} + +/** + * 获取当前数据的 request + */ +export function useCurrentRequest() { + const data = useCurrentDataBySession() + return data.request +} diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useCurrentSessionId.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useCurrentSessionId.ts new file mode 100644 index 0000000000..1b996eda6f --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useCurrentSessionId.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react' +import AIAgentContext from '@/pages/ai-agent/useContext/AIAgentContext' + +/** + * @description 获取当前会话的 SessionID + * @returns {string} 当前会话的 SessionID,如果没有则返回空字符串 + */ +function useCurrentSessionId() { + const sessionId = useContext(AIAgentContext)?.store?.activeChat?.SessionID || '' + return sessionId +} + +export default useCurrentSessionId diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useGetChatDataStoreKey.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useGetChatDataStoreKey.ts index d7e160a453..8e08bc7ee4 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useGetChatDataStoreKey.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useGetChatDataStoreKey.ts @@ -1,82 +1,42 @@ +import { ChatDataStoreKey } from '@/pages/ai-agent/store/ChatDataStore' +import { AISourceEnum, type AISource } from './grpcApi' +import useAIAgentStore from '@/pages/ai-agent/useContext/useStore' import { useCreation } from 'ahooks' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { - ChatDataStore, - ChatDataStoreKey, - histroyAiStore, - FlowAiStore, - aiChatDataStore, - knowledgeBaseDataStore, - WebFuzzerAiStore, - irifyAiCodeAuditPageAiStore, - yakRunnerPageAiStore, -} from '@/pages/ai-agent/store/ChatDataStore' -import type { Domain } from '@/pages/ai-agent/store/constants' -import type { AISource } from './grpcApi' -export const getChatDataStoreKey = (store?: ChatDataStore): ChatDataStoreKey => { - switch (store) { - case histroyAiStore: +function useGetChatDataStoreKey() { + const { setting } = useAIAgentStore() + const chatDataStoreKey: ChatDataStoreKey = useCreation(() => { + return getImageStoreKeyByAISource(setting.Source || 'ai') + }, [setting.Source]) + return chatDataStoreKey +} + +export default useGetChatDataStoreKey +/** + * 根据后端返回的会话数据来源转换为前端对应的数据存储路径key + */ +export const getImageStoreKeyByAISource = (source: AISource): ChatDataStoreKey => { + switch (source) { + case AISourceEnum.aiAgent: + case AISourceEnum.other: + case AISourceEnum.im: + return 'aiChatDataStore' + case AISourceEnum.history: return 'histroyAiStore' - case FlowAiStore: + case AISourceEnum.flow: return 'FlowAiStore' - case aiChatDataStore: - return 'aiChatDataStore' - case knowledgeBaseDataStore: + case AISourceEnum.knowledgeBase: return 'knowledgeBaseDataStore' - case irifyAiCodeAuditPageAiStore: + case AISourceEnum.webFuzzer: + return 'WebFuzzerAiStore' + case AISourceEnum.irify: return 'irifyAiCodeAuditPageAiStore' - case yakRunnerPageAiStore: + case AISourceEnum.yakRunner: return 'yakRunnerPageAiStore' default: - if (store instanceof WebFuzzerAiStore) return 'WebFuzzerAiStore' return 'unknown' } } -function useGetChatDataStoreKey() { - const { chatIPCEvents } = useChatIPCDispatcher() - const chatDataStoreKey = useCreation((): ChatDataStoreKey => { - return getChatDataStoreKey(chatIPCEvents.fetchChatDataStore()) - }, [chatIPCEvents]) - return { chatDataStoreKey } as const -} - -export const getAISourceFromChatDataStoreKey = (key: ChatDataStoreKey): AISource | undefined => { - switch (key) { - case 'histroyAiStore': - return 'history' - case 'FlowAiStore': - return 'flow' - case 'aiChatDataStore': - return 'ai' - case 'knowledgeBaseDataStore': - return 'knowledgeBase' - case 'WebFuzzerAiStore': - return 'webFuzzer' - case 'irifyAiCodeAuditPageAiStore': - return 'irify' - case 'yakRunnerPageAiStore': - return 'yakRunner' - default: - return undefined - } -} - -/** AISource 映射到 IndexedDB 消息存储域(空 source 兼容老数据,归入 ai 域;im 归入 ai 域复用) */ -export const getDomainFromAISource = (source?: AISource): Domain => { - if (!source) return 'ai' - if (source === 'im') return 'ai' - return source as Domain -} - /** AI Agent 侧栏历史会话:包含 ai、im 来源与兼容老数据的空 source */ export const AI_AGENT_HISTORY_AI_SOURCES: AISource[] = ['ai', 'im', ''] - -/** 各业务页嵌入历史会话:仅查询对应单一 source */ -export const getAISourceListFromChatDataStoreKey = (key: ChatDataStoreKey): AISource[] => { - const source = getAISourceFromChatDataStoreKey(key) - return source ? [source] : [] -} - -export default useGetChatDataStoreKey diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadHistory.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadHistory.ts index a7a58030ef..21934225da 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadHistory.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadHistory.ts @@ -1,5 +1,5 @@ import { useMemoizedFn } from 'ahooks' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' const PREPEND_OFFSET = 1000000 diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadOlder.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadOlder.ts new file mode 100644 index 0000000000..1bda87d7dc --- /dev/null +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/useLoadOlder.ts @@ -0,0 +1,334 @@ +import { useMemoizedFn } from 'ahooks' +import { useEffect, useRef, useState } from 'react' +import { useStore } from 'zustand' +import type { ListRange } from 'react-virtuoso' +import { globalSessionEngine } from './ChatMultiSessionController' +import { useCurrentStore, useCurrentRawData } from './useCurrentDataBySession' +import useCurrentSessionId from './useCurrentSessionId' +import { AITaskStatus } from './grpcApi' +import type { ChatListRenderType } from './aiRender' + +/** 向上/向下固定预取条数 */ +const LOAD_AHEAD = 10 +/** 向后保留条数(已滚过的不立即淘汰,保留 5 条缓冲) */ +const KEEP_BEHIND = 5 +/** 强制保留最新尾部条数(人在中间看历史时,底部流式输出不丢展示) */ +const TAIL_KEEP = 30 +/** firstItemIndex 起始偏移 */ +const PREPEND_OFFSET = 1000000 + +/** + * 上滑加载更旧历史 + 视窗内存回收(casual / task 通用)。 + * + * - Virtuoso startReached 触发 handleLoadMore,grpcOffset>0 时发 recovery_history 前插更旧事件 + * - firstItemIndex 前插补偿:数据前插时视口不跳动 + * - loading(grpcLoadMoreLoading)期间请求排队,结束后补发;atTop 兜底探针 + * - 互斥防后端表死锁:消息处理中(casualLoading / taskStatus.status=processing)禁止 gRPC + * 向上加载(排队等处理结束补发);反向由 handleSendMessage 拦 grpcLoadMoreLoading 期间的发送 + * + * 内存回收由 rangeChanged 驱动(elements 全量常驻,contents 按需缓存): + * 1. 可见区缺正文 → 立即 hydrate(IDB → 内存 + bump renderNum) + * 2. 按方向额外预取 LOAD_AHEAD 条 + * 3. 淘汰:keep = 视窗 + 向前 LOAD_AHEAD + 向后 KEEP_BEHIND + 最新尾部 TAIL_KEEP; + * contents 里不在 keep 的删除 + * + * @param chatType 'reAct'(casual 空闲对话)/ 'task'(任务对话) + */ +const useLoadOlder = (chatType: ChatListRenderType) => { + const sessionId = useCurrentSessionId() + const store = useCurrentStore() + const rawData = useCurrentRawData() + + /** recovery_history 在途状态(真实 gRPC loading,由 ChatMultiSessionController 置/关) */ + const loading = useStore(store, (s) => s.grpcLoadMoreLoading) + const dataLength = useStore(store, (s) => + chatType === 'reAct' ? s.casualChat.elements.length : s.taskChat.elements.length, + ) + /** + * 消息处理中(自由对话 casualLoading / 任务规划 taskStatus.status=processing)。 + * 处理中禁止 gRPC 向上加载,避免与流式写入并发导致后端表死锁(IDB hydrate 不受影响)。 + */ + const processing = useStore(store, (s) => + chatType === 'reAct' ? s.casualLoading : s.taskStatus.status === AITaskStatus.inProgress, + ) + + /** 上次 startIndex(数组下标),用于判断滚动方向 */ + const lastStartIndexRef = useRef(-1) + + // #region 向上加载(与官方 useLoadHistory 一致:startReached + firstItemIndex 前插补偿 + 排队锁) + const [firstItemIndex, setFirstItemIndex] = useState(PREPEND_OFFSET) + + const isPrependingRef = useRef(false) + const atTopRef = useRef(false) + const wasLoadingRef = useRef(false) + const wasProcessingRef = useRef(false) + + // 排队锁 + const pendingRequestRef = useRef(false) + + // 【核心机制:Render 阶段状态派生】 + const [prevDataLength, setPrevDataLength] = useState(dataLength) + const [prevSessionID, setPrevSessionID] = useState(sessionId) + + if (sessionId !== prevSessionID) { + setFirstItemIndex(PREPEND_OFFSET) + setPrevDataLength(dataLength) + setPrevSessionID(sessionId) + pendingRequestRef.current = false // 切换会话清空排队 + } else if (dataLength !== prevDataLength) { + const diff = dataLength - prevDataLength + if (diff > 0 && isPrependingRef.current) { + setFirstItemIndex((prev) => Math.max(0, prev - diff)) + } + setPrevDataLength(dataLength) + } + + /** 树外(gRPC)是否还有更旧历史 */ + const fetchHasMore = useMemoizedFn(() => !!sessionId && rawData.grpcOffset > 0) + + const loadMore = useMemoizedFn(() => { + if (sessionId) globalSessionEngine.requestRecoveryHistory(sessionId) + }) + + const handleLoadMore = useMemoizedFn(() => { + if (!fetchHasMore() || !sessionId) return + + // gRPC 加载中或消息处理中:排队等结束,防后端表死锁 + if (loading || processing) { + pendingRequestRef.current = true + return + } + + isPrependingRef.current = true + loadMore() + }) + + const handleAtTopStateChange = useMemoizedFn((atTop: boolean) => { + atTopRef.current = atTop + if (atTop) { + handleLoadMore() + } else { + // 离开顶部,清空排队 + pendingRequestRef.current = false + } + }) + + // 统一处理加载完成后的副作用 + useEffect(() => { + // 判定条件:刚结束加载(之前是 true,现在是 false) + if (wasLoadingRef.current && !loading) { + // 在 DOM Commit 后安全释放向上插入的标记 + isPrependingRef.current = false + + // 释放后立刻检查,刚才 loading 期间是不是有被拦截的请求 + if (pendingRequestRef.current) { + pendingRequestRef.current = false + handleLoadMore() + } + // 兜底补拉:延迟探针,loading 结束后仍停在顶部则再拉一次 + else if (atTopRef.current) { + setTimeout(() => { + if (atTopRef.current && fetchHasMore()) { + handleLoadMore() + } + }, 50) + } + } + wasLoadingRef.current = loading + }, [loading, handleLoadMore, fetchHasMore]) + + // 消息处理结束(processing true→false)后,补发排队中的向上加载 + useEffect(() => { + if (wasProcessingRef.current && !processing && pendingRequestRef.current) { + pendingRequestRef.current = false + handleLoadMore() + } + wasProcessingRef.current = processing + }, [processing, handleLoadMore]) + // #endregion + + // 切会话时重置方向跟踪与杂项 Ref + 清空旧 contents(旧 session Map 占内存) + useEffect(() => { + lastStartIndexRef.current = -1 + wasLoadingRef.current = false + wasProcessingRef.current = false + isPrependingRef.current = false + atTopRef.current = false + pendingRequestRef.current = false + // 清空旧 session 的 contents 视窗(保留 IDB,可恢复) + if (sessionId && rawData.contents.size > 0) { + const tokens = [...rawData.contents.keys()] + globalSessionEngine.removeContentsFromMemory(sessionId, tokens) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + + /** 取当前 chatType 的 elements */ + const getElements = useMemoizedFn(() => { + const state = store.getState() + return chatType === 'reAct' ? state.casualChat.elements : state.taskChat.elements + }) + + /** + * 展开 element 为 token 列表(含 group/task 的 children,最多两层:task→group→item)。 + * 真实结构:group 的 children 是叶子 item;task 的 children 可能是 group 或 item, + * group 下不再嵌套 group,所以固定两层展开即可,不需要通用递归。 + */ + const expandTokens = useMemoizedFn((el: { kind: string; token: string }): string[] => { + const state = store.getState() + const tokens = [el.token] + if (el.kind === 'group') { + const children = state.groups[el.token]?.childrenTokens || [] + tokens.push(...children) + } else if (el.kind === 'task') { + const children = state.tasks[el.token]?.childrenTokens || [] + for (const childToken of children) { + tokens.push(childToken) + // task 的 child 可能是 group,再展开一层 group 的 children(叶子 item) + const childGroup = state.groups[childToken] + if (childGroup?.childrenTokens?.length) { + tokens.push(...childGroup.childrenTokens) + } + } + } + return tokens + }) + + /** 收集指定 index 区间内"树里有、contents 缺正文"的 token */ + const collectMissingByRange = useMemoizedFn((startIndex: number, endIndex: number): string[] => { + const elements = getElements() + const lo = Math.max(0, startIndex) + const hi = Math.min(elements.length - 1, endIndex) + const missing: string[] = [] + for (let i = lo; i <= hi; i++) { + const el = elements[i] + if (!el) continue + for (const tok of expandTokens(el)) { + if (!rawData.contents.has(tok)) missing.push(tok) + } + } + return missing + }) + + /** 按 token 列表从 IDB 补灌 + 灌内存 + bump renderNum(按 kind) */ + const hydrateTokens = useMemoizedFn(async (tokens: string[]) => { + if (!tokens.length || !sessionId) return + const rows = await globalSessionEngine.persistGetSessionContents(sessionId, tokens) + const state = store.getState() + for (const row of rows) { + rawData.contents.set(row.token, row.content) + // 按 kind bump renderNum:item→items,group→groups,task→tasks + if (state.items[row.token]) { + store.getState().incrementNodeVersion(row.token, 'item') + } else if (state.groups[row.token]) { + store.getState().incrementNodeVersion(row.token, 'group') + } else if (state.tasks[row.token]) { + store.getState().incrementNodeVersion(row.token, 'task') + } else { + store.getState().incrementNodeVersion(row.token, 'item') + } + } + // IDB 仍缺的 token 不在此触发 gRPC——recovery_history 是前插更旧事件,不是按 token 补正文。 + // 树外更旧历史由 startReached → handleLoadMore(grpcOffset>0)专门处理。 + }) + + /** 计算 keep 集合(视窗 + 向前 LOAD_AHEAD + 向后 KEEP_BEHIND + 最新尾部 TAIL_KEEP) */ + const computeKeepTokens = useMemoizedFn((startIndex: number, endIndex: number): Set => { + const elements = getElements() + if (!elements.length) return new Set() + + const keep = new Set() + // 视窗 + 向前预取(LOAD_AHEAD) + 向后保留(KEEP_BEHIND) + const lo = Math.max(0, startIndex - LOAD_AHEAD) + const hi = Math.min(elements.length - 1, endIndex + KEEP_BEHIND) + for (let i = lo; i <= hi; i++) { + const el = elements[i] + if (!el) continue + for (const tok of expandTokens(el)) keep.add(tok) + } + // 强制保留最新尾部 + const tailStart = Math.max(0, elements.length - TAIL_KEEP) + for (let i = tailStart; i < elements.length; i++) { + const el = elements[i] + if (!el) continue + for (const tok of expandTokens(el)) keep.add(tok) + } + return keep + }) + + /** 淘汰视窗外 contents */ + const evictOutsideViewport = useMemoizedFn((startIndex: number, endIndex: number) => { + if (!sessionId) return + const keep = computeKeepTokens(startIndex, endIndex) + // 强制保留当前活跃 review 数据,避免视窗淘汰导致提交时取不到 + const { currentPlanReviewToken } = store.getState() + if (currentPlanReviewToken.token) keep.add(currentPlanReviewToken.token) + const toEvict: string[] = [] + for (const token of rawData.contents.keys()) { + if (!keep.has(token)) toEvict.push(token) + } + if (toEvict.length) { + globalSessionEngine.removeContentsFromMemory(sessionId, toEvict) + } + }) + + /** + * 视窗变化驱动 hydrate + 淘汰(接 Virtuoso rangeChanged)。 + * 向上加载(gRPC recovery_history)不走这里,由 startReached → handleLoadMore 承担。 + */ + const onRangeChange = useMemoizedFn(({ startIndex, endIndex }: ListRange) => { + if (!sessionId) return + const elements = getElements() + if (!elements.length) return + + // rangeChanged 的 index 是绝对 index(含 firstItemIndex 偏移),换算为数组下标 + const start = startIndex - firstItemIndex + const end = endIndex - firstItemIndex + + // 跟踪滚动方向(startIndex 变小 = 向上滚) + let direction: 'up' | 'down' | 'none' = 'none' + if (lastStartIndexRef.current >= 0) { + if (start < lastStartIndexRef.current) { + direction = 'up' + } else if (start > lastStartIndexRef.current) { + direction = 'down' + } + } + lastStartIndexRef.current = start + + // 1. 可见区缺正文 → 立即 hydrate(保证不空白) + const visibleMissing = collectMissingByRange(start, end) + if (visibleMissing.length) { + void hydrateTokens(visibleMissing) + } + + // 2. 方向侧预取 LOAD_AHEAD 条 + let prefetchTokens: string[] = [] + if (direction === 'up') { + const preLo = Math.max(0, start - LOAD_AHEAD) + const preHi = start - 1 + if (preLo <= preHi) { + prefetchTokens = collectMissingByRange(preLo, preHi) + } + } else if (direction === 'down') { + const preLo = end + 1 + const preHi = Math.min(elements.length - 1, end + LOAD_AHEAD) + if (preLo <= preHi) { + prefetchTokens = collectMissingByRange(preLo, preHi) + } + } + if (prefetchTokens.length > LOAD_AHEAD) { + prefetchTokens = prefetchTokens.slice(0, LOAD_AHEAD) + } + if (prefetchTokens.length) { + void hydrateTokens(prefetchTokens) + } + + // 3. 淘汰视窗外 + evictOutsideViewport(start, end) + }) + + return { onRangeChange, firstItemIndex, handleLoadMore, handleAtTopStateChange, isPrependingRef } +} + +export default useLoadOlder diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useTaskChat.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useTaskChat.ts deleted file mode 100644 index e8be500aff..0000000000 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useTaskChat.ts +++ /dev/null @@ -1,409 +0,0 @@ -import type { - AIChatLogData, - CurrentExecTaskTree, - handleSendFunc, - UseTaskChatEvents, - UseTaskChatParams, - UseTaskChatState, -} from './type' -import type { AIChatQSData, AIReviewType, ReActChatRenderItem, AITaskInfoProps } from './aiRender' -import type { AIAgentGrpcApi, AIOutputEvent } from './grpcApi' -import { useRef, useState } from 'react' -import { useCreation, useMemoizedFn } from 'ahooks' -import { Uint8ArrayToString } from '@/utils/str' -import cloneDeep from 'lodash/cloneDeep' -import { DefaultCurrentExecTaskTree } from './defaultConstant' -import { genBaseAIChatData, genExecTasks, handleGrpcDataPushLog } from './utils' -import { yakitNotify } from '@/utils/notification' -import { AIInputEventSyncTypeEnum, AITaskStatus } from './grpcApi' -import { AIChatQSDataTypeEnum } from './aiRender' -import useGetSetState from '@/pages/pluginHub/hooks/useGetSetState' -import { has } from 'lodash' -import { grpcAIMessageHandlers } from './grpcAIMessageHandlers' - -function useTaskChat(params: UseTaskChatParams): [UseTaskChatState, UseTaskChatEvents] - -function useTaskChat(params: UseTaskChatParams) { - const { - pushLog, - getChatDataStore, - getRequest, - getCurrentTaskPlanID, - onReview, - onReviewExtra, - onReviewRelease, - sendRequest, - } = params || {} - - const handlePushLog = useMemoizedFn((logInfo: AIChatLogData) => { - pushLog && pushLog(logInfo) - }) - - const [elements, setElements, getElements] = useGetSetState([]) - - const getContentMap = useMemoizedFn((mapKey: string) => { - const contentMap = getChatDataStore?.()?.taskChat?.contents - if (!contentMap) return undefined - return contentMap.get(mapKey) - }) - const setContentMap = useMemoizedFn((mapKey: string, value: AIChatQSData) => { - const contentMap = getChatDataStore?.()?.taskChat?.contents - contentMap && contentMap.set(mapKey, value) - }) - - // #region 任务树相关逻辑 - const [plan, setPlan] = useState(cloneDeep(DefaultCurrentExecTaskTree)) - const handleResetPlanTree = useMemoizedFn(() => { - setPlan(cloneDeep(DefaultCurrentExecTaskTree)) - }) - - /** - * 正在执行中的叶子任务的mapKey集合(已结束的叶子任务会被移除) - * 主要为了在任务中断时,手动设置为error状态使用 - */ - const activeLeafTasks = useRef>(new Set()) - const handleResetActiveLeafTasks = useMemoizedFn(() => { - activeLeafTasks.current.clear() - }) - - const handleTaskNode = useMemoizedFn((res: AIOutputEvent, info: AIAgentGrpcApi.ChangeTask) => { - try { - const taskId = getCurrentTaskPlanID?.()?.taskID - const ownTaskId = info.task.task_id || res.TaskId - if (!taskId || !ownTaskId) return - const taskKey = `${taskId}-${ownTaskId}` - const existing = getContentMap(taskKey) - if (existing && existing.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) { - handleGrpcDataPushLog({ - info: res, - pushLog: handlePushLog, - }) - return - } - const chatData: AIChatQSData = - existing ?? - ({ - ...genBaseAIChatData(res), - id: taskKey, - chatType: 'task', - type: AIChatQSDataTypeEnum.TASK_NODE_GROUP, - data: { - taskId: ownTaskId, - taskName: info.task.name, - goal: info.task.goal, - status: info.task.task_status || AITaskStatus.inProgress, - }, - } as AIChatQSData) - - setContentMap(chatData.id, chatData) - if (info.type === 'push_task') { - activeLeafTasks.current.add(chatData.id) - setElements((old) => { - const exists = old.some((item) => item.token === chatData.id && item.type === chatData.type) - if (exists) return old - const last = old[old.length - 1] - if (last.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP) { - // 实时数据下,将默认任务聚合组置底 - old.splice(old.length - 1, 0, { - token: chatData.id, - type: chatData.type, - renderNum: 1, - chatType: 'task', - kind: 'task', - children: [], - }) - return [...old] - } - return [ - ...old, - { token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'task', kind: 'task', children: [] }, - ] - }) - } else if (info.type === 'pop_task') { - // 删除正在执行队列里的叶子任务, 因为当前任务已经结束了 - activeLeafTasks.current.delete(chatData.id) - if (chatData.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP) { - chatData.data.status = info.task.task_status - } - setElements((old) => { - return old.map((item) => { - if (item.token === chatData.id && item.type === chatData.type) { - return { ...item, renderNum: item.renderNum + 1 } - } - return item - }) - }) - } - } catch { - handleGrpcDataPushLog({ - info: res, - pushLog: handlePushLog, - }) - } - }) - - /** 更新任务树指定任务节点的状态 */ - const handleUpdateTaskState = useMemoizedFn((taskId: string, state: AITaskInfoProps['progress']) => { - setPlan((old) => { - return { - ...old, - task_tree: old.task_tree.map((item) => { - if (item.task_id === taskId) item.progress = state - return item - }), - } - }) - }) - - /** 将任务树中, 所有进行中的任务, 变更为中止状态 */ - const handleAbortTaskState = useMemoizedFn(() => { - const leafTasks = activeLeafTasks.current - for (let mapKey of leafTasks) { - const taskNodeInfo = getContentMap(mapKey) - if (!taskNodeInfo || taskNodeInfo.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) { - continue - } - taskNodeInfo.data.status = AITaskStatus.error - setElements((old) => { - return old.map((item) => { - if (item.token === taskNodeInfo.id && item.type === taskNodeInfo.type) { - return { ...item, renderNum: item.renderNum + 1 } - } - return item - }) - }) - } - handleResetActiveLeafTasks() - setPlan((old) => { - return { - ...old, - task_tree: old.task_tree.map((item) => { - if (item.progress === AITaskStatus.inProgress) { - item.progress = AITaskStatus.error - } - return item - }), - } - }) - }) - // #endregion - - // #region review数据-hook缓存数据 - const review = useRef() - const handleGetReview = useMemoizedFn(() => { - return review.current - }) - const handleSetReview = useMemoizedFn((newReview: AIChatQSData | undefined) => { - review.current = cloneDeep(newReview) - }) - const handleResetReview = useMemoizedFn(() => { - review.current = undefined - }) - - // 将 review 数据处理成需要展示的UI数据 - const handleReviewDataToUI = useMemoizedFn((reviewInfo: AIChatQSData) => { - if (reviewInfo.type === AIChatQSDataTypeEnum.PLAN_REVIEW_REQUIRE && reviewInfo.data.optionValue === 'continue') { - // plan_review, 选择是continue选项, 则进行UI任务树的生成 - const tasks = reviewInfo.data - const plans = genExecTasks(tasks.plans.root_task) - setPlan({ - task_tree: cloneDeep(plans), - root_task_name: tasks.plans.root_task.name, - }) - } - }) - - // #endregion - - /** 处理数据方法 */ - const handleSetData = useMemoizedFn((res: AIOutputEvent) => { - try { - let funcKey = res.Type - if (res.Type === 'structured' && res.NodeId === 'stream-finished') { - // stream数据结束标识 - funcKey = res.NodeId - } else if (res.Type === 'api_request_failed' && res.NodeId === 'ai_call_failure') { - funcKey = res.Type - } else if (res.Type === 'structured' && res.NodeId === 'capability_inventory') { - funcKey = res.NodeId - } else if (res.Type === 'perception' && res.NodeId === 'perception') { - funcKey = res.Type - } else if (res.Type === 'current_task_todo_list_update' && res.NodeId === 'current_task_todo_list') { - funcKey = res.Type - } else if (res.NodeId === 'session_snapshot') { - funcKey = res.NodeId - } - - const handleFunc = grpcAIMessageHandlers[funcKey || ''] - if (handleFunc) { - handleFunc({ - res, - info: { chatType: 'task' }, - getCurrentTaskPlanID, - getRequest, - setElements, - getElements, - setContentMap, - getContentMap, - pushLog: handlePushLog, - review: { - handleGetReview, - handleSetReview, - onReview, - onReviewExtra, - onReviewRelease, - handleReviewDataToUI, - sendRequest, - }, - getChatDataStore, - }) - return - } - - const ipcContent = Uint8ArrayToString(res.Content) || '' - - if (res.Type === 'structured' && res.NodeId === 'system') { - const data = JSON.parse(ipcContent) || '' - - if (data && typeof data === 'object' && data?.type === 'push_task') { - // 开始任务的执行 - const info = data as AIAgentGrpcApi.ChangeTask - handleUpdateTaskState(info.task.task_id || res.TaskId, AITaskStatus.inProgress) - handleTaskNode(res, info) - } - - if (data && typeof data === 'object' && data?.type === 'pop_task') { - // 结束任务 & 请求更新任务树最新状态数据 - const info = data as AIAgentGrpcApi.ChangeTask - handleTaskNode(res, info) - sendRequest && sendRequest({ IsSyncMessage: true, SyncType: AIInputEventSyncTypeEnum.SYNC_TYPE_PLAN }) - } - return - } else if (res.Type === 'plan') { - // 更新正在执行的任务树 - const tasks = JSON.parse(ipcContent) as { root_task: AIAgentGrpcApi.PlanTask } - if (has(tasks, 'root_task')) { - const plans = genExecTasks(tasks.root_task) - setPlan({ task_tree: cloneDeep(plans), root_task_name: tasks.root_task.name }) - } else { - setPlan(cloneDeep(DefaultCurrentExecTaskTree)) - } - return - } - - // 未识别类型全部归档到日志处理 - handleGrpcDataPushLog({ info: res, pushLog: handlePushLog }) - } catch (error) { - handleGrpcDataPushLog({ - info: res, - pushLog: handlePushLog, - }) - } - }) - - const handleResetData = useMemoizedFn(() => { - handleResetPlanTree() - handleResetActiveLeafTasks() - setElements([]) - }) - - /** review 界面选项触发事件 */ - const handleSend: handleSendFunc = useMemoizedFn(({ request, optionValue, cb }) => { - try { - const { InteractiveId, InteractiveJSONInput } = request - if (!review.current || review.current.id !== InteractiveId) { - yakitNotify('error', '未获取到 review 信息, 操作无效') - return - } - - const chatData = cloneDeep(review.current) - ;(chatData.data as AIReviewType).selected = InteractiveJSONInput || '' - ;(chatData.data as AIReviewType).optionValue = optionValue - - handleResetReview() - handleReviewDataToUI(chatData) - setContentMap(chatData.id, chatData) - setElements((old) => { - let isUpdate = false - const newArr = old.map((item) => { - if (chatData.taskId && item.token === chatData.taskId && item.kind === 'task') { - item.children.push({ - token: chatData.id, - type: chatData.type, - renderNum: 1, - chatType: 'task', - kind: 'item', - }) - isUpdate = true - return { ...item, renderNum: item.renderNum + 1 } - } - return item - }) - if (!isUpdate) { - newArr.push({ token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'task', kind: 'item' }) - } - return newArr - }) - - cb && cb() - } catch (error) {} - }) - - /** grpc接口关闭后的后续处理逻辑 */ - const handleCloseGrpc = useMemoizedFn(() => { - // 将进行中的任务变更为中止状态 - handleAbortTaskState() - }) - - /** 当前任务规划结束-触发UI展示结束标识 */ - const handlePlanExecEnd = useMemoizedFn((res: AIOutputEvent) => { - const chatData: AIChatQSData = { - ...genBaseAIChatData(res), - chatType: 'task', - type: AIChatQSDataTypeEnum.END_PLAN_AND_EXECUTION, - data: '', - } - setContentMap(chatData.id, chatData) - setElements((old) => [ - ...old, - { token: chatData.id, type: chatData.type, renderNum: 1, chatType: 'task', kind: 'item' }, - ]) - }) - - /** 用户手动介入逻辑 */ - const handleUserManualIntervention = useMemoizedFn((chatInfo: AIChatQSData) => { - try { - setContentMap(chatInfo.id, cloneDeep(chatInfo)) - setElements((old) => [ - ...old, - { token: chatInfo.id, type: chatInfo.type, renderNum: 1, chatType: 'task', kind: 'item' }, - ]) - } catch (error) { - yakitNotify('error', `用户手动干预操作失败: ${error}`) - } - }) - - const state: UseTaskChatState = useCreation(() => { - return { plan, elements } - }, [plan, elements]) - - const events: UseTaskChatEvents = useCreation(() => { - return { - handleSetData, - handleResetData, - handleSend, - handleCloseGrpc, - handlePlanExecEnd, - getContentMap, - setContentMap, - setElements: setElements, - getElements: getElements, - handleUserManualIntervention, - handleResetPlanTree, - } - }, []) - - return [state, events] as const -} - -export default useTaskChat diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/useYakExecResult.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/useYakExecResult.ts deleted file mode 100644 index 38f3ff73ae..0000000000 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/useYakExecResult.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { useRef, useState } from 'react' -import { useCreation, useMemoizedFn } from 'ahooks' -import { Uint8ArrayToString } from '@/utils/str' -import { checkStreamValidity, convertCardInfo } from '@/hook/useHoldGRPCStream/useHoldGRPCStream' -import { StreamResult } from '@/hook/useHoldGRPCStream/useHoldGRPCStreamType' -import { AIChatLogData, UseYakExecResultEvents, UseYakExecResultParams, UseYakExecResultState } from './type' -import { handleGrpcDataPushLog } from './utils' -import { v4 as uuidv4 } from 'uuid' -import { AIAgentGrpcApi, AIOutputEvent } from './grpcApi' -import { AIYakExecFileRecord } from './aiRender' -import useThrottleState from '@/hook/useThrottleState' - -// 属于该 hook 处理数据的类型 -export const UseYakExecResultTypes = ['yak_exec_result'] - -function useYakExecResult(params?: UseYakExecResultParams): [UseYakExecResultState, UseYakExecResultEvents] - -function useYakExecResult(params?: UseYakExecResultParams) { - const handlePushLog = useMemoizedFn((log: AIChatLogData) => { - if (params?.pushLog) { - params.pushLog(log) - } - }) - - // card - const cardKVPair = useRef>(new Map()) - const cardTimeRef = useRef(null) - const [card, setCard] = useState([]) - const [yakExecResultLogs, setYakExecResultLogs] = useState([]) // log - - const execFileRecordOrder = useRef(1) - /** 插件执行过程中的文件操作记录 */ - const [execFileRecord, setExecFileRecord] = useThrottleState>(new Map(), { - wait: 2000, - }) - const updateExecFileRecord = useMemoizedFn((CallToolID: string, info: StreamResult.Log) => { - try { - setExecFileRecord((old) => { - const newMap = new Map(old) - const keyName = CallToolID || 'system' - const record = newMap.get(keyName) || [] - record.push({ ...info, id: uuidv4(), order: execFileRecordOrder.current++ }) - newMap.set(keyName, [...record]) - return newMap - }) - } catch (error) {} - }) - - const onHandleCard = useMemoizedFn((value: AIAgentGrpcApi.AICardMessage) => { - const logData = value.content as StreamResult.Log - const checkInfo: AIAgentGrpcApi.AICard = checkStreamValidity(value.content as StreamResult.Log) - if (!checkInfo) return - const { id, data, tags } = checkInfo - const { timestamp } = logData - const originData = cardKVPair.current.get(id) - if (originData && originData.Timestamp > timestamp) { - return - } - cardKVPair.current.set(id, { - Id: id, - Data: data, - Timestamp: timestamp, - Tags: Array.isArray(tags) ? tags : [], - }) - onSetCard() - }) - const onSetCard = useMemoizedFn(() => { - if (cardTimeRef.current) return - cardTimeRef.current = setTimeout(() => { - const cacheCard: AIAgentGrpcApi.AIInfoCard[] = convertCardInfo(cardKVPair.current) - setCard(() => [...cacheCard]) - cardTimeRef.current = null - }, 500) - }) - - /** - * @description 该方法可以记录yak_exec_result中所有的日志,根但是目前只对接level:file;后续据可需求更改 - */ - const onHandleYakExecResultLogs = useMemoizedFn((obj: AIAgentGrpcApi.AICardMessage) => { - const log = obj.content as StreamResult.Log - setYakExecResultLogs((perLog) => [...perLog, { ...log, id: uuidv4() }]) - }) - - const onHandleYakExecResult = useMemoizedFn((CallToolID: string, value: AIAgentGrpcApi.AIPluginExecResult) => { - try { - if (!value?.IsMessage) return - const message = value?.Message || '' - const obj: AIAgentGrpcApi.AICardMessage = JSON.parse(Buffer.from(message, 'base64').toString('utf8')) - - if (obj.type !== 'log') return - const content = obj.content as StreamResult.Log - switch (content.level) { - case 'feature-status-card-data': - onHandleCard(obj) - break - case 'file': - updateExecFileRecord(CallToolID, content) - break - default: - break - } - } catch (error) {} - }) - - /** AI执行过程中的status状态卡片化处理 */ - const onAIStatusHandleCard = useMemoizedFn((info: { key: string; value: string; timestamp: number }) => { - const { key, value, timestamp } = info - const originData = cardKVPair.current.get(key) - if (originData && originData.Timestamp > timestamp) { - return - } - cardKVPair.current.set(key, { - Id: key, - Data: value, - Timestamp: timestamp, - Tags: [], - }) - onSetCard() - }) - - const handleSetData = useMemoizedFn((res: AIOutputEvent) => { - try { - let ipcContent = Uint8ArrayToString(res.Content) || '' - - if (res.Type === 'yak_exec_result') { - const data = JSON.parse(ipcContent) as AIAgentGrpcApi.AIPluginExecResult - onHandleYakExecResult(res.CallToolID || '', data) - return - } - if (res.Type === 'structured' && res.NodeId === 'status') { - // 执行状态卡片处理 - const data = JSON.parse(ipcContent) as { key: string; value: string } - if (data.key !== 're-act-loading-status-key') { - onAIStatusHandleCard({ ...data, timestamp: res.Timestamp }) - } - return - } - } catch (error) { - handleGrpcDataPushLog({ info: res, pushLog: handlePushLog }) - } - }) - - /** 重置所有数据 */ - const handleResetData = useMemoizedFn(() => { - cardKVPair.current = new Map() - cardTimeRef.current = null - setCard([]) - setYakExecResultLogs([]) - execFileRecordOrder.current = 1 - setExecFileRecord(new Map()) - }) - - const handleSetYakResult = useMemoizedFn((newData: UseYakExecResultState) => { - setCard(newData.card || []) - setExecFileRecord(newData.execFileRecord || new Map()) - setYakExecResultLogs(newData.yakExecResultLogs || []) - }) - - const state: UseYakExecResultState = useCreation(() => { - return { card, execFileRecord, yakExecResultLogs } - }, [card, execFileRecord, yakExecResultLogs]) - - const events: UseYakExecResultEvents = useCreation(() => { - return { handleSetData, handleResetData, handleSetYakResult } - }, []) - - return [state, events] as const -} - -export default useYakExecResult diff --git a/app/renderer/src/main/src/pages/ai-re-act/hooks/utils.ts b/app/renderer/src/main/src/pages/ai-re-act/hooks/utils.ts index 5d93d7362a..deada93a84 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/hooks/utils.ts +++ b/app/renderer/src/main/src/pages/ai-re-act/hooks/utils.ts @@ -3,42 +3,46 @@ */ import type { AIAgentSetting } from '@/pages/ai-agent/aiAgentType' import type { DialogueRecord } from '@/pages/ai-agent/store/type' -import type { AITaskInfoProps, ReActChatRenderItem, AIChatQSDataType, TodoListCardData } from './aiRender' -import type { AIChatLogToInfo, AIChatLogData, TaskChatTaskInfo, AIMessageHandlerParams } from './type' -import type { AIAgentGrpcApi, AIOutputEvent } from './grpcApi' -import { AIChatQSDataTypeEnum } from './aiRender' -import { AIToDoListStatusEnum, generateTaskChatExecution } from '@/pages/ai-agent/defaultConstant' -import { Uint8ArrayToString } from '@/utils/str' +import { + type AITaskInfoProps, + type ReActChatRenderItem, + type AIChatQSDataType, + type TodoListCardData, + type ChatListRenderType, + AIChatQSDataTypeEnum, +} from './aiRender' +import { AITaskStatus, type AIAgentGrpcApi, type AIOutputEvent } from './grpcApi' +import { AIToDoListStatusEnum } from '@/pages/ai-agent/defaultConstant' import { v4 as uuidv4 } from 'uuid' import { JSONParseLog } from '@/utils/tool' +import { aiAgentLogEmitter } from './AIAgentLogEmitter' +import cloneDeep from 'lodash/cloneDeep' +import { DefaultTaskPlanEndGate } from './defaultConstant' +import type { ChatMultiSessionController } from './ChatMultiSessionController' +import { persistIndependentItem } from './persist/contentPersistHelper' +import type { AIAgentChatMetaData } from '@/pages/ai-agent/type/aiChat' -/** 生成任务的唯一标识 */ -export const generateTaskId = (params: { - chatType: ReActChatRenderItem['chatType'] - res: AIOutputEvent - /** 获取当前任务规划的问题ID信息 */ - getCurrentTaskPlanID?: () => TaskChatTaskInfo | undefined - /** 获取当前自由对话父任务 ID */ - getTaskId?: () => string - getContentMap: AIMessageHandlerParams['getContentMap'] +/** + * 任务节点内的数据生成任务节点ID + * @param isExist 生成的任务节点是否已经存在,不存在则不是任务节点数据,归为默认节点内的数据 + */ +export const generateTaskNodeDataID = (params: { + chatType: ChatListRenderType + planID?: string + taskID: AIOutputEvent['TaskId'] + isExist: (key: string) => boolean }) => { - const { chatType, res, getCurrentTaskPlanID, getTaskId, getContentMap } = params - /** 任务规划走 getCurrentTaskPlanID;自由对话走 getTaskId */ - const parentTaskId = (chatType === 'task' ? getCurrentTaskPlanID?.()?.taskID : getTaskId?.()) || '' - if (res.TaskId) { - if (!parentTaskId) return undefined - const taskGroup = getContentMap(`${parentTaskId}-${res.TaskId}`) - if (taskGroup?.type === AIChatQSDataTypeEnum.TASK_NODE_GROUP) { - return taskGroup.id - } - } - if (chatType === 'task' && getCurrentTaskPlanID?.()?.taskID) { - const defaultKey = `${getCurrentTaskPlanID()?.taskID}-unknown` - if (getContentMap(defaultKey)?.type === AIChatQSDataTypeEnum.TASK_DEFAULT_GROUP) { - return defaultKey - } + const { chatType, planID, taskID, isExist } = params + const taskKey = planID ? `${planID}-${taskID}` : undefined + + if (chatType === 'reAct') { + if (taskKey && isExist(taskKey)) return taskKey + return undefined + } else { + if (taskKey && isExist(taskKey)) return taskKey + if (planID) return `${planID}-default` + return undefined } - return undefined } /** 生成AI-UI展示的必须基础数据 */ @@ -51,35 +55,73 @@ export const genBaseAIChatData = (info: AIOutputEvent) => { } } -/** 生成一个异常日志数据的对象 */ -export const genErrorLogData = ( - Timestamp: AIChatLogToInfo['Timestamp'], - message: AIChatLogToInfo['data']['message'], -): AIChatLogToInfo => { - return { - type: 'log', - Timestamp, - data: { level: 'error', message: message }, - } +/** + * end_plan_and_execution & react_task_status_changed 终态齐套后,才把 pendingStatus 落到 taskStatus.status + * 任一未到则保持 processing(等待中) + */ +export const trySettleTaskPlanEnd = ( + store: ReturnType['store'], + meta: AIAgentChatMetaData, +) => { + const gate = meta.taskPlanEndGate + if (!gate.endReceived || !gate.pendingStatus) return + store.getState().updateTaskLoadingStatus({ status: gate.pendingStatus }) + store.getState().updateState({ cancelTaskLoading: false }) + meta.taskPlanEndGate = cloneDeep(DefaultTaskPlanEndGate) } -/** 将接口数据(AIOutputEvent)转换为日志数据(AIAgentGrpcApi.Log), 并push到日志队列中 */ -export const handleGrpcDataPushLog = (params: { info: AIOutputEvent; pushLog: (log: AIChatLogData) => void }) => { - try { - const { info, pushLog } = params - // 这类类型的数据从日志数据中屏蔽掉,后续的stream类型逻辑会使用到 - if (info.Type === 'stream_start') return - let ipcContent = Uint8ArrayToString(info.Content) || '' - const logInfo: AIChatLogData = { - type: 'log', - Timestamp: info.Timestamp, - data: { - level: `${info.Type}-${info.NodeId}`, - message: ipcContent, - }, +/** 任务规划 end 事件:结构收尾 + 清展示文案 + 推进门闩(不清 id / 不直接改 status) */ +export const handleTaskPlanEnd: ( + requestInfo: ReturnType & { sessionId: string }, + /** 是否是聊天结束事件,如果是则清展示文案为已结束 */ + isChatEnd?: boolean, +) => void = (requestInfo, isChatEnd = false) => { + const { sessionId, store, rawData, meta } = requestInfo + + // 将UI列表里正在执行中的任务组状态变成error + const actives = Array.from(meta.currentTaskPlanActiveNode) + meta.currentTaskPlanActiveNode.clear() + for (const active of actives) { + const taskNodeInfo = rawData.contents.get(active) + if (!taskNodeInfo || taskNodeInfo.type !== AIChatQSDataTypeEnum.TASK_NODE_GROUP) { + continue } - pushLog(logInfo) - } catch (error) {} + taskNodeInfo.data.status = AITaskStatus.error + store.getState().incrementNodeVersion(taskNodeInfo.id, 'task') + persistIndependentItem(sessionId, taskNodeInfo) + } + + // 将当前正在执行的任务树里, 进行中的节点状态变成error + const newPlanTree = cloneDeep(store.getState().taskChat.plan) + newPlanTree.task_tree = newPlanTree.task_tree.map((item) => { + if (item.progress === AITaskStatus.inProgress) item.progress = AITaskStatus.error + return item + }) + store.getState().updatePlanTree(newPlanTree) + + // end 只清展示文案,保留 taskID / coordinatorId / status(status 由 settle 写) + store.getState().updateTaskLoadingStatus({ plan: '已结束', task: '已结束' }) + if (isChatEnd) { + meta.taskPlanEndGate = cloneDeep(DefaultTaskPlanEndGate) + } else { + meta.taskPlanEndGate.endReceived = true + trySettleTaskPlanEnd(store, meta) + } +} + +/** Agent 往日志窗口推送日志数据 */ +export const pushLogToOtherWindow = (params: { + sessionId: string + Timestamp: AIOutputEvent['Timestamp'] + level: string + message: string +}) => { + aiAgentLogEmitter.dispatch({ + session: params.sessionId, + type: 'log', + Timestamp: params.Timestamp, + log: { level: params.level, message: params.message }, + }) } // #region 处理任务规划-任务树相关方法 @@ -119,18 +161,6 @@ export const genExecTasks = (taskTree: AIAgentGrpcApi.PlanTask) => { } // #endregion -/** 将树结构任务列表转换成一维数组 */ -export const handleFlatAITree = (sum: AITaskInfoProps[], task: AIAgentGrpcApi.PlanTask, level = 1) => { - if (!Array.isArray(sum)) return null - const hasSubtasks = !!(task.subtasks && task.subtasks.length > 0) - sum.push({ ...generateTaskChatExecution(task), level, isLeaf: !hasSubtasks }) - if (hasSubtasks) { - for (let subtask of task.subtasks!) { - handleFlatAITree(sum, subtask, level + 1) - } - } -} - /** 是否自动执行review的continue操作 */ export const isAutoExecuteReviewContinue = (params: { type?: string; getFunc?: () => AIAgentSetting | undefined }) => { try { @@ -171,7 +201,7 @@ export const isToolExecStream = (nodeID: string) => { * indexedDB 数据库数据转 ReActChatRenderItem */ export const indexedDBDataToReActChatRenderItem = ( - chatType: ReActChatRenderItem['chatType'], + chatType: ChatListRenderType, data: DialogueRecord[], ): ReActChatRenderItem[] => data.map((item) => { diff --git a/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditor.tsx b/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditor.tsx index dda8dc2c5e..e925187775 100644 --- a/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditor.tsx +++ b/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditor.tsx @@ -62,7 +62,6 @@ import { import { PageNodeItemProps, usePageInfo } from '@/store/pageInfo' import { shallow } from 'zustand/shallow' import { grpcGetAIToolById } from '@/pages/ai-agent/aiToolList/utils' -import { apiGetGlobalNetworkConfig } from '@/pages/spaceEngine/utils' import { setAIModal } from '@/pages/ai-agent/aiModelList/AIModelList' import styles from './AIToolEditor.module.scss' import { DbOperateMessage } from '@/pages/layout/mainOperatorContent/utils' diff --git a/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditorType.d.ts b/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditorType.d.ts index 7bdf1d576d..c3d981885a 100644 --- a/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditorType.d.ts +++ b/app/renderer/src/main/src/pages/aiTool/AIToolEditor/AIToolEditorType.d.ts @@ -1,5 +1,6 @@ import { AIOnlineModelListProps } from '@/pages/ai-agent/aiModelList/AIModelListType' import { SaveAIToolRequest } from '@/pages/ai-agent/type/aiTool' +import { ForwardedRef } from 'react' export interface AIToolEditorProps { pageId: string @@ -8,7 +9,7 @@ export interface AIToolEditorProps { } export interface AIToolEditorInfoFormRef { setFormValues: (values: SaveAIToolRequest) => void - getFormValues: () => Promise + getFormValues: () => Promise } export interface AIToolEditorInfoFormProps { ref?: ForwardedRef diff --git a/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx b/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx index 8554dd658c..5c2bd59260 100644 --- a/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx +++ b/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx @@ -1,5 +1,5 @@ -import React, { CSSProperties, memo, useEffect, useLayoutEffect, useMemo, useRef, useState, createRef } from 'react' -import { Form, Result, Space, Popover, Tooltip, Divider, Descriptions } from 'antd' +import React, { CSSProperties, useEffect, useLayoutEffect, useMemo, useRef, useState, createRef } from 'react' +import { Form, Result, Space, Tooltip, Divider, Descriptions } from 'antd' import { IMonacoEditor, NewHTTPPacketEditor, @@ -105,7 +105,6 @@ import { } from '@/assets/icon/outline' import emiter from '@/utils/eventBus/eventBus' import { HistoryAIReActChatProvider, useHistoryAIReActChat } from '@/components/historyAIReActChat' -import { WebFuzzerAiStore } from '@/pages/ai-agent/store/ChatDataStore' import { applyHttpFuzzRequestChangeToWebFuzzerPage, registerWebFuzzerPageApplyRequestFromCard, @@ -115,9 +114,8 @@ import { registerWebFuzzerPageOnAIFuzzStatus, type WebFuzzerCasualReplaceReviewPayload, } from './webFuzzerAiRequestApplyBridge' -import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' -import { AIInputFooterRightEnum, AIInputInnerFeatureEnum } from '@/pages/ai-agent/template/type' -import { AIAgentGrpcApi } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIInputFooterRightEnum } from '@/pages/ai-agent/template/type' +import { AISourceEnum } from '@/pages/ai-re-act/hooks/grpcApi' import { shallow } from 'zustand/shallow' import { usePageInfo, PageNodeItemProps, WebFuzzerPageInfoProps, getFuzzerProcessedCacheData } from '@/store/pageInfo' import { YakitCopyText } from '@/components/yakitUI/YakitCopyText/YakitCopyText' @@ -189,7 +187,6 @@ import { YakitDrawer } from '@/components/yakitUI/YakitDrawer/YakitDrawer' import { PublicHTTPHistoryIcon } from '@/routes/publicIcon' import { useProxy } from '@/hook/useProxy' import { MITMConsts } from '../mitm/MITMConsts' -import { RemoteGV } from '@/yakitGV' import { YakitSwitch } from '@/components/yakitUI/YakitSwitch/YakitSwitch' import { useChunkAutoScrollToBottom } from './hooks/useAutoScrollToBottom' const tOriginal = i18n.getFixedT(null, ['yakitUi', 'webFuzzer']) @@ -2661,9 +2658,6 @@ const HTTPFuzzerPageCore: React.FC = (props) => { historyAIReActChatBridge.handleStart({ qs: template.prompt, focusMode: focusModeLoop, - extraValue: { - showQS: template.label, - }, }) }) @@ -3464,12 +3458,12 @@ const HTTPFuzzerPageCore: React.FC = (props) => { /** 每个 Web Fuzzer 页签独立 WebFuzzerAiStore,避免多开时共用内存缓存导致会话数据互相覆盖 */ const HTTPFuzzerPage: React.FC = (props) => { - const fuzzerAiChatDataStore = useCreation(() => new WebFuzzerAiStore(props.id), [props.id]) return ( diff --git a/app/renderer/src/main/src/pages/hTTPHistoryAnalysis/HTTPHistory/HTTPHistoryFilter.tsx b/app/renderer/src/main/src/pages/hTTPHistoryAnalysis/HTTPHistory/HTTPHistoryFilter.tsx index 439ada3c33..e3263949d9 100644 --- a/app/renderer/src/main/src/pages/hTTPHistoryAnalysis/HTTPHistory/HTTPHistoryFilter.tsx +++ b/app/renderer/src/main/src/pages/hTTPHistoryAnalysis/HTTPHistory/HTTPHistoryFilter.tsx @@ -18,7 +18,6 @@ import { OutlineCogIcon, OutlineFilterIcon, OutlineMessageCirclePlusIcon, - OutlinePlusIcon, OutlineRefreshIcon, OutlineReplyIcon, OutlineSearchIcon, @@ -84,7 +83,7 @@ import { HistoryProcess, HistoryTab } from '@/components/HTTPHistory' import { useBuiltinTagList } from '@/components/HTTPFlowTable/useBuiltinTagList' import { useCampare } from '@/hook/useCompare/useCompare' import { v4 as uuidv4 } from 'uuid' -import { cloneDeep, isEqual, toArray } from 'lodash' +import { cloneDeep, isEqual } from 'lodash' import { MitmExtractAggregateFlowFilterRow } from '@/utils/yakQueryHTTPFlow' import { showByRightContext } from '@/components/yakitUI/YakitMenu/showByRightContext' import { randomString } from '@/utils/randomUtil' @@ -141,11 +140,11 @@ import { FilterConfig, HTTPFlowTableFormConfiguration, } from '@/components/HTTPFlowTable/HTTPFlowTableFormConfiguration/HTTPFlowTableFormConfiguration' -import { FlowAiStore } from '@/pages/ai-agent/store/ChatDataStore' -import { AIInputFooterRightEnum, AIInputInnerFeatureEnum } from '@/pages/ai-agent/template/type' +import { AIInputFooterRightEnum } from '@/pages/ai-agent/template/type' import { HistoryAIReActChatProvider, useHistoryAIReActChat } from '@/components/historyAIReActChat' import { HTTPFlowRuleDataFilter } from '@/components/HTTPFlowTable/HTTPFlowRuleDataFilter' import { isFilterSectionActive, safeParse } from '../HTTPHistoryAnalysis.utils' +import { AISourceEnum } from '@/pages/ai-re-act/hooks/grpcApi' const { ipcRenderer } = window.require('electron') interface HTTPHistoryFilterProps { @@ -425,7 +424,12 @@ HTTPHistoryFilterInner.displayName = 'HTTPHistoryFilterInner' export const HTTPHistoryFilter: React.FC = (props) => { return ( - + ) diff --git a/app/renderer/src/main/src/pages/irifyAiCodeAudit/IrifyAiCodeAuditPage.tsx b/app/renderer/src/main/src/pages/irifyAiCodeAudit/IrifyAiCodeAuditPage.tsx index 9db6e1ba7c..084cc8231f 100644 --- a/app/renderer/src/main/src/pages/irifyAiCodeAudit/IrifyAiCodeAuditPage.tsx +++ b/app/renderer/src/main/src/pages/irifyAiCodeAudit/IrifyAiCodeAuditPage.tsx @@ -2,10 +2,10 @@ import React, { useEffect, useRef, useState } from 'react' import { useMemoizedFn } from 'ahooks' import emiter from '@/utils/eventBus/eventBus' import { AuditCodePageInfoProps } from '@/store/pageInfo' -import { irifyAiCodeAuditPageAiStore } from '@/pages/ai-agent/store/ChatDataStore' import { IrifyAiCodeAuditStyle, resolveIrifyFocusModeLoop, isIrifyAuditStyleConfirmed } from './irifyAiCodeAuditStyle' import { HistoryAIReActChatProvider } from '@/components/historyAIReActChat' -import { AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' +import { AIInputEvent, AISourceEnum } from '@/pages/ai-re-act/hooks/grpcApi' +import { YakitRoute } from '@/enums/yakitRoute' import { IrifyAiCodeAuditSidePanelLayout } from './IrifyAiCodeAuditSidePanelLayout' import { appendIrifyWorkbenchAttachments, @@ -133,7 +133,9 @@ const IrifyAiCodeAuditPageInner: React.FC = ({ auditC return ( diff --git a/app/renderer/src/main/src/pages/yakRunner/YakRunner.tsx b/app/renderer/src/main/src/pages/yakRunner/YakRunner.tsx index 4eaba639b3..b583aaa9c3 100644 --- a/app/renderer/src/main/src/pages/yakRunner/YakRunner.tsx +++ b/app/renderer/src/main/src/pages/yakRunner/YakRunner.tsx @@ -68,7 +68,6 @@ import { randomString } from '@/utils/randomUtil' import { YakitTabsProps } from '@/components/yakitSideTab/YakitSideTabType' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' import { HistoryAIReActChatProvider } from '@/components/historyAIReActChat' -import { yakRunnerPageAiStore } from '@/pages/ai-agent/store/ChatDataStore' import { YAK_RUNNER_FOCUS_MODE_CODE_SECURITY_AUDIT } from '@/constants/focusMode' import { YakRunnerAiAttachProvider, YakRunnerAiAttachRef, useYakRunnerAiAttachRef } from './YakRunnerAiAttachContext' import { YakRunnerAiSidePanel } from './YakRunnerAiSidePanel' @@ -86,6 +85,7 @@ import { type YakRunnerCasualCodeReplaceReviewPayload, } from './yakRunnerAiCodeApplyBridge' import { syncYakRunnerPatchWorkingDraft } from './yakRunnerAiCodePatchApply' +import { AISourceEnum } from '../ai-re-act/hooks/grpcApi' const { ipcRenderer } = window.require('electron') // 模拟tabs分块及对应文件 @@ -1278,9 +1278,10 @@ const YakRunnerWorkbench: React.FC = (props) => { const YakRunnerWithAIInner: React.FC = (props) => { return ( diff --git a/app/renderer/src/main/src/utils/eventBus/events/aiAgent.ts b/app/renderer/src/main/src/utils/eventBus/events/aiAgent.ts index b325108084..bc466a31e2 100644 --- a/app/renderer/src/main/src/utils/eventBus/events/aiAgent.ts +++ b/app/renderer/src/main/src/utils/eventBus/events/aiAgent.ts @@ -32,16 +32,6 @@ export type AIAgentEventProps = { /** 文件系统默认展开路径 */ fileSystemDefaultExpand: string - /** AIReviewRule相关数据发生编发,是否热更新 - * @name reviewPolicy {AIStartParams["ReviewPolicy"] - * @name aiReviewRiskControlScore {AIStartParams["AIReviewRiskControlScore"] - */ - onRefreshAIReviewRuleSelect: string - - /** 删除会话列表里的会话时, 通知useChatIPC-hook对应会话数据已被删除,不需断开保存操作 */ - onDelChats: string - /** 嵌入侧栏 ReviewPolicy 变更,通知其他已挂载 Provider 同步 */ - onRefreshHistoryAIEmbeddedSetting: string /** * 会话数据管理 * @name type "refresh" | "clear" | "prependSession" | "updateSession" | "loadNextPage" diff --git a/app/renderer/src/main/src/utils/openWebsite.tsx b/app/renderer/src/main/src/utils/openWebsite.tsx index cfe89a3258..61148e0fe1 100644 --- a/app/renderer/src/main/src/utils/openWebsite.tsx +++ b/app/renderer/src/main/src/utils/openWebsite.tsx @@ -14,8 +14,6 @@ import type { ConcurrentStreamFramePayload } from '@/pages/ai-agent/components/C import { yakitDialog, yakitShell, yakitWindow } from '@/services/electronBridge' const tOriginal = i18n.getFixedT(null, ['utils', 'yakitUi']) -export type OpenAIConcurrentStreamPayload = ConcurrentStreamFramePayload - const { ipcRenderer } = window.require('electron') const toWritableText = (data?: Uint8Array | string) => { @@ -76,10 +74,7 @@ export interface OpenAIConcurrentStreamOptions { } /** 打开并发流 aux 子窗,创建时传入 elements 等帧数据 */ -export const openAIConcurrentStream = ( - data: OpenAIConcurrentStreamPayload, - options?: OpenAIConcurrentStreamOptions, -) => { +export const openAIConcurrentStream = (data: ConcurrentStreamFramePayload, options?: OpenAIConcurrentStreamOptions) => { if (!options?.silent) { yakitNotify('info', tOriginal('OpenWebsite.openingNewWindow')) } diff --git a/vitest.config.ts b/vitest.config.ts index 18dcbb765e..e44dda7ef2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,19 @@ import path from 'path' const ENGINE_LINK_SRC = path.resolve(__dirname, 'app/renderer/engine-link-startup/src') const RENDERER_MAIN_SRC = path.resolve(__dirname, 'app/renderer/src/main/src') +const I18N_STUB = path.resolve(__dirname, 'app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18nStub.ts') +const I18NEXT_BACKEND_STUB = path.resolve( + __dirname, + 'app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/i18next-resources-to-backend.ts', +) +const ELECTRON_BRIDGE_STUB = path.resolve( + __dirname, + 'app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/electronBridgeStub.ts', +) +const STYLE_STUB = path.resolve( + __dirname, + 'app/renderer/src/main/src/pages/ai-re-act/hooks/__test__/stubs/styleStub.ts', +) function resolveAtRoot(root: string, id: string) { const rel = id.startsWith('@/') ? id.slice(2) : id @@ -32,11 +45,33 @@ function resolveAtRoot(root: string, id: string) { /** Vitest 覆盖三个子项目:app/main、app/renderer/src/main、app/renderer/engine-link-startup(与 scripts/ci-select-vitest-tests.js 一致) */ export default defineConfig({ plugins: [ + { + name: 'stub-style-modules', + enforce: 'pre', + resolveId(id) { + const clean = id.split('?')[0] + if (/\.(css|scss|sass|less)$/.test(clean)) { + return STYLE_STUB + } + return null + }, + load(id) { + if (id === STYLE_STUB || /\.(css|scss|sass|less)(\?.*)?$/.test(id)) { + return 'export default {}' + } + return null + }, + }, { name: 'resolve-at-monorepo', enforce: 'pre', resolveId(id, importer) { + if (id === 'i18next-resources-to-backend') return I18NEXT_BACKEND_STUB + if (id === '@/i18n/i18n' || id === 'i18n/i18n') return I18N_STUB + if (id === '@/services/electronBridge') return ELECTRON_BRIDGE_STUB + if (!id.startsWith('@/')) return null + const eng = resolveAtRoot(ENGINE_LINK_SRC, id) const main = resolveAtRoot(RENDERER_MAIN_SRC, id) const imp = (importer || '').split(path.sep).join('/') @@ -55,11 +90,15 @@ export default defineConfig({ '@engne': path.resolve(__dirname, 'app/renderer/engine-link-startup/src'), '@engine': path.resolve(__dirname, 'app/renderer/engine-link-startup/src'), '@app': path.resolve(__dirname, 'app'), + 'i18next-resources-to-backend': I18NEXT_BACKEND_STUB, + '@/i18n/i18n': I18N_STUB, + '@/services/electronBridge': ELECTRON_BRIDGE_STUB, }, }, test: { environment: 'jsdom', globals: true, + css: false, // run from repo root root: path.resolve(__dirname), // run renderer setup to register testing-library matchers