NeonClary Cursor commited on
Commit
c44fab6
·
0 Parent(s):

Import LLMChats3 master as CCAI baseline

Browse files

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (44) hide show
  1. .env.example +25 -0
  2. .gitattributes +1 -0
  3. .gitignore +11 -0
  4. Dockerfile +27 -0
  5. README.md +58 -0
  6. backend/app/__init__.py +0 -0
  7. backend/app/api/__init__.py +0 -0
  8. backend/app/api/chat.py +227 -0
  9. backend/app/api/models.py +41 -0
  10. backend/app/clients/__init__.py +0 -0
  11. backend/app/clients/hana_client.py +447 -0
  12. backend/app/clients/llm_router.py +225 -0
  13. backend/app/clients/openai_compat.py +150 -0
  14. backend/app/config.py +247 -0
  15. backend/app/main.py +116 -0
  16. backend/app/middleware/__init__.py +0 -0
  17. backend/app/middleware/rate_limit.py +94 -0
  18. backend/app/services/__init__.py +0 -0
  19. backend/app/services/orchestrator.py +391 -0
  20. backend/app/services/persona.py +143 -0
  21. backend/requirements.txt +8 -0
  22. docker-compose.yml +10 -0
  23. frontend/.gitignore +23 -0
  24. frontend/package-lock.json +0 -0
  25. frontend/package.json +34 -0
  26. frontend/public/favicon.ico +0 -0
  27. frontend/public/index.html +14 -0
  28. frontend/public/manifest.json +25 -0
  29. frontend/public/neon-logo.png +3 -0
  30. frontend/public/robots.txt +2 -0
  31. frontend/src/App.js +381 -0
  32. frontend/src/components/AuthBadge.js +33 -0
  33. frontend/src/components/ChatArea.js +57 -0
  34. frontend/src/components/ChatControls.js +56 -0
  35. frontend/src/components/DevMenu.js +207 -0
  36. frontend/src/components/ExportBar.js +79 -0
  37. frontend/src/components/LLMSelector.js +159 -0
  38. frontend/src/components/MessageBubble.js +27 -0
  39. frontend/src/components/PersonaAccordion.js +184 -0
  40. frontend/src/index.js +10 -0
  41. frontend/src/styles/components.css +1336 -0
  42. frontend/src/styles/layout.css +176 -0
  43. frontend/src/styles/variables.css +108 -0
  44. 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

  • SHA256: 9c9839f8c25e457a57c2d30bffefbf7d31607b071ad85004445054f899c5ec28
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
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)}>&times;</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 &middot; {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
+ &mdash; {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
+ &mdash; {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
+ }