mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-01-16 06:11:43 -08:00
refactor: only use message id
This commit is contained in:
parent
b590fdeb23
commit
081fc16eec
9 changed files with 567 additions and 296 deletions
4
.github/instructions/agent.instructions.md
vendored
4
.github/instructions/agent.instructions.md
vendored
|
|
@ -1,4 +1,4 @@
|
|||
---
|
||||
applyTo: '**/*.ts'
|
||||
applyTo: '**/*.ts|tsx'
|
||||
---
|
||||
用英文注释,编辑完成后用pnpm exec eslint --fix。我使用powershell,但尽量用无须审批的vscode内置功能,少用需要人类审批的shell。
|
||||
用英文注释,编辑完成后用pnpm exec eslint --fix。我使用powershell,但尽量用无须审批的vscode内置功能,少用需要人类审批的shell,例如尽量不要通过创建新文件再用powershell覆盖原文件的方式来更新文件。
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
84
src/services/agentInstance/utilities.ts
Normal file
84
src/services/agentInstance/utilities.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue