diff --git a/.github/instructions/agent.instructions.md b/.github/instructions/agent.instructions.md index 6bee606b..7a8d6412 100644 --- a/.github/instructions/agent.instructions.md +++ b/.github/instructions/agent.instructions.md @@ -1,4 +1,4 @@ --- -applyTo: '**/*.ts' +applyTo: '**/*.ts|tsx' --- -用英文注释,编辑完成后用pnpm exec eslint --fix。我使用powershell,但尽量用无须审批的vscode内置功能,少用需要人类审批的shell。 \ No newline at end of file +用英文注释,编辑完成后用pnpm exec eslint --fix。我使用powershell,但尽量用无须审批的vscode内置功能,少用需要人类审批的shell,例如尽量不要通过创建新文件再用powershell覆盖原文件的方式来更新文件。 \ No newline at end of file diff --git a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessageBubble.tsx b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessageBubble.tsx index 2a71b0ca..21499de5 100644 --- a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessageBubble.tsx +++ b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessageBubble.tsx @@ -1,11 +1,11 @@ // Message bubble component with avatar and content -import { AgentInstanceMessage } from '@/services/agentInstance/interface'; import PersonIcon from '@mui/icons-material/Person'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import { Avatar, Box } from '@mui/material'; import { styled } from '@mui/material/styles'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; +import { useAgentChatStore } from '../../../../../store/agentChatStore'; import { MessageRenderer } from './MessageRenderer'; const BubbleContainer = styled(Box)<{ $isUser: boolean }>` @@ -61,57 +61,19 @@ const MessageContent = styled(Box)<{ $isUser: boolean; $isStreaming?: boolean }> `; interface MessageBubbleProps { - message: AgentInstanceMessage; - isUser: boolean; + messageId: string; // 只接收消息ID } /** * Message bubble component with avatar and content */ -export const MessageBubble: React.FC = ({ message, isUser }) => { - // Track if the message is streaming (being generated) - const [isStreaming, setIsStreaming] = useState(false); +export const MessageBubble: React.FC = ({ messageId }) => { + const message = useAgentChatStore(state => state.getMessageById(messageId)); + const isStreaming = useAgentChatStore(state => state.isMessageStreaming(messageId)); - // Monitor the message for streaming state - useEffect(() => { - // Message is streaming if it's from the assistant/agent and meets streaming criteria - const streamingState = !isUser && - (message.role === 'agent' || message.role === 'assistant') && - ( - // Check explicit streaming flag if available - message.metadata?.isStreaming === true || - // Check agent working state via the message - message.metadata?.agentState === 'working' || - // Check if message is incomplete (missing completion flag) - (message.metadata?.isComplete !== true && message.metadata?.isComplete !== undefined) - ); - // DEBUG: console streamingState - console.log(`streamingState`, streamingState); + if (!message) return null; - // Only update streaming state when necessary to avoid re-renders - setIsStreaming(streamingState); - - // Add cleanup timer to ensure animation stops even if metadata doesn't update properly - let animationTimeout: NodeJS.Timeout | null = null; - - // If streaming, set a timeout to eventually stop animation if no further updates occur - if (streamingState) { - // After 15 seconds, assume message is complete even if metadata doesn't update - // This prevents animations from running indefinitely - animationTimeout = setTimeout(() => { - setIsStreaming(false); - }, 15000); // 15 seconds timeout (reduced from 30s to prevent longer animations) - } else { - // If message is no longer streaming, ensure we update state immediately - setIsStreaming(false); - } - - return () => { - if (animationTimeout) { - clearTimeout(animationTimeout); - } - }; - }, [message, isUser]); + const isUser = message.role === 'user'; return ( diff --git a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessagesContainer.tsx b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessagesContainer.tsx index fc6dc408..dc73c6f1 100644 --- a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessagesContainer.tsx +++ b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/components/MessagesContainer.tsx @@ -1,6 +1,5 @@ // Messages container component -import { AgentInstanceMessage } from '@/services/agentInstance/interface'; import { Box } from '@mui/material'; import { styled } from '@mui/material/styles'; import React, { ReactNode } from 'react'; @@ -18,23 +17,23 @@ const Container = styled(Box)` `; interface MessagesContainerProps { - messages: AgentInstanceMessage[]; + messageIds: string[]; children?: ReactNode; } /** * Container component for all chat messages * Displays messages as message bubbles and can render additional content (loading states, errors, etc.) + * 使用消息 ID 来减少不必要的重渲染 */ -export const MessagesContainer: React.FC = ({ messages, children }) => { +export const MessagesContainer: React.FC = ({ messageIds, children }) => { return ( - {/* Render messages as message bubbles */} - {messages.map((message) => ( + {/* 只传递消息 ID 给子组件 */} + {messageIds.map((messageId) => ( ))} diff --git a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/hooks/useMessageHandling.tsx b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/hooks/useMessageHandling.tsx index 564b6d14..f3cc0b53 100644 --- a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/hooks/useMessageHandling.tsx +++ b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/hooks/useMessageHandling.tsx @@ -1,6 +1,6 @@ // Message handling hook for chat component -import { AgentInstance } from '@services/agentInstance/interface'; import { KeyboardEvent, useCallback, useState } from 'react'; +import { AgentWithoutMessages } from '../../../../../store/agentChatStore'; interface UseMessageHandlingProps { agentId: string | undefined; @@ -8,7 +8,7 @@ interface UseMessageHandlingProps { isUserAtBottom: () => boolean; isUserAtBottomReference: React.RefObject; debouncedScrollToBottom: () => void; - agent: AgentInstance | null; // Using the proper AgentInstance type from the AgentChatStore + agent: AgentWithoutMessages | null; // Updated to use AgentWithoutMessages type } /** diff --git a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/index.tsx b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/index.tsx index a7e3214b..1e5138b1 100644 --- a/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/index.tsx +++ b/src/pages/Agent/components/TabContent/TabTypes/ChatTabContent/index.tsx @@ -1,7 +1,7 @@ // Chat tab content component - Modular version with message rendering system import { Box, CircularProgress, Typography } from '@mui/material'; -import { AgentInstanceMessage } from '@services/agentInstance/interface'; +// Import services and hooks import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -20,7 +20,7 @@ import { useScrollHandling } from './hooks/useScrollHandling'; import { isChatTab } from './utils/tabTypeGuards'; // Import store hooks to fetch agent data -import { useAgentChatStore } from '../../../../store/agentChatStore'; +import { AgentWithoutMessages, useAgentChatStore } from '../../../../store/agentChatStore'; import { TabItem } from '../../../../types/tab'; /** @@ -36,7 +36,7 @@ interface ChatTabContentProps { * Displays a chat interface for interacting with an AI agent * Only works with IChatTab objects */ -const ChatTabContent: React.FC = ({ tab }) => { +export const ChatTabContent: React.FC = ({ tab }) => { const { t } = useTranslation('agent'); // Type checking @@ -106,13 +106,15 @@ const ChatTabContent: React.FC = ({ tab }) => { if (unsub) unsub(); }; }, [tab.agentId, fetchAgent, subscribeToUpdates]); + const orderedMessageIds = useAgentChatStore(state => state.orderedMessageIds); // Effect to handle initial scroll when agent is first loaded useEffect(() => { // Only scroll to bottom on initial agent load, not on every agent update - if (agent && !loading && agent.messages.length > 0) { + const currentAgent: AgentWithoutMessages | null = agent; + if (currentAgent && !loading && orderedMessageIds.length > 0) { // Use a ref to track if initial scroll has happened for this agent - const agentId = agent.id; + const agentId = currentAgent.id; // Check if we've already scrolled for this agent if (!hasInitialScrollBeenDone(agentId)) { @@ -122,20 +124,18 @@ const ChatTabContent: React.FC = ({ tab }) => { markInitialScrollAsDone(agentId); } } - }, [agent?.id, loading, debouncedScrollToBottom, hasInitialScrollBeenDone, markInitialScrollAsDone]); + }, [agent?.id, loading, debouncedScrollToBottom, hasInitialScrollBeenDone, markInitialScrollAsDone, orderedMessageIds]); + // Effect to scroll to bottom when messages change useEffect(() => { - if (!agent?.messages.length) return; + if (!orderedMessageIds.length) return; // Always use debounced scroll to prevent UI jumping for all message updates if (isUserAtBottomReference.current) { debouncedScrollToBottom(); } - }, [agent?.messages, isUserAtBottomReference, debouncedScrollToBottom]); - - // Organize messages for display - const messages: AgentInstanceMessage[] = agent?.messages || []; + }, [orderedMessageIds.length, isUserAtBottomReference, debouncedScrollToBottom]); const isWorking = loading || agent?.status.state === 'working'; return ( @@ -158,7 +158,7 @@ const ChatTabContent: React.FC = ({ tab }) => { {/* Messages container with all chat bubbles */} - + {/* Error state */} {error && ( @@ -167,14 +167,14 @@ const ChatTabContent: React.FC = ({ tab }) => { )} {/* Empty state */} - {!loading && !error && messages.length === 0 && ( + {!loading && !error && orderedMessageIds.length === 0 && ( {t('Agent.StartConversation')} )} {/* Loading state - when first loading the agent */} - {loading && messages.length === 0 && ( + {loading && orderedMessageIds.length === 0 && ( {t('Agent.LoadingChat')} @@ -204,5 +204,3 @@ const ChatTabContent: React.FC = ({ tab }) => { ); }; - -export { ChatTabContent }; diff --git a/src/pages/Agent/store/agentChatStore.ts b/src/pages/Agent/store/agentChatStore.ts index aa89b568..9b9b1bf3 100644 --- a/src/pages/Agent/store/agentChatStore.ts +++ b/src/pages/Agent/store/agentChatStore.ts @@ -1,27 +1,76 @@ -import { AgentInstance } from '@services/agentInstance/interface'; +import { AgentInstance, AgentInstanceMessage } from '@services/agentInstance/interface'; +import { Subscription } from 'rxjs'; import { create } from 'zustand'; +// Type for agent data without messages - exported for use in other components +export type AgentWithoutMessages = Omit; + interface AgentChatState { // State loading: boolean; error: Error | null; - agent: AgentInstance | null; + agent: AgentWithoutMessages | null; + // Store messages separately in a Map for more efficient updates + messages: Map; + // Store message IDs in order to maintain backend's message ordering + orderedMessageIds: string[]; + // Track which messages are currently streaming + streamingMessageIds: Set; + + // Helper method to process agent data + processAgentData: (fullAgent: AgentInstance) => { + agent: AgentWithoutMessages; + messages: Map; + orderedMessageIds: string[]; + }; // Actions fetchAgent: (agentId: string) => Promise; subscribeToUpdates: (agentId: string) => (() => void) | undefined; sendMessage: (agentId: string, content: string) => Promise; - createAgent: (agentDefinitionId?: string) => Promise; - updateAgent: (agentId: string, data: Partial) => Promise; + createAgent: (agentDefinitionId?: string) => Promise; + updateAgent: (agentId: string, data: Partial) => Promise; cancelAgent: (agentId: string) => Promise; clearError: () => void; + + // Message-specific actions + setMessageStreaming: (messageId: string, isStreaming: boolean) => void; + isMessageStreaming: (messageId: string) => boolean; + getMessageById: (messageId: string) => AgentInstanceMessage | undefined; } -export const useAgentChatStore = create((set) => ({ +export const useAgentChatStore = create((set, get) => ({ // Initial state loading: false, error: null, agent: null, + messages: new Map(), + orderedMessageIds: [], + streamingMessageIds: new Set(), + + // Helper to process agent data and update store + // This centralizes the logic for extracting messages from a full agent instance + // and preparing the data structure for the store + processAgentData: (fullAgent: AgentInstance) => { + // Create a messages map for efficient lookup + const messagesMap = new Map(); + + // Messages are already sorted by the backend in ascending order by modified time + // Just map them to maintain that order in our orderedIds array + const orderedIds = fullAgent.messages.map(message => { + messagesMap.set(message.id, message); + return message.id; + }); + + // Separate agent data from messages + const { messages: _, ...agentWithoutMessages } = fullAgent; + + return { + agent: agentWithoutMessages, + messages: messagesMap, + orderedMessageIds: orderedIds, + }; + }, // Fetch agent instance fetchAgent: async (agentId: string) => { @@ -29,9 +78,12 @@ export const useAgentChatStore = create((set) => ({ try { set({ loading: true, error: null }); - const agent = await window.service.agentInstance.getAgent(agentId); - if (agent) { - set({ agent }); + const fullAgent = await window.service.agentInstance.getAgent(agentId); + + if (fullAgent) { + // Process agent data using our helper method + const storeData = get().processAgentData(fullAgent); + set({ ...storeData, error: null }); } } catch (error) { set({ error: error instanceof Error ? error : new Error(String(error)) }); @@ -46,10 +98,78 @@ export const useAgentChatStore = create((set) => ({ if (!agentId) return undefined; try { - const subscription = window.observables.agentInstance.subscribeToAgentUpdates(agentId).subscribe({ - next: (agent) => { - if (agent) { - set({ agent }); + // Track message-specific subscriptions for cleanup + const messageSubscriptions = new Map(); + + // Subscribe to overall agent updates (primarily for new messages) + const agentSubscription = window.observables.agentInstance.subscribeToAgentUpdates(agentId).subscribe({ + next: (fullAgent) => { + // Ensure fullAgent exists before processing + if (!fullAgent) return; + + // Extract current state + const { messages: currentMessages, orderedMessageIds: currentOrderedIds } = get(); + const newMessageIds: string[] = []; + + // Process new messages - backend already sorts messages by modified time + fullAgent.messages.forEach(message => { + const existingMessage = currentMessages.get(message.id); + + // If this is a new message + if (!existingMessage) { + // Add new message to the map + currentMessages.set(message.id, message); + newMessageIds.push(message.id); + + // Subscribe to AI message updates + if ((message.role === 'agent' || message.role === 'assistant') && !messageSubscriptions.has(message.id)) { + // Mark as streaming + get().setMessageStreaming(message.id, true); + + // Create message-specific subscription + // DEBUG: console agentId, message.id + console.log(`agentId, message.id`, agentId, message.id); + messageSubscriptions.set( + message.id, + window.observables.agentInstance.subscribeToAgentUpdates(agentId, message.id).subscribe({ + next: (status) => { + // DEBUG: console status.message + console.log(`status.message`, status?.state, status?.message?.content); + if (status?.message) { + // Update the message in our map + get().messages.set(status.message.id, status.message); + // Check if completed + if (status.state === 'completed') { + get().setMessageStreaming(status.message.id, false); + } + } + }, + error: (error) => { + console.error(`Error in message subscription for ${message.id}:`, error); + }, + complete: () => { + get().setMessageStreaming(message.id, false); + messageSubscriptions.delete(message.id); + }, + }), + ); + } + } + }); + + // Extract agent data without messages + const { messages: _, ...agentWithoutMessages } = fullAgent; + + // Update state based on whether we have new messages + if (newMessageIds.length > 0) { + // Update agent and append new message IDs to maintain order + set({ + agent: agentWithoutMessages, + orderedMessageIds: [...currentOrderedIds, ...newMessageIds], + }); + } else { + // No new messages, just update agent state + set({ agent: agentWithoutMessages }); } }, error: (error) => { @@ -60,7 +180,10 @@ export const useAgentChatStore = create((set) => ({ // Return cleanup function return () => { - subscription.unsubscribe(); + agentSubscription.unsubscribe(); + messageSubscriptions.forEach(subscription => { + subscription.unsubscribe(); + }); }; } catch (error) { console.error('Failed to subscribe to agent updates:', error); @@ -87,16 +210,20 @@ export const useAgentChatStore = create((set) => ({ } }, - // Create new agent instance createAgent: async (agentDefinitionId?: string) => { try { set({ loading: true }); - const newAgent = await window.service.agentInstance.createAgent(agentDefinitionId); + const fullAgent = await window.service.agentInstance.createAgent(agentDefinitionId); + + // Process agent data using our helper method + const storeData = get().processAgentData(fullAgent); + set({ - agent: newAgent, + ...storeData, error: null, }); - return newAgent; + + return storeData.agent; } catch (error) { set({ error: error instanceof Error ? error : new Error(String(error)) }); console.error('Failed to create agent:', error); @@ -106,7 +233,6 @@ export const useAgentChatStore = create((set) => ({ } }, - // Update agent updateAgent: async (agentId: string, data: Partial) => { if (!agentId) { set({ error: new Error('No agent ID provided') }); @@ -115,12 +241,13 @@ export const useAgentChatStore = create((set) => ({ try { set({ loading: true }); - const updatedAgent = await window.service.agentInstance.updateAgent(agentId, data); + const fullAgent = await window.service.agentInstance.updateAgent(agentId, data); + const storeData = get().processAgentData(fullAgent); set({ - agent: updatedAgent, + ...storeData, error: null, }); - return updatedAgent; + return storeData.agent; } catch (error) { set({ error: error instanceof Error ? error : new Error(String(error)) }); console.error('Failed to update agent:', error); @@ -136,13 +263,40 @@ export const useAgentChatStore = create((set) => ({ try { await window.service.agentInstance.cancelAgent(agentId); + + // Clear streaming state for all messages + const { streamingMessageIds } = get(); + if (streamingMessageIds.size > 0) { + const newStreamingIds = new Set(); + set({ streamingMessageIds: newStreamingIds }); + } } catch (error) { console.error('Failed to cancel agent:', error); } }, - // Clear error clearError: () => { set({ error: null }); }, + + setMessageStreaming: (messageId: string, isStreaming: boolean) => { + const { streamingMessageIds } = get(); + const newStreamingIds = new Set(streamingMessageIds); + + if (isStreaming) { + newStreamingIds.add(messageId); + } else { + newStreamingIds.delete(messageId); + } + + set({ streamingMessageIds: newStreamingIds }); + }, + + isMessageStreaming: (messageId: string) => { + return get().streamingMessageIds.has(messageId); + }, + + getMessageById: (messageId: string) => { + return get().messages.get(messageId); + }, })); diff --git a/src/pages/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx b/src/pages/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx index 8b728d24..841cd51f 100644 --- a/src/pages/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx +++ b/src/pages/Preferences/sections/ExternalAPI/components/ProviderConfig.tsx @@ -54,7 +54,7 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: const [availableDefaultProviders, setAvailableDefaultProviders] = useState([]); const [selectedDefaultProvider, setSelectedDefaultProvider] = useState(''); - const [providerForms, setProviderForms] = useState>({}); + const [providerForms, setProviderForms] = useState>({}); const [modelDialogOpen, setModelDialogOpen] = useState(false); const [currentProvider, setCurrentProvider] = useState(null); const [selectedDefaultModel, setSelectedDefaultModel] = useState(''); @@ -108,10 +108,18 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: const handleFormChange = async (providerName: string, field: keyof AIProviderConfig, value: string) => { try { - setProviderForms(previous => ({ - ...previous, - [providerName]: { ...previous[providerName], [field]: value }, - })); + setProviderForms(previous => { + const currentForm = previous[providerName]; + if (!currentForm) return previous; + + return { + ...previous, + [providerName]: { + ...currentForm, + [field]: value, + } as ProviderFormState, + }; + }); await window.service.externalAPI.updateProvider(providerName, { [field]: value }); showMessage(t('Preference.SettingsSaved'), 'success'); } catch (error) { @@ -134,8 +142,8 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: const openAddModelDialog = (providerName: string) => { setCurrentProvider(providerName); const provider = defaultProvidersConfig.providers.find(p => p.provider === providerName) as AIProviderConfig | undefined; - const currentModels = providerForms[providerName].models || []; - const currentModelNames = new Set(currentModels.map(m => m.name)); + const currentModels = providerForms[providerName]?.models; + const currentModelNames = new Set(currentModels?.map(m => m.name)); if (provider) { setAvailableDefaultModels(provider.models.filter(m => !currentModelNames.has(m.name))); @@ -167,21 +175,28 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: }; const handleModelFormChange = (providerName: string, field: string, value: string | ModelFeature[]) => { - setProviderForms(previous => ({ - ...previous, - [providerName]: { - ...previous[providerName], - newModel: { - ...previous[providerName].newModel, - [field]: value, - }, - }, - })); - }; + setProviderForms(previous => { + const currentForm = previous[providerName]; + if (!currentForm) return previous; + return { + ...previous, + [providerName]: { + ...currentForm, + newModel: { + ...currentForm.newModel, + [field]: value, + }, + } as ProviderFormState, + }; + }); + }; const handleFeatureChange = (providerName: string, feature: ModelFeature, checked: boolean) => { setProviderForms(previous => { - const newFeatures = [...previous[providerName].newModel.features]; + const currentForm = previous[providerName]; + if (!currentForm) return previous; + + const newFeatures = [...currentForm.newModel.features]; if (checked && !newFeatures.includes(feature)) { newFeatures.push(feature); @@ -195,12 +210,16 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: return { ...previous, [providerName]: { - ...previous[providerName], + ...currentForm, newModel: { - ...previous[providerName].newModel, + ...currentForm.newModel, features: newFeatures, + } satisfies { + name: string; + caption: string; + features: ModelFeature[]; }, - }, + } as ProviderFormState, }; }); }; @@ -210,11 +229,17 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: try { const form = providerForms[currentProvider]; - const newModel: ModelInfo = { + if (!form) { + showMessage(t('Preference.FailedToAddModel'), 'error'); + return; + } + + // Create model with proper type checking using satisfies + const newModel = { name: form.newModel.name, caption: form.newModel.caption || undefined, features: form.newModel.features, - }; + } satisfies ModelInfo; if (!newModel.name) { showMessage(t('Preference.ModelNameRequired'), 'error'); @@ -227,18 +252,23 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: } const updatedModels = [...form.models, newModel]; - setProviderForms(previous => ({ - ...previous, - [currentProvider]: { - ...previous[currentProvider], - models: updatedModels, - newModel: { - name: '', - caption: '', - features: ['language' as ModelFeature], - }, - }, - })); + setProviderForms(previous => { + const currentForm = previous[currentProvider]; + if (!currentForm) return previous; + + return { + ...previous, + [currentProvider]: { + ...currentForm, + models: updatedModels, + newModel: { + name: '', + caption: '', + features: ['language' as ModelFeature], + }, + } as ProviderFormState, + }; + }); const provider = providers.find(p => p.provider === currentProvider); if (provider) { @@ -272,15 +302,25 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: const removeModel = async (providerName: string, modelName: string) => { try { const form = providerForms[providerName]; + if (!form) { + showMessage(t('Preference.FailedToRemoveModel'), 'error'); + return; + } + const updatedModels = form.models.filter(m => m.name !== modelName); - setProviderForms(previous => ({ - ...previous, - [providerName]: { - ...previous[providerName], - models: updatedModels, - }, - })); + setProviderForms(previous => { + const currentForm = previous[providerName]; + if (!currentForm) return previous; + + return { + ...previous, + [providerName]: { + ...currentForm, + models: updatedModels, + } as ProviderFormState, + }; + }); await window.service.externalAPI.updateProvider(providerName, { models: updatedModels, @@ -312,14 +352,44 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: return; } - const newProvider: AIProviderConfig = { + // Find similar preset provider to get appropriate default model + let defaultModel: ModelInfo | undefined; + const similarPresetProvider = defaultProvidersConfig.providers.find( + p => p.providerClass === newProviderForm.providerClass, + ); + + // If there's a similar provider, use its first model as the default + if (similarPresetProvider && similarPresetProvider.models.length > 0) { + // Clone the first model from the similar provider using explicit typing for features + const baseModel = similarPresetProvider.models[0]; + + // Ensure features are properly typed as ModelFeature[] + const typedFeatures = baseModel.features.map(feature => feature as ModelFeature); + + // Create the default model with proper type safety + defaultModel = { + name: baseModel.name, + caption: `${baseModel.caption || baseModel.name} (${newProviderForm.provider})`, + features: typedFeatures, + } satisfies ModelInfo; + + // Safely handle metadata if it exists using in operator for type checking + if ('metadata' in baseModel && baseModel.metadata) { + // Using type assertion after checking existence with 'in' operator + defaultModel.metadata = { ...baseModel.metadata }; + } + } + // If no similar provider found, don't create a default model + + // Create new provider configuration with type checking using satisfies + const newProvider = { provider: newProviderForm.provider, providerClass: newProviderForm.providerClass, baseURL: newProviderForm.baseURL, - models: [], + models: defaultModel ? [defaultModel] : [], // Only add model if one was found isPreset: false, enabled: true, - }; + } satisfies AIProviderConfig; await window.service.externalAPI.updateProvider(newProviderForm.provider, newProvider); const updatedProviders = [...providers, newProvider]; @@ -329,7 +399,7 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: [newProvider.provider]: { apiKey: '', baseURL: newProvider.baseURL || '', - models: [], + models: newProvider.models, newModel: { name: '', caption: '', @@ -339,6 +409,15 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: })); setSelectedTabIndex(updatedProviders.length - 1); setNewProviderForm({ provider: '', providerClass: 'openAICompatible', baseURL: '' }); + + // Set the new provider and default model as the default selection if a model was found + if (changeDefaultModel && defaultModel) { + try { + await changeDefaultModel(newProvider.provider, defaultModel.name); + } catch (error) { + console.error('Failed to set default model for new provider:', error); + } + } setShowAddProviderForm(false); showMessage(t('Preference.ProviderAddedSuccessfully'), 'success'); } catch (error) { @@ -465,9 +544,9 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }: onClose={closeModelDialog} onAddModel={handleAddModel} currentProvider={currentProvider} - newModelForm={(currentProvider && providerForms[currentProvider]) + newModelForm={currentProvider && providerForms[currentProvider] ? providerForms[currentProvider].newModel - : { name: '', caption: '', features: ['language'] }} + : { name: '', caption: '', features: ['language' as ModelFeature] }} availableDefaultModels={availableDefaultModels} selectedDefaultModel={selectedDefaultModel} onSelectDefaultModel={setSelectedDefaultModel} diff --git a/src/services/agentInstance/index.ts b/src/services/agentInstance/index.ts index f01be709..4969e8af 100644 --- a/src/services/agentInstance/index.ts +++ b/src/services/agentInstance/index.ts @@ -18,6 +18,7 @@ import serviceIdentifier from '@services/serviceIdentifier'; import { IWikiService } from '@services/wiki/interface'; import { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from './interface'; +import { AGENT_INSTANCE_FIELDS, createAgentInstanceData, createAgentMessage, MESSAGE_FIELDS } from './utilities'; @injectable() export class AgentInstanceService implements IAgentInstanceService { @@ -110,29 +111,8 @@ export class AgentInstanceService implements IAgentInstanceService { throw new Error(`Agent definition not found: ${agentDefinitionID}`); } - // Create new agent instance - const instanceId = nanoid(); - const now = new Date(); - - // Initialize agent status - const initialStatus: AgentInstanceLatestStatus = { - state: 'completed', - modified: now, - }; - - // Extract necessary fields from agentDef - const { avatarUrl, aiApiConfig } = agentDef; - - const instanceData = { - id: instanceId, - agentDefId: agentDef.id, - name: `${agentDef.name} - ${new Date().toLocaleString()}`, - status: initialStatus, - avatarUrl, - aiApiConfig, - messages: [], - closed: false, - }; + // Create new agent instance using utility function + const { instanceData, instanceId, now } = createAgentInstanceData(agentDef); // Create and save entity const instanceEntity = this.agentInstanceRepository!.create(instanceData); @@ -180,7 +160,7 @@ export class AgentInstanceService implements IAgentInstanceService { } return { - ...pick(instanceEntity, ['id', 'agentDefId', 'name', 'status', 'created', 'modified', 'avatarUrl', 'aiApiConfig', 'closed']), + ...pick(instanceEntity, AGENT_INSTANCE_FIELDS), messages: instanceEntity.messages || [], }; } catch (error) { @@ -197,10 +177,15 @@ export class AgentInstanceService implements IAgentInstanceService { this.ensureRepositories(); try { - // Get existing instance + // Get existing instance with messages const instanceEntity = await this.agentInstanceRepository!.findOne({ where: { id: agentId }, relations: ['messages'], + order: { + messages: { + modified: 'ASC', // Ensure messages are sorted in ascending order by modified time + }, + }, }); if (!instanceEntity) { @@ -234,24 +219,30 @@ export class AgentInstanceService implements IAgentInstanceService { }); for (const message of newMessages) { - const messageEntity = this.agentMessageRepository!.create({ - id: message.id, - agentId: agentId, - role: message.role, - content: message.content, - contentType: message.contentType, - metadata: message.metadata, - }); + const messageEntity = this.agentMessageRepository!.create( + pick(message, MESSAGE_FIELDS) as AgentInstanceMessage, + ); await this.agentMessageRepository!.save(messageEntity); + + // Add new message to the instance entity + if (!instanceEntity.messages) { + instanceEntity.messages = []; + } + instanceEntity.messages.push(messageEntity); } } - // Reload complete instance data - const updatedAgent = await this.getAgent(agentId) as AgentInstance; + // Construct the response object directly from the entity + // This avoids an additional database query with getAgent() + const updatedAgent: AgentInstance = { + ...pick(instanceEntity, AGENT_INSTANCE_FIELDS), + messages: instanceEntity.messages || [], + }; - // Notify subscribers about the updates - await this.notifyAgentUpdate(agentId); + // Notify subscribers about the updates with the already available data + // This avoids another database query within notifyAgentUpdate + await this.notifyAgentUpdate(agentId, updatedAgent); return updatedAgent; } catch (error) { @@ -325,7 +316,7 @@ export class AgentInstanceService implements IAgentInstanceService { return instances.map(entity => { return { - ...pick(entity, ['id', 'agentDefId', 'name', 'status', 'created', 'modified', 'avatarUrl', 'aiApiConfig', 'closed']), + ...pick(entity, AGENT_INSTANCE_FIELDS), messages: entity.messages || [], }; }); @@ -352,15 +343,13 @@ export class AgentInstanceService implements IAgentInstanceService { // Create user message const messageId = nanoid(); const now = new Date(); - const userMessage: AgentInstanceMessage = { - id: messageId, - agentId, + // Use helper function to create message with proper structure + const userMessage = createAgentMessage(messageId, agentId, { role: 'user', content: content.text, contentType: 'text/plain', - modified: now, - ...(content.file ? { metadata: { file: content.file } } : {}), - }; + metadata: content.file ? { file: content.file } : undefined, + }); // Save user message const messageEntity = this.agentMessageRepository!.create(userMessage); @@ -369,7 +358,8 @@ export class AgentInstanceService implements IAgentInstanceService { // Update agent status to "working" logger.debug(`Sending message to agent ${agentId}, appending user message to ${agentInstance.messages.length} existing messages`, { method: 'sendMsgToAgent' }); - await this.updateAgent(agentId, { + // Update agent and use the returned value directly instead of querying again + const updatedAgent = await this.updateAgent(agentId, { status: { state: 'working', modified: now, @@ -383,12 +373,6 @@ export class AgentInstanceService implements IAgentInstanceService { throw new Error(`Agent definition not found: ${agentInstance.agentDefId}`); } - // Get updated agent instance - const updatedAgent = await this.getAgent(agentId); - if (!updatedAgent) { - throw new Error(`Failed to get updated agent instance: ${agentId}`); - } - // Get appropriate handler const handlerId = agentDefinition.handlerID; if (!handlerId) { @@ -403,7 +387,7 @@ export class AgentInstanceService implements IAgentInstanceService { const cancelToken = { value: false }; this.cancelTokenMap.set(agentId, cancelToken); const handlerContext: AgentHandlerContext = { - agent: updatedAgent, + agent: updatedAgent, // 直接使用updateAgent返回的结果 agentDef: agentDefinition, isCancelled: () => cancelToken.value, }; @@ -412,8 +396,19 @@ export class AgentInstanceService implements IAgentInstanceService { // Create async generator const generator = handler(handlerContext); + // Track the last message for completion handling + let lastResult: AgentInstanceLatestStatus | undefined; + for await (const result of generator) { + // Store the last result for completion handling + lastResult = result; + if (result.message?.content) { + // Ensure message has correct modification timestamp + if (!result.message.modified) { + result.message.modified = new Date(); + } + this.debounceUpdateMessage( result.message, agentId, @@ -421,6 +416,8 @@ export class AgentInstanceService implements IAgentInstanceService { // Update status subscribers const statusKey = `${agentId}:${result.message.id}`; + // DEBUG: console statusKey + console.log(`statusKey`, statusKey, this.statusSubjects.has(statusKey)); if (this.statusSubjects.has(statusKey)) { this.statusSubjects.get(statusKey)?.next(result); } @@ -435,6 +432,26 @@ export class AgentInstanceService implements IAgentInstanceService { }); } + // Handle stream completion without fetching agent again + if (lastResult?.message) { + // Complete the message stream directly using the last message from the generator + const statusKey = `${agentId}:${lastResult.message.id}`; + if (this.statusSubjects.has(statusKey)) { + const subject = this.statusSubjects.get(statusKey); + if (subject) { + // Send final update with completed state + subject.next({ + state: 'completed', + message: lastResult.message, + modified: new Date(), + }); + // Complete the Observable and remove the subject + subject.complete(); + this.statusSubjects.delete(statusKey); + } + } + } + // Remove cancel token after generator completes this.cancelTokenMap.delete(agentId); } catch (error) { @@ -558,6 +575,8 @@ export class AgentInstanceService implements IAgentInstanceService { // If messageId provided, subscribe to specific message status updates if (messageId) { const statusKey = `${agentId}:${messageId}`; + // DEBUG: console statusKey + console.log(`subscribeToAgentUpdates statusKey`, statusKey); if (!this.statusSubjects.has(statusKey)) { this.statusSubjects.set(statusKey, new BehaviorSubject(undefined)); @@ -566,11 +585,14 @@ export class AgentInstanceService implements IAgentInstanceService { if (agent) { const message = agent.messages.find(m => m.id === messageId); if (message) { - this.statusSubjects.get(statusKey)?.next({ + // 创建状态对象,注意不再检查 isComplete + const status: AgentInstanceLatestStatus = { state: agent.status.state, message, modified: message.modified, - }); + }; + + this.statusSubjects.get(statusKey)?.next(status); } } }).catch(error => { @@ -598,25 +620,16 @@ export class AgentInstanceService implements IAgentInstanceService { /** * Notify agent subscription of updates - * When called with only agentId, it will fetch the agent data from database before notifying - * When called with agentData, it will use the provided data for immediate notification without database query * @param agentId Agent ID - * @param agentData Optional in-memory agent data to use for immediate notification + * @param agentData Agent data to use for notification */ - private async notifyAgentUpdate(agentId: string, agentData?: AgentInstance): Promise { + private async notifyAgentUpdate(agentId: string, agentData: AgentInstance): Promise { try { // Only notify if there are active subscriptions if (this.agentInstanceSubjects.has(agentId)) { - if (agentData) { - // Immediate notification with provided data (no database query) - this.agentInstanceSubjects.get(agentId)?.next(agentData); - logger.debug(`Real-time notification for agent ${agentId}`, { method: 'notifyAgentUpdate', immediate: true }); - } else { - // Fetch data from database before notification - const agent = await this.getAgent(agentId); - this.agentInstanceSubjects.get(agentId)?.next(agent); - logger.debug(`Notified subscribers for agent ${agentId}`, { method: 'notifyAgentUpdate', immediate: false }); - } + // Use the provided data for notification (no database query) + this.agentInstanceSubjects.get(agentId)?.next(agentData); + logger.debug(`Notified subscribers for agent ${agentId}`, { method: 'notifyAgentUpdate' }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -640,42 +653,15 @@ export class AgentInstanceService implements IAgentInstanceService { contentPreview: message.content.substring(0, 50), }); - // If we have an agent ID, immediately notify the frontend with the updated message - // This provides real-time streaming updates without waiting for database writes - if (agentId && this.agentInstanceSubjects.has(agentId)) { - try { - // Get current agent state (but don't await a database call) - const currentSubject = this.agentInstanceSubjects.get(agentId); - if (currentSubject) { - const currentAgent = currentSubject.getValue(); - - if (currentAgent) { - // Create an updated version of the agent with the latest message content - const updatedAgent = { ...currentAgent }; - - // Find and update the message in the local copy - const messageIndex = updatedAgent.messages.findIndex(msg => msg.id === messageId); - if (messageIndex >= 0) { - // Update existing message - updatedAgent.messages[messageIndex] = { - ...updatedAgent.messages[messageIndex], - content: message.content, - ...(message.contentType && { contentType: message.contentType }), - ...(message.metadata && { metadata: message.metadata }), - ...(message.reasoning_content && { reasoning_content: message.reasoning_content }), - }; - } else { - // Add new message if it doesn't exist yet - updatedAgent.messages = [...updatedAgent.messages, message]; - } - - // Use immediate update with in-memory data without DB query - void this.notifyAgentUpdate(agentId, updatedAgent); - } - } - } catch (error) { - logger.error(`Failed to send real-time update: ${error instanceof Error ? error.message : String(error)}`); - // Continue with database update even if real-time update fails + // Update status subscribers for specific message if available + if (agentId) { + const statusKey = `${agentId}:${messageId}`; + if (this.statusSubjects.has(statusKey)) { + this.statusSubjects.get(statusKey)?.next({ + state: 'working', + message, + modified: message.modified ?? new Date(), + }); } } @@ -686,66 +672,75 @@ export class AgentInstanceService implements IAgentInstanceService { async (msgData: AgentInstanceMessage, aid?: string) => { try { this.ensureRepositories(); - if (this.dataSource) { - // Use ORM transaction - await this.dataSource.transaction(async transaction => { - const messageRepo = transaction.getRepository(AgentInstanceMessageEntity); - const messageEntity = await messageRepo.findOne({ - where: { id: messageId }, - }); + // ensureRepositories guarantees dataSource is available + await this.dataSource!.transaction(async transaction => { + const messageRepo = transaction.getRepository(AgentInstanceMessageEntity); + const messageEntity = await messageRepo.findOne({ + where: { id: messageId }, + }); - if (messageEntity) { - // Update message content - messageEntity.content = msgData.content; - if (msgData.contentType) messageEntity.contentType = msgData.contentType; - if (msgData.metadata) messageEntity.metadata = msgData.metadata; - messageEntity.modified = new Date(); + if (messageEntity) { + // Update message content + messageEntity.content = msgData.content; + if (msgData.contentType) messageEntity.contentType = msgData.contentType; + if (msgData.metadata) messageEntity.metadata = msgData.metadata; + messageEntity.modified = new Date(); - await messageRepo.save(messageEntity); - - // Database is now updated, but we don't need to notify again here - // since we've already sent real-time updates before the database operation - // This prevents duplicate notifications and keeps UI responsive - } else if (aid) { - // Create new message if it doesn't exist and agentId provided - const now = new Date(); - const newMessage = messageRepo.create({ - id: messageId, - agentId: aid, - // Use destructuring with default value to handle undefined case + await messageRepo.save(messageEntity); + } else if (aid) { + // Create new message if it doesn't exist and agentId provided + // Create message using utility function + const newMessage = messageRepo.create( + createAgentMessage(messageId, aid, { role: msgData.role, content: msgData.content, - contentType: msgData.contentType || 'text/plain', - modified: now, + contentType: msgData.contentType, metadata: msgData.metadata, - }); + }), + ); - await messageRepo.save(newMessage); + await messageRepo.save(newMessage); - // Update agent instance message list - const agentInstance = await this.getAgent(aid); - if (agentInstance) { - logger.debug(`Creating new message and appending to ${agentInstance.messages.length} existing messages`, { + // Get agent instance repository for transaction + const agentRepo = transaction.getRepository(AgentInstanceEntity); + + // Get agent instance within the current transaction + const agentEntity = await agentRepo.findOne({ + where: { id: aid }, + relations: ['messages'], + }); + + if (agentEntity) { + // Add the new message to the agent entity + if (!agentEntity.messages) { + agentEntity.messages = []; + } + agentEntity.messages.push(newMessage); + + // Save the updated agent entity + await agentRepo.save(agentEntity); + + // Construct agent data from entity directly without additional query + const updatedAgent: AgentInstance = { + ...pick(agentEntity, AGENT_INSTANCE_FIELDS), + messages: agentEntity.messages || [], + }; + + // Notify subscribers directly without additional queries + if (this.agentInstanceSubjects.has(aid)) { + this.agentInstanceSubjects.get(aid)?.next(updatedAgent); + logger.debug(`Notified agent subscribers of new message: ${messageId}`, { method: 'debounceUpdateMessage', - messageId, agentId: aid, }); - - await this.updateAgent(aid, { - messages: [...agentInstance.messages, newMessage], // Append message at the end to maintain chronological order (ASC) - }); - - // For new messages, we need to notify after database is updated - // as this is the first time the message exists in the database - setImmediate(() => { - void this.notifyAgentUpdate(aid); - }); } } else { - logger.warn(`Cannot create message: missing agent ID for message ID: ${messageId}`); + logger.warn(`Agent instance not found for message: ${messageId}`); } - }); - } + } else { + logger.warn(`Cannot create message: missing agent ID for message ID: ${messageId}`); + } + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to update/create message content: ${errorMessage}`); diff --git a/src/services/agentInstance/utilities.ts b/src/services/agentInstance/utilities.ts new file mode 100644 index 00000000..04f75b22 --- /dev/null +++ b/src/services/agentInstance/utilities.ts @@ -0,0 +1,84 @@ +/** + * Utility functions and constants for agent instance service + */ +import { nanoid } from 'nanoid'; +import { AgentInstance, AgentInstanceLatestStatus, AgentInstanceMessage } from './interface'; + +/** + * Create initial data for a new agent instance + * @param agentDefinition Agent definition + * @returns Initial agent instance data + */ +export function createAgentInstanceData(agentDefinition: { id: string; name: string; avatarUrl?: string; aiApiConfig?: Record }): { + instanceData: Omit; + instanceId: string; + now: Date; +} { + const instanceId = nanoid(); + const now = new Date(); + + // Initialize agent status + const initialStatus: AgentInstanceLatestStatus = { + state: 'completed', + modified: now, + }; + + // Extract necessary fields from agent definition + const { avatarUrl, aiApiConfig } = agentDefinition; + + const instanceData = { + id: instanceId, + agentDefId: agentDefinition.id, + name: `${agentDefinition.name} - ${new Date().toLocaleString()}`, + status: initialStatus, + avatarUrl, + aiApiConfig, + messages: [], + closed: false, + }; + + return { instanceData, instanceId, now }; +} + +/** + * Create a new agent message object with required fields + * @param id Message ID + * @param agentId Agent instance ID + * @param message Base message data + * @returns Complete message object + */ +export function createAgentMessage( + id: string, + agentId: string, + message: Pick, +): AgentInstanceMessage { + return { + id, + agentId, + role: message.role, + content: message.content, + contentType: message.contentType || 'text/plain', + modified: new Date(), + metadata: message.metadata, + }; +} + +/** + * Message fields to be extracted when creating message entities + */ +export const MESSAGE_FIELDS = ['id', 'agentId', 'role', 'content', 'contentType', 'metadata'] as const; + +/** + * Agent instance fields to be extracted when retrieving instances + */ +export const AGENT_INSTANCE_FIELDS = [ + 'id', + 'agentDefId', + 'name', + 'status', + 'created', + 'modified', + 'avatarUrl', + 'aiApiConfig', + 'closed', +] as const;