From 4ebc1fa3cc99e4bf139ff0c3c7fc51e595be4039 Mon Sep 17 00:00:00 2001 From: Mabe Date: Sat, 20 Jun 2026 23:00:05 +0200 Subject: [PATCH] init --- .env.example | 12 ++ .gitignore | 7 + README.md | 43 ++++++ backend/Dockerfile | 6 + backend/app/__init__.py | 0 backend/app/auth.py | 95 ++++++++++++ backend/app/main.py | 96 ++++++++++++ backend/app/schemas.py | 20 +++ backend/requirements.txt | 5 + docker-compose.yml | 25 ++++ frontend/Dockerfile | 6 + frontend/package-lock.json | 25 ++++ frontend/package.json | 22 +++ frontend/src/App.tsx | 284 ++++++++++++++++++++++++++++++++++++ frontend/src/index.html | 12 ++ frontend/src/main.tsx | 9 ++ frontend/tsconfig.json | 21 +++ frontend/tsconfig.node.json | 9 ++ frontend/vite.config.ts | 10 ++ 19 files changed, 707 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/main.py create mode 100644 backend/app/schemas.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/index.html create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ddd772f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3571988 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.env +node_modules/ +dist/ +build/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..be095bd --- /dev/null +++ b/README.md @@ -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`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a2a188e --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..a23ca96 --- /dev/null +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1d815e8 --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..0772077 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5331a66 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31429e0 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1bbabd8 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8d02b6e --- /dev/null +++ b/frontend/package-lock.json @@ -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" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..90e04f6 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..14a79fe --- /dev/null +++ b/frontend/src/App.tsx @@ -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 { + 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 { + 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([]) + const [input, setInput] = useState('') + const [token, setToken] = useState(getLocalToken()) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const controllerRef = useRef(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 ( +
+
+

AI Chat

+
+ {isAuthenticated ? ( + + ) : ( + + )} +
+
+ +
+
+

Chat

+ {!isAuthenticated &&

Please log in to start chatting.

} + {error &&
{error}
} +
+ {messages.map((message, index) => ( +
+ {message.role}: +

{message.content}

+
+ ))} +
+
+ setInput(event.target.value)} + disabled={!isAuthenticated || loading} + style={{ flex: 1, minWidth: 0, padding: '8px 12px' }} + placeholder="Type your message..." + /> + +
+ {loading &&

Waiting for response...

} +
+
+
+ ) +} + +export default App diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..cec90d8 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,12 @@ + + + + + + AI Chat + + +
+ + + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c4c5b6d --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2f27c13 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..16dfedc --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e5197df --- /dev/null +++ b/frontend/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, + }, +})