:lightning: optimize chat streaming
This commit is contained in:
@@ -135,11 +135,27 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||||
try {
|
try {
|
||||||
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
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) => {
|
await streamMessage(payload, storyBibleContent, (chunk: string) => {
|
||||||
if (!event.sender.isDestroyed()) {
|
pending += chunk
|
||||||
event.sender.send('ai:chunk', chunk)
|
if (!flushTimer) {
|
||||||
|
flushTimer = setTimeout(flush, 30)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
if (!event.sender.isDestroyed()) {
|
if (!event.sender.isDestroyed()) {
|
||||||
event.sender.send('ai:done')
|
event.sender.send('ai:done')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, memo } from 'react'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import type { ChatMessage, TextAnnotation } from '../../types/editor'
|
import type { ChatMessage, TextAnnotation } from '../../types/editor'
|
||||||
@@ -30,7 +30,7 @@ interface Props {
|
|||||||
linkedAnnotations?: TextAnnotation[]
|
linkedAnnotations?: TextAnnotation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Element {
|
function ChatMessageItemInner({ message, linkedAnnotations }: Props): JSX.Element {
|
||||||
const { activeFilePath, markSaved } = useEditorStore()
|
const { activeFilePath, markSaved } = useEditorStore()
|
||||||
|
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
@@ -123,3 +123,13 @@ export function ChatMessageItem({ message, linkedAnnotations }: Props): JSX.Elem
|
|||||||
</div>
|
</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)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function ChatPanel(): JSX.Element {
|
|||||||
const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0)
|
const [pendingAttachmentCount, setPendingAttachmentCount] = useState(0)
|
||||||
const [feedbackNotice, setFeedbackNotice] = useState<string | null>(null)
|
const [feedbackNotice, setFeedbackNotice] = useState<string | null>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollRafRef = useRef<number | null>(null)
|
||||||
const prevAnnotationCountRef = useRef(annotations.length)
|
const prevAnnotationCountRef = useRef(annotations.length)
|
||||||
|
|
||||||
// Collapse history when switching files
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
if (scrollRafRef.current !== null) return
|
||||||
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
|
scrollRafRef.current = null
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) {
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
el.scrollTop = el.scrollHeight
|
})
|
||||||
}
|
|
||||||
}, [chatHistory])
|
}, [chatHistory])
|
||||||
|
|
||||||
const hasFile = Boolean(activeFilePath)
|
const hasFile = Boolean(activeFilePath)
|
||||||
|
|||||||
@@ -256,17 +256,24 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
if (last?.role === 'assistant') {
|
if (last?.role === 'assistant') {
|
||||||
history[history.length - 1] = { ...last, content: last.content + chunk }
|
history[history.length - 1] = { ...last, content: last.content + chunk }
|
||||||
}
|
}
|
||||||
if (!s.activeFilePath) return { chatHistory: history }
|
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 } }
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
setAILoading: (isAILoading) => {
|
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
|
// When AI finishes, persist the completed chat history
|
||||||
if (!isAILoading) {
|
if (!isAILoading) {
|
||||||
scheduleSave(() => {
|
scheduleSave(() => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user