Spaces:
Sleeping
Sleeping
NeonClary Cursor commited on
Commit ·
c44fab6
0
Parent(s):
Import LLMChats3 master as CCAI baseline
Browse filesCo-authored-by: Cursor <cursoragent@cursor.com>
- .env.example +25 -0
- .gitattributes +1 -0
- .gitignore +11 -0
- Dockerfile +27 -0
- README.md +58 -0
- backend/app/__init__.py +0 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/chat.py +227 -0
- backend/app/api/models.py +41 -0
- backend/app/clients/__init__.py +0 -0
- backend/app/clients/hana_client.py +447 -0
- backend/app/clients/llm_router.py +225 -0
- backend/app/clients/openai_compat.py +150 -0
- backend/app/config.py +247 -0
- backend/app/main.py +116 -0
- backend/app/middleware/__init__.py +0 -0
- backend/app/middleware/rate_limit.py +94 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/orchestrator.py +391 -0
- backend/app/services/persona.py +143 -0
- backend/requirements.txt +8 -0
- docker-compose.yml +10 -0
- frontend/.gitignore +23 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +34 -0
- frontend/public/favicon.ico +0 -0
- frontend/public/index.html +14 -0
- frontend/public/manifest.json +25 -0
- frontend/public/neon-logo.png +3 -0
- frontend/public/robots.txt +2 -0
- frontend/src/App.js +381 -0
- frontend/src/components/AuthBadge.js +33 -0
- frontend/src/components/ChatArea.js +57 -0
- frontend/src/components/ChatControls.js +56 -0
- frontend/src/components/DevMenu.js +207 -0
- frontend/src/components/ExportBar.js +79 -0
- frontend/src/components/LLMSelector.js +159 -0
- frontend/src/components/MessageBubble.js +27 -0
- frontend/src/components/PersonaAccordion.js +184 -0
- frontend/src/index.js +10 -0
- frontend/src/styles/components.css +1336 -0
- frontend/src/styles/layout.css +176 -0
- frontend/src/styles/variables.css +108 -0
- frontend/src/utils/api.js +159 -0
.env.example
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HANA API (Neon BrainForge)
|
| 2 |
+
HANA_BASE_URL=https://hana.neonaialpha.com
|
| 3 |
+
HANA_USERNAME=guest
|
| 4 |
+
HANA_PASSWORD=password
|
| 5 |
+
# BrainForge/Security (4090-x1-3): OpenAI-compatible API key for direct vLLM (same as HF Space API_KEY in brainforge-webapp).
|
| 6 |
+
# NOT used for HANA /auth/login — see NeonGeckoCom/brainforge-webapp docker_overlay/config/config.yaml
|
| 7 |
+
HANA_KLATCHAT_PASSWORD=sk_klatchat2024
|
| 8 |
+
# Direct vLLM host (default matches demo 4090-x1-3)
|
| 9 |
+
# NEON_SECURITY_VLLM_BASE_URL=https://4090-x1-3.neonaiservices2.com/vllm0
|
| 10 |
+
|
| 11 |
+
# Neon vLLM direct access
|
| 12 |
+
VLLM_API_KEY=sk_n30nn30n
|
| 13 |
+
|
| 14 |
+
# Comparison providers
|
| 15 |
+
FIREWORKS_API_KEY=your-fireworks-api-key-here
|
| 16 |
+
TOGETHER_API_KEY=your-together-api-key-here
|
| 17 |
+
OPENAI_API_KEY=your-openai-api-key-here
|
| 18 |
+
GEMINI_API_KEY=your-gemini-api-key-here
|
| 19 |
+
|
| 20 |
+
# Orchestrator LLM (used to monitor conversation endings)
|
| 21 |
+
# Defaults to gpt-4o-mini via OpenAI if OPENAI_API_KEY is set
|
| 22 |
+
ORCHESTRATOR_MODEL=gpt-4o-mini
|
| 23 |
+
|
| 24 |
+
# App
|
| 25 |
+
CORS_ORIGINS=http://localhost:3000
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
node_modules/
|
| 5 |
+
frontend/build/
|
| 6 |
+
.DS_Store
|
| 7 |
+
*.egg-info/
|
| 8 |
+
dist/
|
| 9 |
+
.venv/
|
| 10 |
+
venv/
|
| 11 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS frontend-build
|
| 2 |
+
WORKDIR /app/frontend
|
| 3 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 4 |
+
RUN npm ci
|
| 5 |
+
COPY frontend/ ./
|
| 6 |
+
ENV REACT_APP_API_URL=
|
| 7 |
+
RUN npm run build
|
| 8 |
+
|
| 9 |
+
FROM python:3.12-slim
|
| 10 |
+
|
| 11 |
+
RUN useradd -m -u 1000 user
|
| 12 |
+
USER user
|
| 13 |
+
ENV HOME=/home/user \
|
| 14 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 15 |
+
PYTHONUNBUFFERED=1
|
| 16 |
+
|
| 17 |
+
WORKDIR $HOME/app
|
| 18 |
+
|
| 19 |
+
COPY --chown=user backend/requirements.txt ./
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
COPY --chown=user backend/ ./
|
| 23 |
+
COPY --chown=user --from=frontend-build /app/frontend/build ./static
|
| 24 |
+
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AI Conversations
|
| 3 |
+
emoji: 💬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
hf_oauth: true
|
| 9 |
+
hf_oauth_scopes:
|
| 10 |
+
- read-repos
|
| 11 |
+
pinned: false
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# AI Conversations (LLMChats3)
|
| 15 |
+
|
| 16 |
+
A web app that lets two LLMs have a natural conversation. Select two LLMs, configure their personas, and watch them chat — complete with an orchestrator that manages natural conversation endings.
|
| 17 |
+
|
| 18 |
+
## Quick Start (local development)
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
# 1. Clone and set up environment
|
| 22 |
+
cp .env.example .env
|
| 23 |
+
# Edit .env with your API keys
|
| 24 |
+
|
| 25 |
+
# 2. Backend
|
| 26 |
+
cd backend
|
| 27 |
+
pip install -r requirements.txt
|
| 28 |
+
uvicorn app.main:app --reload --port 8000
|
| 29 |
+
|
| 30 |
+
# 3. Frontend (in a separate terminal)
|
| 31 |
+
cd frontend
|
| 32 |
+
npm install
|
| 33 |
+
npm start
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Docker
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
cp .env.example .env
|
| 40 |
+
# Edit .env with your API keys
|
| 41 |
+
docker compose up --build
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## HuggingFace Spaces Deployment
|
| 45 |
+
|
| 46 |
+
This app is deployed as a Docker Space at [neongeckocom/AI_Conversations](https://huggingface.co/spaces/neongeckocom/AI_Conversations). API keys are stored as Space Secrets.
|
| 47 |
+
|
| 48 |
+
Rate limiting: 20 conversations/day per IP for anonymous users. Sign in with HuggingFace as a neongeckocom org member for unlimited access.
|
| 49 |
+
|
| 50 |
+
## Features
|
| 51 |
+
|
| 52 |
+
- Select any two LLMs from multiple providers (OpenAI, Gemini, Fireworks, Together, Neon)
|
| 53 |
+
- Configure rich personas with names, profiles, identity prompts, and writing samples
|
| 54 |
+
- Structured or freeform persona input modes with file upload support
|
| 55 |
+
- Watch LLMs converse naturally with an orchestrator managing conversation flow
|
| 56 |
+
- Automatic conversation ending detection with graceful wrap-up
|
| 57 |
+
- Export chats as .txt or .md, plus full API logs for developers
|
| 58 |
+
- HuggingFace OAuth integration with org-based rate limiting
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/api/__init__.py
ADDED
|
File without changes
|
backend/app/api/chat.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 8 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.services.persona import generate_role_prompt, generate_role_prompt_freeform
|
| 13 |
+
from app.services.orchestrator import (
|
| 14 |
+
Session, Persona, create_session, get_session, run_conversation,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
LOG = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Request models
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
class GenerateRoleRequest(BaseModel):
|
| 26 |
+
model_id: str
|
| 27 |
+
name: str = ""
|
| 28 |
+
profile: str = ""
|
| 29 |
+
identity: str = ""
|
| 30 |
+
samples: str = ""
|
| 31 |
+
role_style: str = "exact"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class GenerateRoleFreeformRequest(BaseModel):
|
| 35 |
+
model_id: str
|
| 36 |
+
name: str = ""
|
| 37 |
+
text: str = ""
|
| 38 |
+
role_style: str = "ai_completed"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class SetOrchestratorRequest(BaseModel):
|
| 42 |
+
model_id: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class SetSpeedPriorityRequest(BaseModel):
|
| 46 |
+
enabled: bool
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class StartChatRequest(BaseModel):
|
| 50 |
+
persona_a_model_id: str
|
| 51 |
+
persona_a_name: str
|
| 52 |
+
persona_a_role: str
|
| 53 |
+
|
| 54 |
+
persona_b_model_id: str
|
| 55 |
+
persona_b_name: str
|
| 56 |
+
persona_b_role: str
|
| 57 |
+
|
| 58 |
+
starter_text: str | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
# Endpoints
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
@router.get("/chat/orchestrator")
|
| 66 |
+
async def api_get_orchestrator():
|
| 67 |
+
return {"model_id": settings.orchestrator_model}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.put("/chat/orchestrator")
|
| 71 |
+
async def api_set_orchestrator(req: SetOrchestratorRequest):
|
| 72 |
+
settings.orchestrator_model = req.model_id
|
| 73 |
+
return {"model_id": settings.orchestrator_model}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/chat/speed-priority")
|
| 77 |
+
async def api_get_speed_priority():
|
| 78 |
+
return {"enabled": settings.speed_priority}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.put("/chat/speed-priority")
|
| 82 |
+
async def api_set_speed_priority(req: SetSpeedPriorityRequest):
|
| 83 |
+
settings.speed_priority = req.enabled
|
| 84 |
+
return {"enabled": settings.speed_priority}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.post("/chat/generate-role")
|
| 88 |
+
async def api_generate_role(req: GenerateRoleRequest):
|
| 89 |
+
result = await generate_role_prompt(
|
| 90 |
+
model_id=req.model_id,
|
| 91 |
+
name=req.name,
|
| 92 |
+
profile=req.profile,
|
| 93 |
+
identity=req.identity,
|
| 94 |
+
samples=req.samples,
|
| 95 |
+
role_style=req.role_style,
|
| 96 |
+
)
|
| 97 |
+
if result.get("error"):
|
| 98 |
+
raise HTTPException(status_code=400, detail=result["error"])
|
| 99 |
+
return result
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post("/chat/generate-role-freeform")
|
| 103 |
+
async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
|
| 104 |
+
result = await generate_role_prompt_freeform(
|
| 105 |
+
model_id=req.model_id,
|
| 106 |
+
name=req.name,
|
| 107 |
+
text=req.text,
|
| 108 |
+
role_style=req.role_style,
|
| 109 |
+
)
|
| 110 |
+
if result.get("error"):
|
| 111 |
+
raise HTTPException(status_code=400, detail=result["error"])
|
| 112 |
+
return result
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/chat/start")
|
| 116 |
+
async def api_start_chat(req: StartChatRequest, request: Request):
|
| 117 |
+
"""Create a session and return a streaming SSE response for the conversation."""
|
| 118 |
+
from app.middleware.rate_limit import check_rate_limit, record_conversation
|
| 119 |
+
|
| 120 |
+
allowed, remaining = check_rate_limit(request)
|
| 121 |
+
if not allowed:
|
| 122 |
+
return JSONResponse(
|
| 123 |
+
status_code=429,
|
| 124 |
+
content={
|
| 125 |
+
"detail": "Daily conversation limit reached (20/day). Sign in with HuggingFace as a neongeckocom org member for unlimited access.",
|
| 126 |
+
"remaining": 0,
|
| 127 |
+
},
|
| 128 |
+
)
|
| 129 |
+
record_conversation(request)
|
| 130 |
+
|
| 131 |
+
ra = settings.resolve_model(req.persona_a_model_id)
|
| 132 |
+
rb = settings.resolve_model(req.persona_b_model_id)
|
| 133 |
+
|
| 134 |
+
if not ra:
|
| 135 |
+
raise HTTPException(400, f"Unknown model: {req.persona_a_model_id}")
|
| 136 |
+
if not rb:
|
| 137 |
+
raise HTTPException(400, f"Unknown model: {req.persona_b_model_id}")
|
| 138 |
+
|
| 139 |
+
session = create_session()
|
| 140 |
+
session.persona_a = Persona(
|
| 141 |
+
name=req.persona_a_name or "Persona A",
|
| 142 |
+
model_id=ra["model_id"],
|
| 143 |
+
role_prompt=req.persona_a_role,
|
| 144 |
+
base_url=ra.get("base_url", ""),
|
| 145 |
+
api_key=ra.get("api_key", ""),
|
| 146 |
+
display_name=ra["display_name"],
|
| 147 |
+
is_neon=ra.get("is_neon", False),
|
| 148 |
+
hana_model_id=ra.get("hana_model_id", ""),
|
| 149 |
+
persona_name=ra.get("persona_name", ""),
|
| 150 |
+
neon_direct_vllm=ra.get("neon_direct_vllm", False),
|
| 151 |
+
vllm_base_url=ra.get("vllm_base_url", ""),
|
| 152 |
+
vllm_api_key=ra.get("vllm_api_key", ""),
|
| 153 |
+
)
|
| 154 |
+
session.persona_b = Persona(
|
| 155 |
+
name=req.persona_b_name or "Persona B",
|
| 156 |
+
model_id=rb["model_id"],
|
| 157 |
+
role_prompt=req.persona_b_role,
|
| 158 |
+
base_url=rb.get("base_url", ""),
|
| 159 |
+
api_key=rb.get("api_key", ""),
|
| 160 |
+
display_name=rb["display_name"],
|
| 161 |
+
is_neon=rb.get("is_neon", False),
|
| 162 |
+
hana_model_id=rb.get("hana_model_id", ""),
|
| 163 |
+
persona_name=rb.get("persona_name", ""),
|
| 164 |
+
neon_direct_vllm=rb.get("neon_direct_vllm", False),
|
| 165 |
+
vllm_base_url=rb.get("vllm_base_url", ""),
|
| 166 |
+
vllm_api_key=rb.get("vllm_api_key", ""),
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
async def event_stream():
|
| 170 |
+
yield f"event: session\ndata: {json.dumps({'session_id': session.session_id})}\n\n"
|
| 171 |
+
async for chunk in run_conversation(session, req.starter_text):
|
| 172 |
+
yield chunk
|
| 173 |
+
|
| 174 |
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.get("/chat/{session_id}/export")
|
| 178 |
+
async def api_export_chat(session_id: str, fmt: str = "txt"):
|
| 179 |
+
session = get_session(session_id)
|
| 180 |
+
if not session:
|
| 181 |
+
raise HTTPException(404, "Session not found")
|
| 182 |
+
|
| 183 |
+
if fmt == "md":
|
| 184 |
+
return _export_md(session)
|
| 185 |
+
return _export_txt(session)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@router.get("/chat/{session_id}/api-log")
|
| 189 |
+
async def api_export_log(session_id: str):
|
| 190 |
+
session = get_session(session_id)
|
| 191 |
+
if not session:
|
| 192 |
+
raise HTTPException(404, "Session not found")
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"session_id": session_id,
|
| 196 |
+
"log": session.api_log,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
# Export helpers
|
| 202 |
+
# ---------------------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
def _export_txt(session: Session) -> dict:
|
| 205 |
+
lines = [f"LLMChats3 Conversation Log", "=" * 40, ""]
|
| 206 |
+
if session.persona_a:
|
| 207 |
+
lines.append(f"Participant 1: {session.persona_a.name} ({session.persona_a.display_name})")
|
| 208 |
+
if session.persona_b:
|
| 209 |
+
lines.append(f"Participant 2: {session.persona_b.name} ({session.persona_b.display_name})")
|
| 210 |
+
lines.append("")
|
| 211 |
+
for m in session.messages:
|
| 212 |
+
lines.append(f"{m['speaker']}: {m['text']}")
|
| 213 |
+
lines.append("")
|
| 214 |
+
return {"filename": "chat_export.txt", "content": "\n".join(lines)}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _export_md(session: Session) -> dict:
|
| 218 |
+
lines = ["# LLMChats3 Conversation Log", ""]
|
| 219 |
+
if session.persona_a:
|
| 220 |
+
lines.append(f"**Participant 1:** {session.persona_a.name} (*{session.persona_a.display_name}*)")
|
| 221 |
+
if session.persona_b:
|
| 222 |
+
lines.append(f"**Participant 2:** {session.persona_b.name} (*{session.persona_b.display_name}*)")
|
| 223 |
+
lines.append("\n---\n")
|
| 224 |
+
for m in session.messages:
|
| 225 |
+
lines.append(f"**{m['speaker']}:** {m['text']}")
|
| 226 |
+
lines.append("")
|
| 227 |
+
return {"filename": "chat_export.md", "content": "\n".join(lines)}
|
backend/app/api/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
|
| 8 |
+
from app.clients.hana_client import hana_client
|
| 9 |
+
from app.config import settings
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
LOG = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/models")
|
| 16 |
+
async def get_models():
|
| 17 |
+
"""Return all available LLMs: Neon models from HANA + comparison providers."""
|
| 18 |
+
neon_models = []
|
| 19 |
+
try:
|
| 20 |
+
neon_models = await hana_client.get_models()
|
| 21 |
+
except Exception as exc:
|
| 22 |
+
LOG.warning("HANA models unavailable: %s", exc)
|
| 23 |
+
|
| 24 |
+
providers = []
|
| 25 |
+
for p in settings.providers:
|
| 26 |
+
providers.append({
|
| 27 |
+
"id": p["id"],
|
| 28 |
+
"name": p["name"],
|
| 29 |
+
"models": [
|
| 30 |
+
{"id": m["id"], "name": m["name"], "params": m.get("params", "")}
|
| 31 |
+
for m in p["models"]
|
| 32 |
+
],
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
return JSONResponse(
|
| 36 |
+
content={
|
| 37 |
+
"neon_models": neon_models,
|
| 38 |
+
"providers": providers,
|
| 39 |
+
},
|
| 40 |
+
headers={"Cache-Control": "no-store"},
|
| 41 |
+
)
|
backend/app/clients/__init__.py
ADDED
|
File without changes
|
backend/app/clients/hana_client.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
|
| 12 |
+
LOG = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class HanaClient:
|
| 16 |
+
"""Handles HANA API authentication, model/persona discovery, and inference."""
|
| 17 |
+
|
| 18 |
+
def __init__(self) -> None:
|
| 19 |
+
self._base = settings.hana_base_url.rstrip("/")
|
| 20 |
+
self._access_token: str = ""
|
| 21 |
+
self._refresh_token: str = ""
|
| 22 |
+
self._token_expiry: float = 0
|
| 23 |
+
self._klatchat_access_token: str = ""
|
| 24 |
+
self._klatchat_refresh_token: str = ""
|
| 25 |
+
self._klatchat_token_expiry: float = 0
|
| 26 |
+
self._client = httpx.AsyncClient(timeout=30.0)
|
| 27 |
+
self._persona_cache: dict[str, str | None] = {}
|
| 28 |
+
|
| 29 |
+
def _uses_klatchat_model(self, model_id: str) -> bool:
|
| 30 |
+
"""Legacy HANA-JWT path for Security — disabled when using direct vLLM (brainforge-webapp pattern)."""
|
| 31 |
+
if settings._neon_security_direct_vllm_enabled(model_id):
|
| 32 |
+
return False
|
| 33 |
+
if not (settings.hana_password_klatchat or "").strip():
|
| 34 |
+
return False
|
| 35 |
+
mid = (model_id or "").lower()
|
| 36 |
+
return "brainforge/security" in mid or "/security@" in mid
|
| 37 |
+
|
| 38 |
+
async def authenticate(self) -> None:
|
| 39 |
+
resp = await self._client.post(
|
| 40 |
+
f"{self._base}/auth/login",
|
| 41 |
+
json={
|
| 42 |
+
"username": settings.hana_username,
|
| 43 |
+
"password": settings.hana_password,
|
| 44 |
+
"token_name": "LLMChats3",
|
| 45 |
+
"client_id": "llm-chat-tool",
|
| 46 |
+
},
|
| 47 |
+
)
|
| 48 |
+
resp.raise_for_status()
|
| 49 |
+
data = resp.json()
|
| 50 |
+
self._access_token = data["access_token"]
|
| 51 |
+
self._refresh_token = data["refresh_token"]
|
| 52 |
+
self._token_expiry = data.get("expiration", time.time() + 3600)
|
| 53 |
+
LOG.info("HANA auth success (user=%s)", data.get("username"))
|
| 54 |
+
|
| 55 |
+
async def authenticate_klatchat(self) -> None:
|
| 56 |
+
"""Login for BrainForge/Security (4090) using HANA_KLATCHAT_PASSWORD."""
|
| 57 |
+
uname = (settings.hana_username_klatchat or settings.hana_username).strip()
|
| 58 |
+
pwd = (settings.hana_password_klatchat or "").strip()
|
| 59 |
+
if not pwd:
|
| 60 |
+
raise ValueError("hana_password_klatchat not set")
|
| 61 |
+
resp = await self._client.post(
|
| 62 |
+
f"{self._base}/auth/login",
|
| 63 |
+
json={
|
| 64 |
+
"username": uname,
|
| 65 |
+
"password": pwd,
|
| 66 |
+
"token_name": "LLMChats3",
|
| 67 |
+
"client_id": "llm-chat-tool",
|
| 68 |
+
},
|
| 69 |
+
)
|
| 70 |
+
resp.raise_for_status()
|
| 71 |
+
data = resp.json()
|
| 72 |
+
self._klatchat_access_token = data["access_token"]
|
| 73 |
+
self._klatchat_refresh_token = data["refresh_token"]
|
| 74 |
+
self._klatchat_token_expiry = data.get("expiration", time.time() + 3600)
|
| 75 |
+
LOG.info("HANA klatchat auth success (user=%s)", data.get("username"))
|
| 76 |
+
|
| 77 |
+
async def _ensure_token(self) -> None:
|
| 78 |
+
if time.time() >= self._token_expiry - 60:
|
| 79 |
+
try:
|
| 80 |
+
resp = await self._client.post(
|
| 81 |
+
f"{self._base}/auth/refresh",
|
| 82 |
+
json={
|
| 83 |
+
"access_token": self._access_token,
|
| 84 |
+
"refresh_token": self._refresh_token,
|
| 85 |
+
},
|
| 86 |
+
)
|
| 87 |
+
resp.raise_for_status()
|
| 88 |
+
data = resp.json()
|
| 89 |
+
self._access_token = data["access_token"]
|
| 90 |
+
self._refresh_token = data["refresh_token"]
|
| 91 |
+
self._token_expiry = data.get("expiration", time.time() + 3600)
|
| 92 |
+
LOG.info("HANA token refreshed")
|
| 93 |
+
except Exception:
|
| 94 |
+
LOG.warning("Token refresh failed, re-authenticating")
|
| 95 |
+
await self.authenticate()
|
| 96 |
+
|
| 97 |
+
async def _ensure_klatchat_token(self) -> None:
|
| 98 |
+
if not (settings.hana_password_klatchat or "").strip():
|
| 99 |
+
await self._ensure_token()
|
| 100 |
+
return
|
| 101 |
+
if not self._klatchat_access_token or time.time() >= self._klatchat_token_expiry - 60:
|
| 102 |
+
try:
|
| 103 |
+
resp = await self._client.post(
|
| 104 |
+
f"{self._base}/auth/refresh",
|
| 105 |
+
json={
|
| 106 |
+
"access_token": self._klatchat_access_token,
|
| 107 |
+
"refresh_token": self._klatchat_refresh_token,
|
| 108 |
+
},
|
| 109 |
+
)
|
| 110 |
+
resp.raise_for_status()
|
| 111 |
+
data = resp.json()
|
| 112 |
+
self._klatchat_access_token = data["access_token"]
|
| 113 |
+
self._klatchat_refresh_token = data["refresh_token"]
|
| 114 |
+
self._klatchat_token_expiry = data.get("expiration", time.time() + 3600)
|
| 115 |
+
LOG.info("HANA klatchat token refreshed")
|
| 116 |
+
except Exception:
|
| 117 |
+
LOG.warning("Klatchat token refresh failed, re-authenticating")
|
| 118 |
+
await self.authenticate_klatchat()
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def _headers(self) -> dict[str, str]:
|
| 122 |
+
return {
|
| 123 |
+
"Authorization": f"Bearer {self._access_token}",
|
| 124 |
+
"Content-Type": "application/json",
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
def _headers_klatchat(self) -> dict[str, str]:
|
| 128 |
+
return {
|
| 129 |
+
"Authorization": f"Bearer {self._klatchat_access_token}",
|
| 130 |
+
"Content-Type": "application/json",
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
def _headers_for_model(self, model_id: str) -> dict[str, str]:
|
| 134 |
+
if self._uses_klatchat_model(model_id) and (settings.hana_password_klatchat or "").strip():
|
| 135 |
+
return self._headers_klatchat()
|
| 136 |
+
return self._headers
|
| 137 |
+
|
| 138 |
+
async def _ensure_headers_for_model(self, model_id: str) -> dict[str, str]:
|
| 139 |
+
if self._uses_klatchat_model(model_id) and (settings.hana_password_klatchat or "").strip():
|
| 140 |
+
await self._ensure_klatchat_token()
|
| 141 |
+
return self._headers_klatchat()
|
| 142 |
+
await self._ensure_token()
|
| 143 |
+
return self._headers
|
| 144 |
+
|
| 145 |
+
def _parse_models_payload(self, data: dict[str, Any]) -> list[dict[str, Any]]:
|
| 146 |
+
models: list[dict[str, Any]] = []
|
| 147 |
+
for m in data.get("models", []):
|
| 148 |
+
model_id = f"{m['name']}@{m['version']}"
|
| 149 |
+
personas = []
|
| 150 |
+
for p in m.get("personas", []):
|
| 151 |
+
pname = p.get("persona_name", "")
|
| 152 |
+
sp = p.get("system_prompt") or ""
|
| 153 |
+
cache_key = f"{model_id}:{pname}"
|
| 154 |
+
self._persona_cache[cache_key] = sp if sp else None
|
| 155 |
+
personas.append({
|
| 156 |
+
"id": p.get("id", p.get("persona_name", "")),
|
| 157 |
+
"persona_name": pname,
|
| 158 |
+
"description": p.get("description"),
|
| 159 |
+
"system_prompt": p.get("system_prompt"),
|
| 160 |
+
"enabled": p.get("enabled", True),
|
| 161 |
+
})
|
| 162 |
+
models.append({
|
| 163 |
+
"name": m["name"],
|
| 164 |
+
"version": m["version"],
|
| 165 |
+
"model_id": model_id,
|
| 166 |
+
"personas": personas,
|
| 167 |
+
})
|
| 168 |
+
return models
|
| 169 |
+
|
| 170 |
+
async def _merge_direct_vllm_security_models(
|
| 171 |
+
self, models: list[dict[str, Any]], seen: set[str]
|
| 172 |
+
) -> None:
|
| 173 |
+
"""Merge models from OpenAI-compatible GET /v1/models (NeonGeckoCom/brainforge-webapp pattern)."""
|
| 174 |
+
base = (settings.neon_security_vllm_base_url or "").strip().rstrip("/")
|
| 175 |
+
key = (settings.hana_password_klatchat or "").strip()
|
| 176 |
+
if not base or not key:
|
| 177 |
+
return
|
| 178 |
+
url = f"{base}/v1/models"
|
| 179 |
+
try:
|
| 180 |
+
resp = await self._client.get(
|
| 181 |
+
url,
|
| 182 |
+
headers={"Authorization": f"Bearer {key}"},
|
| 183 |
+
)
|
| 184 |
+
if resp.status_code != 200:
|
| 185 |
+
LOG.warning("Neon direct vLLM %s -> %s", url, resp.status_code)
|
| 186 |
+
return
|
| 187 |
+
payload = resp.json()
|
| 188 |
+
added = 0
|
| 189 |
+
for o in payload.get("data", []) or []:
|
| 190 |
+
mid = (o.get("id") or "").strip()
|
| 191 |
+
if not mid or "@" not in mid:
|
| 192 |
+
continue
|
| 193 |
+
if mid in seen:
|
| 194 |
+
continue
|
| 195 |
+
name, version = mid.split("@", 1)
|
| 196 |
+
vanilla = {
|
| 197 |
+
"id": "vanilla",
|
| 198 |
+
"persona_name": "vanilla",
|
| 199 |
+
"description": None,
|
| 200 |
+
"system_prompt": None,
|
| 201 |
+
"enabled": True,
|
| 202 |
+
}
|
| 203 |
+
self._persona_cache[f"{mid}:vanilla"] = None
|
| 204 |
+
models.append({
|
| 205 |
+
"name": name,
|
| 206 |
+
"version": version,
|
| 207 |
+
"model_id": mid,
|
| 208 |
+
"personas": [vanilla],
|
| 209 |
+
})
|
| 210 |
+
seen.add(mid)
|
| 211 |
+
added += 1
|
| 212 |
+
if added:
|
| 213 |
+
LOG.info("Merged %s model(s) from direct vLLM %s", added, base)
|
| 214 |
+
# region agent log
|
| 215 |
+
try:
|
| 216 |
+
with open(r"c:\Users\dream\CCAI-Demo-FEAT_Config\debug-c86901.log", "a", encoding="utf-8") as _df:
|
| 217 |
+
_df.write(
|
| 218 |
+
json.dumps(
|
| 219 |
+
{
|
| 220 |
+
"sessionId": "c86901",
|
| 221 |
+
"hypothesisId": "H5",
|
| 222 |
+
"location": "LLMChats3/hana_client._merge_direct_vllm",
|
| 223 |
+
"message": "direct_vllm_models",
|
| 224 |
+
"data": {
|
| 225 |
+
"app": "LLMChats3",
|
| 226 |
+
"url": url,
|
| 227 |
+
"http_status": resp.status_code,
|
| 228 |
+
"added_count": added,
|
| 229 |
+
},
|
| 230 |
+
"timestamp": int(time.time() * 1000),
|
| 231 |
+
}
|
| 232 |
+
)
|
| 233 |
+
+ "\n"
|
| 234 |
+
)
|
| 235 |
+
except Exception:
|
| 236 |
+
pass
|
| 237 |
+
# endregion
|
| 238 |
+
except Exception as exc:
|
| 239 |
+
LOG.warning("Neon direct vLLM model merge failed: %s", exc)
|
| 240 |
+
|
| 241 |
+
async def _enrich_security_personas_from_hana(self, models: list[dict[str, Any]]) -> None:
|
| 242 |
+
"""Replace Security personas with HANA get_personas when the server allows (persona prompts live on HANA)."""
|
| 243 |
+
for m in models:
|
| 244 |
+
mid = m.get("model_id") or ""
|
| 245 |
+
if "security" not in mid.lower():
|
| 246 |
+
continue
|
| 247 |
+
try:
|
| 248 |
+
plist = await self.get_personas(mid)
|
| 249 |
+
if plist:
|
| 250 |
+
m["personas"] = plist
|
| 251 |
+
for p in plist:
|
| 252 |
+
pname = p.get("persona_name", "")
|
| 253 |
+
sp = p.get("system_prompt") or ""
|
| 254 |
+
self._persona_cache[f"{mid}:{pname}"] = sp if sp else None
|
| 255 |
+
LOG.info("Enriched Security personas from HANA get_personas for %s", mid)
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
LOG.debug("HANA get_personas enrichment skipped for %s: %s", mid, exc)
|
| 258 |
+
|
| 259 |
+
async def _ensure_security_model_stub_if_missing(self, models: list[dict[str, Any]]) -> None:
|
| 260 |
+
"""If vLLM merge failed (401) and HANA supplement failed, still list Security so the UI can show it."""
|
| 261 |
+
sid = "BrainForge/Security@2026.03.18"
|
| 262 |
+
wanted = [x.strip() for x in (settings.hana_neon_model_supplement_ids or "").split(",") if x.strip()]
|
| 263 |
+
if not any((w == sid or ("security" in w.lower() and "@" in w)) for w in wanted):
|
| 264 |
+
return
|
| 265 |
+
if any((m.get("model_id") == sid) for m in models):
|
| 266 |
+
return
|
| 267 |
+
try:
|
| 268 |
+
plist = await self.get_personas(sid)
|
| 269 |
+
name, ver = sid.split("@", 1)
|
| 270 |
+
for p in plist or []:
|
| 271 |
+
pname = p.get("persona_name", "")
|
| 272 |
+
sp = p.get("system_prompt") or ""
|
| 273 |
+
self._persona_cache[f"{sid}:{pname}"] = sp if sp else None
|
| 274 |
+
models.append({
|
| 275 |
+
"name": name,
|
| 276 |
+
"version": ver,
|
| 277 |
+
"model_id": sid,
|
| 278 |
+
"personas": plist or [],
|
| 279 |
+
})
|
| 280 |
+
LOG.info("Security model added via HANA get_personas (stub path)")
|
| 281 |
+
except Exception as exc:
|
| 282 |
+
LOG.warning(
|
| 283 |
+
"Security model not in HANA/vLLM responses (%s); adding minimal stub entry.",
|
| 284 |
+
exc,
|
| 285 |
+
)
|
| 286 |
+
vanilla = {
|
| 287 |
+
"id": "vanilla",
|
| 288 |
+
"persona_name": "vanilla",
|
| 289 |
+
"description": "Set HANA_KLATCHAT_PASSWORD to the 4090-x1-3 OpenAI API key (same as HF Space) so vLLM discovery works; persona prompts come from HANA when allowed.",
|
| 290 |
+
"system_prompt": None,
|
| 291 |
+
"enabled": True,
|
| 292 |
+
}
|
| 293 |
+
self._persona_cache[f"{sid}:vanilla"] = None
|
| 294 |
+
models.append({
|
| 295 |
+
"name": "BrainForge/Security",
|
| 296 |
+
"version": "2026.03.18",
|
| 297 |
+
"model_id": sid,
|
| 298 |
+
"personas": [vanilla],
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
async def get_models(self) -> list[dict[str, Any]]:
|
| 302 |
+
await self._ensure_token()
|
| 303 |
+
resp = await self._client.post(
|
| 304 |
+
f"{self._base}/brainforge/get_models",
|
| 305 |
+
headers=self._headers,
|
| 306 |
+
json={},
|
| 307 |
+
)
|
| 308 |
+
resp.raise_for_status()
|
| 309 |
+
models = self._parse_models_payload(resp.json())
|
| 310 |
+
seen = {m["model_id"] for m in models}
|
| 311 |
+
|
| 312 |
+
if (settings.hana_password_klatchat or "").strip() and (settings.neon_security_vllm_base_url or "").strip():
|
| 313 |
+
await self._merge_direct_vllm_security_models(models, seen)
|
| 314 |
+
|
| 315 |
+
await self._append_supplement_models(models)
|
| 316 |
+
await self._enrich_security_personas_from_hana(models)
|
| 317 |
+
await self._ensure_security_model_stub_if_missing(models)
|
| 318 |
+
# region agent log
|
| 319 |
+
try:
|
| 320 |
+
mids = [m.get("model_id", "") for m in models]
|
| 321 |
+
with open(r"c:\Users\dream\CCAI-Demo-FEAT_Config\debug-c86901.log", "a", encoding="utf-8") as _df:
|
| 322 |
+
_df.write(
|
| 323 |
+
json.dumps(
|
| 324 |
+
{
|
| 325 |
+
"sessionId": "c86901",
|
| 326 |
+
"hypothesisId": "H3",
|
| 327 |
+
"location": "LLMChats3/hana_client.get_models",
|
| 328 |
+
"message": "merged_models",
|
| 329 |
+
"data": {
|
| 330 |
+
"app": "LLMChats3",
|
| 331 |
+
"model_count": len(models),
|
| 332 |
+
"has_security_in_list": any("security" in (x or "").lower() for x in mids),
|
| 333 |
+
"model_ids_tail": mids[-8:],
|
| 334 |
+
},
|
| 335 |
+
"timestamp": int(time.time() * 1000),
|
| 336 |
+
}
|
| 337 |
+
)
|
| 338 |
+
+ "\n"
|
| 339 |
+
)
|
| 340 |
+
except Exception:
|
| 341 |
+
pass
|
| 342 |
+
# endregion
|
| 343 |
+
return models
|
| 344 |
+
|
| 345 |
+
async def get_personas(self, model_id: str) -> list[dict[str, Any]]:
|
| 346 |
+
"""Fetch personas for a model_id (used when get_models omits a model)."""
|
| 347 |
+
hdrs = await self._ensure_headers_for_model(model_id)
|
| 348 |
+
resp = await self._client.post(
|
| 349 |
+
f"{self._base}/brainforge/get_personas",
|
| 350 |
+
headers=hdrs,
|
| 351 |
+
json={"model_id": model_id},
|
| 352 |
+
)
|
| 353 |
+
resp.raise_for_status()
|
| 354 |
+
data = resp.json()
|
| 355 |
+
out = []
|
| 356 |
+
for p in data.get("personas", []):
|
| 357 |
+
pname = p.get("persona_name", "")
|
| 358 |
+
sp = p.get("system_prompt") or ""
|
| 359 |
+
cache_key = f"{model_id}:{pname}"
|
| 360 |
+
self._persona_cache[cache_key] = sp if sp else None
|
| 361 |
+
out.append({
|
| 362 |
+
"id": p.get("id", p.get("persona_name", "")),
|
| 363 |
+
"persona_name": pname,
|
| 364 |
+
"description": p.get("description"),
|
| 365 |
+
"system_prompt": p.get("system_prompt"),
|
| 366 |
+
"enabled": p.get("enabled", True),
|
| 367 |
+
})
|
| 368 |
+
return out
|
| 369 |
+
|
| 370 |
+
async def _append_supplement_models(self, models: list[dict[str, Any]]) -> None:
|
| 371 |
+
"""Merge models listed in settings but missing from get_models (HANA may omit some)."""
|
| 372 |
+
raw = (settings.hana_neon_model_supplement_ids or "").strip()
|
| 373 |
+
extras = [x.strip() for x in raw.split(",") if x.strip()]
|
| 374 |
+
seen = {m["model_id"] for m in models}
|
| 375 |
+
for mid in extras:
|
| 376 |
+
if mid in seen:
|
| 377 |
+
continue
|
| 378 |
+
if "@" not in mid:
|
| 379 |
+
LOG.warning("Invalid supplement model_id (expected name@version): %s", mid)
|
| 380 |
+
continue
|
| 381 |
+
try:
|
| 382 |
+
personas = await self.get_personas(mid)
|
| 383 |
+
name, version = mid.split("@", 1)
|
| 384 |
+
models.append({
|
| 385 |
+
"name": name,
|
| 386 |
+
"version": version,
|
| 387 |
+
"model_id": mid,
|
| 388 |
+
"personas": personas,
|
| 389 |
+
})
|
| 390 |
+
seen.add(mid)
|
| 391 |
+
LOG.info("Merged supplement Neon model from get_personas: %s", mid)
|
| 392 |
+
except Exception as exc:
|
| 393 |
+
LOG.debug("Supplement Neon model %s not merged: %s", mid, exc)
|
| 394 |
+
|
| 395 |
+
def get_persona_system_prompt(self, model_id: str, persona_name: str) -> str | None:
|
| 396 |
+
"""Look up a persona's built-in system_prompt from the cache."""
|
| 397 |
+
return self._persona_cache.get(f"{model_id}:{persona_name}")
|
| 398 |
+
|
| 399 |
+
async def get_inference(
|
| 400 |
+
self,
|
| 401 |
+
query: str,
|
| 402 |
+
model_id: str,
|
| 403 |
+
persona_name: str,
|
| 404 |
+
system_prompt: str | None = None,
|
| 405 |
+
history: list[tuple[str, str]] | None = None,
|
| 406 |
+
temperature: float = 0.7,
|
| 407 |
+
max_tokens: int = 1024,
|
| 408 |
+
) -> dict[str, Any]:
|
| 409 |
+
hdrs = await self._ensure_headers_for_model(model_id)
|
| 410 |
+
hist = [[role, content] for role, content in (history or [])]
|
| 411 |
+
|
| 412 |
+
persona_payload: dict[str, Any] = {"persona_name": persona_name}
|
| 413 |
+
if system_prompt:
|
| 414 |
+
persona_payload["system_prompt"] = system_prompt
|
| 415 |
+
|
| 416 |
+
body = {
|
| 417 |
+
"query": query,
|
| 418 |
+
"history": hist,
|
| 419 |
+
"persona": persona_payload,
|
| 420 |
+
"model": model_id,
|
| 421 |
+
"max_tokens": max_tokens,
|
| 422 |
+
"temperature": temperature,
|
| 423 |
+
"extra_body": {},
|
| 424 |
+
"llm_name": model_id.split("@")[0] if "@" in model_id else model_id,
|
| 425 |
+
"llm_revision": model_id.split("@")[1] if "@" in model_id else "",
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
t0 = time.time()
|
| 429 |
+
resp = await self._client.post(
|
| 430 |
+
f"{self._base}/brainforge/get_inference",
|
| 431 |
+
headers=hdrs,
|
| 432 |
+
json=body,
|
| 433 |
+
)
|
| 434 |
+
elapsed = time.time() - t0
|
| 435 |
+
resp.raise_for_status()
|
| 436 |
+
data = resp.json()
|
| 437 |
+
return {
|
| 438 |
+
"response": data.get("response", ""),
|
| 439 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 440 |
+
"finish_reason": data.get("finish_reason", "stop"),
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
async def close(self) -> None:
|
| 444 |
+
await self._client.aclose()
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
hana_client = HanaClient()
|
backend/app/clients/llm_router.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from app.clients.openai_compat import openai_chat_completion
|
| 8 |
+
from app.clients.hana_client import hana_client
|
| 9 |
+
|
| 10 |
+
LOG = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
RACE_DELAY_SECONDS = 5.0
|
| 13 |
+
|
| 14 |
+
_FALLBACK_CHAIN = [
|
| 15 |
+
"gemini-2.0-flash",
|
| 16 |
+
"gpt-4.1-mini",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _pick_fallback(exclude_model_id: str) -> dict | None:
|
| 21 |
+
"""Return the first usable fallback model that isn't the one we're already calling."""
|
| 22 |
+
from app.config import settings
|
| 23 |
+
|
| 24 |
+
for candidate_id in _FALLBACK_CHAIN:
|
| 25 |
+
if candidate_id == exclude_model_id:
|
| 26 |
+
continue
|
| 27 |
+
resolved = settings.resolve_model(candidate_id)
|
| 28 |
+
if resolved and not resolved.get("is_neon"):
|
| 29 |
+
return resolved
|
| 30 |
+
|
| 31 |
+
for prov in settings.providers:
|
| 32 |
+
for m in prov["models"]:
|
| 33 |
+
if m["id"] == exclude_model_id:
|
| 34 |
+
continue
|
| 35 |
+
resolved = settings.resolve_model(m["id"])
|
| 36 |
+
if resolved and not resolved.get("is_neon"):
|
| 37 |
+
return resolved
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def chat_completion(
|
| 42 |
+
resolved: dict,
|
| 43 |
+
messages: list[dict[str, str]],
|
| 44 |
+
temperature: float = 0.7,
|
| 45 |
+
max_tokens: int = 1024,
|
| 46 |
+
timeout: float | None = None,
|
| 47 |
+
) -> dict[str, Any]:
|
| 48 |
+
"""Unified LLM call that routes Neon models through HANA and others through OpenAI-compat."""
|
| 49 |
+
if resolved.get("is_neon"):
|
| 50 |
+
return await _call_hana(resolved, messages, temperature, max_tokens)
|
| 51 |
+
|
| 52 |
+
from app.config import settings
|
| 53 |
+
if settings.speed_priority:
|
| 54 |
+
return await _racing_openai(resolved, messages, temperature, max_tokens, timeout)
|
| 55 |
+
|
| 56 |
+
return await _plain_openai(resolved, messages, temperature, max_tokens, timeout)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def _plain_openai(
|
| 60 |
+
resolved: dict,
|
| 61 |
+
messages: list[dict[str, str]],
|
| 62 |
+
temperature: float,
|
| 63 |
+
max_tokens: int,
|
| 64 |
+
timeout: float | None,
|
| 65 |
+
) -> dict[str, Any]:
|
| 66 |
+
return await openai_chat_completion(
|
| 67 |
+
base_url=resolved["base_url"],
|
| 68 |
+
api_key=resolved["api_key"],
|
| 69 |
+
model=resolved["model_id"],
|
| 70 |
+
messages=messages,
|
| 71 |
+
temperature=temperature,
|
| 72 |
+
max_tokens=max_tokens,
|
| 73 |
+
timeout=timeout,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def _racing_openai(
|
| 78 |
+
resolved: dict,
|
| 79 |
+
messages: list[dict[str, str]],
|
| 80 |
+
temperature: float,
|
| 81 |
+
max_tokens: int,
|
| 82 |
+
timeout: float | None,
|
| 83 |
+
) -> dict[str, Any]:
|
| 84 |
+
"""Start the primary request; after RACE_DELAY_SECONDS fire a fallback and race them."""
|
| 85 |
+
primary_task = asyncio.create_task(
|
| 86 |
+
_plain_openai(resolved, messages, temperature, max_tokens, timeout),
|
| 87 |
+
name=f"primary:{resolved['model_id']}",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
done, _ = await asyncio.wait({primary_task}, timeout=RACE_DELAY_SECONDS)
|
| 91 |
+
if done:
|
| 92 |
+
return primary_task.result()
|
| 93 |
+
|
| 94 |
+
fallback_resolved = _pick_fallback(resolved["model_id"])
|
| 95 |
+
if not fallback_resolved:
|
| 96 |
+
LOG.info("Speed-priority: no fallback available, waiting for primary %s", resolved["model_id"])
|
| 97 |
+
return await primary_task
|
| 98 |
+
|
| 99 |
+
LOG.info(
|
| 100 |
+
"Speed-priority: %s still pending after %.1fs — racing with fallback %s",
|
| 101 |
+
resolved["model_id"], RACE_DELAY_SECONDS, fallback_resolved["model_id"],
|
| 102 |
+
)
|
| 103 |
+
fallback_task = asyncio.create_task(
|
| 104 |
+
_plain_openai(fallback_resolved, messages, temperature, max_tokens, timeout),
|
| 105 |
+
name=f"fallback:{fallback_resolved['model_id']}",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
done, pending = await asyncio.wait(
|
| 109 |
+
{primary_task, fallback_task}, return_when=asyncio.FIRST_COMPLETED,
|
| 110 |
+
)
|
| 111 |
+
winner = done.pop()
|
| 112 |
+
result = winner.result()
|
| 113 |
+
|
| 114 |
+
if result.get("error"):
|
| 115 |
+
if pending:
|
| 116 |
+
other = pending.pop()
|
| 117 |
+
try:
|
| 118 |
+
other_result = await other
|
| 119 |
+
if not other_result.get("error"):
|
| 120 |
+
LOG.info("Speed-priority: winner had error, using other result")
|
| 121 |
+
return other_result
|
| 122 |
+
except Exception:
|
| 123 |
+
pass
|
| 124 |
+
return result
|
| 125 |
+
|
| 126 |
+
for task in pending:
|
| 127 |
+
task.cancel()
|
| 128 |
+
|
| 129 |
+
used_model = result.get("model", "")
|
| 130 |
+
if winner is fallback_task:
|
| 131 |
+
LOG.info("Speed-priority: fallback %s won the race", used_model)
|
| 132 |
+
result["used_fallback"] = True
|
| 133 |
+
result["original_model"] = resolved["model_id"]
|
| 134 |
+
else:
|
| 135 |
+
LOG.info("Speed-priority: primary %s won the race", used_model)
|
| 136 |
+
|
| 137 |
+
return result
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def _call_neon_direct_vllm(
|
| 141 |
+
resolved: dict,
|
| 142 |
+
messages: list[dict[str, str]],
|
| 143 |
+
temperature: float,
|
| 144 |
+
max_tokens: int,
|
| 145 |
+
) -> dict[str, Any]:
|
| 146 |
+
"""BrainForge/Security on 4090-x1-3: OpenAI-compatible vLLM; still use HANA persona base when cached."""
|
| 147 |
+
builtin_sp = hana_client.get_persona_system_prompt(
|
| 148 |
+
resolved["hana_model_id"], resolved["persona_name"]
|
| 149 |
+
)
|
| 150 |
+
msgs = [dict(m) for m in messages]
|
| 151 |
+
if builtin_sp:
|
| 152 |
+
if msgs and msgs[0].get("role") == "system":
|
| 153 |
+
msgs[0] = {
|
| 154 |
+
"role": "system",
|
| 155 |
+
"content": msgs[0]["content"] + "\n\n[Neon persona base from HANA]\n" + builtin_sp,
|
| 156 |
+
}
|
| 157 |
+
else:
|
| 158 |
+
msgs.insert(0, {"role": "system", "content": "[Neon persona base from HANA]\n" + builtin_sp})
|
| 159 |
+
|
| 160 |
+
result = await openai_chat_completion(
|
| 161 |
+
base_url=resolved["vllm_base_url"],
|
| 162 |
+
api_key=resolved["vllm_api_key"],
|
| 163 |
+
model=resolved["hana_model_id"],
|
| 164 |
+
messages=msgs,
|
| 165 |
+
temperature=temperature,
|
| 166 |
+
max_tokens=max_tokens,
|
| 167 |
+
)
|
| 168 |
+
return {
|
| 169 |
+
"response": result.get("response", ""),
|
| 170 |
+
"elapsed_seconds": result.get("elapsed_seconds", 0),
|
| 171 |
+
"model": resolved["model_id"],
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
async def _call_hana(
|
| 176 |
+
resolved: dict,
|
| 177 |
+
messages: list[dict[str, str]],
|
| 178 |
+
temperature: float,
|
| 179 |
+
max_tokens: int,
|
| 180 |
+
) -> dict[str, Any]:
|
| 181 |
+
if resolved.get("neon_direct_vllm"):
|
| 182 |
+
return await _call_neon_direct_vllm(resolved, messages, temperature, max_tokens)
|
| 183 |
+
|
| 184 |
+
system_context = ""
|
| 185 |
+
query = ""
|
| 186 |
+
history: list[tuple[str, str]] = []
|
| 187 |
+
|
| 188 |
+
for msg in messages:
|
| 189 |
+
if msg["role"] == "system":
|
| 190 |
+
system_context = msg["content"]
|
| 191 |
+
elif msg["role"] == "user":
|
| 192 |
+
query = msg["content"]
|
| 193 |
+
elif msg["role"] == "assistant":
|
| 194 |
+
history.append(("assistant", msg["content"]))
|
| 195 |
+
|
| 196 |
+
if system_context:
|
| 197 |
+
query = f"[Context: {system_context}]\n\n{query}"
|
| 198 |
+
|
| 199 |
+
builtin_sp = hana_client.get_persona_system_prompt(
|
| 200 |
+
resolved["hana_model_id"], resolved["persona_name"]
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
result = await hana_client.get_inference(
|
| 205 |
+
query=query,
|
| 206 |
+
model_id=resolved["hana_model_id"],
|
| 207 |
+
persona_name=resolved["persona_name"],
|
| 208 |
+
system_prompt=builtin_sp,
|
| 209 |
+
history=history if history else None,
|
| 210 |
+
temperature=temperature,
|
| 211 |
+
max_tokens=max_tokens,
|
| 212 |
+
)
|
| 213 |
+
return {
|
| 214 |
+
"response": result.get("response", ""),
|
| 215 |
+
"elapsed_seconds": result.get("elapsed_seconds", 0),
|
| 216 |
+
"model": resolved["model_id"],
|
| 217 |
+
}
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
LOG.exception("HANA inference failed for %s: %s", resolved["model_id"], exc)
|
| 220 |
+
return {
|
| 221 |
+
"response": f"[Error]: {exc}",
|
| 222 |
+
"elapsed_seconds": 0,
|
| 223 |
+
"model": resolved["model_id"],
|
| 224 |
+
"error": True,
|
| 225 |
+
}
|
backend/app/clients/openai_compat.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
LOG = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
_shared_client: httpx.AsyncClient | None = None
|
| 14 |
+
|
| 15 |
+
_THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
| 16 |
+
_REASONING_BLOCK_RE = re.compile(
|
| 17 |
+
r"<(reasoning|reflection|inner_thoughts|scratchpad)>.*?</\1>",
|
| 18 |
+
re.DOTALL,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
_MAX_COMPLETION_TOKEN_MODELS = {
|
| 22 |
+
"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini",
|
| 23 |
+
"gpt-5", "gpt-oss",
|
| 24 |
+
}
|
| 25 |
+
_NO_TEMPERATURE_MODELS = {"o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _get_client() -> httpx.AsyncClient:
|
| 29 |
+
global _shared_client
|
| 30 |
+
if _shared_client is None or _shared_client.is_closed:
|
| 31 |
+
_shared_client = httpx.AsyncClient(timeout=45.0)
|
| 32 |
+
return _shared_client
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _strip_thinking(text: str) -> str:
|
| 36 |
+
"""Remove chain-of-thought artifacts from any model's response."""
|
| 37 |
+
text = _THINK_TAG_RE.sub("", text)
|
| 38 |
+
text = _REASONING_BLOCK_RE.sub("", text)
|
| 39 |
+
return text.strip()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _detect_thinking_model(model: str, msg: dict) -> bool:
|
| 43 |
+
"""Detect thinking models from the response itself, not just the model name."""
|
| 44 |
+
if msg.get("reasoning_content") or msg.get("reasoning"):
|
| 45 |
+
return True
|
| 46 |
+
content = msg.get("content") or ""
|
| 47 |
+
if _THINK_TAG_RE.search(content):
|
| 48 |
+
return True
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def openai_chat_completion(
|
| 53 |
+
base_url: str,
|
| 54 |
+
api_key: str,
|
| 55 |
+
model: str,
|
| 56 |
+
messages: list[dict[str, str]],
|
| 57 |
+
temperature: float = 0.7,
|
| 58 |
+
max_tokens: int = 1024,
|
| 59 |
+
timeout: float | None = None,
|
| 60 |
+
) -> dict[str, Any]:
|
| 61 |
+
"""Send a chat completion request to any OpenAI-compatible endpoint."""
|
| 62 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 63 |
+
headers = {
|
| 64 |
+
"Authorization": f"Bearer {api_key}",
|
| 65 |
+
"Content-Type": "application/json",
|
| 66 |
+
}
|
| 67 |
+
needs_mct = any(model.startswith(prefix) for prefix in _MAX_COMPLETION_TOKEN_MODELS)
|
| 68 |
+
skip_temp = any(model.startswith(prefix) for prefix in _NO_TEMPERATURE_MODELS)
|
| 69 |
+
|
| 70 |
+
effective_max = max_tokens * 4
|
| 71 |
+
effective_timeout = max(timeout * 2, 120) if timeout else timeout
|
| 72 |
+
|
| 73 |
+
body: dict[str, Any] = {
|
| 74 |
+
"model": model,
|
| 75 |
+
"messages": messages,
|
| 76 |
+
}
|
| 77 |
+
if needs_mct:
|
| 78 |
+
body["max_completion_tokens"] = effective_max
|
| 79 |
+
else:
|
| 80 |
+
body["max_tokens"] = effective_max
|
| 81 |
+
if not skip_temp:
|
| 82 |
+
body["temperature"] = temperature
|
| 83 |
+
|
| 84 |
+
req_timeout = httpx.Timeout(effective_timeout) if effective_timeout else None
|
| 85 |
+
client = _get_client()
|
| 86 |
+
t0 = time.time()
|
| 87 |
+
for attempt in range(2):
|
| 88 |
+
try:
|
| 89 |
+
resp = await client.post(url, json=body, headers=headers, timeout=req_timeout)
|
| 90 |
+
if resp.status_code >= 400 and attempt == 0:
|
| 91 |
+
LOG.warning("Error %d on %s (attempt 1), retrying in 1.1s", resp.status_code, model)
|
| 92 |
+
await asyncio.sleep(1.1)
|
| 93 |
+
continue
|
| 94 |
+
elapsed = time.time() - t0
|
| 95 |
+
resp.raise_for_status()
|
| 96 |
+
data = resp.json()
|
| 97 |
+
choices = data.get("choices", [])
|
| 98 |
+
text = ""
|
| 99 |
+
finish_reason = ""
|
| 100 |
+
had_thinking = False
|
| 101 |
+
if choices:
|
| 102 |
+
msg = choices[0].get("message") or {}
|
| 103 |
+
text = msg.get("content") or ""
|
| 104 |
+
finish_reason = choices[0].get("finish_reason") or ""
|
| 105 |
+
had_thinking = _detect_thinking_model(model, msg)
|
| 106 |
+
text = _strip_thinking(text)
|
| 107 |
+
|
| 108 |
+
if had_thinking:
|
| 109 |
+
LOG.info("Stripped thinking content from %s response", model)
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
"response": text.strip(),
|
| 113 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 114 |
+
"model": data.get("model", model),
|
| 115 |
+
"finish_reason": finish_reason,
|
| 116 |
+
}
|
| 117 |
+
except httpx.HTTPStatusError as exc:
|
| 118 |
+
if attempt == 0:
|
| 119 |
+
LOG.warning("HTTPStatusError on %s (attempt 1), retrying", model)
|
| 120 |
+
await asyncio.sleep(1.1)
|
| 121 |
+
continue
|
| 122 |
+
elapsed = time.time() - t0
|
| 123 |
+
detail = exc.response.text[:300] if exc.response else str(exc)
|
| 124 |
+
LOG.error("OpenAI-compat %s error %s: %s", base_url, exc.response.status_code, detail)
|
| 125 |
+
return {
|
| 126 |
+
"response": f"[Error {exc.response.status_code}]: {detail}",
|
| 127 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 128 |
+
"model": model,
|
| 129 |
+
"error": True,
|
| 130 |
+
}
|
| 131 |
+
except Exception as exc:
|
| 132 |
+
if attempt == 0:
|
| 133 |
+
LOG.warning("Exception on %s (attempt 1), retrying: %s", model, exc)
|
| 134 |
+
await asyncio.sleep(1.1)
|
| 135 |
+
continue
|
| 136 |
+
elapsed = time.time() - t0
|
| 137 |
+
LOG.exception("OpenAI-compat request failed: %s", exc)
|
| 138 |
+
return {
|
| 139 |
+
"response": f"[Error]: {exc}",
|
| 140 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 141 |
+
"model": model,
|
| 142 |
+
"error": True,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
async def close_shared_client() -> None:
|
| 147 |
+
global _shared_client
|
| 148 |
+
if _shared_client and not _shared_client.is_closed:
|
| 149 |
+
await _shared_client.aclose()
|
| 150 |
+
_shared_client = None
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from pydantic import AliasChoices, Field
|
| 6 |
+
from pydantic_settings import BaseSettings
|
| 7 |
+
|
| 8 |
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 9 |
+
_ENV_FILE = _PROJECT_ROOT / ".env"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
hana_base_url: str = "https://hana.neonaialpha.com"
|
| 14 |
+
hana_username: str = "guest"
|
| 15 |
+
hana_password: str = "password"
|
| 16 |
+
# BrainForge/Security (4090 x1-3): separate HANA login — use HANA_KLATCHAT_PASSWORD or HANA_PASSWORD_KLATCHAT in project-root .env
|
| 17 |
+
hana_username_klatchat: str = ""
|
| 18 |
+
# Same value as HuggingFace Space secret API_KEY for 4090-x1-3 — OpenAI-compatible Bearer, NOT HANA /auth/login password.
|
| 19 |
+
hana_password_klatchat: str = Field(
|
| 20 |
+
default="",
|
| 21 |
+
validation_alias=AliasChoices("HANA_KLATCHAT_PASSWORD", "HANA_PASSWORD_KLATCHAT"),
|
| 22 |
+
)
|
| 23 |
+
# Direct vLLM base (no /v1); matches brainforge-webapp docker config 4090-x1-3 host.
|
| 24 |
+
neon_security_vllm_base_url: str = "https://4090-x1-3.neonaiservices2.com/vllm0"
|
| 25 |
+
# Comma-separated model_id values to merge via get_personas when get_models omits them (needs HANA access)
|
| 26 |
+
hana_neon_model_supplement_ids: str = "BrainForge/Security@2026.03.18"
|
| 27 |
+
|
| 28 |
+
vllm_api_key: str = ""
|
| 29 |
+
|
| 30 |
+
fireworks_api_key: str = ""
|
| 31 |
+
together_api_key: str = ""
|
| 32 |
+
openai_api_key: str = ""
|
| 33 |
+
gemini_api_key: str = ""
|
| 34 |
+
mistral_api_key: str = ""
|
| 35 |
+
|
| 36 |
+
orchestrator_model: str = "gpt-4o-mini"
|
| 37 |
+
speed_priority: bool = False
|
| 38 |
+
|
| 39 |
+
cors_origins: str = "http://localhost:3000,http://localhost:3001,http://localhost:3002"
|
| 40 |
+
|
| 41 |
+
model_config = {"env_file": str(_ENV_FILE), "env_file_encoding": "utf-8"}
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def cors_origin_list(self) -> list[str]:
|
| 45 |
+
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
| 46 |
+
|
| 47 |
+
def _neon_security_direct_vllm_enabled(self, hana_model_id: str) -> bool:
|
| 48 |
+
"""BrainForge/Security on 4090-x1-3: same pattern as brainforge-webapp (direct vLLM + API key)."""
|
| 49 |
+
if "security" not in (hana_model_id or "").lower():
|
| 50 |
+
return False
|
| 51 |
+
return bool((self.hana_password_klatchat or "").strip() and (self.neon_security_vllm_base_url or "").strip())
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def providers(self) -> list[dict]:
|
| 55 |
+
"""Build the flat list of all available LLM providers and their models."""
|
| 56 |
+
providers: list[dict] = []
|
| 57 |
+
fw_url = "https://api.fireworks.ai/inference/v1"
|
| 58 |
+
fw_key = self.fireworks_api_key
|
| 59 |
+
fw_ok = fw_key and fw_key != "your-fireworks-api-key-here"
|
| 60 |
+
tg_url = "https://api.together.xyz/v1"
|
| 61 |
+
tg_key = self.together_api_key
|
| 62 |
+
tg_ok = tg_key and tg_key != "your-together-api-key-here"
|
| 63 |
+
|
| 64 |
+
if fw_ok:
|
| 65 |
+
providers.append({
|
| 66 |
+
"id": "kimi",
|
| 67 |
+
"name": "Kimi",
|
| 68 |
+
"base_url": fw_url,
|
| 69 |
+
"api_key": fw_key,
|
| 70 |
+
"models": [
|
| 71 |
+
{"id": "accounts/fireworks/models/kimi-k2-thinking", "name": "Kimi K2 Thinking", "params": "1T (32B active)"},
|
| 72 |
+
{"id": "accounts/fireworks/models/kimi-k2-instruct-0905", "name": "Kimi K2 Instruct 0905", "params": "1T (32B active)"},
|
| 73 |
+
{"id": "accounts/fireworks/models/kimi-k2p5", "name": "Kimi K2.5", "params": "1T (32B active)"},
|
| 74 |
+
],
|
| 75 |
+
})
|
| 76 |
+
providers.append({
|
| 77 |
+
"id": "deepseek",
|
| 78 |
+
"name": "DeepSeek",
|
| 79 |
+
"base_url": fw_url,
|
| 80 |
+
"api_key": fw_key,
|
| 81 |
+
"models": [
|
| 82 |
+
{"id": "accounts/fireworks/models/deepseek-v3p1", "name": "DeepSeek V3.1", "params": "671B (37B active)"},
|
| 83 |
+
{"id": "accounts/fireworks/models/deepseek-v3p2", "name": "DeepSeek V3.2", "params": "671B (37B active)"},
|
| 84 |
+
],
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
oai_ok = self.openai_api_key and self.openai_api_key != "your-openai-api-key-here"
|
| 88 |
+
if oai_ok or fw_ok or tg_ok:
|
| 89 |
+
oai_models = []
|
| 90 |
+
if oai_ok:
|
| 91 |
+
oai_models.extend([
|
| 92 |
+
{"id": "gpt-5.4", "name": "GPT-5.4", "params": "Undisclosed"},
|
| 93 |
+
{"id": "gpt-4.1", "name": "GPT-4.1", "params": "Undisclosed"},
|
| 94 |
+
{"id": "gpt-4o", "name": "GPT-4o", "params": "~200B (estimated)"},
|
| 95 |
+
{"id": "gpt-4o-mini", "name": "GPT-4o Mini", "params": "~8B (estimated)"},
|
| 96 |
+
{"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "params": "Undisclosed"},
|
| 97 |
+
{"id": "o4-mini", "name": "o4-Mini", "params": "Undisclosed"},
|
| 98 |
+
])
|
| 99 |
+
if fw_ok:
|
| 100 |
+
oai_models.append({
|
| 101 |
+
"id": "accounts/fireworks/models/gpt-oss-120b",
|
| 102 |
+
"name": "GPT-OSS 120B",
|
| 103 |
+
"params": "117B (5.1B active)",
|
| 104 |
+
"base_url": fw_url,
|
| 105 |
+
"api_key": fw_key,
|
| 106 |
+
})
|
| 107 |
+
if tg_ok:
|
| 108 |
+
oai_models.append({
|
| 109 |
+
"id": "openai/gpt-oss-20b",
|
| 110 |
+
"name": "GPT-OSS 20B",
|
| 111 |
+
"params": "~20B",
|
| 112 |
+
"base_url": tg_url,
|
| 113 |
+
"api_key": tg_key,
|
| 114 |
+
})
|
| 115 |
+
if oai_models:
|
| 116 |
+
providers.append({
|
| 117 |
+
"id": "openai",
|
| 118 |
+
"name": "OpenAI",
|
| 119 |
+
"base_url": "https://api.openai.com/v1",
|
| 120 |
+
"api_key": self.openai_api_key if oai_ok else "",
|
| 121 |
+
"models": oai_models,
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
mistral_ok = self.mistral_api_key and self.mistral_api_key != "your-mistral-api-key-here"
|
| 125 |
+
if mistral_ok:
|
| 126 |
+
providers.append({
|
| 127 |
+
"id": "mistral",
|
| 128 |
+
"name": "Mistral",
|
| 129 |
+
"base_url": "https://api.mistral.ai/v1",
|
| 130 |
+
"api_key": self.mistral_api_key,
|
| 131 |
+
"models": [
|
| 132 |
+
{"id": "mistral-small-2506", "name": "Mistral Small 3.2", "params": "24B"},
|
| 133 |
+
{"id": "mistral-small-2603", "name": "Mistral Small 4", "params": "119B"},
|
| 134 |
+
{"id": "devstral-2512", "name": "Devstral2", "params": "123B"},
|
| 135 |
+
],
|
| 136 |
+
})
|
| 137 |
+
providers.append({
|
| 138 |
+
"id": "qwen",
|
| 139 |
+
"name": "Qwen",
|
| 140 |
+
"base_url": tg_url,
|
| 141 |
+
"api_key": tg_key,
|
| 142 |
+
"models": [
|
| 143 |
+
{"id": "Qwen/Qwen3-VL-8B-Instruct", "name": "Qwen3 VL 8B", "params": "8B"},
|
| 144 |
+
],
|
| 145 |
+
})
|
| 146 |
+
providers.append({
|
| 147 |
+
"id": "meta",
|
| 148 |
+
"name": "Meta Llama",
|
| 149 |
+
"base_url": tg_url,
|
| 150 |
+
"api_key": tg_key,
|
| 151 |
+
"models": [
|
| 152 |
+
{"id": "meta-llama/Llama-3.3-70B-Instruct-Turbo", "name": "Llama 3.3 70B Turbo", "params": "70B"},
|
| 153 |
+
{"id": "meta-llama/Meta-Llama-3-8B-Instruct-Lite", "name": "Llama 3 8B Lite", "params": "8B"},
|
| 154 |
+
],
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
if self.gemini_api_key and self.gemini_api_key != "your-gemini-api-key-here":
|
| 158 |
+
providers.append({
|
| 159 |
+
"id": "gemini",
|
| 160 |
+
"name": "Google Gemini",
|
| 161 |
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
| 162 |
+
"api_key": self.gemini_api_key,
|
| 163 |
+
"models": [
|
| 164 |
+
{"id": "gemini-2.0-flash", "name": "Gemini 2.0 Flash", "params": "Undisclosed"},
|
| 165 |
+
{"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash", "params": "Undisclosed"},
|
| 166 |
+
{"id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro", "params": "Undisclosed"},
|
| 167 |
+
],
|
| 168 |
+
})
|
| 169 |
+
return providers
|
| 170 |
+
|
| 171 |
+
def resolve_model(self, model_id: str) -> dict | None:
|
| 172 |
+
"""Given a model_id, return {base_url, api_key, model_id, ...} or None.
|
| 173 |
+
|
| 174 |
+
Handles both external providers and Neon HANA models (prefixed with 'neon:').
|
| 175 |
+
"""
|
| 176 |
+
if model_id.startswith("neon:"):
|
| 177 |
+
parts = model_id.split(":", 2)
|
| 178 |
+
if len(parts) == 3:
|
| 179 |
+
hana_model_id = parts[1]
|
| 180 |
+
persona_name = parts[2]
|
| 181 |
+
out: dict = {
|
| 182 |
+
"is_neon": True,
|
| 183 |
+
"model_id": model_id,
|
| 184 |
+
"hana_model_id": hana_model_id,
|
| 185 |
+
"persona_name": persona_name,
|
| 186 |
+
"display_name": persona_name,
|
| 187 |
+
"provider": "Neon",
|
| 188 |
+
"base_url": self.hana_base_url,
|
| 189 |
+
"api_key": "",
|
| 190 |
+
}
|
| 191 |
+
if self._neon_security_direct_vllm_enabled(hana_model_id):
|
| 192 |
+
out["neon_direct_vllm"] = True
|
| 193 |
+
out["vllm_base_url"] = f"{self.neon_security_vllm_base_url.rstrip('/')}/v1"
|
| 194 |
+
out["vllm_api_key"] = self.hana_password_klatchat
|
| 195 |
+
return out
|
| 196 |
+
|
| 197 |
+
for prov in self.providers:
|
| 198 |
+
for m in prov["models"]:
|
| 199 |
+
if m["id"] == model_id:
|
| 200 |
+
return {
|
| 201 |
+
"base_url": m.get("base_url", prov["base_url"]),
|
| 202 |
+
"api_key": m.get("api_key", prov["api_key"]),
|
| 203 |
+
"model_id": m["id"],
|
| 204 |
+
"display_name": m["name"],
|
| 205 |
+
"provider": prov["name"],
|
| 206 |
+
}
|
| 207 |
+
return None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
settings = Settings()
|
| 211 |
+
|
| 212 |
+
# region agent log
|
| 213 |
+
def _agent_log_settings_env() -> None:
|
| 214 |
+
import json
|
| 215 |
+
import time
|
| 216 |
+
|
| 217 |
+
_path = r"c:\Users\dream\CCAI-Demo-FEAT_Config\debug-c86901.log"
|
| 218 |
+
try:
|
| 219 |
+
raw = _ENV_FILE.read_text(encoding="utf-8") if _ENV_FILE.is_file() else ""
|
| 220 |
+
data = {
|
| 221 |
+
"app": "LLMChats3",
|
| 222 |
+
"env_file": str(_ENV_FILE),
|
| 223 |
+
"env_file_exists": _ENV_FILE.is_file(),
|
| 224 |
+
"dotenv_line_HANA_KLATCHAT_PASSWORD": "HANA_KLATCHAT_PASSWORD" in raw,
|
| 225 |
+
"dotenv_line_HANA_PASSWORD_KLATCHAT": "HANA_PASSWORD_KLATCHAT" in raw,
|
| 226 |
+
"settings_hana_password_klatchat_nonempty": bool((settings.hana_password_klatchat or "").strip()),
|
| 227 |
+
}
|
| 228 |
+
with open(_path, "a", encoding="utf-8") as f:
|
| 229 |
+
f.write(
|
| 230 |
+
json.dumps(
|
| 231 |
+
{
|
| 232 |
+
"sessionId": "c86901",
|
| 233 |
+
"hypothesisId": "H1",
|
| 234 |
+
"location": "LLMChats3/config.py:settings",
|
| 235 |
+
"message": "env_binding",
|
| 236 |
+
"data": data,
|
| 237 |
+
"timestamp": int(time.time() * 1000),
|
| 238 |
+
}
|
| 239 |
+
)
|
| 240 |
+
+ "\n"
|
| 241 |
+
)
|
| 242 |
+
except Exception:
|
| 243 |
+
pass
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
_agent_log_settings_env()
|
| 247 |
+
# endregion
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import secrets
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, Request
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
from app.clients.hana_client import hana_client
|
| 16 |
+
from app.clients.openai_compat import close_shared_client
|
| 17 |
+
from app.api import models, chat
|
| 18 |
+
from app.middleware.rate_limit import (
|
| 19 |
+
get_oauth_username, is_org_member, get_remaining, check_rate_limit,
|
| 20 |
+
record_conversation,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
logging.basicConfig(level=logging.INFO)
|
| 24 |
+
LOG = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@asynccontextmanager
|
| 30 |
+
async def lifespan(app: FastAPI):
|
| 31 |
+
LOG.info("Starting up — authenticating with HANA...")
|
| 32 |
+
try:
|
| 33 |
+
await hana_client.authenticate()
|
| 34 |
+
await hana_client.get_models()
|
| 35 |
+
LOG.info("HANA auth complete, persona cache populated.")
|
| 36 |
+
except Exception as exc:
|
| 37 |
+
LOG.warning("HANA auth failed (Neon models will be unavailable): %s", exc)
|
| 38 |
+
yield
|
| 39 |
+
LOG.info("Shutting down — closing HTTP clients...")
|
| 40 |
+
await hana_client.close()
|
| 41 |
+
await close_shared_client()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
app = FastAPI(title="AI Conversations", version="1.0.0", lifespan=lifespan)
|
| 45 |
+
|
| 46 |
+
app.add_middleware(
|
| 47 |
+
SessionMiddleware,
|
| 48 |
+
secret_key=os.getenv("SESSION_SECRET", secrets.token_hex(32)),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
app.add_middleware(
|
| 52 |
+
CORSMiddleware,
|
| 53 |
+
allow_origins=["*"],
|
| 54 |
+
allow_credentials=True,
|
| 55 |
+
allow_methods=["*"],
|
| 56 |
+
allow_headers=["*"],
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
from huggingface_hub import attach_huggingface_oauth
|
| 61 |
+
attach_huggingface_oauth(app)
|
| 62 |
+
LOG.info("HuggingFace OAuth endpoints registered.")
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
LOG.warning("HF OAuth not available (local dev mode): %s", exc)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
app.include_router(models.router, prefix="/api")
|
| 68 |
+
app.include_router(chat.router, prefix="/api")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.get("/api/health")
|
| 72 |
+
async def health():
|
| 73 |
+
return {"status": "ok"}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.get("/api/auth/status")
|
| 77 |
+
async def auth_status(request: Request):
|
| 78 |
+
username = get_oauth_username(request)
|
| 79 |
+
if username is None:
|
| 80 |
+
remaining = get_remaining(request)
|
| 81 |
+
return {
|
| 82 |
+
"logged_in": False,
|
| 83 |
+
"username": None,
|
| 84 |
+
"is_org_member": False,
|
| 85 |
+
"remaining_conversations": remaining,
|
| 86 |
+
}
|
| 87 |
+
member = is_org_member(request)
|
| 88 |
+
remaining = -1 if member else get_remaining(request)
|
| 89 |
+
return {
|
| 90 |
+
"logged_in": True,
|
| 91 |
+
"username": username,
|
| 92 |
+
"is_org_member": member,
|
| 93 |
+
"remaining_conversations": remaining,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.get("/api/rate-limit/status")
|
| 98 |
+
async def rate_limit_status(request: Request):
|
| 99 |
+
remaining = get_remaining(request)
|
| 100 |
+
return {"remaining": remaining, "daily_limit": 20}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if STATIC_DIR.is_dir():
|
| 104 |
+
from starlette.staticfiles import StaticFiles
|
| 105 |
+
from starlette.responses import FileResponse
|
| 106 |
+
|
| 107 |
+
@app.get("/{full_path:path}")
|
| 108 |
+
async def serve_spa(request: Request, full_path: str):
|
| 109 |
+
file_path = STATIC_DIR / full_path
|
| 110 |
+
if file_path.is_file():
|
| 111 |
+
return FileResponse(file_path)
|
| 112 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 113 |
+
|
| 114 |
+
LOG.info("Serving frontend from %s", STATIC_DIR)
|
| 115 |
+
else:
|
| 116 |
+
LOG.info("No static directory found at %s — frontend not served.", STATIC_DIR)
|
backend/app/middleware/__init__.py
ADDED
|
File without changes
|
backend/app/middleware/rate_limit.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from datetime import date
|
| 7 |
+
|
| 8 |
+
from fastapi import Request
|
| 9 |
+
|
| 10 |
+
LOG = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
ORG_NAME = os.getenv("HF_RATE_LIMIT_ORG", "neongeckocom")
|
| 13 |
+
DAILY_LIMIT = int(os.getenv("HF_RATE_LIMIT_DAILY", "20"))
|
| 14 |
+
|
| 15 |
+
_ip_counts: dict[str, dict] = defaultdict(lambda: {"date": "", "count": 0})
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _today() -> str:
|
| 19 |
+
return date.today().isoformat()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def is_org_member(request: Request) -> bool:
|
| 23 |
+
try:
|
| 24 |
+
from huggingface_hub import parse_huggingface_oauth
|
| 25 |
+
oauth_info = parse_huggingface_oauth(request)
|
| 26 |
+
if oauth_info is None:
|
| 27 |
+
return False
|
| 28 |
+
orgs = oauth_info.user_info.orgs or []
|
| 29 |
+
return any(o.preferred_username == ORG_NAME for o in orgs)
|
| 30 |
+
except Exception:
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_oauth_username(request: Request) -> str | None:
|
| 35 |
+
try:
|
| 36 |
+
from huggingface_hub import parse_huggingface_oauth
|
| 37 |
+
oauth_info = parse_huggingface_oauth(request)
|
| 38 |
+
if oauth_info is None:
|
| 39 |
+
return None
|
| 40 |
+
return oauth_info.user_info.preferred_username
|
| 41 |
+
except Exception:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_client_ip(request: Request) -> str:
|
| 46 |
+
forwarded = request.headers.get("x-forwarded-for")
|
| 47 |
+
if forwarded:
|
| 48 |
+
return forwarded.split(",")[0].strip()
|
| 49 |
+
return request.client.host if request.client else "unknown"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def check_rate_limit(request: Request) -> tuple[bool, int]:
|
| 53 |
+
"""Returns (allowed, remaining). If org member, remaining is -1 (unlimited)."""
|
| 54 |
+
if is_org_member(request):
|
| 55 |
+
return True, -1
|
| 56 |
+
|
| 57 |
+
ip = get_client_ip(request)
|
| 58 |
+
today = _today()
|
| 59 |
+
|
| 60 |
+
entry = _ip_counts[ip]
|
| 61 |
+
if entry["date"] != today:
|
| 62 |
+
entry["date"] = today
|
| 63 |
+
entry["count"] = 0
|
| 64 |
+
|
| 65 |
+
remaining = max(0, DAILY_LIMIT - entry["count"])
|
| 66 |
+
if entry["count"] >= DAILY_LIMIT:
|
| 67 |
+
return False, 0
|
| 68 |
+
|
| 69 |
+
return True, remaining
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def record_conversation(request: Request) -> None:
|
| 73 |
+
"""Increment the counter after a conversation is successfully started."""
|
| 74 |
+
if is_org_member(request):
|
| 75 |
+
return
|
| 76 |
+
ip = get_client_ip(request)
|
| 77 |
+
today = _today()
|
| 78 |
+
entry = _ip_counts[ip]
|
| 79 |
+
if entry["date"] != today:
|
| 80 |
+
entry["date"] = today
|
| 81 |
+
entry["count"] = 0
|
| 82 |
+
entry["count"] += 1
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def get_remaining(request: Request) -> int:
|
| 86 |
+
"""Get remaining conversations for a client. -1 means unlimited."""
|
| 87 |
+
if is_org_member(request):
|
| 88 |
+
return -1
|
| 89 |
+
ip = get_client_ip(request)
|
| 90 |
+
today = _today()
|
| 91 |
+
entry = _ip_counts[ip]
|
| 92 |
+
if entry["date"] != today:
|
| 93 |
+
return DAILY_LIMIT
|
| 94 |
+
return max(0, DAILY_LIMIT - entry["count"])
|
backend/app/services/__init__.py
ADDED
|
File without changes
|
backend/app/services/orchestrator.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import random
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from typing import Any, AsyncIterator
|
| 10 |
+
|
| 11 |
+
from app.clients.openai_compat import openai_chat_completion
|
| 12 |
+
from app.clients.llm_router import chat_completion as unified_chat_completion
|
| 13 |
+
from app.config import settings
|
| 14 |
+
|
| 15 |
+
LOG = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Prompt templates
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
_BREVITY = (
|
| 22 |
+
" Keep your reply short — 2-4 sentences, like a casual chat message, not an email or essay."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
AUTO_START_PROMPT = (
|
| 26 |
+
"You're starting a conversation with someone new. Here is some information about them: "
|
| 27 |
+
"{other_role}\n\n"
|
| 28 |
+
"Consider connections between this information and what you know about yourself, and say "
|
| 29 |
+
"something that could start a conversation with that new person. Speak in the first person, "
|
| 30 |
+
"as if directly to the other person." + _BREVITY
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
FIRST_REPLY_PROMPT = (
|
| 34 |
+
"Someone just started a conversation with you, this is what they said: {last_message}\n\n"
|
| 35 |
+
"Consider connections between this conversation starter and what you know about yourself, "
|
| 36 |
+
"and say something that could continue the conversation with that new person. Speak in the "
|
| 37 |
+
"first person, as if directly to the other person." + _BREVITY
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
CONTINUE_PROMPT = (
|
| 41 |
+
"You are having a conversation with another person, here is the conversation so far:\n\n"
|
| 42 |
+
"{history}\n\n"
|
| 43 |
+
"Consider how human conversations generally progress, and provide a response. If the last "
|
| 44 |
+
"reply in the conversation is one which might indicate a human is losing interest in or "
|
| 45 |
+
"wrapping up the conversation, then make a response which will help wrap up and close the "
|
| 46 |
+
"conversation." + _BREVITY
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
WINDING_NEXT_PROMPT = (
|
| 50 |
+
"You are having a conversation with another person, here is the conversation so far:\n\n"
|
| 51 |
+
"{history}\n\n"
|
| 52 |
+
"Consider how human conversations generally progress, and provide a response which will "
|
| 53 |
+
"wrap up and close the conversation. This is the last reply you will give in this "
|
| 54 |
+
"conversation." + _BREVITY
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
WINDING_FINAL_PROMPT = (
|
| 58 |
+
"You are having a conversation with another person, here is the conversation so far:\n\n"
|
| 59 |
+
"{history}\n\n"
|
| 60 |
+
"Consider how human conversations generally progress, and focus on the last two messages "
|
| 61 |
+
"in this conversation. Provide a very short response which closes the conversation."
|
| 62 |
+
+ _BREVITY
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
ORCHESTRATOR_CHECK_PROMPT = (
|
| 66 |
+
"You are monitoring a conversation between two people. Your job is to determine whether "
|
| 67 |
+
"the latest message indicates the speaker is losing interest or wrapping up the conversation. "
|
| 68 |
+
"Reply with ONLY a JSON object: {{\"winding_down\": true}} or {{\"winding_down\": false}}. "
|
| 69 |
+
"No other text.\n\nLatest message:\n{message}"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# Session data
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class Persona:
|
| 79 |
+
name: str
|
| 80 |
+
model_id: str
|
| 81 |
+
role_prompt: str
|
| 82 |
+
base_url: str = ""
|
| 83 |
+
api_key: str = ""
|
| 84 |
+
display_name: str = ""
|
| 85 |
+
is_neon: bool = False
|
| 86 |
+
hana_model_id: str = ""
|
| 87 |
+
persona_name: str = ""
|
| 88 |
+
neon_direct_vllm: bool = False
|
| 89 |
+
vllm_base_url: str = ""
|
| 90 |
+
vllm_api_key: str = ""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class Session:
|
| 95 |
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 96 |
+
persona_a: Persona | None = None
|
| 97 |
+
persona_b: Persona | None = None
|
| 98 |
+
messages: list[dict[str, str]] = field(default_factory=list)
|
| 99 |
+
api_log: list[dict[str, Any]] = field(default_factory=list)
|
| 100 |
+
a_count: int = 0
|
| 101 |
+
b_count: int = 0
|
| 102 |
+
end_mode: bool = False
|
| 103 |
+
finished: bool = False
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
_sessions: dict[str, Session] = {}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def get_session(sid: str) -> Session | None:
|
| 110 |
+
return _sessions.get(sid)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def create_session() -> Session:
|
| 114 |
+
s = Session()
|
| 115 |
+
_sessions[s.session_id] = s
|
| 116 |
+
return s
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
# Helpers
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
|
| 123 |
+
def _format_history(messages: list[dict[str, str]]) -> str:
|
| 124 |
+
lines = []
|
| 125 |
+
for m in messages:
|
| 126 |
+
lines.append(f"{m['speaker']}: {m['text']}")
|
| 127 |
+
return "\n".join(lines)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def _call_llm(
|
| 131 |
+
persona: Persona,
|
| 132 |
+
system_content: str,
|
| 133 |
+
user_content: str,
|
| 134 |
+
session: Session,
|
| 135 |
+
label: str = "",
|
| 136 |
+
max_tokens: int = 500,
|
| 137 |
+
timeout: float = 20,
|
| 138 |
+
) -> str:
|
| 139 |
+
system_with_directive = (
|
| 140 |
+
system_content + "\n\nIMPORTANT: Respond ONLY with your in-character dialogue. "
|
| 141 |
+
"Do NOT include your reasoning, thought process, analysis of the prompt, "
|
| 142 |
+
"meta-commentary, internal monologue, or draft notes. Output ONLY the words "
|
| 143 |
+
"your character would actually say aloud."
|
| 144 |
+
)
|
| 145 |
+
messages = [
|
| 146 |
+
{"role": "system", "content": system_with_directive},
|
| 147 |
+
{"role": "user", "content": user_content},
|
| 148 |
+
]
|
| 149 |
+
log_entry: dict[str, Any] = {
|
| 150 |
+
"timestamp": time.time(),
|
| 151 |
+
"label": label,
|
| 152 |
+
"model": persona.model_id,
|
| 153 |
+
"request": {"messages": messages, "max_tokens": max_tokens},
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
resolved = {
|
| 157 |
+
"model_id": persona.model_id,
|
| 158 |
+
"base_url": persona.base_url,
|
| 159 |
+
"api_key": persona.api_key,
|
| 160 |
+
"is_neon": persona.is_neon,
|
| 161 |
+
"hana_model_id": persona.hana_model_id,
|
| 162 |
+
"persona_name": persona.persona_name,
|
| 163 |
+
"neon_direct_vllm": persona.neon_direct_vllm,
|
| 164 |
+
"vllm_base_url": persona.vllm_base_url,
|
| 165 |
+
"vllm_api_key": persona.vllm_api_key,
|
| 166 |
+
}
|
| 167 |
+
result = await unified_chat_completion(
|
| 168 |
+
resolved=resolved,
|
| 169 |
+
messages=messages,
|
| 170 |
+
temperature=0.7,
|
| 171 |
+
max_tokens=max_tokens,
|
| 172 |
+
timeout=timeout,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
log_entry["response"] = result
|
| 176 |
+
session.api_log.append(log_entry)
|
| 177 |
+
|
| 178 |
+
return result.get("response", ""), result.get("elapsed_seconds", 0)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
async def _call_orchestrator(
|
| 182 |
+
prompt: str,
|
| 183 |
+
session: Session,
|
| 184 |
+
label: str = "",
|
| 185 |
+
) -> str:
|
| 186 |
+
resolved = settings.resolve_model(settings.orchestrator_model)
|
| 187 |
+
if not resolved:
|
| 188 |
+
LOG.warning("Orchestrator model %s not found, using first available", settings.orchestrator_model)
|
| 189 |
+
for prov in settings.providers:
|
| 190 |
+
for m in prov["models"]:
|
| 191 |
+
resolved = {
|
| 192 |
+
"base_url": m.get("base_url", prov["base_url"]),
|
| 193 |
+
"api_key": m.get("api_key", prov["api_key"]),
|
| 194 |
+
"model_id": m["id"],
|
| 195 |
+
}
|
| 196 |
+
break
|
| 197 |
+
if resolved:
|
| 198 |
+
break
|
| 199 |
+
|
| 200 |
+
if not resolved:
|
| 201 |
+
return '{"winding_down": false}'
|
| 202 |
+
|
| 203 |
+
messages = [
|
| 204 |
+
{"role": "system", "content": "You are a conversation monitor. Respond only with the requested JSON."},
|
| 205 |
+
{"role": "user", "content": prompt},
|
| 206 |
+
]
|
| 207 |
+
log_entry: dict[str, Any] = {
|
| 208 |
+
"timestamp": time.time(),
|
| 209 |
+
"label": f"orchestrator:{label}",
|
| 210 |
+
"model": resolved["model_id"],
|
| 211 |
+
"request": {"messages": messages},
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
result = await openai_chat_completion(
|
| 215 |
+
base_url=resolved["base_url"],
|
| 216 |
+
api_key=resolved["api_key"],
|
| 217 |
+
model=resolved["model_id"],
|
| 218 |
+
messages=messages,
|
| 219 |
+
temperature=0.2,
|
| 220 |
+
max_tokens=256,
|
| 221 |
+
timeout=20,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
log_entry["response"] = result
|
| 225 |
+
session.api_log.append(log_entry)
|
| 226 |
+
|
| 227 |
+
return result.get("response", "")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _parse_json_bool(raw: str, key: str) -> bool:
|
| 231 |
+
try:
|
| 232 |
+
raw = raw.strip()
|
| 233 |
+
if raw.startswith("```"):
|
| 234 |
+
raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0]
|
| 235 |
+
return json.loads(raw).get(key, False)
|
| 236 |
+
except Exception:
|
| 237 |
+
lower = raw.lower()
|
| 238 |
+
return f'"{key}": true' in lower or f'"{key}":true' in lower
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# Main conversation loop (yields SSE events)
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
async def run_conversation(
|
| 246 |
+
session: Session,
|
| 247 |
+
starter_text: str | None = None,
|
| 248 |
+
) -> AsyncIterator[str]:
|
| 249 |
+
pa = session.persona_a
|
| 250 |
+
pb = session.persona_b
|
| 251 |
+
if not pa or not pb:
|
| 252 |
+
yield _sse("error", {"message": "Both personas must be configured"})
|
| 253 |
+
return
|
| 254 |
+
|
| 255 |
+
participants = [pa, pb]
|
| 256 |
+
starter_idx = random.randint(0, 1)
|
| 257 |
+
starter = participants[starter_idx]
|
| 258 |
+
responder = participants[1 - starter_idx]
|
| 259 |
+
|
| 260 |
+
yield _sse("status", {"message": "Starting conversation..."})
|
| 261 |
+
|
| 262 |
+
# --- First message ---
|
| 263 |
+
if starter_text:
|
| 264 |
+
# Show the user-provided starter as the first message from the starter LLM,
|
| 265 |
+
# then send it to the responder to reply to (no LLM call for the opener).
|
| 266 |
+
_add_message(session, starter, starter_text.strip(), starter_idx)
|
| 267 |
+
yield _sse("message", _msg_payload(session.messages[-1], starter_idx))
|
| 268 |
+
|
| 269 |
+
reply_prompt = FIRST_REPLY_PROMPT.format(last_message=starter_text.strip())
|
| 270 |
+
second_msg, second_elapsed = await _call_llm(
|
| 271 |
+
responder, responder.role_prompt, reply_prompt, session,
|
| 272 |
+
label=f"first_reply:{responder.name}",
|
| 273 |
+
)
|
| 274 |
+
_add_message(session, responder, second_msg, 1 - starter_idx, second_elapsed)
|
| 275 |
+
yield _sse("message", _msg_payload(session.messages[-1], 1 - starter_idx))
|
| 276 |
+
else:
|
| 277 |
+
user_prompt = AUTO_START_PROMPT.format(other_role=responder.role_prompt)
|
| 278 |
+
first_msg, first_elapsed = await _call_llm(
|
| 279 |
+
starter, starter.role_prompt, user_prompt, session,
|
| 280 |
+
label=f"start:{starter.name}",
|
| 281 |
+
)
|
| 282 |
+
_add_message(session, starter, first_msg, starter_idx, first_elapsed)
|
| 283 |
+
yield _sse("message", _msg_payload(session.messages[-1], starter_idx))
|
| 284 |
+
|
| 285 |
+
reply_prompt = FIRST_REPLY_PROMPT.format(last_message=first_msg)
|
| 286 |
+
second_msg, second_elapsed = await _call_llm(
|
| 287 |
+
responder, responder.role_prompt, reply_prompt, session,
|
| 288 |
+
label=f"first_reply:{responder.name}",
|
| 289 |
+
)
|
| 290 |
+
_add_message(session, responder, second_msg, 1 - starter_idx, second_elapsed)
|
| 291 |
+
yield _sse("message", _msg_payload(session.messages[-1], 1 - starter_idx))
|
| 292 |
+
|
| 293 |
+
# --- Continue loop ---
|
| 294 |
+
current_idx = starter_idx
|
| 295 |
+
while not session.finished:
|
| 296 |
+
current = participants[current_idx]
|
| 297 |
+
history_text = _format_history(session.messages)
|
| 298 |
+
|
| 299 |
+
# Check orchestrator on the last message
|
| 300 |
+
last_msg_text = session.messages[-1]["text"]
|
| 301 |
+
|
| 302 |
+
if not session.end_mode:
|
| 303 |
+
orch_raw = await _call_orchestrator(
|
| 304 |
+
ORCHESTRATOR_CHECK_PROMPT.format(message=last_msg_text),
|
| 305 |
+
session,
|
| 306 |
+
label="winding_check",
|
| 307 |
+
)
|
| 308 |
+
winding = _parse_json_bool(orch_raw, "winding_down")
|
| 309 |
+
|
| 310 |
+
if winding:
|
| 311 |
+
session.end_mode = True
|
| 312 |
+
|
| 313 |
+
# Force wrap-up at 8 messages each
|
| 314 |
+
if not session.end_mode:
|
| 315 |
+
if session.a_count >= 8 and session.b_count >= 8:
|
| 316 |
+
session.end_mode = True
|
| 317 |
+
|
| 318 |
+
if session.end_mode:
|
| 319 |
+
# Penultimate message: current speaker wraps up
|
| 320 |
+
history_text = _format_history(session.messages)
|
| 321 |
+
wrap_msg, wrap_elapsed = await _call_llm(
|
| 322 |
+
current, current.role_prompt,
|
| 323 |
+
WINDING_NEXT_PROMPT.format(history=history_text),
|
| 324 |
+
session, label=f"winding_next:{current.name}",
|
| 325 |
+
)
|
| 326 |
+
_add_message(session, current, wrap_msg, current_idx, wrap_elapsed)
|
| 327 |
+
yield _sse("message", _msg_payload(session.messages[-1], current_idx))
|
| 328 |
+
|
| 329 |
+
# Final message: other speaker closes
|
| 330 |
+
other_idx = 1 - current_idx
|
| 331 |
+
other = participants[other_idx]
|
| 332 |
+
history_text = _format_history(session.messages)
|
| 333 |
+
final_msg, final_elapsed = await _call_llm(
|
| 334 |
+
other, other.role_prompt,
|
| 335 |
+
WINDING_FINAL_PROMPT.format(history=history_text),
|
| 336 |
+
session, label=f"winding_final:{other.name}",
|
| 337 |
+
)
|
| 338 |
+
_add_message(session, other, final_msg, other_idx, final_elapsed)
|
| 339 |
+
yield _sse("message", _msg_payload(session.messages[-1], other_idx))
|
| 340 |
+
|
| 341 |
+
session.finished = True
|
| 342 |
+
yield _sse("system", {"text": "End of Chat"})
|
| 343 |
+
break
|
| 344 |
+
|
| 345 |
+
# Normal continue
|
| 346 |
+
prompt = CONTINUE_PROMPT.format(history=history_text)
|
| 347 |
+
response, resp_elapsed = await _call_llm(
|
| 348 |
+
current, current.role_prompt, prompt, session,
|
| 349 |
+
label=f"continue:{current.name}",
|
| 350 |
+
)
|
| 351 |
+
_add_message(session, current, response, current_idx, resp_elapsed)
|
| 352 |
+
yield _sse("message", _msg_payload(session.messages[-1], current_idx))
|
| 353 |
+
|
| 354 |
+
current_idx = 1 - current_idx
|
| 355 |
+
|
| 356 |
+
yield _sse("done", {})
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# ---------------------------------------------------------------------------
|
| 360 |
+
# SSE helpers
|
| 361 |
+
# ---------------------------------------------------------------------------
|
| 362 |
+
|
| 363 |
+
def _add_message(session: Session, persona: Persona, text: str, speaker_idx: int, elapsed: float = 0) -> None:
|
| 364 |
+
session.messages.append({
|
| 365 |
+
"speaker": persona.name,
|
| 366 |
+
"speaker_idx": speaker_idx,
|
| 367 |
+
"model_id": persona.model_id,
|
| 368 |
+
"model_display": persona.display_name,
|
| 369 |
+
"text": text,
|
| 370 |
+
"timestamp": time.time(),
|
| 371 |
+
"elapsed_seconds": round(elapsed, 2),
|
| 372 |
+
})
|
| 373 |
+
if speaker_idx == 0:
|
| 374 |
+
session.a_count += 1
|
| 375 |
+
else:
|
| 376 |
+
session.b_count += 1
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _msg_payload(msg: dict, speaker_idx: int) -> dict:
|
| 380 |
+
return {
|
| 381 |
+
"speaker": msg["speaker"],
|
| 382 |
+
"speaker_idx": speaker_idx,
|
| 383 |
+
"model_display": msg["model_display"],
|
| 384 |
+
"text": msg["text"],
|
| 385 |
+
"timestamp": msg["timestamp"],
|
| 386 |
+
"elapsed_seconds": msg.get("elapsed_seconds", 0),
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _sse(event: str, data: dict) -> str:
|
| 391 |
+
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
backend/app/services/persona.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from app.clients.llm_router import chat_completion
|
| 6 |
+
from app.config import settings
|
| 7 |
+
|
| 8 |
+
LOG = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
# ---------------------------------------------------------------------------
|
| 11 |
+
# Structured input prompts
|
| 12 |
+
# ---------------------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
STRUCTURED_AI_COMPLETED_PROMPT = (
|
| 15 |
+
"You will receive structured information about a character or persona: a name, an identity "
|
| 16 |
+
"statement, a profile, and optionally writing/speech samples. Some fields may be sparse or "
|
| 17 |
+
"missing. Write a complete, vivid 3-5 sentence role prompt that an LLM can use to "
|
| 18 |
+
"convincingly embody this persona in a conversation.\n\n"
|
| 19 |
+
"If any fields are sparse, infer plausible personality traits, speech patterns, interests, "
|
| 20 |
+
"and conversational style from whatever clues are available. Fill in realistic detail so "
|
| 21 |
+
"the role prompt is rich and actionable — never produce a vague or skeletal prompt.\n\n"
|
| 22 |
+
"Cover: personality, tone and speech patterns, background/expertise, interests and "
|
| 23 |
+
"motivations, and how they would naturally interact in a casual conversation.\n\n"
|
| 24 |
+
"The name is: {name}\n"
|
| 25 |
+
"The identity statement is: {identity}\n"
|
| 26 |
+
"The profile is: {profile}\n"
|
| 27 |
+
"Here are the writing and/or speech samples: {samples}"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
STRUCTURED_EXACT_PROMPT = (
|
| 31 |
+
"You will receive structured information about a character or persona: a name, an identity "
|
| 32 |
+
"statement, a profile, and optionally writing/speech samples. Combine this information into "
|
| 33 |
+
"a coherent 3-5 sentence role prompt that an LLM can use to embody this persona in a "
|
| 34 |
+
"conversation.\n\n"
|
| 35 |
+
"IMPORTANT: Use ONLY the information explicitly provided. Do not invent, assume, or infer "
|
| 36 |
+
"any traits, background, opinions, or speech patterns beyond what is stated. Your job is "
|
| 37 |
+
"purely to organize and lightly rephrase the provided facts into a smooth, usable role "
|
| 38 |
+
"prompt — add linking words and natural sentence flow, but no new content. If a field is "
|
| 39 |
+
"empty or says '(not provided)', simply omit it.\n\n"
|
| 40 |
+
"The name is: {name}\n"
|
| 41 |
+
"The identity statement is: {identity}\n"
|
| 42 |
+
"The profile is: {profile}\n"
|
| 43 |
+
"Here are the writing and/or speech samples: {samples}"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Freeform input prompts
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
FREEFORM_AI_COMPLETED_PROMPT = (
|
| 51 |
+
"You will receive freeform information about a character or persona. The input may be "
|
| 52 |
+
"detailed (with writing samples, background, etc.) or very brief (just a name or a short "
|
| 53 |
+
"description). Regardless of how much is provided, write a complete, vivid 3-5 sentence "
|
| 54 |
+
"role prompt that an LLM can use to convincingly embody this persona in a conversation.\n\n"
|
| 55 |
+
"If the input is sparse, infer plausible personality traits, speech patterns, interests, "
|
| 56 |
+
"and conversational style from whatever clues are available (the name, any title or "
|
| 57 |
+
"occupation, context, etc.). Fill in realistic detail so the role prompt is rich and "
|
| 58 |
+
"actionable — never produce a vague or skeletal prompt.\n\n"
|
| 59 |
+
"Cover: personality, tone and speech patterns, background/expertise, interests and "
|
| 60 |
+
"motivations, and how they would naturally interact in a casual conversation.\n\n"
|
| 61 |
+
"The persona's name is: {name}\n\n"
|
| 62 |
+
"Here is everything provided about this persona:\n"
|
| 63 |
+
"---\n{text}\n---"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
FREEFORM_EXACT_PROMPT = (
|
| 67 |
+
"You will receive freeform information about a character or persona. Combine this "
|
| 68 |
+
"information into a coherent 3-5 sentence role prompt that an LLM can use to embody "
|
| 69 |
+
"this persona in a conversation.\n\n"
|
| 70 |
+
"IMPORTANT: Use ONLY the information explicitly provided. Do not invent, assume, or infer "
|
| 71 |
+
"any traits, background, opinions, or speech patterns beyond what is stated. Your job is "
|
| 72 |
+
"purely to organize and lightly rephrase the user's text into a smooth, usable role "
|
| 73 |
+
"prompt — add linking words and natural sentence flow, but no new content. If very little "
|
| 74 |
+
"was provided, the role prompt should be correspondingly brief.\n\n"
|
| 75 |
+
"The persona's name is: {name}\n\n"
|
| 76 |
+
"Here is everything provided about this persona:\n"
|
| 77 |
+
"---\n{text}\n---"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def _call_llm(model_id: str, prompt_text: str) -> dict:
|
| 82 |
+
resolved = settings.resolve_model(model_id)
|
| 83 |
+
if not resolved:
|
| 84 |
+
return {"role_prompt": "", "error": f"Unknown model: {model_id}"}
|
| 85 |
+
|
| 86 |
+
messages = [
|
| 87 |
+
{"role": "system", "content": (
|
| 88 |
+
"You are a helpful assistant that creates character prompts. "
|
| 89 |
+
"Respond ONLY with the finished role prompt text. Do NOT include your reasoning, "
|
| 90 |
+
"thought process, analysis, draft notes, or any meta-commentary."
|
| 91 |
+
)},
|
| 92 |
+
{"role": "user", "content": prompt_text},
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
result = await chat_completion(
|
| 96 |
+
resolved=resolved,
|
| 97 |
+
messages=messages,
|
| 98 |
+
temperature=0.7,
|
| 99 |
+
max_tokens=512,
|
| 100 |
+
timeout=45,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if result.get("error"):
|
| 104 |
+
return {"role_prompt": "", "error": result["response"]}
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"role_prompt": result["response"],
|
| 108 |
+
"elapsed_seconds": result["elapsed_seconds"],
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def generate_role_prompt(
|
| 113 |
+
model_id: str,
|
| 114 |
+
name: str,
|
| 115 |
+
profile: str,
|
| 116 |
+
identity: str,
|
| 117 |
+
samples: str,
|
| 118 |
+
role_style: str = "exact",
|
| 119 |
+
) -> dict:
|
| 120 |
+
"""Use the selected LLM to distill structured persona inputs into a role prompt."""
|
| 121 |
+
template = STRUCTURED_AI_COMPLETED_PROMPT if role_style == "ai_completed" else STRUCTURED_EXACT_PROMPT
|
| 122 |
+
prompt_text = template.format(
|
| 123 |
+
name=name or "(not provided)",
|
| 124 |
+
identity=identity or "(not provided)",
|
| 125 |
+
profile=profile or "(not provided)",
|
| 126 |
+
samples=samples or "(not provided)",
|
| 127 |
+
)
|
| 128 |
+
return await _call_llm(model_id, prompt_text)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def generate_role_prompt_freeform(
|
| 132 |
+
model_id: str,
|
| 133 |
+
name: str,
|
| 134 |
+
text: str,
|
| 135 |
+
role_style: str = "ai_completed",
|
| 136 |
+
) -> dict:
|
| 137 |
+
"""Use the selected LLM to distill a single freeform text block into a role prompt."""
|
| 138 |
+
template = FREEFORM_AI_COMPLETED_PROMPT if role_style == "ai_completed" else FREEFORM_EXACT_PROMPT
|
| 139 |
+
prompt_text = template.format(
|
| 140 |
+
name=name or "(not provided)",
|
| 141 |
+
text=text or "(not provided)",
|
| 142 |
+
)
|
| 143 |
+
return await _call_llm(model_id, prompt_text)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.32.0
|
| 3 |
+
httpx>=0.27.0
|
| 4 |
+
pydantic-settings>=2.6.0
|
| 5 |
+
python-multipart>=0.0.12
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
huggingface_hub>=0.25.0
|
| 8 |
+
itsdangerous>=2.2.0
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
app:
|
| 3 |
+
build: .
|
| 4 |
+
ports:
|
| 5 |
+
- "8000:8000"
|
| 6 |
+
env_file:
|
| 7 |
+
- .env
|
| 8 |
+
environment:
|
| 9 |
+
CORS_ORIGINS: "http://localhost:8000,http://localhost:3000"
|
| 10 |
+
restart: unless-stopped
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.js
|
| 7 |
+
|
| 8 |
+
# testing
|
| 9 |
+
/coverage
|
| 10 |
+
|
| 11 |
+
# production
|
| 12 |
+
/build
|
| 13 |
+
|
| 14 |
+
# misc
|
| 15 |
+
.DS_Store
|
| 16 |
+
.env.local
|
| 17 |
+
.env.development.local
|
| 18 |
+
.env.test.local
|
| 19 |
+
.env.production.local
|
| 20 |
+
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"dependencies": {
|
| 6 |
+
"lucide-react": "^0.577.0",
|
| 7 |
+
"react": "^19.2.4",
|
| 8 |
+
"react-dom": "^19.2.4",
|
| 9 |
+
"react-markdown": "^10.1.0",
|
| 10 |
+
"react-scripts": "5.0.1",
|
| 11 |
+
"remark-gfm": "^4.0.1"
|
| 12 |
+
},
|
| 13 |
+
"scripts": {
|
| 14 |
+
"start": "react-scripts start",
|
| 15 |
+
"build": "react-scripts build",
|
| 16 |
+
"test": "react-scripts test",
|
| 17 |
+
"eject": "react-scripts eject"
|
| 18 |
+
},
|
| 19 |
+
"eslintConfig": {
|
| 20 |
+
"extends": "react-app"
|
| 21 |
+
},
|
| 22 |
+
"browserslist": {
|
| 23 |
+
"production": [
|
| 24 |
+
">0.2%",
|
| 25 |
+
"not dead",
|
| 26 |
+
"not op_mini all"
|
| 27 |
+
],
|
| 28 |
+
"development": [
|
| 29 |
+
"last 1 chrome version",
|
| 30 |
+
"last 1 firefox version",
|
| 31 |
+
"last 1 safari version"
|
| 32 |
+
]
|
| 33 |
+
}
|
| 34 |
+
}
|
frontend/public/favicon.ico
ADDED
|
|
frontend/public/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-theme="light">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<link rel="icon" type="image/png" href="/neon-logo.png" />
|
| 7 |
+
<meta name="theme-color" content="#6366F1" />
|
| 8 |
+
<meta name="description" content="Neon.ai - AI to AI Conversations" />
|
| 9 |
+
<title>Neon.ai - AI to AI Conversations</title>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="root"></div>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
frontend/public/manifest.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"short_name": "React App",
|
| 3 |
+
"name": "Create React App Sample",
|
| 4 |
+
"icons": [
|
| 5 |
+
{
|
| 6 |
+
"src": "favicon.ico",
|
| 7 |
+
"sizes": "64x64 32x32 24x24 16x16",
|
| 8 |
+
"type": "image/x-icon"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"src": "logo192.png",
|
| 12 |
+
"type": "image/png",
|
| 13 |
+
"sizes": "192x192"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"src": "logo512.png",
|
| 17 |
+
"type": "image/png",
|
| 18 |
+
"sizes": "512x512"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"start_url": ".",
|
| 22 |
+
"display": "standalone",
|
| 23 |
+
"theme_color": "#000000",
|
| 24 |
+
"background_color": "#ffffff"
|
| 25 |
+
}
|
frontend/public/neon-logo.png
ADDED
|
Git LFS Details
|
frontend/public/robots.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# https://www.robotstxt.org/robotstxt.html
|
| 2 |
+
User-agent: *
|
frontend/src/App.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
+
import { Sun, Moon } from 'lucide-react';
|
| 3 |
+
import LLMSelector from './components/LLMSelector';
|
| 4 |
+
import PersonaAccordion from './components/PersonaAccordion';
|
| 5 |
+
import ChatControls from './components/ChatControls';
|
| 6 |
+
import ChatArea from './components/ChatArea';
|
| 7 |
+
import DevMenu from './components/DevMenu';
|
| 8 |
+
import AuthBadge from './components/AuthBadge';
|
| 9 |
+
import { fetchModels, generateRole, generateRoleFreeform, startChat, getOrchestrator, setOrchestrator, getSpeedPriority, setSpeedPriority, exportChat, exportApiLog, getAuthStatus } from './utils/api';
|
| 10 |
+
import './styles/variables.css';
|
| 11 |
+
import './styles/layout.css';
|
| 12 |
+
import './styles/components.css';
|
| 13 |
+
|
| 14 |
+
const EMPTY_PERSONA = { name: '', profile: '', identity: '', samples: '' };
|
| 15 |
+
|
| 16 |
+
function getDisplayName(modelId, providers, neonModels) {
|
| 17 |
+
if (!modelId) return '';
|
| 18 |
+
if (modelId.startsWith('neon:')) {
|
| 19 |
+
return modelId.split(':')[2] || modelId;
|
| 20 |
+
}
|
| 21 |
+
for (const p of (providers || [])) {
|
| 22 |
+
for (const m of p.models) {
|
| 23 |
+
if (m.id === modelId) return m.name;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
return modelId;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export default function App() {
|
| 30 |
+
const [theme, setTheme] = useState(() =>
|
| 31 |
+
window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
| 32 |
+
);
|
| 33 |
+
const [providers, setProviders] = useState([]);
|
| 34 |
+
const [neonModels, setNeonModels] = useState([]);
|
| 35 |
+
const [selections, setSelections] = useState([]);
|
| 36 |
+
const [personaA, setPersonaA] = useState({ ...EMPTY_PERSONA });
|
| 37 |
+
const [personaB, setPersonaB] = useState({ ...EMPTY_PERSONA });
|
| 38 |
+
const [accordionOpen, setAccordionOpen] = useState(true);
|
| 39 |
+
const [messages, setMessages] = useState([]);
|
| 40 |
+
const [systemMessages, setSystemMessages] = useState([]);
|
| 41 |
+
const [isRunning, setIsRunning] = useState(false);
|
| 42 |
+
const [statusText, setStatusText] = useState('');
|
| 43 |
+
const [sessionId, setSessionId] = useState(null);
|
| 44 |
+
const [chatFinished, setChatFinished] = useState(false);
|
| 45 |
+
const [orchestratorModel, setOrchestratorModel] = useState('');
|
| 46 |
+
const [personaMode, setPersonaMode] = useState('freeform');
|
| 47 |
+
const [roleStyle, setRoleStyle] = useState('ai_completed');
|
| 48 |
+
const [speedPriority, setSpeedPriorityState] = useState(false);
|
| 49 |
+
const [auth, setAuth] = useState(null);
|
| 50 |
+
const [showResponseTime, setShowResponseTime] = useState(false);
|
| 51 |
+
const [showChatStats, setShowChatStats] = useState(false);
|
| 52 |
+
const [rolePrompts, setRolePrompts] = useState(null);
|
| 53 |
+
const [rolePromptsOpen, setRolePromptsOpen] = useState(false);
|
| 54 |
+
const abortRef = useRef(null);
|
| 55 |
+
const lastRoleConfigRef = useRef(null);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 59 |
+
}, [theme]);
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
fetchModels()
|
| 65 |
+
.then(data => {
|
| 66 |
+
setProviders(data.providers || []);
|
| 67 |
+
setNeonModels(data.neon_models || []);
|
| 68 |
+
})
|
| 69 |
+
.catch(err => console.error('Failed to load models:', err));
|
| 70 |
+
getOrchestrator()
|
| 71 |
+
.then(data => setOrchestratorModel(data.model_id || ''))
|
| 72 |
+
.catch(() => {});
|
| 73 |
+
getSpeedPriority()
|
| 74 |
+
.then(data => setSpeedPriorityState(!!data.enabled))
|
| 75 |
+
.catch(() => {});
|
| 76 |
+
getAuthStatus().then(setAuth).catch(() => {});
|
| 77 |
+
}, []);
|
| 78 |
+
|
| 79 |
+
const allModelsFlat = useMemo(() => {
|
| 80 |
+
const list = [];
|
| 81 |
+
for (const p of providers) {
|
| 82 |
+
for (const m of p.models) {
|
| 83 |
+
list.push({ id: m.id, name: m.name, provider: p.name });
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
for (const nm of neonModels) {
|
| 87 |
+
for (const p of (nm.personas || [])) {
|
| 88 |
+
if (p.enabled === false) continue;
|
| 89 |
+
list.push({
|
| 90 |
+
id: `neon:${nm.model_id}:${p.persona_name}`,
|
| 91 |
+
name: p.persona_name,
|
| 92 |
+
provider: `Neon / ${nm.name.split('/').pop()}`,
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
return list;
|
| 97 |
+
}, [providers, neonModels]);
|
| 98 |
+
|
| 99 |
+
const handleOrchestratorChange = useCallback(async (modelId) => {
|
| 100 |
+
try {
|
| 101 |
+
await setOrchestrator(modelId || '');
|
| 102 |
+
setOrchestratorModel(modelId || '');
|
| 103 |
+
} catch (err) {
|
| 104 |
+
console.error('Failed to set orchestrator:', err);
|
| 105 |
+
}
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
const handlePersonaModeChange = useCallback((mode) => {
|
| 109 |
+
setPersonaMode(mode);
|
| 110 |
+
setRoleStyle(mode === 'freeform' ? 'ai_completed' : 'exact');
|
| 111 |
+
}, []);
|
| 112 |
+
|
| 113 |
+
const handleSpeedPriorityChange = useCallback(async (enabled) => {
|
| 114 |
+
try {
|
| 115 |
+
await setSpeedPriority(enabled);
|
| 116 |
+
setSpeedPriorityState(enabled);
|
| 117 |
+
} catch (err) {
|
| 118 |
+
console.error('Failed to set speed priority:', err);
|
| 119 |
+
}
|
| 120 |
+
}, []);
|
| 121 |
+
|
| 122 |
+
const downloadFile = useCallback((filename, content) => {
|
| 123 |
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
| 124 |
+
const url = URL.createObjectURL(blob);
|
| 125 |
+
const a = document.createElement('a');
|
| 126 |
+
a.href = url;
|
| 127 |
+
a.download = filename;
|
| 128 |
+
a.click();
|
| 129 |
+
URL.revokeObjectURL(url);
|
| 130 |
+
}, []);
|
| 131 |
+
|
| 132 |
+
const handleDownloadTxt = useCallback(async () => {
|
| 133 |
+
if (!sessionId) return;
|
| 134 |
+
try {
|
| 135 |
+
const result = await exportChat(sessionId, 'txt');
|
| 136 |
+
downloadFile(result.filename, result.content);
|
| 137 |
+
} catch (err) { console.error('Export failed:', err); }
|
| 138 |
+
}, [sessionId, downloadFile]);
|
| 139 |
+
|
| 140 |
+
const handleDownloadMd = useCallback(async () => {
|
| 141 |
+
if (!sessionId) return;
|
| 142 |
+
try {
|
| 143 |
+
const result = await exportChat(sessionId, 'md');
|
| 144 |
+
downloadFile(result.filename, result.content);
|
| 145 |
+
} catch (err) { console.error('Export failed:', err); }
|
| 146 |
+
}, [sessionId, downloadFile]);
|
| 147 |
+
|
| 148 |
+
const handleDownloadApiLog = useCallback(async () => {
|
| 149 |
+
if (!sessionId) return;
|
| 150 |
+
try {
|
| 151 |
+
const result = await exportApiLog(sessionId);
|
| 152 |
+
downloadFile('api_log.json', JSON.stringify(result, null, 2));
|
| 153 |
+
} catch (err) { console.error('API log export failed:', err); }
|
| 154 |
+
}, [sessionId, downloadFile]);
|
| 155 |
+
|
| 156 |
+
const selectedNameA = selections[0] ? getDisplayName(selections[0], providers, neonModels) : '';
|
| 157 |
+
const selectedNameB = selections[1] ? getDisplayName(selections[1], providers, neonModels) : '';
|
| 158 |
+
|
| 159 |
+
const canStart = selections.length === 2 && !isRunning;
|
| 160 |
+
|
| 161 |
+
const handleStop = useCallback(() => {
|
| 162 |
+
if (abortRef.current) {
|
| 163 |
+
abortRef.current.abort();
|
| 164 |
+
abortRef.current = null;
|
| 165 |
+
}
|
| 166 |
+
setIsRunning(false);
|
| 167 |
+
setChatFinished(true);
|
| 168 |
+
setStatusText('');
|
| 169 |
+
setSystemMessages(prev => [...prev, { text: 'Chat stopped by user.' }]);
|
| 170 |
+
}, []);
|
| 171 |
+
|
| 172 |
+
const handleStart = useCallback(async (starterText) => {
|
| 173 |
+
if (selections.length < 2) return;
|
| 174 |
+
|
| 175 |
+
const controller = new AbortController();
|
| 176 |
+
abortRef.current = controller;
|
| 177 |
+
|
| 178 |
+
setIsRunning(true);
|
| 179 |
+
setAccordionOpen(false);
|
| 180 |
+
setMessages([]);
|
| 181 |
+
setSystemMessages([]);
|
| 182 |
+
setChatFinished(false);
|
| 183 |
+
|
| 184 |
+
try {
|
| 185 |
+
const currentConfig = JSON.stringify({
|
| 186 |
+
selections, personaMode, roleStyle,
|
| 187 |
+
a: personaMode === 'freeform'
|
| 188 |
+
? { name: personaA.name, freeform: personaA.freeform || '' }
|
| 189 |
+
: { name: personaA.name, profile: personaA.profile, identity: personaA.identity, samples: personaA.samples },
|
| 190 |
+
b: personaMode === 'freeform'
|
| 191 |
+
? { name: personaB.name, freeform: personaB.freeform || '' }
|
| 192 |
+
: { name: personaB.name, profile: personaB.profile, identity: personaB.identity, samples: personaB.samples },
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
let cachedPrompts = rolePrompts;
|
| 196 |
+
const configChanged = currentConfig !== lastRoleConfigRef.current;
|
| 197 |
+
|
| 198 |
+
if (configChanged || !cachedPrompts) {
|
| 199 |
+
setStatusText('Generating expert persona roles...');
|
| 200 |
+
|
| 201 |
+
const genA = personaMode === 'freeform'
|
| 202 |
+
? generateRoleFreeform({ model_id: selections[0], name: personaA.name, text: personaA.freeform || '', role_style: roleStyle })
|
| 203 |
+
: generateRole({ model_id: selections[0], name: personaA.name, profile: personaA.profile, identity: personaA.identity, samples: personaA.samples, role_style: roleStyle });
|
| 204 |
+
const genB = personaMode === 'freeform'
|
| 205 |
+
? generateRoleFreeform({ model_id: selections[1], name: personaB.name, text: personaB.freeform || '', role_style: roleStyle })
|
| 206 |
+
: generateRole({ model_id: selections[1], name: personaB.name, profile: personaB.profile, identity: personaB.identity, samples: personaB.samples, role_style: roleStyle });
|
| 207 |
+
|
| 208 |
+
const [roleA, roleB] = await Promise.all([genA, genB]);
|
| 209 |
+
|
| 210 |
+
if (controller.signal.aborted) return;
|
| 211 |
+
|
| 212 |
+
cachedPrompts = {
|
| 213 |
+
a: { name: personaA.name || 'Expert Persona A', model: getDisplayName(selections[0], providers, neonModels), prompt: roleA.role_prompt },
|
| 214 |
+
b: { name: personaB.name || 'Expert Persona B', model: getDisplayName(selections[1], providers, neonModels), prompt: roleB.role_prompt },
|
| 215 |
+
};
|
| 216 |
+
setRolePrompts(cachedPrompts);
|
| 217 |
+
lastRoleConfigRef.current = currentConfig;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
setStatusText('Starting conversation...');
|
| 221 |
+
|
| 222 |
+
await startChat(
|
| 223 |
+
{
|
| 224 |
+
persona_a_model_id: selections[0],
|
| 225 |
+
persona_a_name: cachedPrompts.a.name,
|
| 226 |
+
persona_a_role: cachedPrompts.a.prompt,
|
| 227 |
+
persona_b_model_id: selections[1],
|
| 228 |
+
persona_b_name: cachedPrompts.b.name,
|
| 229 |
+
persona_b_role: cachedPrompts.b.prompt,
|
| 230 |
+
starter_text: starterText,
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
onSession: (data) => setSessionId(data.session_id),
|
| 234 |
+
onMessage: (data) => {
|
| 235 |
+
setMessages(prev => [...prev, data]);
|
| 236 |
+
setStatusText('Conversation in progress...');
|
| 237 |
+
},
|
| 238 |
+
onSystem: (data) => {
|
| 239 |
+
setSystemMessages(prev => [...prev, data]);
|
| 240 |
+
if (data.text === 'End of Chat') {
|
| 241 |
+
setChatFinished(true);
|
| 242 |
+
setStatusText('');
|
| 243 |
+
}
|
| 244 |
+
},
|
| 245 |
+
onStatus: (data) => setStatusText(data.message || ''),
|
| 246 |
+
onError: (data) => {
|
| 247 |
+
setStatusText('');
|
| 248 |
+
setSystemMessages(prev => [...prev, { text: `Error: ${data.message}` }]);
|
| 249 |
+
},
|
| 250 |
+
onDone: () => {
|
| 251 |
+
setIsRunning(false);
|
| 252 |
+
setStatusText('');
|
| 253 |
+
},
|
| 254 |
+
},
|
| 255 |
+
controller.signal,
|
| 256 |
+
);
|
| 257 |
+
} catch (err) {
|
| 258 |
+
if (err.name === 'AbortError') return;
|
| 259 |
+
console.error('Chat error:', err);
|
| 260 |
+
const isRateLimit = err.message && err.message.includes('Daily conversation limit');
|
| 261 |
+
setSystemMessages(prev => [...prev, {
|
| 262 |
+
text: isRateLimit
|
| 263 |
+
? 'Daily conversation limit reached (20/day). Sign in with HuggingFace for unlimited access.'
|
| 264 |
+
: `Error: ${err.message}`,
|
| 265 |
+
}]);
|
| 266 |
+
} finally {
|
| 267 |
+
setIsRunning(false);
|
| 268 |
+
abortRef.current = null;
|
| 269 |
+
getAuthStatus().then(setAuth).catch(() => {});
|
| 270 |
+
}
|
| 271 |
+
}, [selections, personaA, personaB, personaMode, roleStyle, rolePrompts]);
|
| 272 |
+
|
| 273 |
+
return (
|
| 274 |
+
<div className="app">
|
| 275 |
+
<header className="app-header">
|
| 276 |
+
<div className="header-left">
|
| 277 |
+
<a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link">
|
| 278 |
+
<img src="/neon-logo.png" alt="Neon.ai" className="app-logo" />
|
| 279 |
+
</a>
|
| 280 |
+
<h1 className="app-title"><a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">Neon.ai</a> - AI to AI Conversations</h1>
|
| 281 |
+
</div>
|
| 282 |
+
<div className="header-right">
|
| 283 |
+
<AuthBadge auth={auth} />
|
| 284 |
+
<button
|
| 285 |
+
className="icon-btn"
|
| 286 |
+
onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
|
| 287 |
+
title="Toggle theme"
|
| 288 |
+
>
|
| 289 |
+
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
| 290 |
+
</button>
|
| 291 |
+
<DevMenu
|
| 292 |
+
allModels={allModelsFlat}
|
| 293 |
+
orchestratorModel={orchestratorModel}
|
| 294 |
+
onOrchestratorChange={handleOrchestratorChange}
|
| 295 |
+
personaMode={personaMode}
|
| 296 |
+
onPersonaModeChange={handlePersonaModeChange}
|
| 297 |
+
roleStyle={roleStyle}
|
| 298 |
+
onRoleStyleChange={setRoleStyle}
|
| 299 |
+
speedPriority={speedPriority}
|
| 300 |
+
onSpeedPriorityChange={handleSpeedPriorityChange}
|
| 301 |
+
showResponseTime={showResponseTime}
|
| 302 |
+
onShowResponseTimeChange={setShowResponseTime}
|
| 303 |
+
showChatStats={showChatStats}
|
| 304 |
+
onShowChatStatsChange={setShowChatStats}
|
| 305 |
+
rolePrompts={rolePrompts}
|
| 306 |
+
onShowRolePrompts={() => setRolePromptsOpen(true)}
|
| 307 |
+
onDownloadChatTxt={handleDownloadTxt}
|
| 308 |
+
onDownloadChatMd={handleDownloadMd}
|
| 309 |
+
onDownloadApiLog={handleDownloadApiLog}
|
| 310 |
+
hasChat={messages.length > 0}
|
| 311 |
+
hasApiLog={!!sessionId}
|
| 312 |
+
/>
|
| 313 |
+
</div>
|
| 314 |
+
</header>
|
| 315 |
+
|
| 316 |
+
<main className="app-main">
|
| 317 |
+
<LLMSelector
|
| 318 |
+
providers={providers}
|
| 319 |
+
neonModels={neonModels}
|
| 320 |
+
selections={selections}
|
| 321 |
+
onSelectionsChange={setSelections}
|
| 322 |
+
/>
|
| 323 |
+
|
| 324 |
+
<div className="content">
|
| 325 |
+
<PersonaAccordion
|
| 326 |
+
isOpen={accordionOpen}
|
| 327 |
+
onToggle={() => setAccordionOpen(o => !o)}
|
| 328 |
+
personaA={personaA}
|
| 329 |
+
personaB={personaB}
|
| 330 |
+
onChangeA={setPersonaA}
|
| 331 |
+
onChangeB={setPersonaB}
|
| 332 |
+
selectedNameA={selectedNameA}
|
| 333 |
+
selectedNameB={selectedNameB}
|
| 334 |
+
mode={personaMode}
|
| 335 |
+
/>
|
| 336 |
+
|
| 337 |
+
<ChatControls
|
| 338 |
+
onStart={handleStart}
|
| 339 |
+
onStop={handleStop}
|
| 340 |
+
disabled={!canStart}
|
| 341 |
+
isRunning={isRunning}
|
| 342 |
+
/>
|
| 343 |
+
|
| 344 |
+
<ChatArea
|
| 345 |
+
messages={messages}
|
| 346 |
+
systemMessages={systemMessages}
|
| 347 |
+
isRunning={isRunning}
|
| 348 |
+
statusText={statusText}
|
| 349 |
+
showResponseTime={showResponseTime}
|
| 350 |
+
showChatStats={showChatStats}
|
| 351 |
+
/>
|
| 352 |
+
</div>
|
| 353 |
+
</main>
|
| 354 |
+
<footer className="app-footer">
|
| 355 |
+
Copyright Neon.ai. All rights reserved.{' '}
|
| 356 |
+
<a href="https://www.neon.ai/contact" target="_blank" rel="noopener noreferrer">Patents and licensing</a>
|
| 357 |
+
</footer>
|
| 358 |
+
|
| 359 |
+
{rolePromptsOpen && rolePrompts && (
|
| 360 |
+
<div className="modal-overlay" onClick={() => setRolePromptsOpen(false)}>
|
| 361 |
+
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
| 362 |
+
<div className="modal-header">
|
| 363 |
+
<h2>Generated Role Prompts</h2>
|
| 364 |
+
<button className="modal-close" onClick={() => setRolePromptsOpen(false)}>×</button>
|
| 365 |
+
</div>
|
| 366 |
+
<div className="modal-body">
|
| 367 |
+
<div className="role-prompt-section">
|
| 368 |
+
<h3>{rolePrompts.a.name} <span className="role-prompt-model">({rolePrompts.a.model})</span></h3>
|
| 369 |
+
<pre className="role-prompt-text">{rolePrompts.a.prompt}</pre>
|
| 370 |
+
</div>
|
| 371 |
+
<div className="role-prompt-section">
|
| 372 |
+
<h3>{rolePrompts.b.name} <span className="role-prompt-model">({rolePrompts.b.model})</span></h3>
|
| 373 |
+
<pre className="role-prompt-text">{rolePrompts.b.prompt}</pre>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
)}
|
| 379 |
+
</div>
|
| 380 |
+
);
|
| 381 |
+
}
|
frontend/src/components/AuthBadge.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { LogIn, LogOut, User } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function AuthBadge({ auth }) {
|
| 5 |
+
if (!auth) return null;
|
| 6 |
+
|
| 7 |
+
if (auth.logged_in) {
|
| 8 |
+
return (
|
| 9 |
+
<div className="auth-badge">
|
| 10 |
+
<User size={14} />
|
| 11 |
+
<span className="auth-username">{auth.username}</span>
|
| 12 |
+
{auth.is_org_member && <span className="auth-org-tag">org</span>}
|
| 13 |
+
{!auth.is_org_member && auth.remaining_conversations >= 0 && (
|
| 14 |
+
<span className="auth-remaining">{auth.remaining_conversations} left</span>
|
| 15 |
+
)}
|
| 16 |
+
<a href="/oauth/huggingface/logout" className="auth-link" title="Sign out">
|
| 17 |
+
<LogOut size={13} />
|
| 18 |
+
</a>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="auth-badge">
|
| 25 |
+
{auth.remaining_conversations >= 0 && (
|
| 26 |
+
<span className="auth-remaining">{auth.remaining_conversations}/20 chats</span>
|
| 27 |
+
)}
|
| 28 |
+
<a href="/oauth/huggingface/login" className="auth-link auth-login">
|
| 29 |
+
<LogIn size={13} /> Sign in
|
| 30 |
+
</a>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
frontend/src/components/ChatArea.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef, useMemo } from 'react';
|
| 2 |
+
import MessageBubble from './MessageBubble';
|
| 3 |
+
|
| 4 |
+
export default function ChatArea({ messages, systemMessages, isRunning, statusText, showResponseTime, showChatStats }) {
|
| 5 |
+
const endRef = useRef(null);
|
| 6 |
+
|
| 7 |
+
useEffect(() => {
|
| 8 |
+
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 9 |
+
}, [messages, systemMessages]);
|
| 10 |
+
|
| 11 |
+
const hasContent = messages.length > 0 || systemMessages.length > 0;
|
| 12 |
+
const chatEnded = systemMessages.some(s => s.text === 'End of Chat');
|
| 13 |
+
|
| 14 |
+
const stats = useMemo(() => {
|
| 15 |
+
if (!chatEnded || messages.length === 0) return null;
|
| 16 |
+
const totalTime = messages.reduce((sum, m) => sum + (m.elapsed_seconds || 0), 0);
|
| 17 |
+
return { count: messages.length, totalTime: totalTime.toFixed(1) };
|
| 18 |
+
}, [chatEnded, messages]);
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="chat-area">
|
| 22 |
+
{!hasContent && !isRunning && (
|
| 23 |
+
<div className="chat-empty">
|
| 24 |
+
Select two LLMs, configure expert personas, and start a conversation.
|
| 25 |
+
</div>
|
| 26 |
+
)}
|
| 27 |
+
|
| 28 |
+
{messages.map((msg, i) => (
|
| 29 |
+
<MessageBubble key={i} message={msg} showResponseTime={showResponseTime} />
|
| 30 |
+
))}
|
| 31 |
+
|
| 32 |
+
{systemMessages.map((sys, i) => (
|
| 33 |
+
<div
|
| 34 |
+
key={`sys-${i}`}
|
| 35 |
+
className={`system-message ${sys.text === 'End of Chat' ? 'end-of-chat' : ''}`}
|
| 36 |
+
>
|
| 37 |
+
{sys.text}
|
| 38 |
+
</div>
|
| 39 |
+
))}
|
| 40 |
+
|
| 41 |
+
{showChatStats && stats && (
|
| 42 |
+
<div className="chat-stats">
|
| 43 |
+
{stats.count} messages · {stats.totalTime}s total generation time
|
| 44 |
+
</div>
|
| 45 |
+
)}
|
| 46 |
+
|
| 47 |
+
{isRunning && statusText && (
|
| 48 |
+
<div className="status-bar">
|
| 49 |
+
<div className="spinner" />
|
| 50 |
+
<span>{statusText}</span>
|
| 51 |
+
</div>
|
| 52 |
+
)}
|
| 53 |
+
|
| 54 |
+
<div ref={endRef} />
|
| 55 |
+
</div>
|
| 56 |
+
);
|
| 57 |
+
}
|
frontend/src/components/ChatControls.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Play, Shuffle, Square } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function ChatControls({ onStart, onStop, disabled, isRunning }) {
|
| 5 |
+
const [starterText, setStarterText] = useState('');
|
| 6 |
+
|
| 7 |
+
const handleStartWithText = () => {
|
| 8 |
+
onStart(starterText.trim() || null);
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const handleAutoStart = () => {
|
| 12 |
+
onStart(null);
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="chat-controls">
|
| 17 |
+
{isRunning ? (
|
| 18 |
+
<button className="btn-stop" onClick={onStop} title="Stop the conversation">
|
| 19 |
+
<Square size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 20 |
+
Stop Chat
|
| 21 |
+
</button>
|
| 22 |
+
) : (
|
| 23 |
+
<>
|
| 24 |
+
<button
|
| 25 |
+
className="btn-primary"
|
| 26 |
+
onClick={handleAutoStart}
|
| 27 |
+
disabled={disabled}
|
| 28 |
+
title="Let the LLMs start on their own"
|
| 29 |
+
>
|
| 30 |
+
<Shuffle size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 31 |
+
Let Them Start
|
| 32 |
+
</button>
|
| 33 |
+
<input
|
| 34 |
+
type="text"
|
| 35 |
+
placeholder="Let them start on their own, or enter a conversation starter here"
|
| 36 |
+
value={starterText}
|
| 37 |
+
onChange={e => setStarterText(e.target.value)}
|
| 38 |
+
disabled={disabled}
|
| 39 |
+
onKeyDown={e => {
|
| 40 |
+
if (e.key === 'Enter' && !disabled) handleStartWithText();
|
| 41 |
+
}}
|
| 42 |
+
/>
|
| 43 |
+
<button
|
| 44 |
+
className="btn-primary"
|
| 45 |
+
onClick={handleStartWithText}
|
| 46 |
+
disabled={disabled}
|
| 47 |
+
title="Start with your message"
|
| 48 |
+
>
|
| 49 |
+
<Play size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 50 |
+
Start Chat With My Prompt
|
| 51 |
+
</button>
|
| 52 |
+
</>
|
| 53 |
+
)}
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
frontend/src/components/DevMenu.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
| 2 |
+
import { ChevronRight, Download, Settings2, Search, Check, Eye, EyeOff, FileText, Square, CheckSquare } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function DevMenu({
|
| 5 |
+
allModels,
|
| 6 |
+
orchestratorModel,
|
| 7 |
+
onOrchestratorChange,
|
| 8 |
+
personaMode,
|
| 9 |
+
onPersonaModeChange,
|
| 10 |
+
roleStyle,
|
| 11 |
+
onRoleStyleChange,
|
| 12 |
+
speedPriority,
|
| 13 |
+
onSpeedPriorityChange,
|
| 14 |
+
showResponseTime,
|
| 15 |
+
onShowResponseTimeChange,
|
| 16 |
+
showChatStats,
|
| 17 |
+
onShowChatStatsChange,
|
| 18 |
+
rolePrompts,
|
| 19 |
+
onShowRolePrompts,
|
| 20 |
+
onDownloadApiLog,
|
| 21 |
+
onDownloadChatTxt,
|
| 22 |
+
onDownloadChatMd,
|
| 23 |
+
hasApiLog,
|
| 24 |
+
hasChat,
|
| 25 |
+
}) {
|
| 26 |
+
const [open, setOpen] = useState(false);
|
| 27 |
+
const [orchOpen, setOrchOpen] = useState(false);
|
| 28 |
+
const [q, setQ] = useState('');
|
| 29 |
+
const wrapRef = useRef(null);
|
| 30 |
+
const searchRef = useRef(null);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
if (orchOpen && searchRef.current) searchRef.current.focus();
|
| 34 |
+
}, [orchOpen]);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
function handleClickOutside(e) {
|
| 38 |
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
| 39 |
+
setOpen(false);
|
| 40 |
+
setOrchOpen(false);
|
| 41 |
+
setQ('');
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 45 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
const filtered = useMemo(() => {
|
| 49 |
+
const s = q.trim().toLowerCase();
|
| 50 |
+
if (!s) return allModels;
|
| 51 |
+
return allModels.filter(row => {
|
| 52 |
+
const hay = `${row.name} ${row.id} ${row.provider || ''}`.toLowerCase();
|
| 53 |
+
return hay.includes(s);
|
| 54 |
+
});
|
| 55 |
+
}, [allModels, q]);
|
| 56 |
+
|
| 57 |
+
const currentName = useMemo(() => {
|
| 58 |
+
if (!orchestratorModel) return 'Default (backend)';
|
| 59 |
+
const m = allModels.find(m => m.id === orchestratorModel);
|
| 60 |
+
return m ? m.name : orchestratorModel;
|
| 61 |
+
}, [orchestratorModel, allModels]);
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<div className="dev-wrap" ref={wrapRef}>
|
| 65 |
+
<div className="dev-download-btns">
|
| 66 |
+
<button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatTxt}>
|
| 67 |
+
<Download size={14} /> .txt
|
| 68 |
+
</button>
|
| 69 |
+
<button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatMd}>
|
| 70 |
+
<Download size={14} /> .md
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="dev-dropdown-header">
|
| 75 |
+
<button className="icon-btn" onClick={() => { setOpen(o => !o); setOrchOpen(false); setQ(''); }} title="Settings">
|
| 76 |
+
<Settings2 size={16} />
|
| 77 |
+
</button>
|
| 78 |
+
{open && (
|
| 79 |
+
<div className="dev-panel">
|
| 80 |
+
<button onClick={() => { setOrchOpen(o => !o); setQ(''); }}>
|
| 81 |
+
Orchestrator model… <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
|
| 82 |
+
</button>
|
| 83 |
+
<div className="dev-panel-divider" />
|
| 84 |
+
<div className="dev-panel-label">Response priority</div>
|
| 85 |
+
<button
|
| 86 |
+
className={`dev-panel-choice ${!speedPriority ? 'dev-panel-choice-active' : ''}`}
|
| 87 |
+
onClick={() => onSpeedPriorityChange(false)}
|
| 88 |
+
>
|
| 89 |
+
{!speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 90 |
+
Prioritize model choice
|
| 91 |
+
</button>
|
| 92 |
+
<button
|
| 93 |
+
className={`dev-panel-choice ${speedPriority ? 'dev-panel-choice-active' : ''}`}
|
| 94 |
+
onClick={() => onSpeedPriorityChange(true)}
|
| 95 |
+
>
|
| 96 |
+
{speedPriority ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 97 |
+
Prioritize conversation speed
|
| 98 |
+
</button>
|
| 99 |
+
<div className="dev-panel-divider" />
|
| 100 |
+
<div className="dev-panel-label">Expert persona input</div>
|
| 101 |
+
<button
|
| 102 |
+
className={`dev-panel-choice ${personaMode === 'structured' ? 'dev-panel-choice-active' : ''}`}
|
| 103 |
+
onClick={() => onPersonaModeChange('structured')}
|
| 104 |
+
>
|
| 105 |
+
{personaMode === 'structured' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 106 |
+
Structured expert persona input
|
| 107 |
+
</button>
|
| 108 |
+
<button
|
| 109 |
+
className={`dev-panel-choice ${personaMode === 'freeform' ? 'dev-panel-choice-active' : ''}`}
|
| 110 |
+
onClick={() => onPersonaModeChange('freeform')}
|
| 111 |
+
>
|
| 112 |
+
{personaMode === 'freeform' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 113 |
+
Freeform expert persona input
|
| 114 |
+
</button>
|
| 115 |
+
<div className="dev-panel-divider" />
|
| 116 |
+
<div className="dev-panel-label">Role generation</div>
|
| 117 |
+
<button
|
| 118 |
+
className={`dev-panel-choice ${roleStyle === 'ai_completed' ? 'dev-panel-choice-active' : ''}`}
|
| 119 |
+
onClick={() => onRoleStyleChange('ai_completed')}
|
| 120 |
+
>
|
| 121 |
+
{roleStyle === 'ai_completed' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 122 |
+
AI completed roles
|
| 123 |
+
</button>
|
| 124 |
+
<button
|
| 125 |
+
className={`dev-panel-choice ${roleStyle === 'exact' ? 'dev-panel-choice-active' : ''}`}
|
| 126 |
+
onClick={() => onRoleStyleChange('exact')}
|
| 127 |
+
>
|
| 128 |
+
{roleStyle === 'exact' ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 129 |
+
Exact user roles
|
| 130 |
+
</button>
|
| 131 |
+
<div className="dev-panel-divider" />
|
| 132 |
+
<div className="dev-panel-label">Display options</div>
|
| 133 |
+
<button
|
| 134 |
+
className={`dev-panel-choice ${showResponseTime ? 'dev-panel-choice-active' : ''}`}
|
| 135 |
+
onClick={() => onShowResponseTimeChange(!showResponseTime)}
|
| 136 |
+
>
|
| 137 |
+
{showResponseTime ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 138 |
+
Response times on messages
|
| 139 |
+
</button>
|
| 140 |
+
<button
|
| 141 |
+
className={`dev-panel-choice ${showChatStats ? 'dev-panel-choice-active' : ''}`}
|
| 142 |
+
onClick={() => onShowChatStatsChange(!showChatStats)}
|
| 143 |
+
>
|
| 144 |
+
{showChatStats ? <CheckSquare size={16} className="dev-check-icon" /> : <Square size={16} className="dev-check-icon" />}
|
| 145 |
+
Chat stats after end
|
| 146 |
+
</button>
|
| 147 |
+
<button className="dev-panel-choice" disabled={!rolePrompts} onClick={() => { onShowRolePrompts(); setOpen(false); }}>
|
| 148 |
+
<FileText size={14} className="dev-check-icon" />
|
| 149 |
+
View role prompts
|
| 150 |
+
</button>
|
| 151 |
+
<div className="dev-panel-divider" />
|
| 152 |
+
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatTxt(); setOpen(false); }}>
|
| 153 |
+
Download chat as .txt
|
| 154 |
+
</button>
|
| 155 |
+
<button disabled={!hasChat} className="dev-panel-download-item" onClick={() => { onDownloadChatMd(); setOpen(false); }}>
|
| 156 |
+
Download chat as .md
|
| 157 |
+
</button>
|
| 158 |
+
<button disabled={!hasApiLog} onClick={() => { onDownloadApiLog(); setOpen(false); }}>
|
| 159 |
+
Download full API history
|
| 160 |
+
</button>
|
| 161 |
+
</div>
|
| 162 |
+
)}
|
| 163 |
+
|
| 164 |
+
{open && orchOpen && (
|
| 165 |
+
<div className="dev-sub-panel">
|
| 166 |
+
<div className="dev-sub-header">
|
| 167 |
+
<span className="dev-sub-title">Orchestrator</span>
|
| 168 |
+
<span className="dev-sub-current">{currentName}</span>
|
| 169 |
+
</div>
|
| 170 |
+
<div className="dev-sub-search">
|
| 171 |
+
<Search size={14} className="dev-sub-search-icon" />
|
| 172 |
+
<input
|
| 173 |
+
ref={searchRef}
|
| 174 |
+
type="search"
|
| 175 |
+
placeholder="Search models…"
|
| 176 |
+
value={q}
|
| 177 |
+
onChange={e => setQ(e.target.value)}
|
| 178 |
+
/>
|
| 179 |
+
</div>
|
| 180 |
+
<ul className="dev-sub-list">
|
| 181 |
+
<li>
|
| 182 |
+
<button
|
| 183 |
+
className={`dev-sub-item ${!orchestratorModel ? 'dev-sub-item-active' : ''}`}
|
| 184 |
+
onClick={() => { onOrchestratorChange(null); setOrchOpen(false); setOpen(false); setQ(''); }}
|
| 185 |
+
>
|
| 186 |
+
<strong>Default (backend)</strong>
|
| 187 |
+
<span className="dev-sub-provider">Use server default</span>
|
| 188 |
+
</button>
|
| 189 |
+
</li>
|
| 190 |
+
{filtered.map(m => (
|
| 191 |
+
<li key={m.id}>
|
| 192 |
+
<button
|
| 193 |
+
className={`dev-sub-item ${orchestratorModel === m.id ? 'dev-sub-item-active' : ''}`}
|
| 194 |
+
onClick={() => { onOrchestratorChange(m.id); setOrchOpen(false); setOpen(false); setQ(''); }}
|
| 195 |
+
>
|
| 196 |
+
<strong>{m.name}</strong>
|
| 197 |
+
<span className="dev-sub-provider">{m.provider}</span>
|
| 198 |
+
</button>
|
| 199 |
+
</li>
|
| 200 |
+
))}
|
| 201 |
+
</ul>
|
| 202 |
+
</div>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
);
|
| 207 |
+
}
|
frontend/src/components/ExportBar.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Download, Settings } from 'lucide-react';
|
| 3 |
+
import { exportChat, exportApiLog } from '../utils/api';
|
| 4 |
+
|
| 5 |
+
export default function ExportBar({ sessionId }) {
|
| 6 |
+
const [devOpen, setDevOpen] = useState(false);
|
| 7 |
+
const dropdownRef = useRef(null);
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
const handleClickOutside = (e) => {
|
| 11 |
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
| 12 |
+
setDevOpen(false);
|
| 13 |
+
}
|
| 14 |
+
};
|
| 15 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 16 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 17 |
+
}, []);
|
| 18 |
+
|
| 19 |
+
const downloadFile = (filename, content) => {
|
| 20 |
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
| 21 |
+
const url = URL.createObjectURL(blob);
|
| 22 |
+
const a = document.createElement('a');
|
| 23 |
+
a.href = url;
|
| 24 |
+
a.download = filename;
|
| 25 |
+
a.click();
|
| 26 |
+
URL.revokeObjectURL(url);
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const handleExport = async (fmt) => {
|
| 30 |
+
try {
|
| 31 |
+
const result = await exportChat(sessionId, fmt);
|
| 32 |
+
downloadFile(result.filename, result.content);
|
| 33 |
+
} catch (err) {
|
| 34 |
+
console.error('Export failed:', err);
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const handleApiLogExport = async () => {
|
| 39 |
+
try {
|
| 40 |
+
const result = await exportApiLog(sessionId);
|
| 41 |
+
downloadFile('api_log.json', JSON.stringify(result, null, 2));
|
| 42 |
+
setDevOpen(false);
|
| 43 |
+
} catch (err) {
|
| 44 |
+
console.error('API log export failed:', err);
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
if (!sessionId) return null;
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div className="export-bar">
|
| 52 |
+
<button className="btn-secondary" onClick={() => handleExport('txt')}>
|
| 53 |
+
<Download size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 54 |
+
Download .txt
|
| 55 |
+
</button>
|
| 56 |
+
<button className="btn-secondary" onClick={() => handleExport('md')}>
|
| 57 |
+
<Download size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
|
| 58 |
+
Download .md
|
| 59 |
+
</button>
|
| 60 |
+
|
| 61 |
+
<div className="dev-dropdown" ref={dropdownRef}>
|
| 62 |
+
<button
|
| 63 |
+
className="icon-btn"
|
| 64 |
+
onClick={() => setDevOpen(o => !o)}
|
| 65 |
+
title="Developer Options"
|
| 66 |
+
>
|
| 67 |
+
<Settings size={16} />
|
| 68 |
+
</button>
|
| 69 |
+
{devOpen && (
|
| 70 |
+
<div className="dev-dropdown-menu">
|
| 71 |
+
<button onClick={handleApiLogExport}>
|
| 72 |
+
Download Full API Log
|
| 73 |
+
</button>
|
| 74 |
+
</div>
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
}
|
frontend/src/components/LLMSelector.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useState } from 'react';
|
| 2 |
+
import { Cloud, ChevronDown, ChevronRight, User } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function LLMSelector({ providers, neonModels, selections, onSelectionsChange }) {
|
| 5 |
+
const [openGroups, setOpenGroups] = useState({});
|
| 6 |
+
|
| 7 |
+
const toggleGroup = (key) => {
|
| 8 |
+
setOpenGroups(prev => ({ ...prev, [key]: !prev[key] }));
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
const handleClick = useCallback((modelId) => {
|
| 12 |
+
onSelectionsChange(prev => {
|
| 13 |
+
const isSelected = prev.includes(modelId);
|
| 14 |
+
const isBoth = prev.length === 2 && prev[0] === modelId && prev[1] === modelId;
|
| 15 |
+
|
| 16 |
+
if (isBoth) return [];
|
| 17 |
+
|
| 18 |
+
if (isSelected) return [modelId, modelId];
|
| 19 |
+
|
| 20 |
+
if (prev.length < 2) return [...prev, modelId];
|
| 21 |
+
|
| 22 |
+
return [prev[1], modelId];
|
| 23 |
+
});
|
| 24 |
+
}, [onSelectionsChange]);
|
| 25 |
+
|
| 26 |
+
const getIndicatorClass = (modelId) => {
|
| 27 |
+
const [a, b] = selections;
|
| 28 |
+
if (a === modelId && b === modelId) return 'select-indicator double-selected';
|
| 29 |
+
if (a === modelId) return 'select-indicator selected-a';
|
| 30 |
+
if (b === modelId) return 'select-indicator selected-b';
|
| 31 |
+
return 'select-indicator';
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const getLabel = (modelId) => {
|
| 35 |
+
const [a, b] = selections;
|
| 36 |
+
if (a === modelId && b === modelId) return 'AB';
|
| 37 |
+
if (a === modelId) return 'A';
|
| 38 |
+
if (b === modelId) return 'B';
|
| 39 |
+
return '';
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const shortName = (name) => name.split('/').pop() || name;
|
| 43 |
+
|
| 44 |
+
const renderModel = (model) => (
|
| 45 |
+
<button
|
| 46 |
+
key={model.id}
|
| 47 |
+
className="model-btn"
|
| 48 |
+
onClick={() => handleClick(model.id)}
|
| 49 |
+
>
|
| 50 |
+
<div className={getIndicatorClass(model.id)}>
|
| 51 |
+
{getLabel(model.id) && <span className="selection-label">{getLabel(model.id)}</span>}
|
| 52 |
+
</div>
|
| 53 |
+
<span className="model-name">{model.name}</span>
|
| 54 |
+
{model.params && <span className="model-params">{model.params}</span>}
|
| 55 |
+
</button>
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
const renderNeonPersona = (persona) => (
|
| 59 |
+
<button
|
| 60 |
+
key={persona.id}
|
| 61 |
+
className="neon-persona-item"
|
| 62 |
+
onClick={() => handleClick(persona.id)}
|
| 63 |
+
>
|
| 64 |
+
<div className={getIndicatorClass(persona.id)}>
|
| 65 |
+
{getLabel(persona.id) && <span className="selection-label">{getLabel(persona.id)}</span>}
|
| 66 |
+
</div>
|
| 67 |
+
<div className="persona-details">
|
| 68 |
+
<div className="persona-name-row">
|
| 69 |
+
<User size={12} />
|
| 70 |
+
{persona.name}
|
| 71 |
+
</div>
|
| 72 |
+
{persona.systemPrompt && (
|
| 73 |
+
<div className="persona-prompt-preview">
|
| 74 |
+
{persona.systemPrompt.slice(0, 120)}
|
| 75 |
+
{persona.systemPrompt.length > 120 ? '…' : ''}
|
| 76 |
+
</div>
|
| 77 |
+
)}
|
| 78 |
+
{!persona.systemPrompt && (
|
| 79 |
+
<div className="persona-prompt-preview">No system prompt (vanilla)</div>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
</button>
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<div className="sidebar">
|
| 87 |
+
<h2 className="sidebar-title">AI Models</h2>
|
| 88 |
+
|
| 89 |
+
{(neonModels || []).length > 0 && (
|
| 90 |
+
<div className="sidebar-section">
|
| 91 |
+
<h3 className="selector-title">
|
| 92 |
+
<img src="/neon-logo.png" alt="" className="selector-title-icon" />
|
| 93 |
+
Neon.ai Models
|
| 94 |
+
</h3>
|
| 95 |
+
<div className="neon-model-list">
|
| 96 |
+
{[...(neonModels || [])].sort((a, b) => shortName(a.name).localeCompare(shortName(b.name))).map(model => {
|
| 97 |
+
const key = `neon-${model.model_id}`;
|
| 98 |
+
const isOpen = !!openGroups[key];
|
| 99 |
+
const activePersonas = (model.personas || []).filter(p => p.enabled !== false);
|
| 100 |
+
return (
|
| 101 |
+
<div key={key} className="neon-model-card">
|
| 102 |
+
<button
|
| 103 |
+
className="neon-model-header"
|
| 104 |
+
onClick={() => toggleGroup(key)}
|
| 105 |
+
>
|
| 106 |
+
<div className="neon-model-info">
|
| 107 |
+
<span className="neon-model-name">{shortName(model.name)}</span>
|
| 108 |
+
{model.version && <span className="neon-model-version">v{model.version}</span>}
|
| 109 |
+
</div>
|
| 110 |
+
<div className="neon-model-meta">
|
| 111 |
+
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
| 112 |
+
</div>
|
| 113 |
+
</button>
|
| 114 |
+
{isOpen && (
|
| 115 |
+
<div className="neon-persona-list">
|
| 116 |
+
{activePersonas.map(persona => renderNeonPersona({
|
| 117 |
+
id: `neon:${model.model_id}:${persona.persona_name}`,
|
| 118 |
+
name: persona.persona_name,
|
| 119 |
+
systemPrompt: persona.system_prompt || '',
|
| 120 |
+
}))}
|
| 121 |
+
</div>
|
| 122 |
+
)}
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
})}
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
{(providers || []).length > 0 && (
|
| 131 |
+
<div className="sidebar-section">
|
| 132 |
+
<h3 className="selector-title">
|
| 133 |
+
<Cloud size={16} />
|
| 134 |
+
Other Models
|
| 135 |
+
</h3>
|
| 136 |
+
{[...(providers || [])].sort((a, b) => a.name.localeCompare(b.name)).map(provider => {
|
| 137 |
+
const key = `prov-${provider.id}`;
|
| 138 |
+
const isOpen = !!openGroups[key];
|
| 139 |
+
return (
|
| 140 |
+
<div key={key} className="provider-group comp-group">
|
| 141 |
+
<button className="provider-accordion-header" onClick={() => toggleGroup(key)}>
|
| 142 |
+
<span className="provider-accordion-title">{provider.name}</span>
|
| 143 |
+
<span className="provider-accordion-meta">
|
| 144 |
+
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
| 145 |
+
</span>
|
| 146 |
+
</button>
|
| 147 |
+
{isOpen && (
|
| 148 |
+
<div className="model-list">
|
| 149 |
+
{provider.models.map(renderModel)}
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
})}
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
</div>
|
| 158 |
+
);
|
| 159 |
+
}
|
frontend/src/components/MessageBubble.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactMarkdown from 'react-markdown';
|
| 3 |
+
import remarkGfm from 'remark-gfm';
|
| 4 |
+
|
| 5 |
+
export default function MessageBubble({ message, showResponseTime }) {
|
| 6 |
+
const isA = message.speaker_idx === 0;
|
| 7 |
+
const side = isA ? 'a' : 'b';
|
| 8 |
+
const initial = message.speaker ? message.speaker.charAt(0).toUpperCase() : (isA ? 'A' : 'B');
|
| 9 |
+
const elapsed = message.elapsed_seconds;
|
| 10 |
+
|
| 11 |
+
return (
|
| 12 |
+
<div className={`message-row speaker-${side}`}>
|
| 13 |
+
<div className={`avatar avatar-${side}`}>
|
| 14 |
+
{initial}
|
| 15 |
+
</div>
|
| 16 |
+
<div className={`message-bubble bubble-${side}`}>
|
| 17 |
+
<div className="message-speaker">{message.speaker}</div>
|
| 18 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 19 |
+
{message.text}
|
| 20 |
+
</ReactMarkdown>
|
| 21 |
+
{showResponseTime && elapsed > 0 && (
|
| 22 |
+
<div className="message-elapsed">{elapsed.toFixed(1)}s</div>
|
| 23 |
+
)}
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
frontend/src/components/PersonaAccordion.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef } from 'react';
|
| 2 |
+
import { ChevronDown, ChevronRight, Upload } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
const IDENTITY_PLACEHOLDER =
|
| 5 |
+
'Example: You are William Shakespeare, the Bard of Avon, and you speak exclusively in Early Modern English. Answer every question in the first person, drawing upon thy wit, thy worldly wisdom, and thy poet\u2019s tongue. Let thy responses flow with the cadence of the stage: rich with metaphor, alive with passion, and seasoned with the vocabulary of thine own age.';
|
| 6 |
+
|
| 7 |
+
const FREEFORM_PLACEHOLDER =
|
| 8 |
+
'Enter information here to give your LLM instructions on its identity and the way it should respond. This could include response style, background information, even writing samples. You can also upload a .txt or .md file.';
|
| 9 |
+
|
| 10 |
+
export default function PersonaAccordion({
|
| 11 |
+
isOpen,
|
| 12 |
+
onToggle,
|
| 13 |
+
personaA,
|
| 14 |
+
personaB,
|
| 15 |
+
onChangeA,
|
| 16 |
+
onChangeB,
|
| 17 |
+
selectedNameA,
|
| 18 |
+
selectedNameB,
|
| 19 |
+
mode,
|
| 20 |
+
}) {
|
| 21 |
+
return (
|
| 22 |
+
<div className="accordion">
|
| 23 |
+
<button className="accordion-header" onClick={onToggle}>
|
| 24 |
+
<span>Expert Persona Configuration</span>
|
| 25 |
+
{isOpen ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
| 26 |
+
</button>
|
| 27 |
+
<div className={`accordion-body ${isOpen ? 'open' : ''}`}>
|
| 28 |
+
<div className="persona-panels">
|
| 29 |
+
{mode === 'freeform' ? (
|
| 30 |
+
<>
|
| 31 |
+
<FreeformPanel
|
| 32 |
+
label="A"
|
| 33 |
+
selectedLLM={selectedNameA}
|
| 34 |
+
data={personaA}
|
| 35 |
+
onChange={onChangeA}
|
| 36 |
+
/>
|
| 37 |
+
<FreeformPanel
|
| 38 |
+
label="B"
|
| 39 |
+
selectedLLM={selectedNameB}
|
| 40 |
+
data={personaB}
|
| 41 |
+
onChange={onChangeB}
|
| 42 |
+
/>
|
| 43 |
+
</>
|
| 44 |
+
) : (
|
| 45 |
+
<>
|
| 46 |
+
<StructuredPanel
|
| 47 |
+
label="A"
|
| 48 |
+
selectedLLM={selectedNameA}
|
| 49 |
+
data={personaA}
|
| 50 |
+
onChange={onChangeA}
|
| 51 |
+
/>
|
| 52 |
+
<StructuredPanel
|
| 53 |
+
label="B"
|
| 54 |
+
selectedLLM={selectedNameB}
|
| 55 |
+
data={personaB}
|
| 56 |
+
onChange={onChangeB}
|
| 57 |
+
/>
|
| 58 |
+
</>
|
| 59 |
+
)}
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function StructuredPanel({ label, selectedLLM, data, onChange }) {
|
| 67 |
+
const update = (field) => (e) => onChange({ ...data, [field]: e.target.value });
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div className="persona-panel">
|
| 71 |
+
<div className="persona-panel-header">
|
| 72 |
+
<span>Expert Persona {label}</span>
|
| 73 |
+
{selectedLLM && (
|
| 74 |
+
<span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-tertiary)' }}>
|
| 75 |
+
— {selectedLLM}
|
| 76 |
+
</span>
|
| 77 |
+
)}
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div className="persona-field">
|
| 81 |
+
<label>Name</label>
|
| 82 |
+
<input
|
| 83 |
+
type="text"
|
| 84 |
+
placeholder="Enter expert persona name"
|
| 85 |
+
value={data.name}
|
| 86 |
+
onChange={update('name')}
|
| 87 |
+
/>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div className="persona-field">
|
| 91 |
+
<label>Profile</label>
|
| 92 |
+
<textarea
|
| 93 |
+
placeholder="Paste a real or fictional profile here"
|
| 94 |
+
value={data.profile}
|
| 95 |
+
onChange={update('profile')}
|
| 96 |
+
/>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="persona-field">
|
| 100 |
+
<label>Identity Prompt</label>
|
| 101 |
+
<textarea
|
| 102 |
+
className="tall"
|
| 103 |
+
placeholder={IDENTITY_PLACEHOLDER}
|
| 104 |
+
value={data.identity}
|
| 105 |
+
onChange={update('identity')}
|
| 106 |
+
/>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div className="persona-field">
|
| 110 |
+
<label>Writing / Speech Sample</label>
|
| 111 |
+
<textarea
|
| 112 |
+
className="tall"
|
| 113 |
+
placeholder="Paste quotes, transcripts, or writing samples here"
|
| 114 |
+
value={data.samples}
|
| 115 |
+
onChange={update('samples')}
|
| 116 |
+
/>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function FreeformPanel({ label, selectedLLM, data, onChange }) {
|
| 123 |
+
const fileRef = useRef(null);
|
| 124 |
+
|
| 125 |
+
const handleFileUpload = (e) => {
|
| 126 |
+
const file = e.target.files?.[0];
|
| 127 |
+
if (!file) return;
|
| 128 |
+
const reader = new FileReader();
|
| 129 |
+
reader.onload = (ev) => {
|
| 130 |
+
onChange({ ...data, freeform: ev.target.result });
|
| 131 |
+
};
|
| 132 |
+
reader.readAsText(file);
|
| 133 |
+
e.target.value = '';
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div className="persona-panel">
|
| 138 |
+
<div className="persona-panel-header">
|
| 139 |
+
<span>Expert Persona {label}</span>
|
| 140 |
+
{selectedLLM && (
|
| 141 |
+
<span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-tertiary)' }}>
|
| 142 |
+
— {selectedLLM}
|
| 143 |
+
</span>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<div className="persona-field">
|
| 148 |
+
<label>Name</label>
|
| 149 |
+
<input
|
| 150 |
+
type="text"
|
| 151 |
+
placeholder="Enter expert persona name"
|
| 152 |
+
value={data.name}
|
| 153 |
+
onChange={(e) => onChange({ ...data, name: e.target.value })}
|
| 154 |
+
/>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<div className="persona-field freeform-field">
|
| 158 |
+
<div className="freeform-label-row">
|
| 159 |
+
<label>Expert Persona Description</label>
|
| 160 |
+
<button
|
| 161 |
+
className="btn-sm btn-outline upload-btn"
|
| 162 |
+
onClick={() => fileRef.current?.click()}
|
| 163 |
+
title="Upload a .txt or .md file"
|
| 164 |
+
>
|
| 165 |
+
<Upload size={13} /> Upload file
|
| 166 |
+
</button>
|
| 167 |
+
<input
|
| 168 |
+
ref={fileRef}
|
| 169 |
+
type="file"
|
| 170 |
+
accept=".txt,.md,.text"
|
| 171 |
+
style={{ display: 'none' }}
|
| 172 |
+
onChange={handleFileUpload}
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
<textarea
|
| 176 |
+
className="freeform-textarea"
|
| 177 |
+
placeholder={FREEFORM_PLACEHOLDER}
|
| 178 |
+
value={data.freeform || ''}
|
| 179 |
+
onChange={(e) => onChange({ ...data, freeform: e.target.value })}
|
| 180 |
+
/>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
);
|
| 184 |
+
}
|
frontend/src/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
|
| 5 |
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 6 |
+
root.render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>
|
| 10 |
+
);
|
frontend/src/styles/components.css
ADDED
|
@@ -0,0 +1,1336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Sidebar: LLM Selector ─────────────────────────────────────── */
|
| 2 |
+
|
| 3 |
+
.sidebar-title {
|
| 4 |
+
font-size: 18px;
|
| 5 |
+
font-weight: 700;
|
| 6 |
+
color: var(--text-primary);
|
| 7 |
+
text-align: center;
|
| 8 |
+
padding-bottom: 8px;
|
| 9 |
+
border-bottom: 2px solid var(--accent-primary);
|
| 10 |
+
margin: 0;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.sidebar-section {
|
| 14 |
+
display: flex;
|
| 15 |
+
flex-direction: column;
|
| 16 |
+
gap: 6px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.selector-title {
|
| 20 |
+
display: flex;
|
| 21 |
+
align-items: center;
|
| 22 |
+
gap: 6px;
|
| 23 |
+
font-size: 15px;
|
| 24 |
+
font-weight: 700;
|
| 25 |
+
color: var(--text-primary);
|
| 26 |
+
text-decoration: underline;
|
| 27 |
+
text-underline-offset: 3px;
|
| 28 |
+
text-decoration-color: var(--border-primary);
|
| 29 |
+
margin: 0;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.selector-title-icon {
|
| 33 |
+
height: 16px;
|
| 34 |
+
width: auto;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.provider-group {
|
| 38 |
+
margin-bottom: 0;
|
| 39 |
+
border: 1px solid var(--border-primary);
|
| 40 |
+
border-radius: 8px;
|
| 41 |
+
overflow: hidden;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.provider-group.comp-group {
|
| 45 |
+
background: var(--comp-bg);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* ── Neon model cards (matching LLMComparisons) ──────────────────── */
|
| 49 |
+
|
| 50 |
+
.neon-model-list {
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-direction: column;
|
| 53 |
+
gap: 6px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.neon-model-card {
|
| 57 |
+
border: 1px solid var(--neon-border);
|
| 58 |
+
border-radius: 8px;
|
| 59 |
+
overflow: hidden;
|
| 60 |
+
background: var(--neon-bg);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.neon-model-header {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
width: 100%;
|
| 68 |
+
padding: 10px 12px;
|
| 69 |
+
background: none;
|
| 70 |
+
border: none;
|
| 71 |
+
color: var(--text-primary);
|
| 72 |
+
font-size: 13px;
|
| 73 |
+
text-align: left;
|
| 74 |
+
cursor: pointer;
|
| 75 |
+
transition: background 0.15s;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.neon-model-header:hover {
|
| 79 |
+
background: var(--card-hover);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.neon-model-info {
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: baseline;
|
| 85 |
+
gap: 6px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.neon-model-name {
|
| 89 |
+
font-weight: 600;
|
| 90 |
+
text-transform: capitalize;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.neon-model-version {
|
| 94 |
+
font-size: 11px;
|
| 95 |
+
color: var(--text-muted);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.neon-model-meta {
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
gap: 6px;
|
| 102 |
+
color: var(--text-tertiary);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.badge.badge-neon {
|
| 106 |
+
display: inline-flex;
|
| 107 |
+
align-items: center;
|
| 108 |
+
padding: 2px 8px;
|
| 109 |
+
border-radius: 9999px;
|
| 110 |
+
font-size: 11px;
|
| 111 |
+
font-weight: 700;
|
| 112 |
+
background: var(--neon-bg);
|
| 113 |
+
color: var(--text-primary);
|
| 114 |
+
border: 1px solid var(--neon-border);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.neon-persona-list {
|
| 118 |
+
border-top: 1px solid var(--border-muted);
|
| 119 |
+
padding: 4px;
|
| 120 |
+
display: flex;
|
| 121 |
+
flex-direction: column;
|
| 122 |
+
gap: 2px;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.neon-persona-item {
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: flex-start;
|
| 128 |
+
gap: 8px;
|
| 129 |
+
width: 100%;
|
| 130 |
+
padding: 8px;
|
| 131 |
+
border-radius: 6px;
|
| 132 |
+
border: none;
|
| 133 |
+
background: none;
|
| 134 |
+
color: var(--text-primary);
|
| 135 |
+
cursor: pointer;
|
| 136 |
+
text-align: left;
|
| 137 |
+
transition: background 0.15s;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.neon-persona-item:hover {
|
| 141 |
+
background: var(--card-hover);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.persona-details {
|
| 145 |
+
flex: 1;
|
| 146 |
+
min-width: 0;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.persona-name-row {
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: center;
|
| 152 |
+
gap: 4px;
|
| 153 |
+
font-size: 13px;
|
| 154 |
+
font-weight: 500;
|
| 155 |
+
color: var(--text-primary);
|
| 156 |
+
text-transform: capitalize;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.persona-prompt-preview {
|
| 160 |
+
font-size: 11px;
|
| 161 |
+
color: var(--text-muted);
|
| 162 |
+
line-height: 1.4;
|
| 163 |
+
margin-top: 2px;
|
| 164 |
+
overflow: hidden;
|
| 165 |
+
display: -webkit-box;
|
| 166 |
+
-webkit-line-clamp: 2;
|
| 167 |
+
-webkit-box-orient: vertical;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.provider-accordion-header {
|
| 171 |
+
display: flex;
|
| 172 |
+
align-items: center;
|
| 173 |
+
justify-content: space-between;
|
| 174 |
+
width: 100%;
|
| 175 |
+
min-width: 0;
|
| 176 |
+
padding: 10px 12px;
|
| 177 |
+
border: none;
|
| 178 |
+
background: none;
|
| 179 |
+
color: var(--text-primary);
|
| 180 |
+
font-size: 13px;
|
| 181 |
+
font-weight: 600;
|
| 182 |
+
text-transform: uppercase;
|
| 183 |
+
transition: background 0.15s;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.provider-accordion-header:hover {
|
| 187 |
+
background: var(--card-hover);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.provider-accordion-title {
|
| 191 |
+
flex: 1;
|
| 192 |
+
min-width: 0;
|
| 193 |
+
text-align: left;
|
| 194 |
+
overflow: hidden;
|
| 195 |
+
text-overflow: ellipsis;
|
| 196 |
+
white-space: nowrap;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.provider-accordion-meta {
|
| 200 |
+
display: flex;
|
| 201 |
+
align-items: center;
|
| 202 |
+
gap: 6px;
|
| 203 |
+
color: var(--text-tertiary);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.provider-model-count {
|
| 207 |
+
font-size: 11px;
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
color: var(--accent-primary);
|
| 210 |
+
background: var(--accent-light);
|
| 211 |
+
padding: 1px 6px;
|
| 212 |
+
border-radius: 10px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.provider-group .model-list {
|
| 216 |
+
padding: 4px;
|
| 217 |
+
border-top: 1px solid var(--border-muted);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.model-list {
|
| 221 |
+
display: flex;
|
| 222 |
+
flex-direction: column;
|
| 223 |
+
gap: 4px;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.model-btn {
|
| 227 |
+
display: flex;
|
| 228 |
+
align-items: center;
|
| 229 |
+
gap: 8px;
|
| 230 |
+
width: 100%;
|
| 231 |
+
min-width: 0;
|
| 232 |
+
padding: 6px 10px;
|
| 233 |
+
border: 1px solid transparent;
|
| 234 |
+
border-radius: 6px;
|
| 235 |
+
background: none;
|
| 236 |
+
color: var(--text-primary);
|
| 237 |
+
font-size: 13px;
|
| 238 |
+
transition: all 0.15s;
|
| 239 |
+
text-align: left;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.model-btn:hover {
|
| 243 |
+
background: var(--card-hover);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.model-btn .model-name {
|
| 247 |
+
flex: 1;
|
| 248 |
+
min-width: 0;
|
| 249 |
+
font-weight: 500;
|
| 250 |
+
overflow: hidden;
|
| 251 |
+
text-overflow: ellipsis;
|
| 252 |
+
white-space: nowrap;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.model-btn .model-params {
|
| 256 |
+
font-size: 10px;
|
| 257 |
+
color: var(--text-muted);
|
| 258 |
+
white-space: nowrap;
|
| 259 |
+
flex-shrink: 0;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/* Selection indicator: circle */
|
| 263 |
+
.select-indicator {
|
| 264 |
+
width: 16px;
|
| 265 |
+
height: 16px;
|
| 266 |
+
border-radius: 50%;
|
| 267 |
+
border: 2px solid var(--text-secondary);
|
| 268 |
+
background: #fff;
|
| 269 |
+
display: flex;
|
| 270 |
+
align-items: center;
|
| 271 |
+
justify-content: center;
|
| 272 |
+
flex-shrink: 0;
|
| 273 |
+
transition: all 0.15s;
|
| 274 |
+
position: relative;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.select-indicator.selected-a {
|
| 278 |
+
border-color: var(--speaker-a-color);
|
| 279 |
+
background: var(--speaker-a-color);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.select-indicator.selected-b {
|
| 283 |
+
border-color: var(--speaker-b-color);
|
| 284 |
+
background: var(--speaker-b-color);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/* Double-select ring for "same LLM both sides" */
|
| 288 |
+
.select-indicator.double-selected {
|
| 289 |
+
box-shadow: 0 0 0 3px var(--card-bg), 0 0 0 5px var(--speaker-a-color);
|
| 290 |
+
border-color: var(--speaker-b-color);
|
| 291 |
+
background: linear-gradient(135deg, var(--speaker-a-color), var(--speaker-b-color));
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.selection-label {
|
| 295 |
+
font-size: 10px;
|
| 296 |
+
font-weight: 700;
|
| 297 |
+
color: #fff;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
/* ── Accordion ─────────────────────────────────────────────────── */
|
| 302 |
+
|
| 303 |
+
.accordion {
|
| 304 |
+
border: 1px solid var(--border-primary);
|
| 305 |
+
border-radius: 12px;
|
| 306 |
+
background: var(--card-bg);
|
| 307 |
+
box-shadow: var(--shadow-sm);
|
| 308 |
+
overflow: hidden;
|
| 309 |
+
margin-bottom: 16px;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.accordion-header {
|
| 313 |
+
display: flex;
|
| 314 |
+
align-items: center;
|
| 315 |
+
justify-content: space-between;
|
| 316 |
+
padding: 14px 20px;
|
| 317 |
+
background: var(--bg-secondary);
|
| 318 |
+
border: none;
|
| 319 |
+
width: 100%;
|
| 320 |
+
font-size: 15px;
|
| 321 |
+
font-weight: 600;
|
| 322 |
+
color: var(--text-primary);
|
| 323 |
+
transition: background 0.15s;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.accordion-header:hover {
|
| 327 |
+
background: var(--bg-tertiary);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.accordion-body {
|
| 331 |
+
max-height: 0;
|
| 332 |
+
overflow: hidden;
|
| 333 |
+
transition: max-height 0.35s ease, padding 0.35s ease;
|
| 334 |
+
padding: 0 20px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.accordion-body.open {
|
| 338 |
+
max-height: calc(100vh - 260px);
|
| 339 |
+
overflow-y: auto;
|
| 340 |
+
padding: 16px 20px 20px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.persona-panels {
|
| 344 |
+
display: grid;
|
| 345 |
+
grid-template-columns: 1fr 1fr;
|
| 346 |
+
gap: 20px;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.persona-panel {
|
| 350 |
+
display: flex;
|
| 351 |
+
flex-direction: column;
|
| 352 |
+
gap: 12px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.persona-panel-header {
|
| 356 |
+
display: flex;
|
| 357 |
+
align-items: center;
|
| 358 |
+
gap: 8px;
|
| 359 |
+
font-size: 14px;
|
| 360 |
+
font-weight: 600;
|
| 361 |
+
color: var(--text-primary);
|
| 362 |
+
padding-bottom: 8px;
|
| 363 |
+
border-bottom: 2px solid var(--border-primary);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.persona-panel:first-child .persona-panel-header {
|
| 367 |
+
border-color: var(--speaker-a-color);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.persona-panel:last-child .persona-panel-header {
|
| 371 |
+
border-color: var(--speaker-b-color);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.persona-field label {
|
| 375 |
+
display: block;
|
| 376 |
+
font-size: 12px;
|
| 377 |
+
font-weight: 600;
|
| 378 |
+
color: var(--text-tertiary);
|
| 379 |
+
margin-bottom: 4px;
|
| 380 |
+
text-transform: uppercase;
|
| 381 |
+
letter-spacing: 0.04em;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.persona-field input,
|
| 385 |
+
.persona-field textarea {
|
| 386 |
+
width: 100%;
|
| 387 |
+
padding: 10px 12px;
|
| 388 |
+
border: 1px solid var(--border-primary);
|
| 389 |
+
border-radius: 8px;
|
| 390 |
+
background: var(--bg-primary);
|
| 391 |
+
color: var(--text-primary);
|
| 392 |
+
font-size: 13px;
|
| 393 |
+
transition: border-color 0.15s;
|
| 394 |
+
resize: vertical;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.persona-field input:focus,
|
| 398 |
+
.persona-field textarea:focus {
|
| 399 |
+
outline: none;
|
| 400 |
+
border-color: var(--accent-primary);
|
| 401 |
+
box-shadow: 0 0 0 3px var(--accent-light);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.persona-field textarea {
|
| 405 |
+
min-height: 80px;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.persona-field textarea.tall {
|
| 409 |
+
min-height: 120px;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.persona-field input::placeholder,
|
| 413 |
+
.persona-field textarea::placeholder {
|
| 414 |
+
color: var(--text-muted);
|
| 415 |
+
font-style: italic;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.freeform-label-row {
|
| 419 |
+
display: flex;
|
| 420 |
+
align-items: center;
|
| 421 |
+
gap: 10px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.freeform-label-row label {
|
| 425 |
+
flex: 1;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.upload-btn {
|
| 429 |
+
flex-shrink: 0;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.freeform-textarea {
|
| 433 |
+
min-height: 260px;
|
| 434 |
+
resize: vertical;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/* responsive rules consolidated below */
|
| 438 |
+
|
| 439 |
+
/* ── Chat Area ─────────────────────────────────────────────────── */
|
| 440 |
+
|
| 441 |
+
.chat-area {
|
| 442 |
+
flex: 1;
|
| 443 |
+
display: flex;
|
| 444 |
+
flex-direction: column;
|
| 445 |
+
gap: 16px;
|
| 446 |
+
padding: 16px 0;
|
| 447 |
+
overflow-y: auto;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.chat-empty {
|
| 451 |
+
flex: 1;
|
| 452 |
+
display: flex;
|
| 453 |
+
align-items: center;
|
| 454 |
+
justify-content: center;
|
| 455 |
+
color: var(--text-muted);
|
| 456 |
+
font-size: 15px;
|
| 457 |
+
font-style: italic;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
/* ── Message Bubbles ───────────────────────────────────────────── */
|
| 461 |
+
|
| 462 |
+
.message-row {
|
| 463 |
+
display: flex;
|
| 464 |
+
align-items: flex-start;
|
| 465 |
+
gap: 12px;
|
| 466 |
+
margin-bottom: 8px;
|
| 467 |
+
animation: fadeSlideIn 0.3s ease;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.message-row.speaker-a {
|
| 471 |
+
flex-direction: row;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.message-row.speaker-b {
|
| 475 |
+
flex-direction: row-reverse;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
@keyframes fadeSlideIn {
|
| 479 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 480 |
+
to { opacity: 1; transform: translateY(0); }
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/* Geometric shape avatars */
|
| 484 |
+
.avatar {
|
| 485 |
+
width: 40px;
|
| 486 |
+
height: 40px;
|
| 487 |
+
flex-shrink: 0;
|
| 488 |
+
display: flex;
|
| 489 |
+
align-items: center;
|
| 490 |
+
justify-content: center;
|
| 491 |
+
font-weight: 700;
|
| 492 |
+
font-size: 14px;
|
| 493 |
+
color: #fff;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.avatar-a {
|
| 497 |
+
background: var(--speaker-a-color);
|
| 498 |
+
border-radius: 50%;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.avatar-b {
|
| 502 |
+
background: var(--speaker-b-color);
|
| 503 |
+
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.message-bubble {
|
| 507 |
+
max-width: min(520px, 85%);
|
| 508 |
+
padding: 12px 16px;
|
| 509 |
+
border-radius: 16px;
|
| 510 |
+
font-size: 14px;
|
| 511 |
+
line-height: 1.6;
|
| 512 |
+
box-shadow: var(--shadow-sm);
|
| 513 |
+
overflow-wrap: break-word;
|
| 514 |
+
word-break: break-word;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.message-bubble.bubble-a {
|
| 518 |
+
background: var(--speaker-a-bg);
|
| 519 |
+
color: var(--text-primary);
|
| 520 |
+
border: 1px solid color-mix(in srgb, var(--speaker-a-color) 20%, transparent);
|
| 521 |
+
border-bottom-left-radius: 4px;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.message-bubble.bubble-b {
|
| 525 |
+
background: var(--speaker-b-bg);
|
| 526 |
+
color: var(--text-primary);
|
| 527 |
+
border: 1px solid color-mix(in srgb, var(--speaker-b-color) 20%, transparent);
|
| 528 |
+
border-bottom-right-radius: 4px;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.message-speaker {
|
| 532 |
+
font-size: 11px;
|
| 533 |
+
font-weight: 600;
|
| 534 |
+
margin-bottom: 4px;
|
| 535 |
+
text-transform: uppercase;
|
| 536 |
+
letter-spacing: 0.04em;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.speaker-a .message-speaker {
|
| 540 |
+
color: var(--speaker-a-color);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.speaker-b .message-speaker {
|
| 544 |
+
color: var(--speaker-b-color);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.message-bubble p {
|
| 548 |
+
margin: 0 0 8px;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.message-bubble p:last-child {
|
| 552 |
+
margin-bottom: 0;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.message-elapsed {
|
| 556 |
+
margin-top: 6px;
|
| 557 |
+
font-size: 11px;
|
| 558 |
+
color: var(--text-muted);
|
| 559 |
+
text-align: right;
|
| 560 |
+
opacity: 0.7;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
/* System messages */
|
| 564 |
+
.system-message {
|
| 565 |
+
text-align: center;
|
| 566 |
+
padding: 12px;
|
| 567 |
+
color: var(--text-muted);
|
| 568 |
+
font-style: italic;
|
| 569 |
+
font-size: 14px;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.system-message.end-of-chat {
|
| 573 |
+
font-weight: 700;
|
| 574 |
+
font-style: normal;
|
| 575 |
+
color: var(--text-secondary);
|
| 576 |
+
font-size: 16px;
|
| 577 |
+
padding: 20px;
|
| 578 |
+
border-top: 1px solid var(--border-primary);
|
| 579 |
+
margin-top: 8px;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.chat-stats {
|
| 583 |
+
text-align: center;
|
| 584 |
+
padding: 8px 12px;
|
| 585 |
+
font-size: 13px;
|
| 586 |
+
color: var(--text-muted);
|
| 587 |
+
background: var(--bg-secondary);
|
| 588 |
+
border-radius: 8px;
|
| 589 |
+
margin: 4px auto 8px;
|
| 590 |
+
max-width: 400px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
/* ── Chat Controls ─────────────────────────────────────────────── */
|
| 594 |
+
|
| 595 |
+
.chat-controls {
|
| 596 |
+
display: flex;
|
| 597 |
+
gap: 12px;
|
| 598 |
+
align-items: stretch;
|
| 599 |
+
padding: 12px 0;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.chat-controls input {
|
| 603 |
+
flex: 1;
|
| 604 |
+
padding: 10px 14px;
|
| 605 |
+
border: 1px solid var(--border-primary);
|
| 606 |
+
border-radius: 8px;
|
| 607 |
+
background: var(--bg-primary);
|
| 608 |
+
color: var(--text-primary);
|
| 609 |
+
font-size: 14px;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.chat-controls input:focus {
|
| 613 |
+
outline: none;
|
| 614 |
+
border-color: var(--accent-primary);
|
| 615 |
+
box-shadow: 0 0 0 3px var(--accent-light);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.btn-primary {
|
| 619 |
+
padding: 10px 20px;
|
| 620 |
+
border: none;
|
| 621 |
+
border-radius: 8px;
|
| 622 |
+
background: var(--accent-gradient);
|
| 623 |
+
color: #fff;
|
| 624 |
+
font-size: 14px;
|
| 625 |
+
font-weight: 600;
|
| 626 |
+
white-space: nowrap;
|
| 627 |
+
transition: opacity 0.15s, transform 0.1s;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.btn-primary:hover {
|
| 631 |
+
opacity: 0.9;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.btn-primary:active {
|
| 635 |
+
transform: scale(0.97);
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
.btn-primary:disabled {
|
| 639 |
+
opacity: 0.5;
|
| 640 |
+
cursor: not-allowed;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.btn-secondary {
|
| 644 |
+
padding: 10px 20px;
|
| 645 |
+
border: 1px solid var(--border-primary);
|
| 646 |
+
border-radius: 8px;
|
| 647 |
+
background: var(--bg-primary);
|
| 648 |
+
color: var(--text-secondary);
|
| 649 |
+
font-size: 14px;
|
| 650 |
+
font-weight: 500;
|
| 651 |
+
white-space: nowrap;
|
| 652 |
+
transition: all 0.15s;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.btn-secondary:hover {
|
| 656 |
+
background: var(--bg-tertiary);
|
| 657 |
+
border-color: var(--accent-primary);
|
| 658 |
+
color: var(--text-primary);
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.btn-secondary:disabled {
|
| 662 |
+
opacity: 0.5;
|
| 663 |
+
cursor: not-allowed;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.btn-stop {
|
| 667 |
+
padding: 10px 20px;
|
| 668 |
+
border: none;
|
| 669 |
+
border-radius: 8px;
|
| 670 |
+
background: #DC2626;
|
| 671 |
+
color: #fff;
|
| 672 |
+
font-size: 14px;
|
| 673 |
+
font-weight: 600;
|
| 674 |
+
white-space: nowrap;
|
| 675 |
+
transition: opacity 0.15s, transform 0.1s;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.btn-stop:hover {
|
| 679 |
+
opacity: 0.9;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.btn-stop:active {
|
| 683 |
+
transform: scale(0.97);
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
/* ── Header Dev Menu ───────────────────────────────────────────── */
|
| 687 |
+
|
| 688 |
+
.dev-wrap {
|
| 689 |
+
display: flex;
|
| 690 |
+
align-items: center;
|
| 691 |
+
gap: 10px;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.dev-download-btns {
|
| 695 |
+
display: flex;
|
| 696 |
+
gap: 6px;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.btn-sm {
|
| 700 |
+
display: inline-flex;
|
| 701 |
+
align-items: center;
|
| 702 |
+
gap: 5px;
|
| 703 |
+
padding: 6px 10px;
|
| 704 |
+
border-radius: 8px;
|
| 705 |
+
font-size: 12px;
|
| 706 |
+
font-weight: 500;
|
| 707 |
+
cursor: pointer;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.btn-outline {
|
| 711 |
+
border: 1px solid var(--border-primary);
|
| 712 |
+
background: var(--card-bg);
|
| 713 |
+
color: var(--text-secondary);
|
| 714 |
+
transition: border-color 0.15s;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.btn-outline:hover:not(:disabled) {
|
| 718 |
+
border-color: var(--accent-primary);
|
| 719 |
+
color: var(--text-primary);
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.btn-outline:disabled {
|
| 723 |
+
opacity: 0.4;
|
| 724 |
+
cursor: not-allowed;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.btn-ghost {
|
| 728 |
+
border: 1px solid transparent;
|
| 729 |
+
background: transparent;
|
| 730 |
+
color: var(--text-secondary);
|
| 731 |
+
transition: background 0.15s;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
.btn-ghost:hover {
|
| 735 |
+
background: var(--bg-tertiary);
|
| 736 |
+
color: var(--text-primary);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
.dev-dropdown-header {
|
| 741 |
+
position: relative;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.dev-panel {
|
| 745 |
+
position: absolute;
|
| 746 |
+
right: 0;
|
| 747 |
+
top: 100%;
|
| 748 |
+
margin-top: 4px;
|
| 749 |
+
min-width: 220px;
|
| 750 |
+
background: var(--card-bg);
|
| 751 |
+
border: 1px solid var(--border-primary);
|
| 752 |
+
border-radius: 10px;
|
| 753 |
+
padding: 6px;
|
| 754 |
+
z-index: 50;
|
| 755 |
+
box-shadow: var(--shadow-md);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.dev-panel button {
|
| 759 |
+
display: block;
|
| 760 |
+
width: 100%;
|
| 761 |
+
text-align: left;
|
| 762 |
+
padding: 8px 10px;
|
| 763 |
+
border: none;
|
| 764 |
+
background: transparent;
|
| 765 |
+
color: var(--text-secondary);
|
| 766 |
+
border-radius: 6px;
|
| 767 |
+
cursor: pointer;
|
| 768 |
+
font-size: 13px;
|
| 769 |
+
transition: background 0.1s;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
.dev-panel button:hover:not(:disabled) {
|
| 773 |
+
background: var(--bg-tertiary);
|
| 774 |
+
color: var(--text-primary);
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.dev-panel button:disabled {
|
| 778 |
+
opacity: 0.4;
|
| 779 |
+
cursor: not-allowed;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.dev-panel-hint {
|
| 783 |
+
font-size: 10px;
|
| 784 |
+
color: var(--text-muted);
|
| 785 |
+
margin-left: 6px;
|
| 786 |
+
font-style: italic;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
.dev-panel-divider {
|
| 790 |
+
height: 1px;
|
| 791 |
+
background: var(--border-muted);
|
| 792 |
+
margin: 4px 0;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.dev-panel-label {
|
| 796 |
+
font-size: 10px;
|
| 797 |
+
font-weight: 600;
|
| 798 |
+
text-transform: uppercase;
|
| 799 |
+
letter-spacing: 0.05em;
|
| 800 |
+
color: var(--text-muted);
|
| 801 |
+
padding: 4px 10px 2px;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.dev-panel-choice {
|
| 805 |
+
padding-left: 22px !important;
|
| 806 |
+
display: flex !important;
|
| 807 |
+
align-items: center;
|
| 808 |
+
gap: 8px;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.dev-panel-choice-active {
|
| 812 |
+
color: var(--text-primary) !important;
|
| 813 |
+
opacity: 1 !important;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.dev-check-icon {
|
| 817 |
+
flex-shrink: 0;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.dev-panel-choice-active .dev-check-icon {
|
| 821 |
+
color: var(--neon-accent, #22c55e);
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.dev-panel-download-item {
|
| 825 |
+
display: none !important;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
/* ── Orchestrator sub-menu ─────────────────────────────────────── */
|
| 830 |
+
|
| 831 |
+
.dev-sub-panel {
|
| 832 |
+
position: absolute;
|
| 833 |
+
right: 100%;
|
| 834 |
+
top: 0;
|
| 835 |
+
margin-right: 4px;
|
| 836 |
+
width: 280px;
|
| 837 |
+
max-height: 70vh;
|
| 838 |
+
display: flex;
|
| 839 |
+
flex-direction: column;
|
| 840 |
+
background: var(--card-bg);
|
| 841 |
+
border: 1px solid var(--border-primary);
|
| 842 |
+
border-radius: 10px;
|
| 843 |
+
padding: 8px;
|
| 844 |
+
z-index: 51;
|
| 845 |
+
box-shadow: var(--shadow-md);
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
.dev-sub-header {
|
| 849 |
+
display: flex;
|
| 850 |
+
flex-direction: column;
|
| 851 |
+
gap: 2px;
|
| 852 |
+
padding: 4px 6px 8px;
|
| 853 |
+
border-bottom: 1px solid var(--border-primary);
|
| 854 |
+
margin-bottom: 6px;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.dev-sub-title {
|
| 858 |
+
font-size: 12px;
|
| 859 |
+
font-weight: 600;
|
| 860 |
+
color: var(--text-primary);
|
| 861 |
+
text-transform: uppercase;
|
| 862 |
+
letter-spacing: 0.5px;
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
.dev-sub-current {
|
| 866 |
+
font-size: 11px;
|
| 867 |
+
color: var(--text-muted);
|
| 868 |
+
font-style: italic;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
.dev-sub-search {
|
| 872 |
+
position: relative;
|
| 873 |
+
margin-bottom: 6px;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
.dev-sub-search-icon {
|
| 877 |
+
position: absolute;
|
| 878 |
+
left: 8px;
|
| 879 |
+
top: 50%;
|
| 880 |
+
transform: translateY(-50%);
|
| 881 |
+
color: var(--text-muted);
|
| 882 |
+
opacity: 0.5;
|
| 883 |
+
pointer-events: none;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.dev-sub-search input {
|
| 887 |
+
width: 100%;
|
| 888 |
+
box-sizing: border-box;
|
| 889 |
+
padding: 7px 8px 7px 30px;
|
| 890 |
+
border: 1px solid var(--border-primary);
|
| 891 |
+
border-radius: 8px;
|
| 892 |
+
background: var(--bg-primary);
|
| 893 |
+
color: var(--text-primary);
|
| 894 |
+
font-size: 12px;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.dev-sub-search input:focus {
|
| 898 |
+
outline: none;
|
| 899 |
+
border-color: var(--accent-primary);
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
.dev-sub-search input::placeholder {
|
| 903 |
+
color: var(--text-muted);
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
.dev-sub-list {
|
| 907 |
+
list-style: none;
|
| 908 |
+
margin: 0;
|
| 909 |
+
padding: 0;
|
| 910 |
+
overflow-y: auto;
|
| 911 |
+
flex: 1;
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
.dev-sub-list li {
|
| 915 |
+
margin-bottom: 2px;
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
.dev-sub-item {
|
| 919 |
+
width: 100%;
|
| 920 |
+
text-align: left;
|
| 921 |
+
padding: 6px 8px;
|
| 922 |
+
border: 1px solid transparent;
|
| 923 |
+
border-radius: 6px;
|
| 924 |
+
background: transparent;
|
| 925 |
+
color: var(--text-primary);
|
| 926 |
+
cursor: pointer;
|
| 927 |
+
display: flex;
|
| 928 |
+
flex-direction: column;
|
| 929 |
+
gap: 1px;
|
| 930 |
+
font-size: 12px;
|
| 931 |
+
transition: background 0.1s;
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
.dev-sub-item:hover {
|
| 935 |
+
background: var(--bg-tertiary);
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
.dev-sub-item-active {
|
| 939 |
+
border-color: var(--accent-primary);
|
| 940 |
+
background: var(--accent-light);
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.dev-sub-item-active:hover {
|
| 944 |
+
background: var(--accent-light);
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
.dev-sub-item strong {
|
| 948 |
+
font-weight: 500;
|
| 949 |
+
font-size: 12px;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
.dev-sub-provider {
|
| 953 |
+
font-size: 10px;
|
| 954 |
+
color: var(--text-muted);
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
/* responsive rules consolidated below */
|
| 958 |
+
|
| 959 |
+
/* ── Status / Loading ──────────────────────────────────────────── */
|
| 960 |
+
|
| 961 |
+
.status-bar {
|
| 962 |
+
display: flex;
|
| 963 |
+
align-items: center;
|
| 964 |
+
gap: 8px;
|
| 965 |
+
padding: 8px 0;
|
| 966 |
+
color: var(--text-muted);
|
| 967 |
+
font-size: 13px;
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
.spinner {
|
| 971 |
+
width: 16px;
|
| 972 |
+
height: 16px;
|
| 973 |
+
border: 2px solid var(--border-primary);
|
| 974 |
+
border-top-color: var(--accent-primary);
|
| 975 |
+
border-radius: 50%;
|
| 976 |
+
animation: spin 0.8s linear infinite;
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
@keyframes spin {
|
| 980 |
+
to { transform: rotate(360deg); }
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
/* ── Auth Badge ───────────────────────────────────────────────── */
|
| 984 |
+
|
| 985 |
+
.auth-badge {
|
| 986 |
+
display: flex;
|
| 987 |
+
align-items: center;
|
| 988 |
+
gap: 6px;
|
| 989 |
+
font-size: 12px;
|
| 990 |
+
color: var(--text-muted);
|
| 991 |
+
padding: 4px 8px;
|
| 992 |
+
border-radius: 8px;
|
| 993 |
+
background: var(--bg-secondary);
|
| 994 |
+
border: 1px solid var(--border-primary);
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
.auth-username {
|
| 998 |
+
font-weight: 500;
|
| 999 |
+
color: var(--text-secondary);
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
.auth-org-tag {
|
| 1003 |
+
font-size: 10px;
|
| 1004 |
+
font-weight: 600;
|
| 1005 |
+
text-transform: uppercase;
|
| 1006 |
+
padding: 1px 5px;
|
| 1007 |
+
border-radius: 4px;
|
| 1008 |
+
background: var(--accent-primary);
|
| 1009 |
+
color: #fff;
|
| 1010 |
+
letter-spacing: 0.5px;
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
.auth-remaining {
|
| 1014 |
+
font-size: 11px;
|
| 1015 |
+
color: var(--text-muted);
|
| 1016 |
+
white-space: nowrap;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.auth-link {
|
| 1020 |
+
display: inline-flex;
|
| 1021 |
+
align-items: center;
|
| 1022 |
+
gap: 4px;
|
| 1023 |
+
color: var(--text-muted);
|
| 1024 |
+
text-decoration: none;
|
| 1025 |
+
transition: color 0.15s;
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
.auth-link:hover {
|
| 1029 |
+
color: var(--text-primary);
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
.auth-login {
|
| 1033 |
+
color: var(--accent-primary);
|
| 1034 |
+
font-weight: 500;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
.auth-login:hover {
|
| 1038 |
+
color: var(--text-primary);
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
/* ── Footer ─────────────────────────────────────────────────────── */
|
| 1042 |
+
|
| 1043 |
+
.app-footer {
|
| 1044 |
+
padding: 8px 24px;
|
| 1045 |
+
text-align: center;
|
| 1046 |
+
font-size: 10px;
|
| 1047 |
+
color: var(--text-muted);
|
| 1048 |
+
border-top: 1px solid var(--border-primary);
|
| 1049 |
+
background: var(--bg-secondary);
|
| 1050 |
+
flex-shrink: 0;
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
.app-footer a {
|
| 1054 |
+
color: var(--text-muted);
|
| 1055 |
+
text-decoration: underline;
|
| 1056 |
+
transition: color 0.15s;
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.app-footer a:hover {
|
| 1060 |
+
color: var(--text-secondary);
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
/* ── Scrollbar ──────────────────────────────────────────────────── */
|
| 1064 |
+
|
| 1065 |
+
::-webkit-scrollbar {
|
| 1066 |
+
width: 6px;
|
| 1067 |
+
height: 6px;
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
::-webkit-scrollbar-track {
|
| 1071 |
+
background: transparent;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
::-webkit-scrollbar-thumb {
|
| 1075 |
+
background: var(--text-muted);
|
| 1076 |
+
border-radius: 3px;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1080 |
+
background: var(--text-tertiary);
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
/* ══════════════════════════════════════════════════════════════════
|
| 1084 |
+
RESPONSIVE BREAKPOINTS
|
| 1085 |
+
══════════════════════════════════════════════════════════════════ */
|
| 1086 |
+
|
| 1087 |
+
/* ── Tablet (<900px) ──────────────────────────────────────────── */
|
| 1088 |
+
@media (max-width: 900px) {
|
| 1089 |
+
.persona-panels {
|
| 1090 |
+
grid-template-columns: 1fr;
|
| 1091 |
+
}
|
| 1092 |
+
.persona-field textarea {
|
| 1093 |
+
min-height: 60px;
|
| 1094 |
+
}
|
| 1095 |
+
.persona-field textarea.tall {
|
| 1096 |
+
min-height: 80px;
|
| 1097 |
+
}
|
| 1098 |
+
.freeform-textarea {
|
| 1099 |
+
min-height: 160px;
|
| 1100 |
+
}
|
| 1101 |
+
.accordion-header {
|
| 1102 |
+
padding: 12px 16px;
|
| 1103 |
+
font-size: 14px;
|
| 1104 |
+
}
|
| 1105 |
+
.accordion-body.open {
|
| 1106 |
+
padding: 12px 16px 16px;
|
| 1107 |
+
}
|
| 1108 |
+
.chat-controls {
|
| 1109 |
+
flex-wrap: wrap;
|
| 1110 |
+
gap: 8px;
|
| 1111 |
+
}
|
| 1112 |
+
.chat-controls input {
|
| 1113 |
+
flex-basis: 100%;
|
| 1114 |
+
font-size: 13px;
|
| 1115 |
+
}
|
| 1116 |
+
.message-row {
|
| 1117 |
+
gap: 8px;
|
| 1118 |
+
}
|
| 1119 |
+
.avatar {
|
| 1120 |
+
width: 32px;
|
| 1121 |
+
height: 32px;
|
| 1122 |
+
font-size: 12px;
|
| 1123 |
+
}
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
/* ── Small tablet / large phone (<600px) ──────────────────────── */
|
| 1127 |
+
@media (max-width: 600px) {
|
| 1128 |
+
.dev-download-btns {
|
| 1129 |
+
display: none;
|
| 1130 |
+
}
|
| 1131 |
+
.dev-panel-download-item {
|
| 1132 |
+
display: block !important;
|
| 1133 |
+
}
|
| 1134 |
+
.dev-sub-panel {
|
| 1135 |
+
right: 0;
|
| 1136 |
+
top: 100%;
|
| 1137 |
+
margin-right: 0;
|
| 1138 |
+
margin-top: 4px;
|
| 1139 |
+
width: min(280px, calc(100vw - 24px));
|
| 1140 |
+
}
|
| 1141 |
+
.dev-panel {
|
| 1142 |
+
min-width: 200px;
|
| 1143 |
+
}
|
| 1144 |
+
.auth-badge {
|
| 1145 |
+
font-size: 11px;
|
| 1146 |
+
padding: 3px 6px;
|
| 1147 |
+
gap: 4px;
|
| 1148 |
+
}
|
| 1149 |
+
.auth-remaining {
|
| 1150 |
+
display: none;
|
| 1151 |
+
}
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
/* ── Phone (<480px) ───────────────────────────────────────────── */
|
| 1155 |
+
@media (max-width: 480px) {
|
| 1156 |
+
.persona-panels {
|
| 1157 |
+
gap: 12px;
|
| 1158 |
+
}
|
| 1159 |
+
.persona-panel {
|
| 1160 |
+
gap: 8px;
|
| 1161 |
+
}
|
| 1162 |
+
.persona-panel-header {
|
| 1163 |
+
font-size: 13px;
|
| 1164 |
+
padding-bottom: 6px;
|
| 1165 |
+
}
|
| 1166 |
+
.persona-field label {
|
| 1167 |
+
font-size: 11px;
|
| 1168 |
+
}
|
| 1169 |
+
.persona-field input,
|
| 1170 |
+
.persona-field textarea {
|
| 1171 |
+
padding: 8px 10px;
|
| 1172 |
+
font-size: 12px;
|
| 1173 |
+
}
|
| 1174 |
+
.persona-field textarea {
|
| 1175 |
+
min-height: 50px;
|
| 1176 |
+
}
|
| 1177 |
+
.persona-field textarea.tall {
|
| 1178 |
+
min-height: 60px;
|
| 1179 |
+
}
|
| 1180 |
+
.freeform-textarea {
|
| 1181 |
+
min-height: 120px;
|
| 1182 |
+
}
|
| 1183 |
+
.accordion {
|
| 1184 |
+
border-radius: 8px;
|
| 1185 |
+
margin-bottom: 10px;
|
| 1186 |
+
}
|
| 1187 |
+
.accordion-header {
|
| 1188 |
+
padding: 10px 12px;
|
| 1189 |
+
font-size: 13px;
|
| 1190 |
+
}
|
| 1191 |
+
.accordion-body.open {
|
| 1192 |
+
padding: 10px 12px 14px;
|
| 1193 |
+
}
|
| 1194 |
+
.chat-controls {
|
| 1195 |
+
gap: 6px;
|
| 1196 |
+
padding: 8px 0;
|
| 1197 |
+
}
|
| 1198 |
+
.btn-primary,
|
| 1199 |
+
.btn-secondary,
|
| 1200 |
+
.btn-stop {
|
| 1201 |
+
padding: 8px 14px;
|
| 1202 |
+
font-size: 13px;
|
| 1203 |
+
}
|
| 1204 |
+
.message-bubble {
|
| 1205 |
+
padding: 10px 12px;
|
| 1206 |
+
font-size: 13px;
|
| 1207 |
+
border-radius: 12px;
|
| 1208 |
+
}
|
| 1209 |
+
.message-row {
|
| 1210 |
+
gap: 6px;
|
| 1211 |
+
margin-bottom: 6px;
|
| 1212 |
+
}
|
| 1213 |
+
.avatar {
|
| 1214 |
+
width: 28px;
|
| 1215 |
+
height: 28px;
|
| 1216 |
+
font-size: 11px;
|
| 1217 |
+
}
|
| 1218 |
+
.system-message {
|
| 1219 |
+
font-size: 13px;
|
| 1220 |
+
padding: 8px;
|
| 1221 |
+
}
|
| 1222 |
+
.system-message.end-of-chat {
|
| 1223 |
+
font-size: 14px;
|
| 1224 |
+
padding: 14px;
|
| 1225 |
+
}
|
| 1226 |
+
.btn-sm {
|
| 1227 |
+
padding: 5px 8px;
|
| 1228 |
+
font-size: 11px;
|
| 1229 |
+
}
|
| 1230 |
+
.btn-ghost {
|
| 1231 |
+
font-size: 12px;
|
| 1232 |
+
}
|
| 1233 |
+
.auth-badge {
|
| 1234 |
+
display: none;
|
| 1235 |
+
}
|
| 1236 |
+
.app-footer {
|
| 1237 |
+
padding: 6px 12px;
|
| 1238 |
+
}
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
/* ── Role Prompt Modal ─────────────────────────────────────────── */
|
| 1242 |
+
|
| 1243 |
+
.modal-overlay {
|
| 1244 |
+
position: fixed;
|
| 1245 |
+
inset: 0;
|
| 1246 |
+
background: rgba(0, 0, 0, 0.5);
|
| 1247 |
+
display: flex;
|
| 1248 |
+
align-items: center;
|
| 1249 |
+
justify-content: center;
|
| 1250 |
+
z-index: 1000;
|
| 1251 |
+
animation: fadeIn 0.15s ease;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
.modal-content {
|
| 1255 |
+
background: var(--bg-primary);
|
| 1256 |
+
border: 1px solid var(--border-primary);
|
| 1257 |
+
border-radius: 12px;
|
| 1258 |
+
width: min(680px, 90vw);
|
| 1259 |
+
max-height: 80vh;
|
| 1260 |
+
display: flex;
|
| 1261 |
+
flex-direction: column;
|
| 1262 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
.modal-header {
|
| 1266 |
+
display: flex;
|
| 1267 |
+
align-items: center;
|
| 1268 |
+
justify-content: space-between;
|
| 1269 |
+
padding: 16px 20px;
|
| 1270 |
+
border-bottom: 1px solid var(--border-primary);
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
.modal-header h2 {
|
| 1274 |
+
margin: 0;
|
| 1275 |
+
font-size: 16px;
|
| 1276 |
+
font-weight: 600;
|
| 1277 |
+
color: var(--text-primary);
|
| 1278 |
+
}
|
| 1279 |
+
|
| 1280 |
+
.modal-close {
|
| 1281 |
+
background: none;
|
| 1282 |
+
border: none;
|
| 1283 |
+
font-size: 22px;
|
| 1284 |
+
cursor: pointer;
|
| 1285 |
+
color: var(--text-muted);
|
| 1286 |
+
padding: 0 4px;
|
| 1287 |
+
line-height: 1;
|
| 1288 |
+
border-radius: 4px;
|
| 1289 |
+
transition: color 0.15s, background 0.15s;
|
| 1290 |
+
}
|
| 1291 |
+
|
| 1292 |
+
.modal-close:hover {
|
| 1293 |
+
color: var(--text-primary);
|
| 1294 |
+
background: var(--bg-secondary);
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
.modal-body {
|
| 1298 |
+
padding: 20px;
|
| 1299 |
+
overflow-y: auto;
|
| 1300 |
+
display: flex;
|
| 1301 |
+
flex-direction: column;
|
| 1302 |
+
gap: 20px;
|
| 1303 |
+
}
|
| 1304 |
+
|
| 1305 |
+
.role-prompt-section h3 {
|
| 1306 |
+
margin: 0 0 8px;
|
| 1307 |
+
font-size: 14px;
|
| 1308 |
+
font-weight: 600;
|
| 1309 |
+
color: var(--text-primary);
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
.role-prompt-model {
|
| 1313 |
+
font-weight: 400;
|
| 1314 |
+
color: var(--text-muted);
|
| 1315 |
+
font-size: 12px;
|
| 1316 |
+
}
|
| 1317 |
+
|
| 1318 |
+
.role-prompt-text {
|
| 1319 |
+
background: var(--bg-secondary);
|
| 1320 |
+
border: 1px solid var(--border-primary);
|
| 1321 |
+
border-radius: 8px;
|
| 1322 |
+
padding: 12px 14px;
|
| 1323 |
+
font-size: 13px;
|
| 1324 |
+
line-height: 1.6;
|
| 1325 |
+
color: var(--text-secondary);
|
| 1326 |
+
white-space: pre-wrap;
|
| 1327 |
+
word-break: break-word;
|
| 1328 |
+
margin: 0;
|
| 1329 |
+
max-height: 260px;
|
| 1330 |
+
overflow-y: auto;
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
@keyframes fadeIn {
|
| 1334 |
+
from { opacity: 0; }
|
| 1335 |
+
to { opacity: 1; }
|
| 1336 |
+
}
|
frontend/src/styles/layout.css
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.app {
|
| 2 |
+
height: 100vh;
|
| 3 |
+
display: flex;
|
| 4 |
+
flex-direction: column;
|
| 5 |
+
overflow: hidden;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.app-header {
|
| 9 |
+
display: flex;
|
| 10 |
+
align-items: center;
|
| 11 |
+
justify-content: space-between;
|
| 12 |
+
padding: 12px 24px;
|
| 13 |
+
background: var(--card-bg);
|
| 14 |
+
border-bottom: 1px solid var(--border-primary);
|
| 15 |
+
box-shadow: var(--shadow-sm);
|
| 16 |
+
flex-shrink: 0;
|
| 17 |
+
z-index: 100;
|
| 18 |
+
backdrop-filter: blur(12px);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.header-left {
|
| 22 |
+
display: flex;
|
| 23 |
+
align-items: center;
|
| 24 |
+
gap: 10px;
|
| 25 |
+
min-width: 0;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.header-brand-link {
|
| 29 |
+
display: flex;
|
| 30 |
+
align-items: center;
|
| 31 |
+
text-decoration: none;
|
| 32 |
+
transition: opacity 0.15s;
|
| 33 |
+
flex-shrink: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.header-brand-link:hover {
|
| 37 |
+
opacity: 0.8;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.app-logo {
|
| 41 |
+
height: 32px;
|
| 42 |
+
width: auto;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.app-title {
|
| 46 |
+
font-size: 20px;
|
| 47 |
+
font-weight: 700;
|
| 48 |
+
background: var(--accent-gradient);
|
| 49 |
+
-webkit-background-clip: text;
|
| 50 |
+
-webkit-text-fill-color: transparent;
|
| 51 |
+
background-clip: text;
|
| 52 |
+
white-space: nowrap;
|
| 53 |
+
overflow: hidden;
|
| 54 |
+
text-overflow: ellipsis;
|
| 55 |
+
min-width: 0;
|
| 56 |
+
margin: 0;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.app-title-link {
|
| 60 |
+
all: unset;
|
| 61 |
+
cursor: pointer;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.app-title-link:hover {
|
| 65 |
+
opacity: 0.8;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.header-right {
|
| 69 |
+
display: flex;
|
| 70 |
+
align-items: center;
|
| 71 |
+
gap: 8px;
|
| 72 |
+
flex-shrink: 0;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.icon-btn {
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
width: 36px;
|
| 80 |
+
height: 36px;
|
| 81 |
+
border: 1px solid var(--border-primary);
|
| 82 |
+
border-radius: 8px;
|
| 83 |
+
background: var(--bg-primary);
|
| 84 |
+
color: var(--text-secondary);
|
| 85 |
+
transition: all 0.15s;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.icon-btn:hover {
|
| 89 |
+
background: var(--bg-tertiary);
|
| 90 |
+
color: var(--text-primary);
|
| 91 |
+
border-color: var(--accent-primary);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.app-main {
|
| 95 |
+
display: flex;
|
| 96 |
+
flex: 1;
|
| 97 |
+
min-height: 0;
|
| 98 |
+
overflow: hidden;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.sidebar {
|
| 102 |
+
width: 300px;
|
| 103 |
+
min-width: 300px;
|
| 104 |
+
flex-shrink: 0;
|
| 105 |
+
padding: 16px;
|
| 106 |
+
overflow-y: auto;
|
| 107 |
+
overflow-x: hidden;
|
| 108 |
+
border-right: 1px solid var(--border-primary);
|
| 109 |
+
background: var(--bg-secondary);
|
| 110 |
+
display: flex;
|
| 111 |
+
flex-direction: column;
|
| 112 |
+
gap: 16px;
|
| 113 |
+
max-height: calc(100vh - 57px);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.content {
|
| 117 |
+
flex: 1;
|
| 118 |
+
min-height: 0;
|
| 119 |
+
padding: 20px 24px;
|
| 120 |
+
overflow-y: auto;
|
| 121 |
+
display: flex;
|
| 122 |
+
flex-direction: column;
|
| 123 |
+
gap: 0;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* ── Tablet: stack sidebar above content ──────────────────────── */
|
| 127 |
+
@media (max-width: 900px) {
|
| 128 |
+
.app-header {
|
| 129 |
+
padding: 10px 16px;
|
| 130 |
+
}
|
| 131 |
+
.app-main {
|
| 132 |
+
flex-direction: column;
|
| 133 |
+
}
|
| 134 |
+
.sidebar {
|
| 135 |
+
width: 100%;
|
| 136 |
+
min-width: auto;
|
| 137 |
+
max-height: 200px;
|
| 138 |
+
flex-shrink: 0;
|
| 139 |
+
border-right: none;
|
| 140 |
+
border-bottom: 1px solid var(--border-primary);
|
| 141 |
+
}
|
| 142 |
+
.content {
|
| 143 |
+
padding: 16px;
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/* ── Phone ────────────────────────────────────────────────────── */
|
| 148 |
+
@media (max-width: 480px) {
|
| 149 |
+
.app-header {
|
| 150 |
+
padding: 8px 12px;
|
| 151 |
+
gap: 4px;
|
| 152 |
+
}
|
| 153 |
+
.app-logo {
|
| 154 |
+
height: 24px;
|
| 155 |
+
}
|
| 156 |
+
.app-title {
|
| 157 |
+
font-size: 14px;
|
| 158 |
+
}
|
| 159 |
+
.header-left {
|
| 160 |
+
gap: 6px;
|
| 161 |
+
}
|
| 162 |
+
.header-right {
|
| 163 |
+
gap: 4px;
|
| 164 |
+
}
|
| 165 |
+
.icon-btn {
|
| 166 |
+
width: 32px;
|
| 167 |
+
height: 32px;
|
| 168 |
+
}
|
| 169 |
+
.sidebar {
|
| 170 |
+
max-height: 160px;
|
| 171 |
+
padding: 8px;
|
| 172 |
+
}
|
| 173 |
+
.content {
|
| 174 |
+
padding: 12px;
|
| 175 |
+
}
|
| 176 |
+
}
|
frontend/src/styles/variables.css
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root[data-theme="light"] {
|
| 2 |
+
--bg-primary: #FFFFFF;
|
| 3 |
+
--bg-secondary: #F9FAFB;
|
| 4 |
+
--bg-tertiary: #F3F4F6;
|
| 5 |
+
--bg-gradient: linear-gradient(135deg, #EFF6FF 0%, #FFFFFF 50%, #F3E8FF 100%);
|
| 6 |
+
|
| 7 |
+
--text-primary: #111827;
|
| 8 |
+
--text-secondary: #4B5563;
|
| 9 |
+
--text-tertiary: #6B7280;
|
| 10 |
+
--text-muted: #9CA3AF;
|
| 11 |
+
|
| 12 |
+
--accent-primary: #6366F1;
|
| 13 |
+
--accent-secondary: #8B5CF6;
|
| 14 |
+
--accent-gradient: linear-gradient(135deg, #5558E3, #7C4FE8);
|
| 15 |
+
--accent-light: #EEF2FF;
|
| 16 |
+
|
| 17 |
+
--border-primary: #E5E7EB;
|
| 18 |
+
--border-muted: #F3F4F6;
|
| 19 |
+
|
| 20 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 21 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
| 22 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
| 23 |
+
|
| 24 |
+
--card-bg: #FFFFFF;
|
| 25 |
+
--card-hover: #F9FAFB;
|
| 26 |
+
|
| 27 |
+
--neon-accent: #7C4FE8;
|
| 28 |
+
--neon-bg: #EAE0FD;
|
| 29 |
+
--neon-border: #C4B5F0;
|
| 30 |
+
|
| 31 |
+
--comp-bg: #E0EAFD;
|
| 32 |
+
--comp-border: #B8CFF0;
|
| 33 |
+
|
| 34 |
+
--speaker-a-color: #6366F1;
|
| 35 |
+
--speaker-a-bg: #EEF2FF;
|
| 36 |
+
--speaker-b-color: #059669;
|
| 37 |
+
--speaker-b-bg: #ECFDF5;
|
| 38 |
+
|
| 39 |
+
--error-bg: #FEE2E2;
|
| 40 |
+
--error-border: #FECACA;
|
| 41 |
+
--error-text: #991B1B;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
:root[data-theme="dark"] {
|
| 45 |
+
--bg-primary: #1F2937;
|
| 46 |
+
--bg-secondary: #111827;
|
| 47 |
+
--bg-tertiary: #374151;
|
| 48 |
+
--bg-gradient: linear-gradient(135deg, #1F2937 0%, #111827 50%, #374151 100%);
|
| 49 |
+
|
| 50 |
+
--text-primary: #F9FAFB;
|
| 51 |
+
--text-secondary: #D1D5DB;
|
| 52 |
+
--text-tertiary: #9CA3AF;
|
| 53 |
+
--text-muted: #6B7280;
|
| 54 |
+
|
| 55 |
+
--accent-primary: #818CF8;
|
| 56 |
+
--accent-secondary: #A78BFA;
|
| 57 |
+
--accent-gradient: linear-gradient(135deg, #818CF8, #A78BFA);
|
| 58 |
+
--accent-light: #312E81;
|
| 59 |
+
|
| 60 |
+
--border-primary: #374151;
|
| 61 |
+
--border-muted: #1F2937;
|
| 62 |
+
|
| 63 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
| 64 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
| 65 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
| 66 |
+
|
| 67 |
+
--card-bg: #1F2937;
|
| 68 |
+
--card-hover: #374151;
|
| 69 |
+
|
| 70 |
+
--neon-accent: #A78BFA;
|
| 71 |
+
--neon-bg: #2D2640;
|
| 72 |
+
--neon-border: #8B77C0;
|
| 73 |
+
|
| 74 |
+
--comp-bg: #1E2A40;
|
| 75 |
+
--comp-border: #3D5A80;
|
| 76 |
+
|
| 77 |
+
--speaker-a-color: #818CF8;
|
| 78 |
+
--speaker-a-bg: #312E81;
|
| 79 |
+
--speaker-b-color: #34D399;
|
| 80 |
+
--speaker-b-bg: #064E3B;
|
| 81 |
+
|
| 82 |
+
--error-bg: #450A0A;
|
| 83 |
+
--error-border: #7F1D1D;
|
| 84 |
+
--error-text: #FCA5A5;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
*, *::before, *::after {
|
| 88 |
+
box-sizing: border-box;
|
| 89 |
+
margin: 0;
|
| 90 |
+
padding: 0;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
body {
|
| 94 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 95 |
+
background: var(--bg-gradient);
|
| 96 |
+
color: var(--text-primary);
|
| 97 |
+
line-height: 1.5;
|
| 98 |
+
-webkit-font-smoothing: antialiased;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
button {
|
| 102 |
+
cursor: pointer;
|
| 103 |
+
font-family: inherit;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
textarea, input {
|
| 107 |
+
font-family: inherit;
|
| 108 |
+
}
|
frontend/src/utils/api.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_BASE = process.env.REACT_APP_API_URL !== undefined
|
| 2 |
+
? process.env.REACT_APP_API_URL
|
| 3 |
+
: 'http://localhost:8000';
|
| 4 |
+
|
| 5 |
+
export async function fetchModels() {
|
| 6 |
+
const resp = await fetch(`${API_BASE}/api/models`, { cache: 'no-store' });
|
| 7 |
+
if (!resp.ok) throw new Error(`Failed to fetch models: ${resp.status}`);
|
| 8 |
+
return resp.json();
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function generateRole({ model_id, name, profile, identity, samples, role_style }) {
|
| 12 |
+
const resp = await fetch(`${API_BASE}/api/chat/generate-role`, {
|
| 13 |
+
method: 'POST',
|
| 14 |
+
headers: { 'Content-Type': 'application/json' },
|
| 15 |
+
body: JSON.stringify({ model_id, name, profile, identity, samples, role_style }),
|
| 16 |
+
});
|
| 17 |
+
if (!resp.ok) {
|
| 18 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 19 |
+
throw new Error(err.detail || 'Role generation failed');
|
| 20 |
+
}
|
| 21 |
+
return resp.json();
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export async function generateRoleFreeform({ model_id, name, text, role_style }) {
|
| 25 |
+
const resp = await fetch(`${API_BASE}/api/chat/generate-role-freeform`, {
|
| 26 |
+
method: 'POST',
|
| 27 |
+
headers: { 'Content-Type': 'application/json' },
|
| 28 |
+
body: JSON.stringify({ model_id, name, text, role_style }),
|
| 29 |
+
});
|
| 30 |
+
if (!resp.ok) {
|
| 31 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 32 |
+
throw new Error(err.detail || 'Role generation failed');
|
| 33 |
+
}
|
| 34 |
+
return resp.json();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export async function startChat(
|
| 38 |
+
{ persona_a_model_id, persona_a_name, persona_a_role,
|
| 39 |
+
persona_b_model_id, persona_b_name, persona_b_role,
|
| 40 |
+
starter_text },
|
| 41 |
+
{ onSession, onMessage, onSystem, onStatus, onError, onDone },
|
| 42 |
+
abortSignal
|
| 43 |
+
) {
|
| 44 |
+
const resp = await fetch(`${API_BASE}/api/chat/start`, {
|
| 45 |
+
method: 'POST',
|
| 46 |
+
headers: { 'Content-Type': 'application/json' },
|
| 47 |
+
body: JSON.stringify({
|
| 48 |
+
persona_a_model_id, persona_a_name, persona_a_role,
|
| 49 |
+
persona_b_model_id, persona_b_name, persona_b_role,
|
| 50 |
+
starter_text: starter_text || null,
|
| 51 |
+
}),
|
| 52 |
+
signal: abortSignal,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
if (!resp.ok) {
|
| 56 |
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 57 |
+
throw new Error(err.detail || 'Chat start failed');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const reader = resp.body.getReader();
|
| 61 |
+
const decoder = new TextDecoder();
|
| 62 |
+
let buffer = '';
|
| 63 |
+
|
| 64 |
+
try {
|
| 65 |
+
while (true) {
|
| 66 |
+
if (abortSignal?.aborted) break;
|
| 67 |
+
const { value, done } = await reader.read();
|
| 68 |
+
if (done) break;
|
| 69 |
+
buffer += decoder.decode(value, { stream: true });
|
| 70 |
+
|
| 71 |
+
const parts = buffer.split('\n\n');
|
| 72 |
+
buffer = parts.pop();
|
| 73 |
+
|
| 74 |
+
for (const part of parts) {
|
| 75 |
+
const lines = part.trim().split('\n');
|
| 76 |
+
let eventType = 'message';
|
| 77 |
+
let data = '';
|
| 78 |
+
for (const line of lines) {
|
| 79 |
+
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
| 80 |
+
else if (line.startsWith('data: ')) data = line.slice(6);
|
| 81 |
+
}
|
| 82 |
+
if (!data) continue;
|
| 83 |
+
try {
|
| 84 |
+
const parsed = JSON.parse(data);
|
| 85 |
+
switch (eventType) {
|
| 86 |
+
case 'session': onSession?.(parsed); break;
|
| 87 |
+
case 'message': onMessage?.(parsed); break;
|
| 88 |
+
case 'system': onSystem?.(parsed); break;
|
| 89 |
+
case 'status': onStatus?.(parsed); break;
|
| 90 |
+
case 'error': onError?.(parsed); break;
|
| 91 |
+
case 'done': onDone?.(); break;
|
| 92 |
+
default: break;
|
| 93 |
+
}
|
| 94 |
+
} catch (e) {
|
| 95 |
+
console.warn('SSE parse error', e, data);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
} finally {
|
| 100 |
+
reader.releaseLock();
|
| 101 |
+
}
|
| 102 |
+
onDone?.();
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export async function getOrchestrator() {
|
| 106 |
+
const resp = await fetch(`${API_BASE}/api/chat/orchestrator`);
|
| 107 |
+
if (!resp.ok) throw new Error('Failed to get orchestrator');
|
| 108 |
+
return resp.json();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export async function setOrchestrator(modelId) {
|
| 112 |
+
const resp = await fetch(`${API_BASE}/api/chat/orchestrator`, {
|
| 113 |
+
method: 'PUT',
|
| 114 |
+
headers: { 'Content-Type': 'application/json' },
|
| 115 |
+
body: JSON.stringify({ model_id: modelId }),
|
| 116 |
+
});
|
| 117 |
+
if (!resp.ok) throw new Error('Failed to set orchestrator');
|
| 118 |
+
return resp.json();
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export async function getSpeedPriority() {
|
| 122 |
+
const resp = await fetch(`${API_BASE}/api/chat/speed-priority`);
|
| 123 |
+
if (!resp.ok) throw new Error('Failed to get speed priority');
|
| 124 |
+
return resp.json();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export async function setSpeedPriority(enabled) {
|
| 128 |
+
const resp = await fetch(`${API_BASE}/api/chat/speed-priority`, {
|
| 129 |
+
method: 'PUT',
|
| 130 |
+
headers: { 'Content-Type': 'application/json' },
|
| 131 |
+
body: JSON.stringify({ enabled }),
|
| 132 |
+
});
|
| 133 |
+
if (!resp.ok) throw new Error('Failed to set speed priority');
|
| 134 |
+
return resp.json();
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export async function exportChat(sessionId, fmt = 'txt') {
|
| 138 |
+
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/export?fmt=${fmt}`);
|
| 139 |
+
if (!resp.ok) throw new Error('Export failed');
|
| 140 |
+
return resp.json();
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
export async function exportApiLog(sessionId) {
|
| 144 |
+
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/api-log`);
|
| 145 |
+
if (!resp.ok) throw new Error('API log export failed');
|
| 146 |
+
return resp.json();
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export async function getAuthStatus() {
|
| 150 |
+
const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
|
| 151 |
+
if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
|
| 152 |
+
return resp.json();
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export async function getRateLimitStatus() {
|
| 156 |
+
const resp = await fetch(`${API_BASE}/api/rate-limit/status`, { credentials: 'include' });
|
| 157 |
+
if (!resp.ok) return { remaining: -1 };
|
| 158 |
+
return resp.json();
|
| 159 |
+
}
|