This commit is contained in:
2026-03-19 01:15:10 +01:00
parent 94b7c2adcc
commit 5ae4f831bf
6 changed files with 41 additions and 129 deletions

View File

@@ -6,6 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Alexander — Java / Fullstack Developer. Switzerland, Bern. Spring Boot, React, AI." />
<title>Alexander — Java / Fullstack Developer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="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=block" />
</head>
<body>
<div id="root"></div>

View File

@@ -26,9 +26,6 @@ export function ChatMessage({ message }: ChatMessageProps) {
}`}
>
{message.content}
{message.isStreaming && (
<span className="inline-block w-1 h-3.5 bg-accent ml-0.5 align-text-bottom animate-pulse" />
)}
</div>
</div>
)

View File

@@ -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<HTMLDivElement>(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 ||

View File

@@ -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 {

View File

@@ -27,83 +27,29 @@ export async function createChat(token: string): Promise<string> {
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<ChatEntryResponse> {
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<string, unknown>).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<ChatEntryResponse>
}

View File

@@ -11,7 +11,6 @@ export interface ChatMessageType {
id: string
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
}
export interface HrGuestTokenResponse {