:lightning: chat rendering
This commit is contained in:
@@ -32,7 +32,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChatMessageItemInner({ message, linkedAnnotations }: Props): JSX.Element {
|
function ChatMessageItemInner({ message, linkedAnnotations }: Props): JSX.Element {
|
||||||
const { activeFilePath, markSaved } = useEditorStore()
|
const activeFilePath = useEditorStore(s => s.activeFilePath)
|
||||||
|
const markSaved = useEditorStore(s => s.markSaved)
|
||||||
|
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
if (message.role !== 'assistant' || !message.content) return null
|
if (message.role !== 'assistant' || !message.content) return null
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ export function ChatPanel(): JSX.Element {
|
|||||||
annotations,
|
annotations,
|
||||||
annotationsByFile,
|
annotationsByFile,
|
||||||
addUserMessage,
|
addUserMessage,
|
||||||
startAssistantMessage,
|
finalizeAssistantMessage,
|
||||||
appendToLastAssistantMessage,
|
setStreamingContent,
|
||||||
|
streamingContent,
|
||||||
setAILoading,
|
setAILoading,
|
||||||
setAIError,
|
setAIError,
|
||||||
setAnnotations,
|
setAnnotations,
|
||||||
@@ -49,6 +50,8 @@ export function ChatPanel(): JSX.Element {
|
|||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
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 streamingContentRef = useRef('')
|
||||||
|
const streamingRafRef = useRef<number | null>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollRafRef = useRef<number | null>(null)
|
const scrollRafRef = useRef<number | null>(null)
|
||||||
const prevAnnotationCountRef = useRef(annotations.length)
|
const prevAnnotationCountRef = useRef(annotations.length)
|
||||||
@@ -71,8 +74,8 @@ export function ChatPanel(): JSX.Element {
|
|||||||
|
|
||||||
setAIError(null)
|
setAIError(null)
|
||||||
setFeedbackNotice(null)
|
setFeedbackNotice(null)
|
||||||
|
streamingContentRef.current = ''
|
||||||
addUserMessage(text, attachments.map(({ name, mimeType }) => ({ name, mimeType })))
|
addUserMessage(text, attachments.map(({ name, mimeType }) => ({ name, mimeType })))
|
||||||
startAssistantMessage()
|
|
||||||
setAILoading(true)
|
setAILoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -89,23 +92,38 @@ export function ChatPanel(): JSX.Element {
|
|||||||
attachments: attachments.length > 0 ? attachments : undefined
|
attachments: attachments.length > 0 ? attachments : undefined
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
appendToLastAssistantMessage(chunk)
|
streamingContentRef.current += chunk
|
||||||
|
if (streamingRafRef.current === null) {
|
||||||
|
streamingRafRef.current = requestAnimationFrame(() => {
|
||||||
|
streamingRafRef.current = null
|
||||||
|
setStreamingContent(streamingContentRef.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// After streaming, parse AI response for annotations.
|
// Cancel any pending rAF and commit to store once
|
||||||
// Attachment-driven messages are tagged 'custom' so they appear with a
|
if (streamingRafRef.current !== null) {
|
||||||
// distinct visual treatment in the Feedback panel.
|
cancelAnimationFrame(streamingRafRef.current)
|
||||||
const currentHistory = useEditorStore.getState().chatHistory
|
streamingRafRef.current = null
|
||||||
const lastMsg = currentHistory[currentHistory.length - 1]
|
}
|
||||||
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
const finalContent = streamingContentRef.current
|
||||||
|
streamingContentRef.current = ''
|
||||||
|
|
||||||
|
if (finalContent.length > 0) {
|
||||||
|
finalizeAssistantMessage(finalContent)
|
||||||
|
|
||||||
const overrideType = attachments.length > 0 ? 'custom' as const : undefined
|
const overrideType = attachments.length > 0 ? 'custom' as const : undefined
|
||||||
const latestContent = useEditorStore.getState().activeFileContent
|
const latestContent = useEditorStore.getState().activeFileContent
|
||||||
const { annotations: parsed, droppedCount } = parseAnnotationsFromAIResponse(lastMsg.content, latestContent, overrideType)
|
const { annotations: parsed, droppedCount } = parseAnnotationsFromAIResponse(finalContent, latestContent, overrideType)
|
||||||
if (parsed.length > 0) {
|
if (parsed.length > 0) {
|
||||||
|
const currentHistory = useEditorStore.getState().chatHistory
|
||||||
|
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||||
setAnnotations(parsed)
|
setAnnotations(parsed)
|
||||||
|
if (lastMsg?.role === 'assistant') {
|
||||||
linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id))
|
linkAnnotationsToMessage(lastMsg.id, parsed.map(a => a.id))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (droppedCount > 0) {
|
if (droppedCount > 0) {
|
||||||
setFeedbackNotice(
|
setFeedbackNotice(
|
||||||
`${droppedCount} feedback item${droppedCount === 1 ? '' : 's'} couldn't be applied — the referenced text has been edited.`
|
`${droppedCount} feedback item${droppedCount === 1 ? '' : 's'} couldn't be applied — the referenced text has been edited.`
|
||||||
@@ -120,7 +138,7 @@ export function ChatPanel(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom when new content arrives (coalesced to one scroll per animation frame)
|
// Auto-scroll to bottom when new content arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRafRef.current !== null) return
|
if (scrollRafRef.current !== null) return
|
||||||
scrollRafRef.current = requestAnimationFrame(() => {
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
@@ -128,7 +146,7 @@ export function ChatPanel(): JSX.Element {
|
|||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
})
|
})
|
||||||
}, [chatHistory])
|
}, [chatHistory, streamingContent])
|
||||||
|
|
||||||
const hasFile = Boolean(activeFilePath)
|
const hasFile = Boolean(activeFilePath)
|
||||||
const allAnnotationsForFile: TextAnnotation[] =
|
const allAnnotationsForFile: TextAnnotation[] =
|
||||||
@@ -137,7 +155,6 @@ export function ChatPanel(): JSX.Element {
|
|||||||
const sessions = (activeFilePath ? chatSessionsByFile[activeFilePath] : undefined) ?? []
|
const sessions = (activeFilePath ? chatSessionsByFile[activeFilePath] : undefined) ?? []
|
||||||
const activeSessionId = activeFilePath ? activeSessionIdByFile[activeFilePath] : undefined
|
const activeSessionId = activeFilePath ? activeSessionIdByFile[activeFilePath] : undefined
|
||||||
|
|
||||||
// Subheader is shown in the chat tab once there is at least one session
|
|
||||||
const showSubheader = tab === 'chat' && sessions.length > 0
|
const showSubheader = tab === 'chat' && sessions.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,7 +178,6 @@ export function ChatPanel(): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Chat sub-header: History link + New Chat button ── */}
|
{/* ── Chat sub-header: History link + New Chat button ── */}
|
||||||
{showSubheader && (
|
{showSubheader && (
|
||||||
<div className="chat-subheader">
|
<div className="chat-subheader">
|
||||||
@@ -219,7 +235,7 @@ export function ChatPanel(): JSX.Element {
|
|||||||
{!hasFile && (
|
{!hasFile && (
|
||||||
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
|
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
|
||||||
)}
|
)}
|
||||||
{hasFile && chatHistory.length === 0 && (
|
{hasFile && chatHistory.length === 0 && !isAILoading && (
|
||||||
<p className="chat-placeholder">
|
<p className="chat-placeholder">
|
||||||
Ask anything about the current chapter — passive voice, plot, character, style...
|
Ask anything about the current chapter — passive voice, plot, character, style...
|
||||||
</p>
|
</p>
|
||||||
@@ -236,11 +252,15 @@ export function ChatPanel(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
|
{isAILoading && (
|
||||||
<div className="chat-typing">
|
<div className="chat-message chat-message-assistant">
|
||||||
<span />
|
<div className="chat-message-label">Editor AI</div>
|
||||||
<span />
|
<div className="chat-message-content">
|
||||||
<span />
|
{streamingContent
|
||||||
|
? <span>{streamingContent}</span>
|
||||||
|
: <div className="chat-typing"><span /><span /><span /></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{aiError && (
|
{aiError && (
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
clearAnnotations,
|
clearAnnotations,
|
||||||
setAnalysisMode,
|
setAnalysisMode,
|
||||||
addUserMessage,
|
addUserMessage,
|
||||||
startAssistantMessage,
|
finalizeAssistantMessage,
|
||||||
appendToLastAssistantMessage,
|
setStreamingContent,
|
||||||
setAILoading,
|
setAILoading,
|
||||||
setAIError,
|
setAIError,
|
||||||
linkAnnotationsToMessage,
|
linkAnnotationsToMessage,
|
||||||
@@ -138,6 +138,9 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
selectionWordCount
|
selectionWordCount
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
|
const streamingContentRef = useRef('')
|
||||||
|
const streamingRafRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const [analyzeOpen, setAnalyzeOpen] = useState(false)
|
const [analyzeOpen, setAnalyzeOpen] = useState(false)
|
||||||
const analyzeButtonRef = useRef<HTMLButtonElement>(null)
|
const analyzeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
const analyzeMenuRef = useRef<HTMLDivElement>(null)
|
const analyzeMenuRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -223,8 +226,8 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
setAIError(null)
|
setAIError(null)
|
||||||
setRightPanelTab('chat')
|
setRightPanelTab('chat')
|
||||||
|
|
||||||
|
streamingContentRef.current = ''
|
||||||
addUserMessage(prompt)
|
addUserMessage(prompt)
|
||||||
startAssistantMessage({ bibleGeneration: true })
|
|
||||||
setAILoading(true)
|
setAILoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -239,9 +242,25 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
userMessage: prompt
|
userMessage: prompt
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
appendToLastAssistantMessage(chunk)
|
streamingContentRef.current += chunk
|
||||||
|
if (streamingRafRef.current === null) {
|
||||||
|
streamingRafRef.current = requestAnimationFrame(() => {
|
||||||
|
streamingRafRef.current = null
|
||||||
|
setStreamingContent(streamingContentRef.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (streamingRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(streamingRafRef.current)
|
||||||
|
streamingRafRef.current = null
|
||||||
|
}
|
||||||
|
const finalContent = streamingContentRef.current
|
||||||
|
streamingContentRef.current = ''
|
||||||
|
if (finalContent.length > 0) {
|
||||||
|
finalizeAssistantMessage(finalContent, { bibleGeneration: true })
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAIError(err instanceof Error ? err.message : 'Generation failed')
|
setAIError(err instanceof Error ? err.message : 'Generation failed')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -286,8 +305,8 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
? 'Please identify every past progressive construction (was/were + verb-ing) in this chapter that would be stronger in simple past.'
|
? 'Please identify every past progressive construction (was/were + verb-ing) in this chapter that would be stronger in simple past.'
|
||||||
: 'Please give me an honest critique of this chapter.'
|
: 'Please give me an honest critique of this chapter.'
|
||||||
|
|
||||||
|
streamingContentRef.current = ''
|
||||||
addUserMessage(prompt)
|
addUserMessage(prompt)
|
||||||
startAssistantMessage()
|
|
||||||
setAILoading(true)
|
setAILoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -302,28 +321,44 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
userMessage: prompt
|
userMessage: prompt
|
||||||
},
|
},
|
||||||
(chunk: string) => {
|
(chunk: string) => {
|
||||||
appendToLastAssistantMessage(chunk)
|
streamingContentRef.current += chunk
|
||||||
|
if (streamingRafRef.current === null) {
|
||||||
|
streamingRafRef.current = requestAnimationFrame(() => {
|
||||||
|
streamingRafRef.current = null
|
||||||
|
setStreamingContent(streamingContentRef.current)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (streamingRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(streamingRafRef.current)
|
||||||
|
streamingRafRef.current = null
|
||||||
|
}
|
||||||
|
const finalContent = streamingContentRef.current
|
||||||
|
streamingContentRef.current = ''
|
||||||
|
|
||||||
|
if (finalContent.length > 0) {
|
||||||
|
finalizeAssistantMessage(finalContent)
|
||||||
|
|
||||||
// Parse annotations from response
|
// Parse annotations from response
|
||||||
const currentHistory = useEditorStore.getState().chatHistory
|
|
||||||
const lastMsg = currentHistory[currentHistory.length - 1]
|
|
||||||
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
|
||||||
// Force annotation type for modes where the classifier might mis-label.
|
|
||||||
const overrideType =
|
const overrideType =
|
||||||
mode === 'show_tell' ? 'show_tell' :
|
mode === 'show_tell' ? 'show_tell' :
|
||||||
mode === 'weak_verbs' ? 'weak_verbs' :
|
mode === 'weak_verbs' ? 'weak_verbs' :
|
||||||
mode === 'cliches' ? 'cliches' :
|
mode === 'cliches' ? 'cliches' :
|
||||||
mode === 'past_progressive' ? 'past_progressive' :
|
mode === 'past_progressive' ? 'past_progressive' :
|
||||||
undefined
|
undefined
|
||||||
const { annotations: newAnnotations } = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent, overrideType)
|
const { annotations: newAnnotations } = parseAnnotationsFromAIResponse(finalContent, activeFileContent, overrideType)
|
||||||
if (newAnnotations.length > 0) {
|
if (newAnnotations.length > 0) {
|
||||||
|
const currentHistory = useEditorStore.getState().chatHistory
|
||||||
|
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||||
const existing = useEditorStore.getState().annotations.filter((a) => a.type !== mode)
|
const existing = useEditorStore.getState().annotations.filter((a) => a.type !== mode)
|
||||||
setAnnotations([...existing, ...newAnnotations])
|
setAnnotations([...existing, ...newAnnotations])
|
||||||
|
if (lastMsg?.role === 'assistant') {
|
||||||
linkAnnotationsToMessage(lastMsg.id, newAnnotations.map(a => a.id))
|
linkAnnotationsToMessage(lastMsg.id, newAnnotations.map(a => a.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAIError(err instanceof Error ? err.message : 'Analysis failed')
|
setAIError(err instanceof Error ? err.message : 'Analysis failed')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ interface EditorState {
|
|||||||
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
|
addUserMessage: (text: string, attachments?: AttachmentMeta[]) => void
|
||||||
startAssistantMessage: (opts?: { bibleGeneration?: boolean }) => void
|
startAssistantMessage: (opts?: { bibleGeneration?: boolean }) => void
|
||||||
appendToLastAssistantMessage: (chunk: string) => void
|
appendToLastAssistantMessage: (chunk: string) => void
|
||||||
|
finalizeAssistantMessage: (content: string, opts?: { bibleGeneration?: boolean }) => void
|
||||||
|
streamingContent: string
|
||||||
|
setStreamingContent: (content: string) => void
|
||||||
setAILoading: (loading: boolean) => void
|
setAILoading: (loading: boolean) => void
|
||||||
setAIError: (error: string | null) => void
|
setAIError: (error: string | null) => void
|
||||||
newChat: () => void
|
newChat: () => void
|
||||||
@@ -216,6 +219,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
chatHistory: [],
|
chatHistory: [],
|
||||||
isAILoading: false,
|
isAILoading: false,
|
||||||
aiError: null,
|
aiError: null,
|
||||||
|
streamingContent: '',
|
||||||
|
|
||||||
addUserMessage: (text, attachments?) => {
|
addUserMessage: (text, attachments?) => {
|
||||||
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text, attachments }
|
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text, attachments }
|
||||||
@@ -274,6 +278,21 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
finalizeAssistantMessage: (content, opts?) => {
|
||||||
|
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content, bibleGeneration: opts?.bibleGeneration }
|
||||||
|
set((s) => {
|
||||||
|
const history = [...s.chatHistory, msg]
|
||||||
|
if (!s.activeFilePath) return { chatHistory: history, streamingContent: '' }
|
||||||
|
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 }, streamingContent: '' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setStreamingContent: (content) => set({ streamingContent: content }),
|
||||||
|
|
||||||
setAILoading: (isAILoading) => {
|
setAILoading: (isAILoading) => {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (isAILoading) return { isAILoading }
|
if (isAILoading) return { isAILoading }
|
||||||
|
|||||||
Reference in New Issue
Block a user