:lightning: optimize chat streaming

This commit is contained in:
2026-03-14 14:00:45 +10:00
parent 1a71a94831
commit 31a359e639
5 changed files with 53 additions and 17 deletions

View File

@@ -135,11 +135,27 @@ export function registerIpcHandlers(): void {
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
let pending = ''
let flushTimer: ReturnType<typeof setTimeout> | null = null
const flush = (): void => {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
if (pending && !event.sender.isDestroyed()) {
event.sender.send('ai:chunk', pending)
pending = ''
}
}
await streamMessage(payload, storyBibleContent, (chunk: string) => {
if (!event.sender.isDestroyed()) {
event.sender.send('ai:chunk', chunk)
pending += chunk
if (!flushTimer) {
flushTimer = setTimeout(flush, 30)
}
})
flush()
if (!event.sender.isDestroyed()) {
event.sender.send('ai:done')
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useMemo, memo } from 'react'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage, TextAnnotation } from '../../types/editor'
@@ -30,7 +30,7 @@ interface Props {
linkedAnnotations?: TextAnnotation[]
}
export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Element {
function ChatMessageItemInner({ message, linkedAnnotations }: Props): JSX.Element {
const { activeFilePath, markSaved } = useEditorStore()
const html = useMemo(() => {
@@ -123,3 +123,13 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
</div>
)
}
function areEqual(prev: Props, next: Props): boolean {
if (prev.message.content !== next.message.content) return false
if (prev.message.id !== next.message.id) return false
const prevIds = prev.linkedAnnotations?.map(a => a.id).join(',') ?? ''
const nextIds = next.linkedAnnotations?.map(a => a.id).join(',') ?? ''
return prevIds === nextIds
}
export const ChatMessageItem = memo(ChatMessageItemInner, areEqual)

View File

@@ -50,6 +50,7 @@ export function ChatPanel(): JSX.Element {
const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0)
const [feedbackNotice, setFeedbackNotice] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const scrollRafRef = useRef<number | null>(null)
const prevAnnotationCountRef = useRef(annotations.length)
// Collapse history when switching files
@@ -119,12 +120,14 @@ export function ChatPanel(): JSX.Element {
}
}
// Auto-scroll to bottom when new content arrives
// Auto-scroll to bottom when new content arrives (coalesced to one scroll per animation frame)
useEffect(() => {
const el = scrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
}
if (scrollRafRef.current !== null) return
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
})
}, [chatHistory])
const hasFile = Boolean(activeFilePath)

View File

@@ -256,17 +256,24 @@ export const useEditorStore = create<EditorState>((set, get) => ({
if (last?.role === 'assistant') {
history[history.length - 1] = { ...last, content: last.content + chunk }
}
if (!s.activeFilePath) return { chatHistory: history }
const activeId = s.activeSessionIdByFile[s.activeFilePath]
const sessions = (s.chatSessionsByFile[s.activeFilePath] ?? []).map(sess =>
sess.id === activeId ? { ...sess, messages: history } : sess
)
return { chatHistory: history, chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: sessions } }
return { chatHistory: history }
})
},
setAILoading: (isAILoading) => {
set({ isAILoading })
set((s) => {
if (isAILoading) return { isAILoading }
// Sync the completed chatHistory into chatSessionsByFile once at stream end
if (!s.activeFilePath) return { isAILoading }
const activeId = s.activeSessionIdByFile[s.activeFilePath]
const sessions = (s.chatSessionsByFile[s.activeFilePath] ?? []).map(sess =>
sess.id === activeId ? { ...sess, messages: s.chatHistory } : sess
)
return {
isAILoading,
chatSessionsByFile: { ...s.chatSessionsByFile, [s.activeFilePath]: sessions }
}
})
// When AI finishes, persist the completed chat history
if (!isAILoading) {
scheduleSave(() => {

File diff suppressed because one or more lines are too long