Files
ai-chat/frontend/src/App.tsx
T

371 lines
12 KiB
TypeScript

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) {
// Prefer SubtleCrypto when available (secure contexts). Fallback to a
// pure-JS implementation for environments where `crypto.subtle` is undefined.
const data = new TextEncoder().encode(text)
if (typeof crypto !== 'undefined' && crypto.subtle && typeof crypto.subtle.digest === 'function') {
const hash = await crypto.subtle.digest('SHA-256', data)
return base64UrlEncode(hash)
}
// JS fallback SHA-256 (returns ArrayBuffer)
function sha256Fallback(message: Uint8Array) {
const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
const K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
])
const ml = message.length * 8
const withOne = new Uint8Array(((message.length + 9 + 63) >> 6) << 6)
withOne.set(message)
withOne[message.length] = 0x80
const view = new DataView(withOne.buffer)
view.setUint32(withOne.length - 4, ml >>> 0)
view.setUint32(withOne.length - 8, Math.floor(ml / 0x100000000))
const w = new Uint32Array(64)
for (let i = 0; i < withOne.length; i += 64) {
for (let t = 0; t < 16; ++t) w[t] = view.getUint32(i + t * 4)
for (let t = 16; t < 64; ++t) {
const s0 = (rightRotate(w[t - 15], 7) ^ rightRotate(w[t - 15], 18) ^ (w[t - 15] >>> 3)) >>> 0
const s1 = (rightRotate(w[t - 2], 17) ^ rightRotate(w[t - 2], 19) ^ (w[t - 2] >>> 10)) >>> 0
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0
}
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
for (let t = 0; t < 64; ++t) {
const S1 = (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) >>> 0
const ch = ((e & f) ^ (~e & g)) >>> 0
const temp1 = (h + S1 + ch + K[t] + w[t]) >>> 0
const S0 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) >>> 0
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
const temp2 = (S0 + maj) >>> 0
h = g; g = f; f = e; e = (d + temp1) >>> 0
d = c; c = b; b = a; a = (temp1 + temp2) >>> 0
}
H[0] = (H[0] + a) >>> 0
H[1] = (H[1] + b) >>> 0
H[2] = (H[2] + c) >>> 0
H[3] = (H[3] + d) >>> 0
H[4] = (H[4] + e) >>> 0
H[5] = (H[5] + f) >>> 0
H[6] = (H[6] + g) >>> 0
H[7] = (H[7] + h) >>> 0
}
const out = new Uint8Array(32)
const outView = new DataView(out.buffer)
for (let i = 0; i < 8; ++i) outView.setUint32(i * 4, H[i])
return out.buffer
}
function rightRotate(value: number, amount: number) {
return (value >>> amount) | (value << (32 - amount))
}
const hashBuf = sha256Fallback(data)
return base64UrlEncode(hashBuf)
}
async function createCodeVerifier() {
const array = new Uint8Array(32)
window.crypto.getRandomValues(array)
return base64UrlEncode(array)
}
function generateUUID() {
// Use native crypto.randomUUID when available, otherwise fallback to RFC4122 v4
// Compatible with older browsers that lack crypto.randomUUID
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
// @ts-ignore
return (crypto as any).randomUUID()
}
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.substr(0, 8)}-${hex.substr(8, 4)}-${hex.substr(12, 4)}-${hex.substr(16, 4)}-${hex.substr(20, 12)}`
}
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 = generateUUID()
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