port
This commit is contained in:
@@ -6,6 +6,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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." />
|
<meta name="description" content="Alexander — Java / Fullstack Developer. Switzerland, Bern. Spring Boot, React, AI." />
|
||||||
<title>Alexander — Java / Fullstack Developer</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{message.content}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import type { ChatMessageType } from '../../types'
|
import type { ChatMessageType } from '../../types'
|
||||||
import { useHrGuestAuth } from '../../hooks/useHrGuestAuth'
|
import { useHrGuestAuth } from '../../hooks/useHrGuestAuth'
|
||||||
import { createChat, streamChatQuery } from '../../services/api'
|
import { createChat, sendChatQuery } from '../../services/api'
|
||||||
import { ChatMessage } from './ChatMessage'
|
import { ChatMessage } from './ChatMessage'
|
||||||
import { ChatInput } from './ChatInput'
|
import { ChatInput } from './ChatInput'
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ export function ChatWidget() {
|
|||||||
|
|
||||||
const { token, isLoading: tokenLoading, error: tokenError } = useHrGuestAuth()
|
const { token, isLoading: tokenLoading, error: tokenError } = useHrGuestAuth()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const abortRef = useRef<(() => void) | null>(null)
|
|
||||||
|
|
||||||
// Auto-scroll to latest message
|
// Auto-scroll to latest message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,64 +44,34 @@ export function ChatWidget() {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: text,
|
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)
|
setIsSending(true)
|
||||||
|
|
||||||
const abort = streamChatQuery(
|
sendChatQuery(chatId, text, token)
|
||||||
chatId,
|
.then((response) => {
|
||||||
text,
|
const assistantMsg: ChatMessageType = {
|
||||||
token,
|
id: `a-${response.id}`,
|
||||||
(chunk) => {
|
role: 'assistant',
|
||||||
setMessages((prev) =>
|
content: response.content,
|
||||||
prev.map((m) =>
|
}
|
||||||
m.id === assistantMsgId
|
setMessages((prev) => [...prev, assistantMsg])
|
||||||
? { ...m, content: m.content + chunk }
|
})
|
||||||
: m
|
.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.map((m) =>
|
setMessages((prev) => [...prev, assistantMsg])
|
||||||
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
|
})
|
||||||
)
|
.finally(() => {
|
||||||
)
|
|
||||||
setIsSending(false)
|
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]
|
[token, chatId, isSending]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clean up on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
abortRef.current?.()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isInputDisabled =
|
const isInputDisabled =
|
||||||
tokenLoading ||
|
tokenLoading ||
|
||||||
!!tokenError ||
|
!!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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
|||||||
@@ -27,83 +27,29 @@ export async function createChat(token: string): Promise<string> {
|
|||||||
return data.id
|
return data.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ChatEntryResponse {
|
||||||
* Sends a chat query and streams the SSE response token by token.
|
id: number
|
||||||
* Returns a cleanup function that aborts the request.
|
content: string
|
||||||
*/
|
role: string
|
||||||
export function streamChatQuery(
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChatQuery(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
message: string,
|
message: string,
|
||||||
token: string,
|
token: string
|
||||||
onChunk: (chunk: string) => void,
|
): Promise<ChatEntryResponse> {
|
||||||
onDone: () => void,
|
const response = await fetch(`${BASE_URL}/api/rag/entry/${chatId}`, {
|
||||||
onError: (error: Error) => void
|
|
||||||
): () => void {
|
|
||||||
const controller = new AbortController()
|
|
||||||
|
|
||||||
fetch(`${BASE_URL}/api/rag/entry/${chatId}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
Accept: 'text/event-stream',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ message }),
|
body: JSON.stringify({ content: message, onlyContext: true }),
|
||||||
signal: controller.signal,
|
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
if (!response.ok) {
|
||||||
if (!response.ok) {
|
throw new Error(`Query failed: ${response.status}`)
|
||||||
throw new Error(`Query failed: ${response.status}`)
|
}
|
||||||
}
|
return response.json() as Promise<ChatEntryResponse>
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface ChatMessageType {
|
|||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
isStreaming?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HrGuestTokenResponse {
|
export interface HrGuestTokenResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user