diff --git a/portfolio-view/index.html b/portfolio-view/index.html index 87f45a4..1acfa94 100644 --- a/portfolio-view/index.html +++ b/portfolio-view/index.html @@ -6,6 +6,9 @@ Alexander — Java / Fullstack Developer + + +
diff --git a/portfolio-view/src/components/chat/ChatMessage.tsx b/portfolio-view/src/components/chat/ChatMessage.tsx index c62f803..944ad8c 100644 --- a/portfolio-view/src/components/chat/ChatMessage.tsx +++ b/portfolio-view/src/components/chat/ChatMessage.tsx @@ -26,9 +26,6 @@ export function ChatMessage({ message }: ChatMessageProps) { }`} > {message.content} - {message.isStreaming && ( - - )} ) diff --git a/portfolio-view/src/components/chat/ChatWidget.tsx b/portfolio-view/src/components/chat/ChatWidget.tsx index 13fcaa6..a0db4b1 100644 --- a/portfolio-view/src/components/chat/ChatWidget.tsx +++ b/portfolio-view/src/components/chat/ChatWidget.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback } from 'react' import type { ChatMessageType } from '../../types' import { useHrGuestAuth } from '../../hooks/useHrGuestAuth' -import { createChat, streamChatQuery } from '../../services/api' +import { createChat, sendChatQuery } from '../../services/api' import { ChatMessage } from './ChatMessage' import { ChatInput } from './ChatInput' @@ -16,7 +16,6 @@ export function ChatWidget() { const { token, isLoading: tokenLoading, error: tokenError } = useHrGuestAuth() const messagesEndRef = useRef(null) - const abortRef = useRef<(() => void) | null>(null) // Auto-scroll to latest message useEffect(() => { @@ -45,64 +44,34 @@ export function ChatWidget() { role: 'user', content: text, } - const assistantMsgId = `a-${Date.now()}` - const assistantMsg: ChatMessageType = { - id: assistantMsgId, - role: 'assistant', - content: '', - isStreaming: true, - } - setMessages((prev) => [...prev, userMsg, assistantMsg]) + setMessages((prev) => [...prev, userMsg]) setIsSending(true) - const abort = streamChatQuery( - chatId, - text, - token, - (chunk) => { - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: m.content + chunk } - : m - ) - ) - }, - () => { - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, isStreaming: false } : m - ) - ) + sendChatQuery(chatId, text, token) + .then((response) => { + const assistantMsg: ChatMessageType = { + id: `a-${response.id}`, + role: 'assistant', + content: response.content, + } + setMessages((prev) => [...prev, assistantMsg]) + }) + .catch((err: unknown) => { + const assistantMsg: ChatMessageType = { + id: `a-err-${Date.now()}`, + role: 'assistant', + content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + } + setMessages((prev) => [...prev, assistantMsg]) + }) + .finally(() => { setIsSending(false) - abortRef.current = null - }, - (err) => { - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: `Error: ${err.message}`, isStreaming: false } - : m - ) - ) - setIsSending(false) - abortRef.current = null - } - ) - - abortRef.current = abort + }) }, [token, chatId, isSending] ) - // Clean up on unmount - useEffect(() => { - return () => { - abortRef.current?.() - } - }, []) - const isInputDisabled = tokenLoading || !!tokenError || diff --git a/portfolio-view/src/index.css b/portfolio-view/src/index.css index 4eaad2b..c626b98 100644 --- a/portfolio-view/src/index.css +++ b/portfolio-view/src/index.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=swap'); - @import "tailwindcss"; @theme { diff --git a/portfolio-view/src/services/api.ts b/portfolio-view/src/services/api.ts index 37e5f17..4b412c9 100644 --- a/portfolio-view/src/services/api.ts +++ b/portfolio-view/src/services/api.ts @@ -27,83 +27,29 @@ export async function createChat(token: string): Promise { return data.id } -/** - * Sends a chat query and streams the SSE response token by token. - * Returns a cleanup function that aborts the request. - */ -export function streamChatQuery( +export interface ChatEntryResponse { + id: number + content: string + role: string + createdAt: string +} + +export async function sendChatQuery( chatId: string, message: string, - token: string, - onChunk: (chunk: string) => void, - onDone: () => void, - onError: (error: Error) => void -): () => void { - const controller = new AbortController() - - fetch(`${BASE_URL}/api/rag/entry/${chatId}`, { + token: string +): Promise { + const response = await fetch(`${BASE_URL}/api/rag/entry/${chatId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, - Accept: 'text/event-stream', + Accept: 'application/json', }, - body: JSON.stringify({ message }), - signal: controller.signal, + body: JSON.stringify({ content: message, onlyContext: true }), }) - .then(async (response) => { - if (!response.ok) { - throw new Error(`Query failed: ${response.status}`) - } - if (!response.body) { - throw new Error('Empty response body') - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) { - onDone() - break - } - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - // Keep the last (possibly incomplete) line in the buffer - buffer = lines.pop() ?? '' - - for (const line of lines) { - if (!line.startsWith('data:')) continue - const data = line.slice(5).trim() - if (data === '[DONE]' || data === '') continue - - // Handle both plain text and JSON payloads: {"content": "..."} - try { - const parsed: unknown = JSON.parse(data) - if ( - parsed !== null && - typeof parsed === 'object' && - 'content' in parsed && - typeof (parsed as Record).content === 'string' - ) { - onChunk((parsed as { content: string }).content) - } else { - onChunk(data) - } - } catch { - onChunk(data) - } - } - } - }) - .catch((error: unknown) => { - if (error instanceof Error && error.name !== 'AbortError') { - onError(error) - } - }) - - return () => controller.abort() + if (!response.ok) { + throw new Error(`Query failed: ${response.status}`) + } + return response.json() as Promise } diff --git a/portfolio-view/src/types/index.ts b/portfolio-view/src/types/index.ts index ca409be..223e230 100644 --- a/portfolio-view/src/types/index.ts +++ b/portfolio-view/src/types/index.ts @@ -11,7 +11,6 @@ export interface ChatMessageType { id: string role: 'user' | 'assistant' content: string - isStreaming?: boolean } export interface HrGuestTokenResponse {