fix(chat): improve handling of streaming messages and add pending state management (#46)

This commit is contained in:
Felix
2026-02-11 13:55:34 +08:00
committed by GitHub
Unverified
parent ab9b8b6e87
commit f9581d2516
2 changed files with 65 additions and 11 deletions

View File

@@ -72,7 +72,11 @@ export function Chat() {
} }
// Extract streaming text for display // Extract streaming text for display
const streamText = streamingMessage ? extractText(streamingMessage) : ''; const streamMsg = streamingMessage && typeof streamingMessage === 'object'
? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number }
: null;
const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : '');
const hasStreamText = streamText.trim().length > 0;
return ( return (
<div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 2.5rem)' }}> <div className="flex flex-col -m-6" style={{ height: 'calc(100vh - 2.5rem)' }}>
@@ -101,12 +105,12 @@ export function Chat() {
))} ))}
{/* Streaming message */} {/* Streaming message */}
{sending && streamText && ( {sending && hasStreamText && (
<ChatMessage <ChatMessage
message={{ message={{
role: 'assistant', role: 'assistant',
content: streamingMessage as unknown as string, content: streamMsg?.content ?? streamText,
timestamp: streamingTimestamp, timestamp: streamMsg?.timestamp ?? streamingTimestamp,
}} }}
showThinking={showThinking} showThinking={showThinking}
isStreaming isStreaming
@@ -114,7 +118,7 @@ export function Chat() {
)} )}
{/* Typing indicator when sending but no stream yet */} {/* Typing indicator when sending but no stream yet */}
{sending && !streamText && ( {sending && !hasStreamText && (
<TypingIndicator /> <TypingIndicator />
)} )}
</> </>

View File

@@ -48,6 +48,8 @@ interface ChatState {
activeRunId: string | null; activeRunId: string | null;
streamingText: string; streamingText: string;
streamingMessage: unknown | null; streamingMessage: unknown | null;
pendingFinal: boolean;
lastUserMessageAt: number | null;
// Sessions // Sessions
sessions: ChatSession[]; sessions: ChatSession[];
@@ -98,6 +100,25 @@ function isToolOnlyMessage(message: RawMessage | undefined): boolean {
return hasTool && !hasText && !hasNonToolContent; return hasTool && !hasText && !hasNonToolContent;
} }
function hasNonToolAssistantContent(message: RawMessage | undefined): boolean {
if (!message) return false;
if (typeof message.content === 'string' && message.content.trim()) return true;
const content = message.content;
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text && block.text.trim()) return true;
if (block.type === 'thinking' && block.thinking && block.thinking.trim()) return true;
if (block.type === 'image') return true;
}
}
const msg = message as unknown as Record<string, unknown>;
if (typeof msg.text === 'string' && msg.text.trim()) return true;
return false;
}
// ── Store ──────────────────────────────────────────────────────── // ── Store ────────────────────────────────────────────────────────
export const useChatStore = create<ChatState>((set, get) => ({ export const useChatStore = create<ChatState>((set, get) => ({
@@ -109,6 +130,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
activeRunId: null, activeRunId: null,
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
pendingFinal: false,
lastUserMessageAt: null,
sessions: [], sessions: [],
currentSessionKey: 'main', currentSessionKey: 'main',
@@ -182,6 +205,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
streamingMessage: null, streamingMessage: null,
activeRunId: null, activeRunId: null,
error: null, error: null,
pendingFinal: false,
lastUserMessageAt: null,
}); });
// Load history for new session // Load history for new session
get().loadHistory(); get().loadHistory();
@@ -199,6 +224,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
streamingMessage: null, streamingMessage: null,
activeRunId: null, activeRunId: null,
error: null, error: null,
pendingFinal: false,
lastUserMessageAt: null,
}); });
// Reload sessions list to include the new one after first message // Reload sessions list to include the new one after first message
get().loadSessions(); get().loadSessions();
@@ -222,6 +249,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : []; const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null; const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
set({ messages: rawMessages, thinkingLevel, loading: false }); set({ messages: rawMessages, thinkingLevel, loading: false });
const { pendingFinal, lastUserMessageAt } = get();
if (pendingFinal) {
const recentAssistant = [...rawMessages].reverse().find((msg) => {
if (msg.role !== 'assistant') return false;
if (!hasNonToolAssistantContent(msg)) return false;
if (lastUserMessageAt && msg.timestamp && msg.timestamp < lastUserMessageAt) return false;
return true;
});
if (recentAssistant) {
set({ sending: false, activeRunId: null, pendingFinal: false });
}
}
} else { } else {
set({ messages: [], loading: false }); set({ messages: [], loading: false });
} }
@@ -252,6 +291,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
error: null, error: null,
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
pendingFinal: false,
lastUserMessageAt: userMsg.timestamp ?? null,
})); }));
try { try {
@@ -295,7 +336,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
abortRun: async () => { abortRun: async () => {
const { currentSessionKey } = get(); const { currentSessionKey } = get();
set({ sending: false, streamingText: '', streamingMessage: null }); set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null });
try { try {
await window.electron.ipcRenderer.invoke( await window.electron.ipcRenderer.invoke(
@@ -331,6 +372,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const finalMsg = event.message as RawMessage | undefined; const finalMsg = event.message as RawMessage | undefined;
if (finalMsg) { if (finalMsg) {
const toolOnly = isToolOnlyMessage(finalMsg); const toolOnly = isToolOnlyMessage(finalMsg);
const hasOutput = hasNonToolAssistantContent(finalMsg);
const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); const msgId = finalMsg.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`);
set((s) => { set((s) => {
// Check if message already exists (prevent duplicates) // Check if message already exists (prevent duplicates)
@@ -340,11 +382,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
return toolOnly ? { return toolOnly ? {
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
pendingFinal: true,
} : { } : {
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
sending: false, sending: hasOutput ? false : s.sending,
activeRunId: null, activeRunId: hasOutput ? null : s.activeRunId,
pendingFinal: hasOutput ? false : true,
}; };
} }
return toolOnly ? { return toolOnly ? {
@@ -355,6 +399,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
}], }],
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
pendingFinal: true,
} : { } : {
messages: [...s.messages, { messages: [...s.messages, {
...finalMsg, ...finalMsg,
@@ -363,13 +408,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
}], }],
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
sending: false, sending: hasOutput ? false : s.sending,
activeRunId: null, activeRunId: hasOutput ? null : s.activeRunId,
pendingFinal: hasOutput ? false : true,
}; };
}); });
} else { } else {
// No message in final event - reload history to get complete data // No message in final event - reload history to get complete data
set({ streamingText: '', streamingMessage: null, sending: false, activeRunId: null }); set({ streamingText: '', streamingMessage: null, pendingFinal: true });
get().loadHistory(); get().loadHistory();
} }
break; break;
@@ -382,6 +428,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
activeRunId: null, activeRunId: null,
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
pendingFinal: false,
lastUserMessageAt: null,
}); });
break; break;
} }
@@ -391,6 +439,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
activeRunId: null, activeRunId: null,
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
pendingFinal: false,
lastUserMessageAt: null,
}); });
break; break;
} }