init
This commit is contained in:
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in New Issue
Block a user