diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts index 39d3eea7b..bb60832f2 100644 --- a/packages/client/src/api/hermes/group-chat.ts +++ b/packages/client/src/api/hermes/group-chat.ts @@ -48,6 +48,7 @@ export interface ChatMessage { reasoning_details?: string | null reasoning_content?: string | null isStreaming?: boolean + isIncomplete?: boolean toolName?: string toolCallId?: string toolArgs?: unknown @@ -55,6 +56,8 @@ export interface ChatMessage { toolResult?: unknown toolStatus?: 'running' | 'done' | 'error' attachments?: Array<{ id: string; name: string; type: string; size: number; url: string }> + /** Captured at stream_start and never overwritten; used for stable sort order. */ + firstSeenAt?: number } export interface MemberInfo { diff --git a/packages/client/src/stores/hermes/group-chat.ts b/packages/client/src/stores/hermes/group-chat.ts index 2f7d0eb37..83933413b 100644 --- a/packages/client/src/stores/hermes/group-chat.ts +++ b/packages/client/src/stores/hermes/group-chat.ts @@ -110,6 +110,8 @@ function mergeFinalMessage(existing: ChatMessage | null, msg: ChatMessage): Chat reasoning: hasText(msg.reasoning) ? msg.reasoning : existing?.reasoning ?? msg.reasoning ?? null, reasoning_content: hasText(msg.reasoning_content) ? msg.reasoning_content : existing?.reasoning_content ?? msg.reasoning_content ?? null, isStreaming: false, + isIncomplete: msg.isIncomplete ?? existing?.isIncomplete ?? undefined, + firstSeenAt: existing?.firstSeenAt || msg.firstSeenAt, attachments: existing?.attachments || msg.attachments, } } @@ -284,7 +286,7 @@ const currentUserAvatar = ref('') } // ─── Computed ─────────────────────────────────────────── - const sortedMessages = computed(() => mapGroupMessages([...messages.value].sort((a, b) => a.timestamp - b.timestamp))) + const sortedMessages = computed(() => mapGroupMessages([...messages.value].sort((a, b) => (a.firstSeenAt || a.timestamp) - (b.firstSeenAt || b.timestamp)))) const memberNames = computed(() => { return members.value.map(m => m.name) @@ -374,6 +376,8 @@ const currentUserAvatar = ref('') !m.tool_calls?.length )) msg.isStreaming = true + // Capture firstSeenAt once — never overwritten, used for stable sort + msg.firstSeenAt = msg.firstSeenAt || msg.timestamp || Date.now() const idx = messages.value.findIndex(m => m.id === msg.id) if (idx >= 0) { const existing = messages.value[idx] @@ -418,9 +422,10 @@ const currentUserAvatar = ref('') messages.value = [...messages.value] }) - socket.on('message_stream_end', (data: { roomId: string; id: string }) => { + socket.on('message_stream_end', (data: { roomId: string; id: string; finishReason?: string }) => { if (data.roomId !== currentRoomId.value) return const idx = messages.value.findIndex(m => m.id === data.id) + const isIncomplete = data.finishReason && data.finishReason !== 'stop' if ( idx >= 0 && !messages.value[idx].content?.trim() && @@ -429,9 +434,12 @@ const currentUserAvatar = ref('') ) { messages.value.splice(idx, 1) } else if (idx >= 0) { + const content = messages.value[idx].content || '' messages.value[idx] = { ...messages.value[idx], isStreaming: false, + isIncomplete: isIncomplete || undefined, + content: isIncomplete && content ? content + ' ⚠️ [回复中断]' : content, } messages.value = [...messages.value] if (needsFinalContentRecovery(messages.value[idx])) { diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index 70a7e8c19..3cee2a8e1 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -290,9 +290,9 @@ class AgentClient { this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta }) } - emitMessageStreamEnd(roomId: string, messageId: string): void { + emitMessageStreamEnd(roomId: string, messageId: string, finishReason?: string): void { this.ensureConnected() - this.socket!.emit('message_stream_end', { roomId, id: messageId }) + this.socket!.emit('message_stream_end', { roomId, id: messageId, finishReason: finishReason || 'stop' }) } getJoinedRooms(): string[] { @@ -561,7 +561,7 @@ class AgentClient { if (lastChunk?.status === 'error') { logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`) await this.sendAgentErrorMessage(roomId, streamMessageId, lastChunk.error || 'Run failed', msg, reasoningContent) - this.emitMessageStreamEnd(roomId, streamMessageId) + this.emitMessageStreamEnd(roomId, streamMessageId, 'error') this.stopTyping(roomId) onStatus?.('ready') return @@ -581,20 +581,20 @@ class AgentClient { reasoning: reasoningContent || null, reasoning_content: reasoningContent || null, }) - this.emitMessageStreamEnd(roomId, streamMessageId) + this.emitMessageStreamEnd(roomId, streamMessageId, 'stop') await this.refreshRoomFullContextEstimate(roomId, sessionId, bridge, instructions, modelContext) onStatus?.('ready') return } logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`) - this.emitMessageStreamEnd(roomId, streamMessageId) + this.emitMessageStreamEnd(roomId, streamMessageId, 'stop') this.stopTyping(roomId) onStatus?.('ready') } catch (err: any) { logger.error(`[AgentClients] ${this.name}: error handling message: ${err.message}`) try { await this.sendAgentErrorMessage(roomId, streamMessageId, err, msg, reasoningContent) - if (streamStarted) this.emitMessageStreamEnd(roomId, streamMessageId) + if (streamStarted) this.emitMessageStreamEnd(roomId, streamMessageId, 'error') } catch (sendErr: any) { logger.warn(`[AgentClients] ${this.name}: failed to send error message: ${sendErr.message}`) }