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