refactor: only use message id

This commit is contained in:
lin onetwo 2025-05-19 00:12:25 +08:00
parent b590fdeb23
commit 081fc16eec
9 changed files with 567 additions and 296 deletions

View file

@ -1,4 +1,4 @@
---
applyTo: '**/*.ts'
applyTo: '**/*.ts|tsx'
---
用英文注释编辑完成后用pnpm exec eslint --fix。我使用powershell但尽量用无须审批的vscode内置功能少用需要人类审批的shell。
用英文注释编辑完成后用pnpm exec eslint --fix。我使用powershell但尽量用无须审批的vscode内置功能少用需要人类审批的shell例如尽量不要通过创建新文件再用powershell覆盖原文件的方式来更新文件

View file

@ -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<MessageBubbleProps> = ({ message, isUser }) => {
// Track if the message is streaming (being generated)
const [isStreaming, setIsStreaming] = useState(false);
export const MessageBubble: React.FC<MessageBubbleProps> = ({ 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 (
<BubbleContainer $isUser={isUser}>

View file

@ -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<MessagesContainerProps> = ({ messages, children }) => {
export const MessagesContainer: React.FC<MessagesContainerProps> = ({ messageIds, children }) => {
return (
<Container id='messages-container'>
{/* Render messages as message bubbles */}
{messages.map((message) => (
{/* 只传递消息 ID 给子组件 */}
{messageIds.map((messageId) => (
<MessageBubble
key={message.id}
message={message}
isUser={message.role === 'user'}
key={messageId}
messageId={messageId}
/>
))}

View file

@ -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<boolean>;
debouncedScrollToBottom: () => void;
agent: AgentInstance | null; // Using the proper AgentInstance type from the AgentChatStore
agent: AgentWithoutMessages | null; // Updated to use AgentWithoutMessages type
}
/**

View file

@ -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<ChatTabContentProps> = ({ tab }) => {
export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
const { t } = useTranslation('agent');
// Type checking
@ -106,13 +106,15 @@ const ChatTabContent: React.FC<ChatTabContentProps> = ({ 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<ChatTabContentProps> = ({ 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<ChatTabContentProps> = ({ tab }) => {
{/* Messages container with all chat bubbles */}
<Box sx={{ position: 'relative', flex: 1, overflow: 'hidden' }}>
<MessagesContainer messages={messages}>
<MessagesContainer messageIds={orderedMessageIds}>
{/* Error state */}
{error && (
<Box sx={{ textAlign: 'center', p: 2, color: 'error.main' }}>
@ -167,14 +167,14 @@ const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
)}
{/* Empty state */}
{!loading && !error && messages.length === 0 && (
{!loading && !error && orderedMessageIds.length === 0 && (
<Box sx={{ textAlign: 'center', p: 4, color: 'text.secondary' }}>
<Typography>{t('Agent.StartConversation')}</Typography>
</Box>
)}
{/* Loading state - when first loading the agent */}
{loading && messages.length === 0 && (
{loading && orderedMessageIds.length === 0 && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<CircularProgress size={24} />
<Typography sx={{ mt: 2 }}>{t('Agent.LoadingChat')}</Typography>
@ -204,5 +204,3 @@ const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
</Box>
);
};
export { ChatTabContent };

View file

@ -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<AgentInstance, 'messages'>;
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<string, AgentInstanceMessage>;
// Store message IDs in order to maintain backend's message ordering
orderedMessageIds: string[];
// Track which messages are currently streaming
streamingMessageIds: Set<string>;
// Helper method to process agent data
processAgentData: (fullAgent: AgentInstance) => {
agent: AgentWithoutMessages;
messages: Map<string, AgentInstanceMessage>;
orderedMessageIds: string[];
};
// Actions
fetchAgent: (agentId: string) => Promise<void>;
subscribeToUpdates: (agentId: string) => (() => void) | undefined;
sendMessage: (agentId: string, content: string) => Promise<void>;
createAgent: (agentDefinitionId?: string) => Promise<AgentInstance | null>;
updateAgent: (agentId: string, data: Partial<AgentInstance>) => Promise<AgentInstance | null>;
createAgent: (agentDefinitionId?: string) => Promise<AgentWithoutMessages | null>;
updateAgent: (agentId: string, data: Partial<AgentInstance>) => Promise<AgentWithoutMessages | null>;
cancelAgent: (agentId: string) => Promise<void>;
clearError: () => void;
// Message-specific actions
setMessageStreaming: (messageId: string, isStreaming: boolean) => void;
isMessageStreaming: (messageId: string) => boolean;
getMessageById: (messageId: string) => AgentInstanceMessage | undefined;
}
export const useAgentChatStore = create<AgentChatState>((set) => ({
export const useAgentChatStore = create<AgentChatState>((set, get) => ({
// Initial state
loading: false,
error: null,
agent: null,
messages: new Map<string, AgentInstanceMessage>(),
orderedMessageIds: [],
streamingMessageIds: new Set<string>(),
// 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<string, AgentInstanceMessage>();
// 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<AgentChatState>((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<AgentChatState>((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<string, Subscription>();
// 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<AgentChatState>((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<AgentChatState>((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<AgentChatState>((set) => ({
}
},
// Update agent
updateAgent: async (agentId: string, data: Partial<AgentInstance>) => {
if (!agentId) {
set({ error: new Error('No agent ID provided') });
@ -115,12 +241,13 @@ export const useAgentChatStore = create<AgentChatState>((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<AgentChatState>((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<string>();
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);
},
}));

View file

@ -54,7 +54,7 @@ export function ProviderConfig({ providers, setProviders, changeDefaultModel }:
const [availableDefaultProviders, setAvailableDefaultProviders] = useState<AIProviderConfig[]>([]);
const [selectedDefaultProvider, setSelectedDefaultProvider] = useState('');
const [providerForms, setProviderForms] = useState<Record<string, ProviderFormState>>({});
const [providerForms, setProviderForms] = useState<Record<string, ProviderFormState | undefined>>({});
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [currentProvider, setCurrentProvider] = useState<string | null>(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}

View file

@ -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<AgentInstanceLatestStatus | undefined>(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<void> {
private async notifyAgentUpdate(agentId: string, agentData: AgentInstance): Promise<void> {
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}`);

View file

@ -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<string, unknown> }): {
instanceData: Omit<AgentInstance, 'created' | 'modified'>;
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, 'role' | 'content' | 'contentType' | 'metadata'>,
): 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;