init
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Ollama Cloud API key
|
||||
OLLAMA_API_KEY=your_ollama_api_key_here
|
||||
|
||||
# OIDC settings
|
||||
OIDC_ISSUER=https://your-issuer.example.com
|
||||
OIDC_AUDIENCE=your-api-audience
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_VERIFY_ISS=true
|
||||
|
||||
# Frontend redirect URI
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:5173
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.env
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,43 @@
|
||||
# AI Chat OIDC + Ollama Cloud
|
||||
|
||||
This repository includes:
|
||||
|
||||
- `backend/` — FastAPI service validating OIDC JWTs and proxying chat to Ollama Cloud with SSE streaming
|
||||
- `frontend/` — React + Vite + TypeScript UI performing OIDC Authorization Code Flow with PKCE
|
||||
- `docker-compose.yml` — brings up backend, frontend, and optional Keycloak (dev-only)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker >= 24.0
|
||||
- Docker Compose >= 2.0
|
||||
- An Ollama Cloud API key
|
||||
- An OIDC issuer URL, client ID, and client secret
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy `.env.example` to `.env` and set values.
|
||||
2. In the frontend, set `VITE_OIDC_REDIRECT_URI` to `http://localhost:5173` or your UI URL.
|
||||
3. If using the local Keycloak dev service, enable the `dev` profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile dev up --build
|
||||
```
|
||||
|
||||
4. If you only want backend/frontend with an external OIDC provider:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health` — health check
|
||||
- `GET /auth/config` — OIDC discovery endpoints for the frontend
|
||||
- `POST /auth/callback` — exchanges OIDC code for tokens
|
||||
- `POST /chat` — protected chat endpoint streaming SSE from Ollama Cloud
|
||||
|
||||
## Notes
|
||||
|
||||
- The backend requires `OLLAMA_API_KEY` in environment.
|
||||
- The frontend stores the access token in `localStorage` for simplicity.
|
||||
- OpenAPI docs are available at `http://localhost:8000/docs`.
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,95 @@
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwk, jwt
|
||||
from pydantic import BaseModel, BaseSettings, Field, HttpUrl
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
oidc_issuer: HttpUrl = Field(..., env="OIDC_ISSUER")
|
||||
oidc_audience: str = Field(..., env="OIDC_AUDIENCE")
|
||||
oidc_client_id: str = Field(..., env="OIDC_CLIENT_ID")
|
||||
oidc_client_secret: Optional[str] = Field(None, env="OIDC_CLIENT_SECRET")
|
||||
oidc_verify_iss: bool = Field(True, env="OIDC_VERIFY_ISS")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
|
||||
class TokenClaims(BaseModel):
|
||||
iss: str
|
||||
sub: str
|
||||
aud: Any
|
||||
exp: int
|
||||
iat: Optional[int]
|
||||
azp: Optional[str]
|
||||
email: Optional[str]
|
||||
preferred_username: Optional[str]
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_discovery() -> Dict[str, Any]:
|
||||
issuer = str(settings.oidc_issuer).rstrip("/")
|
||||
discovery_url = urljoin(issuer + "/", ".well-known/openid-configuration")
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.get(discovery_url)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Unable to load OIDC discovery document from {discovery_url}")
|
||||
return response.json()
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict[str, Any]:
|
||||
discovery = get_discovery()
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
if not jwks_uri:
|
||||
raise RuntimeError("OIDC discovery document does not contain jwks_uri")
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.get(jwks_uri)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Unable to load JWKS from {jwks_uri}")
|
||||
return response.json()
|
||||
|
||||
def get_signing_key(token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
headers = jwt.get_unverified_header(token)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid JWT header") from exc
|
||||
|
||||
kid = headers.get("kid")
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="JWT missing kid header")
|
||||
|
||||
jwks = get_jwks().get("keys", [])
|
||||
for key in jwks:
|
||||
if key.get("kid") == kid:
|
||||
return key
|
||||
|
||||
raise HTTPException(status_code=401, detail="Unable to find matching JWKS key")
|
||||
|
||||
async def validate_token(credentials: HTTPAuthorizationCredentials = Security(security)) -> TokenClaims:
|
||||
if not credentials or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="Missing Bearer authorization header")
|
||||
|
||||
token = credentials.credentials
|
||||
key = get_signing_key(token)
|
||||
public_key = jwk.construct(key)
|
||||
try:
|
||||
verified = jwt.decode(
|
||||
token,
|
||||
public_key,
|
||||
algorithms=[key.get("alg", "RS256")],
|
||||
audience=settings.oidc_audience,
|
||||
issuer=str(settings.oidc_issuer) if settings.oidc_verify_iss else None,
|
||||
)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
|
||||
|
||||
try:
|
||||
return TokenClaims(**verified)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=401, detail="Unable to parse token claims") from exc
|
||||
@@ -0,0 +1,96 @@
|
||||
import os
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth import get_discovery, settings, validate_token
|
||||
from app.schemas import AuthCallbackRequest, AuthConfigResponse, ChatRequest
|
||||
|
||||
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY")
|
||||
OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "https://api.ollama.com/v1/chat/completions")
|
||||
if not OLLAMA_API_KEY:
|
||||
raise RuntimeError("Environment variable OLLAMA_API_KEY is required")
|
||||
|
||||
app = FastAPI(
|
||||
title="OIDC-Protected Ollama Proxy",
|
||||
description="FastAPI backend validating OIDC JWTs and streaming Ollama Cloud responses over SSE.",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
async def event_stream(request: ChatRequest) -> AsyncGenerator[str, None]:
|
||||
payload = {
|
||||
"model": request.model,
|
||||
"messages": [message.dict() for message in request.messages],
|
||||
"stream": True,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {OLLAMA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", OLLAMA_API_URL, json=payload, headers=headers) as response:
|
||||
if response.status_code >= 400:
|
||||
text = await response.aread()
|
||||
raise HTTPException(status_code=502, detail=f"Ollama Cloud error: {text.decode('utf-8', 'ignore')}" )
|
||||
|
||||
async for chunk in response.aiter_text():
|
||||
if not chunk:
|
||||
continue
|
||||
for line in chunk.splitlines():
|
||||
if line.strip():
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/auth/config", response_model=AuthConfigResponse)
|
||||
async def auth_config() -> AuthConfigResponse:
|
||||
discovery = get_discovery()
|
||||
return AuthConfigResponse(
|
||||
issuer=discovery["issuer"],
|
||||
authorization_endpoint=discovery["authorization_endpoint"],
|
||||
token_endpoint=discovery["token_endpoint"],
|
||||
)
|
||||
|
||||
@app.post("/auth/callback")
|
||||
async def auth_callback(payload: AuthCallbackRequest):
|
||||
discovery = get_discovery()
|
||||
token_endpoint = discovery.get("token_endpoint")
|
||||
if not token_endpoint:
|
||||
raise HTTPException(status_code=502, detail="OIDC discovery document missing token_endpoint")
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.oidc_client_id,
|
||||
"code": payload.code,
|
||||
"code_verifier": payload.code_verifier,
|
||||
"redirect_uri": str(payload.redirect_uri),
|
||||
}
|
||||
if settings.oidc_client_secret:
|
||||
data["client_secret"] = settings.oidc_client_secret
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(token_endpoint, data=data, headers={"Accept": "application/json"})
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=f"OIDC token exchange failed: {response.text}")
|
||||
|
||||
return response.json()
|
||||
|
||||
@app.post("/chat", response_class=StreamingResponse)
|
||||
async def chat(request: ChatRequest, token=Depends(validate_token)):
|
||||
return StreamingResponse(event_stream(request), media_type="text/event-stream")
|
||||
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from typing import Literal, List
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: Literal["user", "assistant", "system"]
|
||||
content: str
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
model: str = Field(default="llama2")
|
||||
messages: List[ChatMessage] = Field(default_factory=list)
|
||||
|
||||
class AuthCallbackRequest(BaseModel):
|
||||
code: str
|
||||
code_verifier: str
|
||||
redirect_uri: HttpUrl
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
issuer: HttpUrl
|
||||
authorization_endpoint: HttpUrl
|
||||
token_endpoint: HttpUrl
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.23.2
|
||||
httpx==0.28.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
pydantic==2.8.0
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
environment:
|
||||
- OLLAMA_API_KEY=${OLLAMA_API_KEY}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||
- OIDC_AUDIENCE=${OIDC_AUDIENCE}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||
- OIDC_VERIFY_ISS=${OIDC_VERIFY_ISS:-true}
|
||||
ports:
|
||||
- '8000:8000'
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
environment:
|
||||
- VITE_API_BASE=http://localhost:8000
|
||||
- VITE_OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||
- VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI:-http://localhost:5173}
|
||||
ports:
|
||||
- '5173:5173'
|
||||
depends_on:
|
||||
- backend
|
||||
@@ -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"]
|
||||
Generated
+25
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user