This commit is contained in:
Sun Tao 2025-11-26 14:19:46 +08:00
parent 5055069f59
commit aa1916fa82
4 changed files with 450 additions and 274 deletions

View file

@ -23,7 +23,39 @@ export const ProjectSection = React.forwardRef<HTMLDivElement, ProjectSectionPro
onSkip,
isPauseResumeLoading
}, ref) => {
const chatState = chatStore.getState();
// Subscribe to store changes with throttling to prevent excessive re-renders
const [chatState, setChatState] = React.useState(() => chatStore.getState());
React.useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
let latestState: any = null;
const unsubscribe = chatStore.subscribe((state) => {
latestState = state;
// Throttle updates to max once per 100ms
if (!timeoutId) {
timeoutId = setTimeout(() => {
if (latestState) {
setChatState(latestState);
}
timeoutId = null;
}, 100);
}
});
return () => {
unsubscribe();
if (timeoutId) {
clearTimeout(timeoutId);
// Apply final state on cleanup
if (latestState) {
setChatState(latestState);
}
}
};
}, [chatStore]);
const activeTaskId = chatState.activeTaskId;
if (!activeTaskId || !chatState.tasks[activeTaskId]) {
@ -33,8 +65,17 @@ export const ProjectSection = React.forwardRef<HTMLDivElement, ProjectSectionPro
const task = chatState.tasks[activeTaskId];
const messages = task.messages || [];
// Group messages by query cycles and show in chronological order (oldest first)
const queryGroups = groupMessagesByQuery(messages);
// Create a stable key based on messages content to prevent excessive re-renders
const messagesKey = React.useMemo(() => {
// Only re-compute when message count or last message changes
const lastMsg = messages[messages.length - 1];
return `${messages.length}-${lastMsg?.id || ''}-${lastMsg?.content?.length || 0}`;
}, [messages.length, messages[messages.length - 1]?.id, messages[messages.length - 1]?.content?.length]);
// Memoize grouping to prevent re-creating objects on every render
const queryGroups = React.useMemo(() => {
return groupMessagesByQuery(messages);
}, [messagesKey, messages]);
return (
<motion.div
@ -152,7 +193,7 @@ function groupMessagesByQuery(messages: any[]) {
otherMessages: []
};
}
} else {
} else {
// Other messages (assistant responses, errors, etc.)
if (currentGroup) {
currentGroup.otherMessages.push(message);

View file

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, useSyncExternalStore } from 'react';
import { motion, useMotionValue, useTransform } from 'framer-motion';
import { UserMessageCard } from './MessageItem/UserMessageCard';
import { AgentMessageCard } from './MessageItem/AgentMessageCard';
@ -38,24 +38,35 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
const chatState = chatStore.getState();
const activeTaskId = chatState.activeTaskId;
// Subscribe to streaming decompose text separately for efficient updates
const streamingDecomposeText = useSyncExternalStore(
(callback) => chatStore.subscribe(callback),
() => {
const state = chatStore.getState();
const taskId = state.activeTaskId;
if (!taskId || !state.tasks[taskId]) return '';
return state.tasks[taskId].streamingDecomposeText || '';
}
);
// Show task if this query group has a task message OR if it's the most recent user query during splitting
// During splitting phase (no to_sub_tasks yet), show task for the most recent query only
// Exclude human-reply scenarios (when user is replying to an activeAsk)
const isHumanReply = queryGroup.userMessage &&
const isHumanReply = queryGroup.userMessage &&
activeTaskId &&
chatState.tasks[activeTaskId] &&
(chatState.tasks[activeTaskId].activeAsk ||
// Check if this user message follows an 'ask' message in the message sequence
(() => {
const messages = chatState.tasks[activeTaskId].messages;
const userMessageIndex = messages.findIndex((m: any) => m.id === queryGroup.userMessage.id);
if (userMessageIndex > 0) {
// Check the previous message - if it's an agent message with step 'ask', this is a human-reply
const prevMessage = messages[userMessageIndex - 1];
return prevMessage?.role === 'agent' && prevMessage?.step === 'ask';
}
return false;
})());
(chatState.tasks[activeTaskId].activeAsk ||
// Check if this user message follows an 'ask' message in the message sequence
(() => {
const messages = chatState.tasks[activeTaskId].messages;
const userMessageIndex = messages.findIndex((m: any) => m.id === queryGroup.userMessage.id);
if (userMessageIndex > 0) {
// Check the previous message - if it's an agent message with step 'ask', this is a human-reply
const prevMessage = messages[userMessageIndex - 1];
return prevMessage?.role === 'agent' && prevMessage?.step === 'ask';
}
return false;
})());
const isLastUserQuery = !queryGroup.taskMessage &&
!isHumanReply &&
@ -68,8 +79,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
// Only show the fallback task box for the newest query while the agent is still splitting work.
// Simple Q&A sessions set hasWaitComfirm to true, so we should not render an empty task box there.
// Also, do not show fallback task if we are currently decomposing (streaming text).
const isDecomposing = streamingDecomposeText.length > 0;
const shouldShowFallbackTask =
isLastUserQuery && activeTaskId && !chatState.tasks[activeTaskId].hasWaitComfirm;
isLastUserQuery && activeTaskId && !chatState.tasks[activeTaskId].hasWaitComfirm && !isDecomposing;
const task =
(queryGroup.taskMessage || shouldShowFallbackTask) && activeTaskId
@ -114,7 +127,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
sentinel.style.height = '1px';
sentinel.style.pointerEvents = 'none';
sentinel.style.zIndex = '-1';
// Insert sentinel before the sticky element
taskBoxRef.current.parentNode?.insertBefore(sentinel, taskBoxRef.current);
@ -144,9 +157,9 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
const anyToSubTasksMessage = task?.messages.find((m: any) => m.step === "to_sub_tasks");
const isSkeletonPhase = task && (
(task.status !== 'finished' &&
!anyToSubTasksMessage &&
!task.hasWaitComfirm &&
task.messages.length > 0) ||
!anyToSubTasksMessage &&
!task.hasWaitComfirm &&
task.messages.length > 0) ||
(task.isTakeControl && !anyToSubTasksMessage)
);
@ -156,7 +169,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
data-query-id={queryGroup.queryId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
transition={{
duration: 0.3,
delay: index * 0.1 // Stagger animation for multiple groups
}}
@ -191,16 +204,16 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{
opacity: 1,
animate={{
opacity: 1,
y: 0
}}
transition={{
transition={{
duration: 0.3,
delay: 0.1 // Slight delay for sequencing
}}
>
<div
<div
style={{
transition: 'all 0.3s ease-in-out',
transformOrigin: 'top'
@ -236,105 +249,28 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
{/* Other Messages */}
{queryGroup.otherMessages.map((message) => {
if (message.content.length > 0) {
if (message.step === "end") {
return (
<motion.div
key={`end-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
typewriter={
task?.type !== "replay" ||
(task?.type === "replay" && task?.delayTime !== 0)
}
id={message.id}
content={message.content}
onTyping={() => {}}
/>
{/* File List */}
{message.fileList && (
<div className="flex pl-3 gap-2 flex-wrap">
{message.fileList.map((file: any, fileIndex: number) => (
<motion.div
key={`file-${message.id}-${file.name}-${fileIndex}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
onClick={() => {
chatState.setSelectedFile(activeTaskId as string, file);
chatState.setActiveWorkSpace(activeTaskId as string, "documentWorkSpace");
}}
className="flex items-center gap-2 bg-message-fill-default rounded-sm px-2 py-1 w-[140px] cursor-pointer hover:bg-message-fill-hover transition-colors"
>
<div className="flex flex-col">
<div className="max-w-[100px] font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
{file.name.split(".")[0]}
</div>
<div className="font-medium leading-29 text-xs text-text-body">
{file.type}
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
);
} else if (message.content === "skip") {
return (
<motion.div
key={`skip-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
if (message.content.length > 0) {
if (message.step === "end") {
return (
<motion.div
key={`end-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
key={message.id}
id={message.id}
content="No reply received, task continues..."
onTyping={() => {}}
/>
</motion.div>
);
} else {
return (
<motion.div
key={`message-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
key={message.id}
typewriter={
task?.type !== "replay" ||
(task?.type === "replay" && task?.delayTime !== 0)
}
id={message.id}
content={message.content}
onTyping={() => {}}
attaches={message.attaches}
onTyping={() => { }}
/>
</motion.div>
);
}
} else if (message.step === "end" && message.content === "") {
return (
<motion.div
key={`end-empty-${message.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
{/* File List */}
{message.fileList && (
<div className="flex gap-2 flex-wrap">
<div className="flex pl-3 gap-2 flex-wrap">
{message.fileList.map((file: any, fileIndex: number) => (
<motion.div
key={`file-${message.id}-${file.name}-${fileIndex}`}
@ -345,11 +281,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
chatState.setSelectedFile(activeTaskId as string, file);
chatState.setActiveWorkSpace(activeTaskId as string, "documentWorkSpace");
}}
className="flex items-center gap-2 bg-message-fill-default rounded-2xl px-2 py-1 w-[120px] cursor-pointer hover:bg-message-fill-hover transition-colors"
className="flex items-center gap-2 bg-message-fill-default rounded-sm px-2 py-1 w-[140px] cursor-pointer hover:bg-message-fill-hover transition-colors"
>
<FileText size={16} className="text-icon-primary flex-shrink-0" />
<div className="flex flex-col">
<div className="max-w-48 font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
<div className="max-w-[100px] font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
{file.name.split(".")[0]}
</div>
<div className="font-medium leading-29 text-xs text-text-body">
@ -362,30 +297,122 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
)}
</motion.div>
);
} else if (message.content === "skip") {
return (
<motion.div
key={`skip-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
key={message.id}
id={message.id}
content="No reply received, task continues..."
onTyping={() => { }}
/>
</motion.div>
);
} else {
return (
<motion.div
key={`message-${message.id}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
<AgentMessageCard
key={message.id}
typewriter={
task?.type !== "replay" ||
(task?.type === "replay" && task?.delayTime !== 0)
}
id={message.id}
content={message.content}
onTyping={() => { }}
attaches={message.attaches}
/>
</motion.div>
);
}
} else if (message.step === "end" && message.content === "") {
return (
<motion.div
key={`end-empty-${message.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-col pl-3 gap-4"
>
{message.fileList && (
<div className="flex gap-2 flex-wrap">
{message.fileList.map((file: any, fileIndex: number) => (
<motion.div
key={`file-${message.id}-${file.name}-${fileIndex}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
onClick={() => {
chatState.setSelectedFile(activeTaskId as string, file);
chatState.setActiveWorkSpace(activeTaskId as string, "documentWorkSpace");
}}
className="flex items-center gap-2 bg-message-fill-default rounded-2xl px-2 py-1 w-[120px] cursor-pointer hover:bg-message-fill-hover transition-colors"
>
<FileText size={16} className="text-icon-primary flex-shrink-0" />
<div className="flex flex-col">
<div className="max-w-48 font-bold text-sm text-body text-text-body overflow-hidden text-ellipsis whitespace-nowrap">
{file.name.split(".")[0]}
</div>
<div className="font-medium leading-29 text-xs text-text-body">
{file.type}
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
);
}
// Notice Card
if (
message.step === "notice_card" &&
!task?.isTakeControl &&
task?.cotList && task.cotList.length > 0
) {
return <NoticeCard key={`notice-${message.id}`} />;
}
// Notice Card
if (
message.step === "notice_card" &&
!task?.isTakeControl &&
task?.cotList && task.cotList.length > 0
) {
return <NoticeCard key={`notice-${message.id}`} />;
}
return null;
})}
return null;
})}
{/* Skeleton for loading state */}
{isSkeletonPhase && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<TypeCardSkeleton isTakeControl={task?.isTakeControl || false} />
</motion.div>
)}
{/* Streaming Decompose Text - rendered separately to avoid flickering */}
{isLastUserQuery && streamingDecomposeText && (
<div className="flex flex-col pl-3 gap-4">
<div className="flex items-center gap-2 text-text-label text-sm font-medium">
<span className="animate-pulse">Splitting Tasks</span>
</div>
<div className="bg-message-fill-default/50 rounded-lg p-3 overflow-hidden">
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{streamingDecomposeText}
</pre>
</div>
</div>
)}
{/* Skeleton for loading state */}
{isSkeletonPhase && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<TypeCardSkeleton isTakeControl={task?.isTakeControl || false} />
</motion.div>
)}
</motion.div>
);
};

View file

@ -42,6 +42,8 @@ interface Task {
isTakeControl: boolean;
isTaskEdit: boolean;
isContextExceeded?: boolean;
// Streaming decompose text - stored separately to avoid frequent re-renders
streamingDecomposeText: string;
}
export interface ChatStore {
@ -56,9 +58,10 @@ export interface ChatStore {
setActiveTaskId: (taskId: string) => void;
replay: (taskId: string, question: string, time: number) => Promise<void>;
startTask: (taskId: string, type?: string, shareToken?: string, delayTime?: number, messageContent?: string, messageAttaches?: File[]) => Promise<void>;
handleConfirmTask: (project_id:string, taskId: string, type?: string) => void;
handleConfirmTask: (project_id: string, taskId: string, type?: string) => void;
addMessages: (taskId: string, messages: Message) => void;
setMessages: (taskId: string, messages: Message[]) => void;
updateMessage: (taskId: string, messageId: string, message: Message) => void;
removeMessage: (taskId: string, messageId: string) => void;
setAttaches: (taskId: string, attaches: File[]) => void;
setSummaryTask: (taskId: string, summaryTask: string) => void;
@ -102,6 +105,8 @@ export interface ChatStore {
clearTasks: () => void,
setIsContextExceeded: (taskId: string, isContextExceeded: boolean) => void;
setNextTaskId: (taskId: string | null) => void;
setStreamingDecomposeText: (taskId: string, text: string) => void;
clearStreamingDecomposeText: (taskId: string) => void;
}
export type VanillaChatStore = {
@ -118,6 +123,10 @@ const autoConfirmTimers: Record<string, ReturnType<typeof setTimeout>> = {};
// Track active SSE connections for proper cleanup
const activeSSEControllers: Record<string, AbortController> = {};
// Throttle streaming decompose text updates to prevent excessive re-renders
const streamingDecomposeTextBuffer: Record<string, string> = {};
const streamingDecomposeTextTimers: Record<string, ReturnType<typeof setTimeout>> = {};
const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
(set, get) => ({
activeTaskId: null,
@ -162,6 +171,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
snapshotsTemp: [],
isTakeControl: false,
isTaskEdit: false,
streamingDecomposeText: '',
},
}
}))
@ -212,6 +222,27 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
})
})
},
updateMessage(taskId: string, messageId: string, message: Message) {
set((state) => {
const task = state.tasks[taskId];
if (!task) return state;
const messages = task.messages.map((m) => {
if (m.id === messageId) {
return message;
}
return m;
});
return {
tasks: {
...state.tasks,
[taskId]: {
...task,
messages,
},
},
};
});
},
stopTask(taskId: string) {
// Abort the SSE connection for this task
try {
@ -305,7 +336,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
/**
* Replay creates its own chatStore for each task with replayProject
*/
if(project_id && type !== "replay") {
if (project_id && type !== "replay") {
console.log("Creating a new Chat Instance for current project on end")
const newChatResult = projectStore.appendInitChatStore(project_id);
@ -313,7 +344,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
newTaskId = newChatResult.taskId;
targetChatStore = newChatResult.chatStore;
targetChatStore.getState().setIsPending(newTaskId, true);
//From handleSend if message is given
// Add the message to the new chatStore if provided
if (messageContent) {
@ -329,11 +360,11 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
}
const base_Url = import.meta.env.DEV ? import.meta.env.VITE_PROXY_URL : import.meta.env.VITE_BASE_URL
const api = type == 'share' ?
`${base_Url}/api/chat/share/playback/${shareToken}?delay_time=${delayTime}`
: type == 'replay' ?
`${base_Url}/api/chat/steps/playback/${newTaskId}?delay_time=${delayTime}`
: `${baseURL}/chat`
const api = type == 'share' ?
`${base_Url}/api/chat/share/playback/${shareToken}?delay_time=${delayTime}`
: type == 'replay' ?
`${base_Url}/api/chat/steps/playback/${newTaskId}?delay_time=${delayTime}`
: `${baseURL}/chat`
const { tasks } = get()
let historyId: string | null = projectStore.getHistoryId(project_id);
@ -387,9 +418,9 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
apiModel = {
api_key: res.value,
model_type: cloud_model_type,
model_platform: cloud_model_type.includes('gpt') ? 'openai' :
cloud_model_type.includes('claude') ? 'anthropic' :
cloud_model_type.includes('gemini') ? 'gemini' : 'openai-compatible-model',
model_platform: cloud_model_type.includes('gpt') ? 'openai' :
cloud_model_type.includes('claude') ? 'anthropic' :
cloud_model_type.includes('gemini') ? 'gemini' : 'openai-compatible-model',
api_url: res.api_url,
extra_params: {}
}
@ -451,7 +482,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
} catch (error) {
console.log('get-env-path error', error)
}
// create history
if (!type) {
const authStore = getAuthStore();
@ -478,11 +509,11 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
* TODO(history): Remove historyId handling to support per projectId
* instead in history api
*/
if(project_id && historyId) projectStore.setHistoryId(project_id, historyId);
if (project_id && historyId) projectStore.setHistoryId(project_id, historyId);
})
}
const browser_port = await window.ipcRenderer.invoke('get-browser-port');
// Lock the chatStore reference at the start of SSE session to prevent focus changes
// during active message processing
let lockedChatStore = targetChatStore;
@ -577,8 +608,8 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
// - Task switching: confirmed, new_task_state, end
// - Multi-turn simple answer: wait_confirm
const isTaskSwitchingEvent = agentMessages.step === "confirmed" ||
agentMessages.step === "new_task_state" ||
agentMessages.step === "end";
agentMessages.step === "new_task_state" ||
agentMessages.step === "end";
const isMultiTurnSimpleAnswer = agentMessages.step === "wait_confirm";
@ -612,78 +643,78 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
*/
let currentTaskId = getCurrentTaskId();
const previousChatStore = getCurrentChatStore()
if(agentMessages.step === "confirmed") {
if (agentMessages.step === "confirmed") {
const { question } = agentMessages.data;
const shouldCreateNewChat = project_id && (question || messageContent);
//All except first confirmed event to reuse the existing chatStore
if(shouldCreateNewChat && !skipFirstConfirm) {
/**
* For Tasks where appended to existing project by
* reusing same projectId. Need to create new chatStore
* as it has been skipped earlier in startTask.
*/
const nextTaskId = previousChatStore.nextTaskId || undefined;
const newChatResult = projectStore.appendInitChatStore(project_id || projectStore.activeProjectId!, nextTaskId);
if (newChatResult) {
const { taskId: newTaskId, chatStore: newChatStore } = newChatResult;
// Update references for both scenarios
updateLockedReferences(newChatStore, newTaskId);
newChatStore.getState().setIsPending(newTaskId, false);
if(type === "replay") {
newChatStore.getState().setDelayTime(newTaskId, delayTime as number);
newChatStore.getState().setType(newTaskId, "replay");
}
if (shouldCreateNewChat && !skipFirstConfirm) {
/**
* For Tasks where appended to existing project by
* reusing same projectId. Need to create new chatStore
* as it has been skipped earlier in startTask.
*/
const nextTaskId = previousChatStore.nextTaskId || undefined;
const newChatResult = projectStore.appendInitChatStore(project_id || projectStore.activeProjectId!, nextTaskId);
const lastMessage = previousChatStore.tasks[currentTaskId]?.messages.at(-1);
if(lastMessage?.role === "user" && lastMessage?.id) {
previousChatStore.removeMessage(currentTaskId, lastMessage.id);
}
//Trick: by the time the question is retrieved from event,
//the last message from previous chatStore is at display
newChatStore.getState().addMessages(newTaskId, {
id: generateUniqueId(),
role: "user",
content: question || messageContent as string,
//TODO: The attaches that reach here (when Improve API is called) doesn't reach the backend
attaches: [...(previousChatStore.tasks[currentTaskId]?.attaches || []), ...(messageAttaches || [])],
});
console.log("[NEW CHATSTORE] Created for ", project_id);
if (newChatResult) {
const { taskId: newTaskId, chatStore: newChatStore } = newChatResult;
//Create a new history point
if (!type) {
const authStore = getAuthStore();
// Update references for both scenarios
updateLockedReferences(newChatStore, newTaskId);
newChatStore.getState().setIsPending(newTaskId, false);
const obj = {
"project_id": project_id,
"task_id": newTaskId,
"user_id": authStore.user_id,
"question": question || messageContent || (targetChatStore.getState().tasks[newTaskId]?.messages[0]?.content ?? ''),
"language": systemLanguage,
"model_platform": apiModel.model_platform,
"model_type": apiModel.model_type,
"api_url": modelType === 'cloud' ? "cloud" : apiModel.api_url,
"max_retries": 3,
"file_save_path": "string",
"installed_mcp": "string",
"status": 1,
"tokens": 0
}
await proxyFetchPost(`/api/chat/history`, obj).then(res => {
historyId = res.id;
/**Save history id for replay reuse purposes.
* TODO(history): Remove historyId handling to support per projectId
* instead in history api
*/
if(project_id && historyId) projectStore.setHistoryId(project_id, historyId);
})
}
if (type === "replay") {
newChatStore.getState().setDelayTime(newTaskId, delayTime as number);
newChatStore.getState().setType(newTaskId, "replay");
}
const lastMessage = previousChatStore.tasks[currentTaskId]?.messages.at(-1);
if (lastMessage?.role === "user" && lastMessage?.id) {
previousChatStore.removeMessage(currentTaskId, lastMessage.id);
}
//Trick: by the time the question is retrieved from event,
//the last message from previous chatStore is at display
newChatStore.getState().addMessages(newTaskId, {
id: generateUniqueId(),
role: "user",
content: question || messageContent as string,
//TODO: The attaches that reach here (when Improve API is called) doesn't reach the backend
attaches: [...(previousChatStore.tasks[currentTaskId]?.attaches || []), ...(messageAttaches || [])],
});
console.log("[NEW CHATSTORE] Created for ", project_id);
//Create a new history point
if (!type) {
const authStore = getAuthStore();
const obj = {
"project_id": project_id,
"task_id": newTaskId,
"user_id": authStore.user_id,
"question": question || messageContent || (targetChatStore.getState().tasks[newTaskId]?.messages[0]?.content ?? ''),
"language": systemLanguage,
"model_platform": apiModel.model_platform,
"model_type": apiModel.model_type,
"api_url": modelType === 'cloud' ? "cloud" : apiModel.api_url,
"max_retries": 3,
"file_save_path": "string",
"installed_mcp": "string",
"status": 1,
"tokens": 0
}
await proxyFetchPost(`/api/chat/history`, obj).then(res => {
historyId = res.id;
/**Save history id for replay reuse purposes.
* TODO(history): Remove historyId handling to support per projectId
* instead in history api
*/
if (project_id && historyId) projectStore.setHistoryId(project_id, historyId);
})
}
}
} else {
//NOTE: Triggered only with first "confirmed" in the project
//Handle Original cases - with old chatStore
@ -696,18 +727,18 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
return
}
const {
setNuwFileNum,
setCotList,
getTokens,
setUpdateCount,
addTokens,
setStatus,
addWebViewUrl,
setIsPending,
addMessages,
setHasWaitComfirm,
setSummaryTask,
const {
setNuwFileNum,
setCotList,
getTokens,
setUpdateCount,
addTokens,
setStatus,
addWebViewUrl,
setIsPending,
addMessages,
setHasWaitComfirm,
setSummaryTask,
setTaskAssigning,
setTaskInfo,
setTaskRunning,
@ -721,11 +752,50 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
setElapsed,
setActiveTaskId,
setIsContextExceeded,
setIsTaskEdit} = getCurrentChatStore()
setIsTaskEdit } = getCurrentChatStore()
currentTaskId = getCurrentTaskId();
// if (tasks[currentTaskId].status === 'finished') return
if (agentMessages.step === "decompose_text") {
const { content } = agentMessages.data;
const text = content;
const currentId = getCurrentTaskId();
// Get current buffer or task state
const currentContent = streamingDecomposeTextBuffer[currentId] ||
getCurrentChatStore().tasks[currentId]?.streamingDecomposeText || "";
const newContent = text || "";
let updatedContent = newContent;
if (newContent.startsWith(currentContent)) {
// Accumulated format: new content contains old content -> Replace
updatedContent = newContent;
} else {
// Delta format: new content is a chunk -> Append
updatedContent = currentContent + newContent;
}
// Store in buffer immediately
streamingDecomposeTextBuffer[currentId] = updatedContent;
// Throttle store updates to every 50ms for smoother streaming display
if (!streamingDecomposeTextTimers[currentId]) {
streamingDecomposeTextTimers[currentId] = setTimeout(() => {
const bufferedText = streamingDecomposeTextBuffer[currentId];
if (bufferedText !== undefined) {
getCurrentChatStore().setStreamingDecomposeText(currentId, bufferedText);
}
delete streamingDecomposeTextTimers[currentId];
}, 16);
}
return;
}
if (agentMessages.step === "to_sub_tasks") {
// Clear streaming decompose text when task splitting is done
const currentStore = getCurrentChatStore();
const currentId = getCurrentTaskId();
currentStore.clearStreamingDecomposeText(currentId);
// Check if this is a multi-turn scenario after task completion
const isMultiTurnAfterCompletion = tasks[currentTaskId].status === 'finished';
@ -863,18 +933,18 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
return;
}
if (agentMessages.step === "wait_confirm") {
const {content, question} = agentMessages.data;
const { content, question } = agentMessages.data;
setHasWaitComfirm(currentTaskId, true)
setIsPending(currentTaskId, false)
const currentChatStore = getCurrentChatStore();
//Make sure to add user Message on replay and avoid duplication of first msg
if(question && !(currentChatStore.tasks[currentTaskId].messages.length === 1)) {
if (question && !(currentChatStore.tasks[currentTaskId].messages.length === 1)) {
//Replace the optimistic update if existent.
const lastMessage = currentChatStore.tasks[currentTaskId]?.messages.at(-1);
if(lastMessage?.role === "user" && lastMessage.id && lastMessage.content === question) {
if (lastMessage?.role === "user" && lastMessage.id && lastMessage.content === question) {
currentChatStore.removeMessage(currentTaskId, lastMessage.id)
}
}
addMessages(currentTaskId, {
id: generateUniqueId(),
role: "user",
@ -1075,7 +1145,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
if (target) {
const { agentIndex, taskIndex } = target
const agentName = taskAssigning.find((agent: Agent) => agent.agent_id === assignee_id)?.name
if(agentName!==taskAssigning[agentIndex].name){
if (agentName !== taskAssigning[agentIndex].name) {
taskAssigning[agentIndex].tasks[taskIndex].reAssignTo = agentName
}
}
@ -1126,7 +1196,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
// Only update or add to taskRunning, never duplicate
if (taskRunningIndex === -1) {
// Task not in taskRunning, add it
if(task){
if (task) {
task.status = taskState === "waiting" ? "waiting" : "running";
}
taskRunning!.push(
@ -1322,27 +1392,27 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
}
if (agentMessages.step === "context_too_long") {
console.error('Context too long:', agentMessages.data)
const currentLength = agentMessages.data.current_length || 0;
const maxLength = agentMessages.data.max_length || 100000;
// Show toast notification
toast.dismiss();
toast.error(
`⚠️ Context Limit Exceeded\n\nThe conversation history is too long (${currentLength.toLocaleString()} / ${maxLength.toLocaleString()} characters).\n\nPlease create a new project to continue your work.`,
{
duration: Infinity,
closeButton: true,
}
);
if (agentMessages.step === "context_too_long") {
console.error('Context too long:', agentMessages.data)
const currentLength = agentMessages.data.current_length || 0;
const maxLength = agentMessages.data.max_length || 100000;
// Set flag to block input and set status to pause
setIsContextExceeded(currentTaskId, true);
setStatus(currentTaskId, "pause");
uploadLog(currentTaskId, type);
return
}
// Show toast notification
toast.dismiss();
toast.error(
`⚠️ Context Limit Exceeded\n\nThe conversation history is too long (${currentLength.toLocaleString()} / ${maxLength.toLocaleString()} characters).\n\nPlease create a new project to continue your work.`,
{
duration: Infinity,
closeButton: true,
}
);
// Set flag to block input and set status to pause
setIsContextExceeded(currentTaskId, true);
setStatus(currentTaskId, "pause");
uploadLog(currentTaskId, type);
return
}
if (agentMessages.step === "error") {
try {
@ -1355,8 +1425,8 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
// Safely extract error message with fallback chain
const errorMessage = agentMessages.data?.message ||
(typeof agentMessages.data === 'string' ? agentMessages.data : null) ||
'An error occurred while processing your request';
(typeof agentMessages.data === 'string' ? agentMessages.data : null) ||
'An error occurred while processing your request';
// Mark all incomplete tasks as failed
let taskRunning = [...tasks[currentTaskId].taskRunning];
@ -1438,10 +1508,10 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
const taskIdToRemove = agentMessages.data.task_id as string;
const projectStore = useProjectStore.getState();
//Remove the task from the queue on error
if(project_id) {
if (project_id) {
const project = projectStore.getProjectById(project_id);
if (project && project.queuedMessages) {
const messageToRemove = project.queuedMessages.find(msg =>
const messageToRemove = project.queuedMessages.find(msg =>
msg.task_id === taskIdToRemove || msg.content.includes(taskIdToRemove)
);
if (messageToRemove) {
@ -1467,7 +1537,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
// Find and remove the message with matching task ID
const project = projectStore.getProjectById(project_id);
if (project && project.queuedMessages) {
const messageToRemove = project.queuedMessages.find(msg =>
const messageToRemove = project.queuedMessages.find(msg =>
msg.task_id === taskIdToRemove || msg.content.includes(taskIdToRemove)
);
if (messageToRemove) {
@ -1773,7 +1843,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
const { create, setHasMessages, addMessages, startTask, setActiveTaskId, handleConfirmTask } = get();
//get project id
const project_id = useProjectStore.getState().activeProjectId
if(!project_id) {
if (!project_id) {
console.error("Can't replay task because no project id provided")
return;
}
@ -1996,7 +2066,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
},
}))
},
handleConfirmTask: async (project_id:string, taskId: string, type?: string) => {
handleConfirmTask: async (project_id: string, taskId: string, type?: string) => {
const { tasks, setMessages, setActiveWorkSpace, setStatus, setTaskTime, setTaskInfo, setTaskRunning, setIsTaskEdit } = get();
if (!taskId) return;
@ -2022,7 +2092,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
task: taskInfo,
});
await fetchPost(`/task/${project_id}/start`, {});
setActiveWorkSpace(taskId, 'workflow')
setStatus(taskId, 'running')
}
@ -2435,6 +2505,43 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
...state,
nextTaskId: taskId,
}))
},
setStreamingDecomposeText: (taskId, text) => {
set((state) => {
if (!state.tasks[taskId]) return state;
return {
...state,
tasks: {
...state.tasks,
[taskId]: {
...state.tasks[taskId],
streamingDecomposeText: text,
},
},
};
});
},
clearStreamingDecomposeText: (taskId) => {
// Clear buffer and any pending timer
delete streamingDecomposeTextBuffer[taskId];
if (streamingDecomposeTextTimers[taskId]) {
clearTimeout(streamingDecomposeTextTimers[taskId]);
delete streamingDecomposeTextTimers[taskId];
}
set((state) => {
if (!state.tasks[taskId]) return state;
return {
...state,
tasks: {
...state.tasks,
[taskId]: {
...state.tasks[taskId],
streamingDecomposeText: '',
},
},
};
});
}
})
);

View file

@ -38,7 +38,7 @@ declare global {
toolkitStatus?: AgentStatus;
}[];
failure_count?: number;
reAssignTo?:string;
reAssignTo?: string;
}
interface File {
@ -66,7 +66,7 @@ declare global {
log: AgentMessage[];
img?: string[];
activeWebviewIds?: ActiveWebView[];
tools?:string[];
tools?: string[];
workerInfo?: {
name: string;
description: string;
@ -94,13 +94,13 @@ declare global {
task_id?: string;
summary?: string;
agent_name?: string;
attaches?:File[]
attaches?: File[]
}
interface AgentMessage {
step: string;
data: {
project_id?:string;
project_id?: string;
failure_count?: number;
tokens?: number;
sub_tasks?: TaskInfo[];
@ -125,7 +125,8 @@ declare global {
tools?: string[];
//Context Length
current_length?: number;
max_length?: number
max_length?: number;
text?: string;
};
status?: 'running' | 'filled' | 'completed';
}