port
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface ChatMessageType {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export interface HrGuestTokenResponse {
|
||||
|
||||
Reference in New Issue
Block a user