This commit is contained in:
Mabe
2026-06-20 23:00:05 +02:00
commit 4ebc1fa3cc
19 changed files with 707 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ai-chat-frontend",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-chat-frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ai-chat-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
+284
View File
@@ -0,0 +1,284 @@
import { useEffect, useMemo, useRef, useState } from 'react'
type Message = { role: 'user' | 'assistant' | 'system'; content: string }
type OIDCConfig = {
issuer: string
authorization_endpoint: string
token_endpoint: string
}
const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
const OIDC_REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI || window.location.origin
const OIDC_SCOPE = 'openid profile email'
const OIDC_RESPONSE_TYPE = 'code'
const OIDC_CODE_CHALLENGE_METHOD = 'S256'
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'
function base64UrlEncode(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer)
let str = ''
for (const byte of bytes) {
str += String.fromCharCode(byte)
}
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
async function sha256(text: string) {
const data = new TextEncoder().encode(text)
const hash = await window.crypto.subtle.digest('SHA-256', data)
return base64UrlEncode(hash)
}
async function createCodeVerifier() {
const array = new Uint8Array(32)
window.crypto.getRandomValues(array)
return base64UrlEncode(array)
}
function getLocalToken() {
return window.localStorage.getItem('oidc_access_token')
}
function setLocalToken(token: string) {
window.localStorage.setItem('oidc_access_token', token)
}
function clearLocalToken() {
window.localStorage.removeItem('oidc_access_token')
}
async function fetchOIDCConfig(): Promise<OIDCConfig> {
const response = await fetch(`${API_BASE}/auth/config`)
if (!response.ok) {
throw new Error('Unable to fetch OIDC configuration')
}
return response.json()
}
async function handleRedirectCallback(): Promise<string | null> {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const storedState = window.sessionStorage.getItem('oidc_state')
const codeVerifier = window.sessionStorage.getItem('oidc_code_verifier')
if (!code || !state || state !== storedState || !codeVerifier) {
return null
}
window.history.replaceState({}, document.title, window.location.pathname)
const response = await fetch(`${API_BASE}/auth/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: OIDC_REDIRECT_URI }),
})
const result = await response.json()
if (response.ok && result.access_token) {
setLocalToken(result.access_token)
return result.access_token
}
throw new Error(result.error_description || 'OIDC callback failed')
}
function parseSSE(text: string, onData: (chunk: string) => void) {
const events = text.split('\n\n')
for (const event of events) {
const trimmed = event.trim()
if (!trimmed) continue
const lines = trimmed.split('\n')
const dataLines = lines
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s?/, ''))
if (dataLines.length) {
onData(dataLines.join('\n'))
}
}
}
function App() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [token, setToken] = useState<string | null>(getLocalToken())
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const controllerRef = useRef<AbortController | null>(null)
const isAuthenticated = !!token
useEffect(() => {
handleRedirectCallback()
.then((newToken) => {
if (newToken) {
setToken(newToken)
}
})
.catch((err) => {
console.error(err)
setError(err instanceof Error ? err.message : 'Authentication failed')
})
return () => {
controllerRef.current?.abort()
}
}, [])
const authHeaders = useMemo(
() => ({ Authorization: token ? `Bearer ${token}` : '' }),
[token],
)
async function login() {
try {
const config = await fetchOIDCConfig()
const state = crypto.randomUUID()
const codeVerifier = await createCodeVerifier()
const codeChallenge = await sha256(codeVerifier)
window.sessionStorage.setItem('oidc_state', state)
window.sessionStorage.setItem('oidc_code_verifier', codeVerifier)
const params = new URLSearchParams({
response_type: OIDC_RESPONSE_TYPE,
client_id: OIDC_CLIENT_ID,
redirect_uri: OIDC_REDIRECT_URI,
scope: OIDC_SCOPE,
state,
code_challenge: codeChallenge,
code_challenge_method: OIDC_CODE_CHALLENGE_METHOD,
})
window.location.href = `${config.authorization_endpoint}?${params.toString()}`
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
function logout() {
clearLocalToken()
setToken(null)
setMessages([])
}
async function submitMessage() {
if (!input.trim() || !token) return
const userMessage: Message = { role: 'user', content: input.trim() }
const messageBatch = [...messages, userMessage]
setMessages(messageBatch)
setInput('')
setLoading(true)
setError(null)
const controller = new AbortController()
controllerRef.current = controller
try {
const response = await fetch(`${API_BASE}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
body: JSON.stringify({ model: 'llama2', messages: messageBatch }),
signal: controller.signal,
})
if (!response.ok) {
const payload = await response.text()
throw new Error(payload || 'Chat request failed')
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No streaming body available')
}
const decoder = new TextDecoder()
let buffer = ''
const appendAssistantText = (chunk: string) => {
setMessages((prev) => {
const last = prev[prev.length - 1]
if (last?.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: last.content + chunk }]
}
return [...prev, { role: 'assistant', content: chunk }]
})
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const boundary = buffer.indexOf('\n\n')
if (boundary === -1) continue
const chunk = buffer.slice(0, boundary + 2)
buffer = buffer.slice(boundary + 2)
parseSSE(chunk, appendAssistantText)
}
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Streaming chat failed')
}
} finally {
setLoading(false)
controllerRef.current = null
}
}
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24, fontFamily: 'system-ui, sans-serif' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>AI Chat</h1>
<div>
{isAuthenticated ? (
<button onClick={logout}>Logout</button>
) : (
<button onClick={login}>Login with OIDC</button>
)}
</div>
</header>
<main>
<section>
<h2>Chat</h2>
{!isAuthenticated && <p>Please log in to start chatting.</p>}
{error && <div style={{ color: 'red', marginBottom: 12 }}>{error}</div>}
<div style={{ minHeight: 300, border: '1px solid #ddd', padding: 16, borderRadius: 8, marginBottom: 16 }}>
{messages.map((message, index) => (
<div key={index} style={{ marginBottom: 12 }}>
<strong>{message.role}:</strong>
<p style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>{message.content}</p>
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={!isAuthenticated || loading}
style={{ flex: 1, minWidth: 0, padding: '8px 12px' }}
placeholder="Type your message..."
/>
<button onClick={submitMessage} disabled={!isAuthenticated || loading || !input.trim()}>
Send
</button>
</div>
{loading && <p>Waiting for response...</p>}
</section>
</main>
</div>
)
}
export default App
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
})