diff --git a/client/src/backend_bridge/api_helpers.ts b/client/src/backend_bridge/api_helpers.ts index 7b2f39e..3c83d49 100644 --- a/client/src/backend_bridge/api_helpers.ts +++ b/client/src/backend_bridge/api_helpers.ts @@ -17,46 +17,96 @@ export type LlmChatOptions = { signal?: AbortSignal | undefined; }; +const normalizedOpenAIChatCompletionMessageSchema = z.object({ + content: z.string(), + reasoning_content: z.string().optional(), +}); + +type NormalizedLLMChatMessage = z.infer; + const openAIChatCompletionSchema = z.object({ choices: z.array( z.object({ - message: z.object({ - content: z.string(), + message: normalizedOpenAIChatCompletionMessageSchema.extend({ + reasoning: z.string().nullable().optional(), + reasoning_content: z.string().nullable().optional(), }), }) ), }); -function extractStreamChunkText(chunkPayload: unknown): { mode: 'delta' | 'full' | 'none'; text: string } { +type RawLLMChatMessage = z.infer['choices'][number]['message']; + +type ExtractedStreamChunk = + | { mode: 'delta'; message: Partial } + | { mode: 'full'; message: NormalizedLLMChatMessage } + | { mode: 'none' }; + +function normalizeChatMessage(raw: RawLLMChatMessage): NormalizedLLMChatMessage { + const reasoningContent = raw.reasoning_content || raw.reasoning || undefined; + + return { + ...raw, + reasoning_content: reasoningContent, + }; +} + +function extractStreamMessageFields(value: unknown): Partial | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + + const content = (value as { content?: unknown }).content; + const reasoningContent = + (value as { reasoning_content?: unknown }).reasoning_content || + (value as { reasoning?: unknown }).reasoning; + + if (typeof content !== 'string' && typeof reasoningContent !== 'string') { + return undefined; + } + + return { + ...(typeof content === 'string' ? { content } : {}), + ...(typeof reasoningContent === 'string' ? { reasoning_content: reasoningContent } : {}), + }; +} + +function extractStreamChunkMessage(chunkPayload: unknown): ExtractedStreamChunk { if (!chunkPayload || typeof chunkPayload !== 'object') { - return { mode: 'none', text: '' }; + return { mode: 'none' }; } const choices = (chunkPayload as { choices?: unknown }).choices; if (!Array.isArray(choices) || choices.length === 0) { - return { mode: 'none', text: '' }; + return { mode: 'none' }; } const firstChoice = choices[0]; if (!firstChoice || typeof firstChoice !== 'object') { - return { mode: 'none', text: '' }; + return { mode: 'none' }; } const delta = (firstChoice as { delta?: unknown }).delta; - if (delta && typeof delta === 'object' && typeof (delta as { content?: unknown }).content === 'string') { - return { mode: 'delta', text: (delta as { content: string }).content }; + const deltaMessage = extractStreamMessageFields(delta); + if (deltaMessage) { + return { mode: 'delta', message: deltaMessage }; } const message = (firstChoice as { message?: unknown }).message; - if ( - message && - typeof message === 'object' && - typeof (message as { content?: unknown }).content === 'string' - ) { - return { mode: 'full', text: (message as { content: string }).content }; + const fullMessage = extractStreamMessageFields(message); + if (fullMessage?.content !== undefined) { + return { + mode: 'full', + message: { + content: fullMessage.content, + ...(fullMessage.reasoning_content !== undefined + ? { reasoning_content: fullMessage.reasoning_content } + : {}), + }, + }; } - return { mode: 'none', text: '' }; + return { mode: 'none' }; } function buildLlmHttpError(response: Response, responseText: string) { @@ -68,7 +118,10 @@ function buildLlmHttpError(response: Response, responseText: string) { return new Error(message); } -async function parseLlmStreamingResponse(response: Response, onTokens: LlmTokenCallback): Promise { +async function parseLlmStreamingResponse( + response: Response, + onTokens: LlmTokenCallback +): Promise { const contentType = (response.headers.get('content-type') || '').toLowerCase(); if (!contentType.includes('text/event-stream')) { const body = await response.text(); @@ -83,7 +136,7 @@ async function parseLlmStreamingResponse(response: Response, onTokens: LlmTokenC throw new Error('Streaming response body was empty.'); } - let combinedText = ''; + let combinedMessage: NormalizedLLMChatMessage = { content: '' }; let pendingBuffer = ''; let sawDoneMarker = false; const decoder = new TextDecoder(); @@ -106,16 +159,24 @@ async function parseLlmStreamingResponse(response: Response, onTokens: LlmTokenC let parsedPayload = JSON.parse(payload); - const extracted = extractStreamChunkText(parsedPayload); - if (extracted.mode === 'delta' && extracted.text) { - combinedText += extracted.text; - onTokens(combinedText); + const extracted = extractStreamChunkMessage(parsedPayload); + if (extracted.mode === 'delta') { + if (extracted.message.reasoning_content !== undefined) { + combinedMessage.reasoning_content = + (combinedMessage.reasoning_content ?? '') + extracted.message.reasoning_content; + } + + if (extracted.message.content) { + combinedMessage.content += extracted.message.content; + onTokens(combinedMessage.content); + } + return; } if (extracted.mode === 'full') { - combinedText = extracted.text; - onTokens(combinedText); + combinedMessage = extracted.message; + onTokens(combinedMessage.content); } }; @@ -154,10 +215,13 @@ async function parseLlmStreamingResponse(response: Response, onTokens: LlmTokenC pendingBuffer += decoder.decode(); processBufferedLines(true); - return combinedText; + return combinedMessage; } -export async function executeLlmChat(payload: Record, options: LlmChatOptions = {}) { +export async function executeLlmChat( + payload: Record, + options: LlmChatOptions = {} +): Promise { const { targetUrl, authToken, onTokens, signal } = options; const streamingRequested = typeof onTokens === 'function'; @@ -193,7 +257,12 @@ export async function executeLlmChat(payload: Record, options: } const completion = await parseJSONResponse(openAIChatCompletionSchema, response); - return completion?.choices?.[0]?.message?.content || ''; + const message = completion?.choices?.[0]?.message; + if (message) { + return normalizeChatMessage(message); + } + + return message; } export async function parseJSONResponse( diff --git a/client/src/components/ChatPane.tsx b/client/src/components/ChatPane.tsx index 3ca8ef9..23365b1 100644 --- a/client/src/components/ChatPane.tsx +++ b/client/src/components/ChatPane.tsx @@ -50,7 +50,8 @@ export default function ChatPane() { const [editingMessageId, setEditingMessageId] = useState(undefined); const [editingMessageDraft, setEditingMessageDraft] = useState(''); - const transcriptContainerRef = useRef(null); + const transcriptContainerOuterRef = useRef(null); + const transcriptContainerInnerRef = useRef(null); const autoScrollUnlockedRef = useRef(true); const inputRef = useRef(null); @@ -232,21 +233,8 @@ export default function ChatPane() { return remaining <= 24; }; - const scrollTranscriptToBottom = () => { - setTimeout(() => { - const container = transcriptContainerRef.current; - if (!container || !autoScrollUnlockedRef.current) { - return; - } - - requestAnimationFrame(() => { - container.scrollTop = container.scrollHeight; - }); - }, 25); - }; - const handleTranscriptScroll = () => { - const container = transcriptContainerRef.current; + const container = transcriptContainerOuterRef.current; if (!container) { return; } @@ -302,8 +290,20 @@ export default function ChatPane() { }, [transcript?.countAllMessages() === 0]); useEffect(() => { - scrollTranscriptToBottom(); - }, [transcript]); + if (!transcriptContainerInnerRef.current) { + return; + } + + const observer = new ResizeObserver(() => { + if (autoScrollUnlockedRef.current && transcriptContainerOuterRef.current) { + transcriptContainerOuterRef.current.scrollTop = transcriptContainerOuterRef.current.scrollHeight; + } + }); + + observer.observe(transcriptContainerInnerRef.current); + + return () => observer.disconnect(); + }, [transcriptContainerInnerRef.current]); if (!scenario || !userLocation) { return undefined; @@ -396,135 +396,132 @@ export default function ChatPane() {
- {transcript.countAllMessages() === 0 && ( -
- {participants[0]!.id === userCharacter.id - ? 'Say something to start the conversation, or choose an NPC to speak.' - : isPaused - ? 'Click Speak on a character card to choose who talks next, or Start Chat to let them talk on their own.' - : 'Conversation in progress...'} -
- )} - {transcript.getVisibleMessages(userCharacter.id).map((entry) => ( -
- {entry.isImageMessage() ? ( - Generated - ) : editingMessageId === entry.getId() && entry.isCharacterChatMessage() ? ( -
-
- {entry.getSpeakerName()}: -
-