Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { FC, useState, MouseEvent } from 'react'

import { Typography, Trigger, Modal, Form, Input } from '@arco-design/web-react'
import { Typography, Trigger, Modal, Form, Input, Spin } from '@arco-design/web-react'
import { IconDelete, IconMore } from '@arco-design/web-react/icon'
import { useMemoizedFn, useRequest } from 'ahooks'
import { conversationService } from '@renderer/services/conversation-service'
import chatHistoryIcon from '@renderer/assets/icons/ai-assistant/chat-history.svg'
import renameIcon from '@renderer/assets/icons/rename.svg'
import clsx from 'clsx'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { deleteConversation as deleteConversationAction } from '@renderer/store/chat-history'
export interface ChatHistoryListItemProps {
conversation: any
refreshConversationList?: () => void
Expand All @@ -17,6 +19,12 @@ const ChatHistoryListItem: FC<ChatHistoryListItemProps> = (props) => {
const { conversation, refreshConversationList, handleGetMessages } = props
const [renameVisible, setRenameVisible] = useState(false)
const [form] = Form.useForm()
const dispatch = useAppDispatch()
const backgroundGeneratingConversations = useAppSelector(
(state) => state.chatHistory.backgroundGeneratingConversations
)
const isGenerating = backgroundGeneratingConversations.includes(conversation.id)

const { run: updateConversationTitle } = useRequest(conversationService.updateConversationTitle, { manual: true })
const { run: deleteConversation } = useRequest(conversationService.deleteConversation, { manual: true })
const handleDelete = useMemoizedFn(async () => {
Expand All @@ -27,6 +35,8 @@ const ChatHistoryListItem: FC<ChatHistoryListItemProps> = (props) => {
okText: 'Delete',
onOk: () => {
deleteConversation(conversation.id)
// Sync Redux state so the sidebar removes this item immediately
dispatch(deleteConversationAction(conversation.id))
refreshConversationList?.()
}
})
Expand Down Expand Up @@ -79,11 +89,14 @@ const ChatHistoryListItem: FC<ChatHistoryListItemProps> = (props) => {
{ 'bg-[#F6F7FA]': editMenuVisible }
)}
onClick={(e) => handleGetMessages?.(e, conversation.id)}>
<div className="flex items-center gap-[6px] flex-1">
<img src={chatHistoryIcon} className="block" />
<div className="flex items-center gap-[6px] flex-1 min-w-0">
<img src={chatHistoryIcon} className="block flex-shrink-0" />
<Typography.Text className="!my-0 !flex-1 !text-[13px] !leading-[22px]" ellipsis={{ rows: 1 }}>
{conversation.title || 'Untitled Conversation'}
</Typography.Text>
{isGenerating && (
<Spin size={12} className="flex-shrink-0" />
)}
</div>
<Trigger
updateOnScroll
Expand Down
39 changes: 33 additions & 6 deletions frontend/src/renderer/src/hooks/use-chat-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
chatStreamService
} from '@renderer/services/ChatStreamService'
import { get } from 'lodash'
import { useAppDispatch } from '@renderer/store'
import { addGeneratingConversation, removeGeneratingConversation } from '@renderer/store/chat-history'

export interface ChatState {
messages: ChatMessage[]
Expand All @@ -32,6 +34,8 @@ export interface StreamingMessage {
}

export const useChatStream = () => {
const dispatch = useAppDispatch()

const [chatState, setChatState] = useState<ChatState>({
messages: [],
isLoading: false,
Expand All @@ -42,11 +46,15 @@ export const useChatStream = () => {

const [streamingMessage, setStreamingMessage] = useState<StreamingMessage | null>(null)
const currentStreamingId = useRef<string | null>(null)
// Tracks which conversation is currently being streamed in this hook instance
const currentConversationId = useRef<number>(0)

// Cleanup function
// Cleanup: do NOT abort the stream on unmount so that background generation
// in other conversations continues uninterrupted. The user can always stop
// via the explicit Stop button which calls stopStreaming().
useEffect(() => {
return () => {
chatStreamService.abortStream()
// intentionally empty — background generation should survive navigation
}
}, [])

Expand All @@ -55,6 +63,9 @@ export const useChatStream = () => {
async (query: string, conversation_id: number, context?: ChatStreamRequest['context']) => {
if (!query.trim() || chatState.isLoading) return

// Remember which conversation we're streaming into
currentConversationId.current = conversation_id

// Add user message
const userMessage: ChatMessage = {
role: 'user',
Expand Down Expand Up @@ -104,6 +115,10 @@ export const useChatStream = () => {
messageId: get(event, 'assistant_message_id', prev.messageId)
}))
}
// Mark this conversation as actively generating in global Redux state
if (currentConversationId.current) {
dispatch(addGeneratingConversation(currentConversationId.current))
}
break

case 'thinking':
Expand Down Expand Up @@ -196,6 +211,9 @@ export const useChatStream = () => {
}))
return null
})
if (currentConversationId.current) {
dispatch(removeGeneratingConversation(currentConversationId.current))
}
break

case 'completed':
Expand Down Expand Up @@ -224,6 +242,9 @@ export const useChatStream = () => {
}
setStreamingMessage(null)
currentStreamingId.current = null
if (currentConversationId.current) {
dispatch(removeGeneratingConversation(currentConversationId.current))
}
break

case 'fail':
Expand All @@ -234,6 +255,9 @@ export const useChatStream = () => {
currentStage: 'failed'
}))
setStreamingMessage(null)
if (currentConversationId.current) {
dispatch(removeGeneratingConversation(currentConversationId.current))
}
break

case 'done':
Expand All @@ -244,7 +268,7 @@ export const useChatStream = () => {
}))
break
}
}, [])
}, [dispatch])

// Handle stream error
const handleStreamError = useCallback((error: Error) => {
Expand Down Expand Up @@ -277,7 +301,7 @@ export const useChatStream = () => {

// Clear chat history
const clearChat = useCallback(() => {
chatStreamService.abortStream()
chatStreamService.abortStreamForConversation(currentConversationId.current)
setChatState({
messages: [],
isLoading: false,
Expand All @@ -291,13 +315,16 @@ export const useChatStream = () => {

// Stop the current streaming request
const stopStreaming = useCallback(() => {
chatStreamService.abortStream()
chatStreamService.abortStreamForConversation(currentConversationId.current)
if (currentConversationId.current) {
dispatch(removeGeneratingConversation(currentConversationId.current))
}
setChatState((prev) => ({
...prev,
isLoading: false
}))
setStreamingMessage(null)
}, [])
}, [dispatch])

return {
...chatState,
Expand Down
44 changes: 33 additions & 11 deletions frontend/src/renderer/src/services/ChatStreamService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,26 @@ export interface StreamEvent {

// Streaming chat service class
export class ChatStreamService {
private abortController?: AbortController
// One AbortController per active conversation (keyed by conversation_id).
// Using conversation_id = 0 as a fallback when no id is available.
private controllers: Map<number, AbortController> = new Map()

// Send a streaming chat request
// Send a streaming chat request.
// Only aborts a pre-existing stream for the SAME conversation so that
// background generations in other conversations are unaffected.
async sendStreamMessage(
request: ChatStreamRequest,
onEvent: (event: StreamEvent) => void,
onError?: (error: Error) => void,
onComplete?: () => void
): Promise<void> {
// Cancel the previous request
this.abortStream()
const convId = request.conversation_id ?? 0

this.abortController = new AbortController()
// Cancel any previous stream for THIS conversation only
this.abortStreamForConversation(convId)

const controller = new AbortController()
this.controllers.set(convId, controller)

try {
// Use the baseURL of axiosInstance to ensure consistent ports
Expand All @@ -112,7 +119,7 @@ export class ChatStreamService {
'Content-Type': 'application/json'
},
body: JSON.stringify(request),
signal: this.abortController.signal
signal: controller.signal
})

if (!response.ok) {
Expand Down Expand Up @@ -170,17 +177,32 @@ export class ChatStreamService {

console.error('Stream request failed:', error)
onError?.(error as Error)
} finally {
// Clean up the controller entry when this conversation's stream ends
this.controllers.delete(convId)
}
}

// Cancel the current streaming request
abortStream(): void {
if (this.abortController) {
this.abortController.abort()
this.abortController = undefined
// Cancel the streaming request for a specific conversation
abortStreamForConversation(conversationId: number): void {
const controller = this.controllers.get(conversationId)
if (controller) {
controller.abort()
this.controllers.delete(conversationId)
}
}

// Cancel ALL active streaming requests (e.g., on app shutdown)
abortStream(): void {
this.controllers.forEach((ctrl) => ctrl.abort())
this.controllers.clear()
}

// Return whether a particular conversation currently has an active stream
isStreaming(conversationId: number): boolean {
return this.controllers.has(conversationId)
}

// Generate a session ID
generateSessionId(): string {
return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
Expand Down
28 changes: 27 additions & 1 deletion frontend/src/renderer/src/store/chat-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ChatHistoryState {
loading: boolean
// Error message
error: string | null
// Conversations that currently have an active background generation stream
backgroundGeneratingConversations: number[]
// AI assistant visibility for each page
home: {
aiAssistantVisible: boolean
Expand All @@ -44,6 +46,7 @@ const initialState: ChatHistoryState = {
chatHistoryMessages: [],
loading: false,
error: null,
backgroundGeneratingConversations: [],
home: {
aiAssistantVisible: false
},
Expand Down Expand Up @@ -226,6 +229,26 @@ const chatHistorySlice = createSlice({
*/
resetChatHistory(state) {
Object.assign(state, initialState)
},

// ========== Background Generation Tracking ==========

/**
* Mark a conversation as having an active background generation stream.
*/
addGeneratingConversation(state, action: PayloadAction<number>) {
if (!state.backgroundGeneratingConversations.includes(action.payload)) {
state.backgroundGeneratingConversations.push(action.payload)
}
},

/**
* Remove a conversation from the active-generation set (stream finished/cancelled).
*/
removeGeneratingConversation(state, action: PayloadAction<number>) {
state.backgroundGeneratingConversations = state.backgroundGeneratingConversations.filter(
(id) => id !== action.payload
)
}
}
})
Expand Down Expand Up @@ -254,7 +277,10 @@ export const {
setLoading,
setError,
clearError,
resetChatHistory
resetChatHistory,
// Background generation tracking
addGeneratingConversation,
removeGeneratingConversation
} = chatHistorySlice.actions

export default chatHistorySlice.reducer
Loading