Spaces:
Running
Wire LLM failover for the BrainForge Cybersecurity endpoint
Browse files- Add HTTP Basic auth support to ImprovedVllmClient via the
Authorization default_header so HANA-style credentials
(guest:HANA_KLATCHAT_PASSWORD) authenticate against the
4090-x1-3 BrainForge/Security vLLM endpoint.
- Have bootstrap load HANA_USERNAME_KLATCHAT / HANA_KLATCHAT_PASSWORD
from ~/.secrets/shared.env when api_username is configured, and opt
cybersecurity_config.yaml in via api_username: "guest".
- Stop provider.py from re-registering personas at module import time
with a raw ImprovedVllmClient, which silently overwrote bootstrap's
ResilientLLMClient wrapping and disabled OpenAI fallback.
switch_provider now reuses bootstrap.create_persona_llm and
create_orchestrator_llm to preserve the wrap.
- Rewrite ResilientLLMClient._race_or_fallback per spec: try primary;
on error run fallback sequentially; on >race_timeout_seconds run
fallback in parallel and return whichever finishes first. Bump
default race_timeout_seconds to 60s.
- Fix OpenAIFallbackClient for gpt-5 / o-series: use
max_completion_tokens and normalize persona-id author roles to
"assistant" so OpenAI accepts the orchestrator context.
- Centralize env loading via app/core/env_loader.py and use it from
main.py + bootstrap so the backend always picks up shared.env.
- Polish header theming + branding (Cybersecurity Advisor), restore
the cybersecurity user-profile UX touches, and drop the chat title
from the floating header.
- Add app/core/_debug_log.py NDJSON logger and runtime probes in
chat / resilient / vLLM / OpenAI clients to keep collecting
evidence until the failover path is verified end-to-end.
Co-authored-by: Cursor <cursoragent@cursor.com>
- cybersecurity_config.yaml +4 -3
- multi_llm_chatbot_backend/app/api/routes/chat.py +49 -0
- multi_llm_chatbot_backend/app/api/routes/provider.py +14 -7
- multi_llm_chatbot_backend/app/config.py +1 -0
- multi_llm_chatbot_backend/app/core/_debug_log.py +28 -0
- multi_llm_chatbot_backend/app/core/bootstrap.py +29 -0
- multi_llm_chatbot_backend/app/llm/improved_vllm_client.py +46 -6
- multi_llm_chatbot_backend/app/llm/openai_fallback_client.py +55 -7
- multi_llm_chatbot_backend/app/llm/resilient_client.py +138 -26
- phd-advisor-frontend/public/index.html +2 -2
- phd-advisor-frontend/public/manifest.json +2 -2
- phd-advisor-frontend/src/data/userGuide.js +1 -1
- phd-advisor-frontend/src/styles/CanvasPage.css +3 -7
- phd-advisor-frontend/src/styles/ChatPage.css +3 -8
- phd-advisor-frontend/src/styles/components.css +10 -0
- scripts/patch_canvas_insights.py +130 -0
- scripts/patch_chatpage_end.py +101 -0
- scripts/patch_message_bubble_avatar.py +47 -0
- scripts/patch_sidebar.py +94 -0
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# ============================================================================
|
| 2 |
-
# Cybersecurity Advisor
|
| 3 |
# ============================================================================
|
| 4 |
|
| 5 |
app:
|
|
@@ -25,7 +25,7 @@ homepage:
|
|
| 25 |
Get practical guidance on threats, compliance, incident response, architecture,
|
| 26 |
and career growth from a panel of cybersecurity-focused AI advisors — each
|
| 27 |
bringing a distinct lens to your questions.
|
| 28 |
-
features_title: "Why Use Cybersecurity Advisor
|
| 29 |
features:
|
| 30 |
- title: "Defense in Depth"
|
| 31 |
description: "Receive layered perspectives on risk, controls, detection, and response"
|
|
@@ -176,6 +176,7 @@ llm:
|
|
| 176 |
vllm:
|
| 177 |
api_url: "https://4090-x1-3.neonaiservices2.com/vllm0"
|
| 178 |
api_key: ""
|
|
|
|
| 179 |
model_id: "BrainForge/Security@2026.03.18"
|
| 180 |
neon_persona_orchestrator: "vanilla"
|
| 181 |
neon_persona_advisors: "CybersecurityExpert"
|
|
@@ -185,7 +186,7 @@ llm:
|
|
| 185 |
orchestrator_reasoning_effort: "low"
|
| 186 |
persona_reasoning_effort: "none"
|
| 187 |
resilient:
|
| 188 |
-
race_timeout_seconds:
|
| 189 |
|
| 190 |
rag:
|
| 191 |
embedding_model: "all-MiniLM-L6-v2"
|
|
|
|
| 1 |
# ============================================================================
|
| 2 |
+
# Cybersecurity Advisor — Application Configuration
|
| 3 |
# ============================================================================
|
| 4 |
|
| 5 |
app:
|
|
|
|
| 25 |
Get practical guidance on threats, compliance, incident response, architecture,
|
| 26 |
and career growth from a panel of cybersecurity-focused AI advisors — each
|
| 27 |
bringing a distinct lens to your questions.
|
| 28 |
+
features_title: "Why Use Cybersecurity Advisor?"
|
| 29 |
features:
|
| 30 |
- title: "Defense in Depth"
|
| 31 |
description: "Receive layered perspectives on risk, controls, detection, and response"
|
|
|
|
| 176 |
vllm:
|
| 177 |
api_url: "https://4090-x1-3.neonaiservices2.com/vllm0"
|
| 178 |
api_key: ""
|
| 179 |
+
api_username: "guest"
|
| 180 |
model_id: "BrainForge/Security@2026.03.18"
|
| 181 |
neon_persona_orchestrator: "vanilla"
|
| 182 |
neon_persona_advisors: "CybersecurityExpert"
|
|
|
|
| 186 |
orchestrator_reasoning_effort: "low"
|
| 187 |
persona_reasoning_effort: "none"
|
| 188 |
resilient:
|
| 189 |
+
race_timeout_seconds: 60
|
| 190 |
|
| 191 |
rag:
|
| 192 |
embedding_model: "all-MiniLM-L6-v2"
|
|
@@ -166,15 +166,64 @@ async def chat_stream(
|
|
| 166 |
done_queue: asyncio.Queue = asyncio.Queue()
|
| 167 |
|
| 168 |
async def _run(pid: str) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
try:
|
| 170 |
persona = chat_orchestrator.get_persona(pid)
|
| 171 |
result = await chat_orchestrator.generate_single_persona_response(
|
| 172 |
session, persona,
|
| 173 |
message.response_length or "medium",
|
| 174 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
session.append_message(pid, result["response"])
|
| 176 |
await done_queue.put(result)
|
| 177 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
logger.exception(f"chat-stream _run failed for {pid}: {e}")
|
| 179 |
failed_persona = chat_orchestrator.get_persona(pid)
|
| 180 |
await done_queue.put({
|
|
|
|
| 166 |
done_queue: asyncio.Queue = asyncio.Queue()
|
| 167 |
|
| 168 |
async def _run(pid: str) -> None:
|
| 169 |
+
# #region agent log
|
| 170 |
+
import os as _os
|
| 171 |
+
from app.core._debug_log import dlog
|
| 172 |
+
_probe_persona = chat_orchestrator.get_persona(pid)
|
| 173 |
+
_llm = getattr(_probe_persona, "llm", None) if _probe_persona else None
|
| 174 |
+
_llm_type = type(_llm).__name__ if _llm is not None else None
|
| 175 |
+
_primary_type = type(getattr(_llm, "primary", None)).__name__ if _llm is not None else None
|
| 176 |
+
_fallback_type = type(getattr(_llm, "fallback", None)).__name__ if _llm is not None else None
|
| 177 |
+
_primary_key = getattr(getattr(_llm, "primary", None), "api_key", None)
|
| 178 |
+
_primary_username = getattr(getattr(_llm, "primary", None), "api_username", None)
|
| 179 |
+
_primary_apiurl = getattr(getattr(_llm, "primary", None), "api_url", None)
|
| 180 |
+
_fallback_key = getattr(getattr(_llm, "fallback", None), "api_key", None)
|
| 181 |
+
_fallback_model = getattr(getattr(_llm, "fallback", None), "model", None)
|
| 182 |
+
if not _primary_key and _llm is not None:
|
| 183 |
+
_primary_key = getattr(_llm, "api_key", None)
|
| 184 |
+
_vllm_env = _os.environ.get("VLLM_API_KEY", "")
|
| 185 |
+
_openai_env = _os.environ.get("OPENAI_API_KEY", "")
|
| 186 |
+
dlog("chat.py:_run.entry", f"persona task started", {
|
| 187 |
+
"pid": pid,
|
| 188 |
+
"llm_type": _llm_type,
|
| 189 |
+
"primary_type": _primary_type,
|
| 190 |
+
"primary_api_url": _primary_apiurl,
|
| 191 |
+
"primary_api_username": _primary_username,
|
| 192 |
+
"primary_key_prefix": (_primary_key[:8] + "…") if _primary_key else None,
|
| 193 |
+
"primary_key_len": len(_primary_key) if _primary_key else 0,
|
| 194 |
+
"fallback_type": _fallback_type,
|
| 195 |
+
"fallback_model": _fallback_model,
|
| 196 |
+
"fallback_key_prefix": (_fallback_key[:8] + "…") if _fallback_key else None,
|
| 197 |
+
"fallback_key_len": len(_fallback_key) if _fallback_key else 0,
|
| 198 |
+
"env_vllm_prefix": (_vllm_env[:8] + "…") if _vllm_env else None,
|
| 199 |
+
"env_vllm_len": len(_vllm_env),
|
| 200 |
+
"env_openai_prefix": (_openai_env[:8] + "…") if _openai_env else None,
|
| 201 |
+
"env_openai_len": len(_openai_env),
|
| 202 |
+
}, "F")
|
| 203 |
+
# #endregion
|
| 204 |
try:
|
| 205 |
persona = chat_orchestrator.get_persona(pid)
|
| 206 |
result = await chat_orchestrator.generate_single_persona_response(
|
| 207 |
session, persona,
|
| 208 |
message.response_length or "medium",
|
| 209 |
)
|
| 210 |
+
# #region agent log
|
| 211 |
+
dlog("chat.py:_run.result", "persona response", {
|
| 212 |
+
"pid": pid,
|
| 213 |
+
"response_preview": (result.get("response") or "")[:200],
|
| 214 |
+
"used_documents": result.get("used_documents"),
|
| 215 |
+
}, "B")
|
| 216 |
+
# #endregion
|
| 217 |
session.append_message(pid, result["response"])
|
| 218 |
await done_queue.put(result)
|
| 219 |
except Exception as e:
|
| 220 |
+
# #region agent log
|
| 221 |
+
dlog("chat.py:_run.exception", "persona task exception", {
|
| 222 |
+
"pid": pid,
|
| 223 |
+
"exc_type": type(e).__name__,
|
| 224 |
+
"exc_msg": str(e)[:500],
|
| 225 |
+
}, "B")
|
| 226 |
+
# #endregion
|
| 227 |
logger.exception(f"chat-stream _run failed for {pid}: {e}")
|
| 228 |
failed_persona = chat_orchestrator.get_persona(pid)
|
| 229 |
await done_queue.put({
|
|
@@ -37,11 +37,12 @@ def create_llm_client(provider: str = None):
|
|
| 37 |
else:
|
| 38 |
raise ValueError(f"Unknown provider: {provider}")
|
| 39 |
|
| 40 |
-
#
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
class ProviderSwitch(BaseModel):
|
| 47 |
provider: str
|
|
@@ -65,13 +66,19 @@ async def switch_provider(provider_data: ProviderSwitch):
|
|
| 65 |
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}")
|
| 66 |
|
| 67 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
current_provider = provider_data.provider
|
| 69 |
-
new_llm =
|
| 70 |
llm = new_llm
|
| 71 |
|
| 72 |
chat_orchestrator.llm_client = new_llm
|
| 73 |
|
| 74 |
-
|
|
|
|
| 75 |
chat_orchestrator.personas.clear()
|
| 76 |
for persona in new_personas:
|
| 77 |
chat_orchestrator.register_persona(persona)
|
|
|
|
| 37 |
else:
|
| 38 |
raise ValueError(f"Unknown provider: {provider}")
|
| 39 |
|
| 40 |
+
# NOTE: Personas and `llm` are already created and registered by
|
| 41 |
+
# app/core/bootstrap.py with the correct ResilientLLMClient wrapping
|
| 42 |
+
# (primary vLLM + OpenAI fallback). Re-registering them here at module
|
| 43 |
+
# import time replaces them with a raw vLLM client that lacks the
|
| 44 |
+
# fallback wrapper AND lacks the api_username/api_key wiring, breaking
|
| 45 |
+
# both auth and failover. Bootstrap already handles initial setup.
|
| 46 |
|
| 47 |
class ProviderSwitch(BaseModel):
|
| 48 |
provider: str
|
|
|
|
| 66 |
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}")
|
| 67 |
|
| 68 |
try:
|
| 69 |
+
from app.core.bootstrap import (
|
| 70 |
+
create_orchestrator_llm,
|
| 71 |
+
create_persona_llm,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
current_provider = provider_data.provider
|
| 75 |
+
new_llm = create_orchestrator_llm()
|
| 76 |
llm = new_llm
|
| 77 |
|
| 78 |
chat_orchestrator.llm_client = new_llm
|
| 79 |
|
| 80 |
+
persona_llm = create_persona_llm()
|
| 81 |
+
new_personas = get_default_personas(persona_llm)
|
| 82 |
chat_orchestrator.personas.clear()
|
| 83 |
for persona in new_personas:
|
| 84 |
chat_orchestrator.register_persona(persona)
|
|
@@ -273,6 +273,7 @@ class OllamaConfig(BaseModel):
|
|
| 273 |
class VllmConfig(BaseModel):
|
| 274 |
api_url: str = ""
|
| 275 |
api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
|
|
|
|
| 276 |
model_id: str = ""
|
| 277 |
neon_persona_orchestrator: str = "vanilla"
|
| 278 |
neon_persona_advisors: str = "CybersecurityExpert"
|
|
|
|
| 273 |
class VllmConfig(BaseModel):
|
| 274 |
api_url: str = ""
|
| 275 |
api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
|
| 276 |
+
api_username: str = Field(default=os.getenv("VLLM_API_USERNAME", ""))
|
| 277 |
model_id: str = ""
|
| 278 |
neon_persona_orchestrator: str = "vanilla"
|
| 279 |
neon_persona_advisors: str = "CybersecurityExpert"
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Debug-mode NDJSON logger. Writes to the agent debug log file."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
_LOG_PATH = Path(r"C:\Users\dream\.cursor\projects\Ask-A-Neon-LLM-Demos\debug-1d5c0f.log")
|
| 10 |
+
_SESSION_ID = "1d5c0f"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def dlog(location: str, message: str, data: Any = None, hypothesis_id: str = "") -> None:
|
| 14 |
+
try:
|
| 15 |
+
entry = {
|
| 16 |
+
"sessionId": _SESSION_ID,
|
| 17 |
+
"timestamp": int(time.time() * 1000),
|
| 18 |
+
"location": location,
|
| 19 |
+
"message": message,
|
| 20 |
+
"data": data or {},
|
| 21 |
+
}
|
| 22 |
+
if hypothesis_id:
|
| 23 |
+
entry["hypothesisId"] = hypothesis_id
|
| 24 |
+
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
with _LOG_PATH.open("a", encoding="utf-8") as fh:
|
| 26 |
+
fh.write(json.dumps(entry, default=str) + "\n")
|
| 27 |
+
except Exception:
|
| 28 |
+
pass
|
|
@@ -36,7 +36,35 @@ def _load_shared_env_var(name: str) -> str:
|
|
| 36 |
return ""
|
| 37 |
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
def _vllm_api_key() -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
return (
|
| 41 |
settings.llm.vllm.api_key
|
| 42 |
or os.getenv("VLLM_API_KEY", "")
|
|
@@ -62,6 +90,7 @@ def _build_neon_vllm(neon_persona: str | None) -> ImprovedVllmClient:
|
|
| 62 |
api_key=_vllm_api_key(),
|
| 63 |
model_name=model_name,
|
| 64 |
neon_persona=neon_persona,
|
|
|
|
| 65 |
)
|
| 66 |
|
| 67 |
|
|
|
|
| 36 |
return ""
|
| 37 |
|
| 38 |
|
| 39 |
+
def _vllm_api_username() -> str:
|
| 40 |
+
"""Optional HTTP Basic auth username for the vLLM endpoint.
|
| 41 |
+
|
| 42 |
+
The Neon BrainForge/Security endpoint at 4090-x1-3 requires HTTP Basic
|
| 43 |
+
auth using HANA_USERNAME_KLATCHAT / HANA_KLATCHAT_PASSWORD from
|
| 44 |
+
~/.secrets/shared.env. We allow explicit override via api_username
|
| 45 |
+
in config or VLLM_API_USERNAME env var, falling back to the shared
|
| 46 |
+
HANA_USERNAME_KLATCHAT entry.
|
| 47 |
+
"""
|
| 48 |
+
return (
|
| 49 |
+
settings.llm.vllm.api_username
|
| 50 |
+
or os.getenv("VLLM_API_USERNAME", "")
|
| 51 |
+
or _load_shared_env_var("HANA_USERNAME_KLATCHAT")
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def _vllm_api_key() -> str:
|
| 56 |
+
"""vLLM key/password. If api_username is set (HANA Basic auth), prefer
|
| 57 |
+
HANA_KLATCHAT_PASSWORD; otherwise use the generic VLLM_API_KEY Bearer
|
| 58 |
+
token. This matches the dual-auth nature of the Neon endpoints.
|
| 59 |
+
"""
|
| 60 |
+
if _vllm_api_username():
|
| 61 |
+
return (
|
| 62 |
+
settings.llm.vllm.api_key
|
| 63 |
+
or os.getenv("HANA_KLATCHAT_PASSWORD", "")
|
| 64 |
+
or _load_shared_env_var("HANA_KLATCHAT_PASSWORD")
|
| 65 |
+
or os.getenv("VLLM_API_KEY", "")
|
| 66 |
+
or _load_shared_env_var("VLLM_API_KEY")
|
| 67 |
+
)
|
| 68 |
return (
|
| 69 |
settings.llm.vllm.api_key
|
| 70 |
or os.getenv("VLLM_API_KEY", "")
|
|
|
|
| 90 |
api_key=_vllm_api_key(),
|
| 91 |
model_name=model_name,
|
| 92 |
neon_persona=neon_persona,
|
| 93 |
+
api_username=_vllm_api_username() or None,
|
| 94 |
)
|
| 95 |
|
| 96 |
|
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
from typing import Any, Callable, Dict, List, Optional
|
|
@@ -19,17 +20,33 @@ class ImprovedVllmClient(LLMClient):
|
|
| 19 |
model_name: str = None,
|
| 20 |
neon_persona: str | None = None,
|
| 21 |
model_revision: str | None = None,
|
|
|
|
| 22 |
):
|
| 23 |
self.api_url = api_url
|
| 24 |
self.api_key = api_key
|
|
|
|
| 25 |
self.model_name = model_name
|
| 26 |
self.neon_persona = neon_persona
|
| 27 |
self.model_revision = model_revision
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
self.context_manager = get_context_manager()
|
| 34 |
|
| 35 |
def _resolve_model_revision(self) -> str | None:
|
|
@@ -100,16 +117,39 @@ class ImprovedVllmClient(LLMClient):
|
|
| 100 |
text = response.choices[0].message.content.strip()
|
| 101 |
return self._clean_response(text)
|
| 102 |
|
| 103 |
-
except APIConnectionError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
logger.error(f"Unable to connect to vLLM at {self.api_url}")
|
| 105 |
return "I'm unable to connect to the AI service. Please ensure the vLLM endpoint is available."
|
| 106 |
except APIStatusError as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
logger.error(f"vLLM API error: {e.status_code} - {e.message}")
|
| 108 |
if e.status_code == 404:
|
| 109 |
logger.info("Model not found, will re-discover on next request")
|
| 110 |
self.model_name = None
|
| 111 |
return "The AI service encountered an error. Please try again."
|
| 112 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
logger.error(f"Unexpected error in vLLM client: {str(e)}")
|
| 114 |
return "I encountered an unexpected error. Please try again."
|
| 115 |
|
|
|
|
| 1 |
+
import base64
|
| 2 |
import json
|
| 3 |
import logging
|
| 4 |
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
| 20 |
model_name: str = None,
|
| 21 |
neon_persona: str | None = None,
|
| 22 |
model_revision: str | None = None,
|
| 23 |
+
api_username: str | None = None,
|
| 24 |
):
|
| 25 |
self.api_url = api_url
|
| 26 |
self.api_key = api_key
|
| 27 |
+
self.api_username = api_username or None
|
| 28 |
self.model_name = model_name
|
| 29 |
self.neon_persona = neon_persona
|
| 30 |
self.model_revision = model_revision
|
| 31 |
+
|
| 32 |
+
client_kwargs: dict = {
|
| 33 |
+
"base_url": f"{api_url}/v1",
|
| 34 |
+
"api_key": api_key or "not-needed",
|
| 35 |
+
"timeout": 90.0,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if self.api_username:
|
| 39 |
+
# Some Neon endpoints (e.g. BrainForge/Security at 4090-x1-3)
|
| 40 |
+
# require HTTP Basic auth using HANA-style credentials rather
|
| 41 |
+
# than the regular Bearer token. The OpenAI SDK injects its own
|
| 42 |
+
# Authorization: Bearer <api_key> header on every request, so we
|
| 43 |
+
# override it via default_headers to ensure Basic auth wins.
|
| 44 |
+
basic = base64.b64encode(
|
| 45 |
+
f"{self.api_username}:{api_key or ''}".encode("utf-8")
|
| 46 |
+
).decode("ascii")
|
| 47 |
+
client_kwargs["default_headers"] = {"Authorization": f"Basic {basic}"}
|
| 48 |
+
|
| 49 |
+
self.client = AsyncOpenAI(**client_kwargs)
|
| 50 |
self.context_manager = get_context_manager()
|
| 51 |
|
| 52 |
def _resolve_model_revision(self) -> str | None:
|
|
|
|
| 117 |
text = response.choices[0].message.content.strip()
|
| 118 |
return self._clean_response(text)
|
| 119 |
|
| 120 |
+
except APIConnectionError as e:
|
| 121 |
+
# #region agent log
|
| 122 |
+
from app.core._debug_log import dlog
|
| 123 |
+
dlog("improved_vllm_client.py:generate.conn_err", "vLLM connection error", {
|
| 124 |
+
"api_url": self.api_url,
|
| 125 |
+
"exc_msg": str(e)[:300],
|
| 126 |
+
}, "E")
|
| 127 |
+
# #endregion
|
| 128 |
logger.error(f"Unable to connect to vLLM at {self.api_url}")
|
| 129 |
return "I'm unable to connect to the AI service. Please ensure the vLLM endpoint is available."
|
| 130 |
except APIStatusError as e:
|
| 131 |
+
# #region agent log
|
| 132 |
+
from app.core._debug_log import dlog
|
| 133 |
+
dlog("improved_vllm_client.py:generate.status_err", "vLLM API status error", {
|
| 134 |
+
"status_code": getattr(e, "status_code", None),
|
| 135 |
+
"message": getattr(e, "message", None),
|
| 136 |
+
"api_url": self.api_url,
|
| 137 |
+
"model": self.model_name,
|
| 138 |
+
}, "E")
|
| 139 |
+
# #endregion
|
| 140 |
logger.error(f"vLLM API error: {e.status_code} - {e.message}")
|
| 141 |
if e.status_code == 404:
|
| 142 |
logger.info("Model not found, will re-discover on next request")
|
| 143 |
self.model_name = None
|
| 144 |
return "The AI service encountered an error. Please try again."
|
| 145 |
except Exception as e:
|
| 146 |
+
# #region agent log
|
| 147 |
+
from app.core._debug_log import dlog
|
| 148 |
+
dlog("improved_vllm_client.py:generate.unexpected", "vLLM unexpected error", {
|
| 149 |
+
"exc_type": type(e).__name__,
|
| 150 |
+
"exc_msg": str(e)[:500],
|
| 151 |
+
}, "E")
|
| 152 |
+
# #endregion
|
| 153 |
logger.error(f"Unexpected error in vLLM client: {str(e)}")
|
| 154 |
return "I encountered an unexpected error. Please try again."
|
| 155 |
|
|
@@ -34,11 +34,36 @@ class OpenAIFallbackClient(LLMClient):
|
|
| 34 |
self.client = AsyncOpenAI(api_key=api_key, timeout=120.0)
|
| 35 |
self.context_manager = get_context_manager()
|
| 36 |
|
|
|
|
|
|
|
| 37 |
def _reasoning_kwargs(self) -> Dict[str, Any]:
|
| 38 |
if not self.reasoning_effort or self.reasoning_effort == "none":
|
| 39 |
return {}
|
| 40 |
return {"reasoning_effort": self.reasoning_effort}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
async def generate(
|
| 43 |
self,
|
| 44 |
system_prompt: str,
|
|
@@ -52,13 +77,16 @@ class OpenAIFallbackClient(LLMClient):
|
|
| 52 |
system_prompt=system_prompt,
|
| 53 |
llm_provider="openai",
|
| 54 |
)
|
|
|
|
|
|
|
| 55 |
create_kwargs: Dict[str, Any] = dict(
|
| 56 |
model=self.model,
|
| 57 |
-
messages=
|
| 58 |
-
|
| 59 |
-
max_tokens=max_tokens,
|
| 60 |
**self._reasoning_kwargs(),
|
| 61 |
)
|
|
|
|
|
|
|
| 62 |
if response_mime_type == "application/json":
|
| 63 |
create_kwargs["response_format"] = {"type": "json_object"}
|
| 64 |
|
|
@@ -69,9 +97,26 @@ class OpenAIFallbackClient(LLMClient):
|
|
| 69 |
raise ValueError("OpenAI returned empty content")
|
| 70 |
return self._clean_response(text)
|
| 71 |
except (APIConnectionError, APIStatusError) as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
logger.error("OpenAI API error: %s", exc)
|
| 73 |
raise
|
| 74 |
except Exception as exc:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
logger.error("OpenAI generate failed: %s", exc)
|
| 76 |
raise
|
| 77 |
|
|
@@ -95,14 +140,17 @@ class OpenAIFallbackClient(LLMClient):
|
|
| 95 |
|
| 96 |
try:
|
| 97 |
for _round in range(self._MAX_TOOL_ROUNDS):
|
| 98 |
-
|
|
|
|
| 99 |
model=self.model,
|
| 100 |
-
messages=messages,
|
| 101 |
tools=openai_tools or None,
|
| 102 |
-
|
| 103 |
-
max_tokens=max_tokens,
|
| 104 |
**self._reasoning_kwargs(),
|
| 105 |
)
|
|
|
|
|
|
|
|
|
|
| 106 |
choice = response.choices[0].message
|
| 107 |
if not choice.tool_calls:
|
| 108 |
text = choice.content or ""
|
|
|
|
| 34 |
self.client = AsyncOpenAI(api_key=api_key, timeout=120.0)
|
| 35 |
self.context_manager = get_context_manager()
|
| 36 |
|
| 37 |
+
_ALLOWED_ROLES = {"system", "assistant", "user", "function", "tool", "developer"}
|
| 38 |
+
|
| 39 |
def _reasoning_kwargs(self) -> Dict[str, Any]:
|
| 40 |
if not self.reasoning_effort or self.reasoning_effort == "none":
|
| 41 |
return {}
|
| 42 |
return {"reasoning_effort": self.reasoning_effort}
|
| 43 |
|
| 44 |
+
def _uses_completion_tokens_param(self) -> bool:
|
| 45 |
+
"""gpt-5+ requires `max_completion_tokens`; older models use `max_tokens`."""
|
| 46 |
+
m = (self.model or "").lower()
|
| 47 |
+
return m.startswith("gpt-5") or m.startswith("o1") or m.startswith("o3")
|
| 48 |
+
|
| 49 |
+
def _normalize_messages(self, messages: List[dict]) -> List[dict]:
|
| 50 |
+
"""Map persona-id roles (e.g. 'jerry_huaute') to 'assistant' so OpenAI
|
| 51 |
+
accepts them. Preserve the persona name via the optional 'name' field
|
| 52 |
+
when possible.
|
| 53 |
+
"""
|
| 54 |
+
out: List[dict] = []
|
| 55 |
+
for msg in messages:
|
| 56 |
+
role = msg.get("role", "user")
|
| 57 |
+
if role in self._ALLOWED_ROLES:
|
| 58 |
+
out.append(msg)
|
| 59 |
+
continue
|
| 60 |
+
new_msg = dict(msg)
|
| 61 |
+
new_msg["role"] = "assistant"
|
| 62 |
+
if "name" not in new_msg and isinstance(role, str):
|
| 63 |
+
new_msg["name"] = role[:64]
|
| 64 |
+
out.append(new_msg)
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
async def generate(
|
| 68 |
self,
|
| 69 |
system_prompt: str,
|
|
|
|
| 77 |
system_prompt=system_prompt,
|
| 78 |
llm_provider="openai",
|
| 79 |
)
|
| 80 |
+
normalized_messages = self._normalize_messages(context_window.messages)
|
| 81 |
+
token_kwarg = "max_completion_tokens" if self._uses_completion_tokens_param() else "max_tokens"
|
| 82 |
create_kwargs: Dict[str, Any] = dict(
|
| 83 |
model=self.model,
|
| 84 |
+
messages=normalized_messages,
|
| 85 |
+
**{token_kwarg: max_tokens},
|
|
|
|
| 86 |
**self._reasoning_kwargs(),
|
| 87 |
)
|
| 88 |
+
if not self._uses_completion_tokens_param():
|
| 89 |
+
create_kwargs["temperature"] = temperature
|
| 90 |
if response_mime_type == "application/json":
|
| 91 |
create_kwargs["response_format"] = {"type": "json_object"}
|
| 92 |
|
|
|
|
| 97 |
raise ValueError("OpenAI returned empty content")
|
| 98 |
return self._clean_response(text)
|
| 99 |
except (APIConnectionError, APIStatusError) as exc:
|
| 100 |
+
# #region agent log
|
| 101 |
+
from app.core._debug_log import dlog
|
| 102 |
+
dlog("openai_fallback_client.py:generate.api_err", "OpenAI API error", {
|
| 103 |
+
"exc_type": type(exc).__name__,
|
| 104 |
+
"status_code": getattr(exc, "status_code", None),
|
| 105 |
+
"model": self.model,
|
| 106 |
+
"exc_msg": str(exc)[:500],
|
| 107 |
+
}, "B")
|
| 108 |
+
# #endregion
|
| 109 |
logger.error("OpenAI API error: %s", exc)
|
| 110 |
raise
|
| 111 |
except Exception as exc:
|
| 112 |
+
# #region agent log
|
| 113 |
+
from app.core._debug_log import dlog
|
| 114 |
+
dlog("openai_fallback_client.py:generate.unexpected", "OpenAI generate failed", {
|
| 115 |
+
"exc_type": type(exc).__name__,
|
| 116 |
+
"model": self.model,
|
| 117 |
+
"exc_msg": str(exc)[:500],
|
| 118 |
+
}, "B")
|
| 119 |
+
# #endregion
|
| 120 |
logger.error("OpenAI generate failed: %s", exc)
|
| 121 |
raise
|
| 122 |
|
|
|
|
| 140 |
|
| 141 |
try:
|
| 142 |
for _round in range(self._MAX_TOOL_ROUNDS):
|
| 143 |
+
token_kwarg = "max_completion_tokens" if self._uses_completion_tokens_param() else "max_tokens"
|
| 144 |
+
tool_kwargs: Dict[str, Any] = dict(
|
| 145 |
model=self.model,
|
| 146 |
+
messages=self._normalize_messages(messages),
|
| 147 |
tools=openai_tools or None,
|
| 148 |
+
**{token_kwarg: max_tokens},
|
|
|
|
| 149 |
**self._reasoning_kwargs(),
|
| 150 |
)
|
| 151 |
+
if not self._uses_completion_tokens_param():
|
| 152 |
+
tool_kwargs["temperature"] = temperature
|
| 153 |
+
response = await self.client.chat.completions.create(**tool_kwargs)
|
| 154 |
choice = response.choices[0].message
|
| 155 |
if not choice.tool_calls:
|
| 156 |
text = choice.content or ""
|
|
@@ -41,6 +41,15 @@ class ResilientLLMClient(LLMClient):
|
|
| 41 |
|
| 42 |
async def _run_primary(self, coro_factory):
|
| 43 |
result = await coro_factory(self.primary)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
if isinstance(result, str) and _looks_like_failed_response(result):
|
| 45 |
raise RuntimeError(f"{self.primary_label} returned failure text")
|
| 46 |
if isinstance(result, ToolCallResult) and _looks_like_failed_response(result.text):
|
|
@@ -49,49 +58,152 @@ class ResilientLLMClient(LLMClient):
|
|
| 49 |
|
| 50 |
async def _run_fallback(self, coro_factory):
|
| 51 |
logger.info("Using fallback LLM for %s", self.primary_label)
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
async def _race_or_fallback(self, coro_factory):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
primary_task = asyncio.create_task(self._run_primary(coro_factory))
|
|
|
|
| 56 |
try:
|
| 57 |
-
|
| 58 |
asyncio.shield(primary_task),
|
| 59 |
timeout=self.race_timeout_seconds,
|
| 60 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
except asyncio.TimeoutError:
|
| 62 |
logger.info(
|
| 63 |
-
"%s
|
| 64 |
self.primary_label,
|
| 65 |
self.race_timeout_seconds,
|
| 66 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
except Exception as exc:
|
| 68 |
-
logger.warning("%s failed: %s — using fallback", self.primary_label, exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
if not primary_task.done():
|
| 70 |
primary_task.cancel()
|
| 71 |
-
return await self._run_fallback(coro_factory)
|
| 72 |
-
|
| 73 |
-
if primary_task.done():
|
| 74 |
-
try:
|
| 75 |
-
return primary_task.result()
|
| 76 |
-
except Exception as exc:
|
| 77 |
-
logger.warning("%s failed after wait: %s", self.primary_label, exc)
|
| 78 |
-
return await self._run_fallback(coro_factory)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
fallback_task = asyncio.create_task(self._run_fallback(coro_factory))
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
async def generate(
|
| 97 |
self,
|
|
|
|
| 41 |
|
| 42 |
async def _run_primary(self, coro_factory):
|
| 43 |
result = await coro_factory(self.primary)
|
| 44 |
+
# #region agent log
|
| 45 |
+
from app.core._debug_log import dlog
|
| 46 |
+
preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
|
| 47 |
+
dlog("resilient_client.py:_run_primary", "primary returned", {
|
| 48 |
+
"label": self.primary_label,
|
| 49 |
+
"result_type": type(result).__name__,
|
| 50 |
+
"preview": preview,
|
| 51 |
+
}, "E")
|
| 52 |
+
# #endregion
|
| 53 |
if isinstance(result, str) and _looks_like_failed_response(result):
|
| 54 |
raise RuntimeError(f"{self.primary_label} returned failure text")
|
| 55 |
if isinstance(result, ToolCallResult) and _looks_like_failed_response(result.text):
|
|
|
|
| 58 |
|
| 59 |
async def _run_fallback(self, coro_factory):
|
| 60 |
logger.info("Using fallback LLM for %s", self.primary_label)
|
| 61 |
+
# #region agent log
|
| 62 |
+
from app.core._debug_log import dlog
|
| 63 |
+
dlog("resilient_client.py:_run_fallback.start", "invoking fallback", {
|
| 64 |
+
"label": self.primary_label,
|
| 65 |
+
}, "B")
|
| 66 |
+
# #endregion
|
| 67 |
+
try:
|
| 68 |
+
result = await coro_factory(self.fallback)
|
| 69 |
+
# #region agent log
|
| 70 |
+
preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
|
| 71 |
+
dlog("resilient_client.py:_run_fallback.ok", "fallback returned", {
|
| 72 |
+
"label": self.primary_label,
|
| 73 |
+
"preview": preview,
|
| 74 |
+
}, "B")
|
| 75 |
+
# #endregion
|
| 76 |
+
return result
|
| 77 |
+
except Exception as fe:
|
| 78 |
+
# #region agent log
|
| 79 |
+
dlog("resilient_client.py:_run_fallback.error", "fallback raised", {
|
| 80 |
+
"label": self.primary_label,
|
| 81 |
+
"exc_type": type(fe).__name__,
|
| 82 |
+
"exc_msg": str(fe)[:500],
|
| 83 |
+
}, "B")
|
| 84 |
+
# #endregion
|
| 85 |
+
raise
|
| 86 |
|
| 87 |
async def _race_or_fallback(self, coro_factory):
|
| 88 |
+
"""Spec:
|
| 89 |
+
- Start *primary* immediately.
|
| 90 |
+
- If primary returns successfully → use it.
|
| 91 |
+
- If primary fails (raises, returns recognised failure text) → run
|
| 92 |
+
*fallback* sequentially and return its result.
|
| 93 |
+
- If primary takes longer than ``race_timeout_seconds`` without
|
| 94 |
+
completing → start *fallback* in parallel and return whichever
|
| 95 |
+
finishes first (primary or fallback).
|
| 96 |
+
"""
|
| 97 |
+
# #region agent log
|
| 98 |
+
from app.core._debug_log import dlog
|
| 99 |
+
dlog("resilient_client.py:_race_or_fallback.enter", "starting primary", {
|
| 100 |
+
"label": self.primary_label,
|
| 101 |
+
"timeout_s": self.race_timeout_seconds,
|
| 102 |
+
}, "B")
|
| 103 |
+
# #endregion
|
| 104 |
+
|
| 105 |
primary_task = asyncio.create_task(self._run_primary(coro_factory))
|
| 106 |
+
|
| 107 |
try:
|
| 108 |
+
result = await asyncio.wait_for(
|
| 109 |
asyncio.shield(primary_task),
|
| 110 |
timeout=self.race_timeout_seconds,
|
| 111 |
)
|
| 112 |
+
# #region agent log
|
| 113 |
+
preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
|
| 114 |
+
dlog("resilient_client.py:_race_or_fallback.primary_ok", "primary succeeded", {
|
| 115 |
+
"label": self.primary_label,
|
| 116 |
+
"preview": preview,
|
| 117 |
+
}, "B")
|
| 118 |
+
# #endregion
|
| 119 |
+
return result
|
| 120 |
except asyncio.TimeoutError:
|
| 121 |
logger.info(
|
| 122 |
+
"%s slower than %.1fs — racing fallback in parallel",
|
| 123 |
self.primary_label,
|
| 124 |
self.race_timeout_seconds,
|
| 125 |
)
|
| 126 |
+
# #region agent log
|
| 127 |
+
dlog("resilient_client.py:_race_or_fallback.timeout_race", "racing fallback in parallel", {
|
| 128 |
+
"label": self.primary_label,
|
| 129 |
+
"timeout_s": self.race_timeout_seconds,
|
| 130 |
+
}, "B")
|
| 131 |
+
# #endregion
|
| 132 |
+
return await self._race_first_successful(primary_task, coro_factory)
|
| 133 |
except Exception as exc:
|
| 134 |
+
logger.warning("%s failed: %s — using fallback sequentially", self.primary_label, exc)
|
| 135 |
+
# #region agent log
|
| 136 |
+
dlog("resilient_client.py:_race_or_fallback.primary_fail", "primary raised", {
|
| 137 |
+
"label": self.primary_label,
|
| 138 |
+
"exc_type": type(exc).__name__,
|
| 139 |
+
"exc_msg": str(exc)[:500],
|
| 140 |
+
}, "B")
|
| 141 |
+
# #endregion
|
| 142 |
if not primary_task.done():
|
| 143 |
primary_task.cancel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
+
try:
|
| 146 |
+
return await self._run_fallback(coro_factory)
|
| 147 |
+
except Exception as fb_exc:
|
| 148 |
+
logger.error(
|
| 149 |
+
"Both primary (%s) and fallback failed for this request: %s",
|
| 150 |
+
self.primary_label, fb_exc,
|
| 151 |
+
)
|
| 152 |
+
raise
|
| 153 |
+
|
| 154 |
+
async def _race_first_successful(self, primary_task: asyncio.Task, coro_factory):
|
| 155 |
+
"""Primary is still running past the soft timeout. Start fallback in
|
| 156 |
+
parallel and return whichever completes successfully first. If the
|
| 157 |
+
first one to complete failed, await the other; if both fail, raise.
|
| 158 |
+
"""
|
| 159 |
+
# #region agent log
|
| 160 |
+
from app.core._debug_log import dlog
|
| 161 |
+
# #endregion
|
| 162 |
fallback_task = asyncio.create_task(self._run_fallback(coro_factory))
|
| 163 |
+
pending = {primary_task, fallback_task}
|
| 164 |
+
|
| 165 |
+
winner_text: str | None = None
|
| 166 |
+
winner_obj: Any = None
|
| 167 |
+
last_exc: Exception | None = None
|
| 168 |
+
|
| 169 |
+
while pending:
|
| 170 |
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
| 171 |
+
for task in done:
|
| 172 |
+
which = "primary" if task is primary_task else "fallback"
|
| 173 |
+
try:
|
| 174 |
+
result = task.result()
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
last_exc = exc
|
| 177 |
+
# #region agent log
|
| 178 |
+
dlog("resilient_client.py:_race_first_successful.loser", "task failed", {
|
| 179 |
+
"which": which,
|
| 180 |
+
"exc_type": type(exc).__name__,
|
| 181 |
+
"exc_msg": str(exc)[:300],
|
| 182 |
+
}, "B")
|
| 183 |
+
# #endregion
|
| 184 |
+
continue
|
| 185 |
+
# #region agent log
|
| 186 |
+
preview = (result if isinstance(result, str) else getattr(result, "text", ""))[:200]
|
| 187 |
+
dlog("resilient_client.py:_race_first_successful.winner", "task succeeded", {
|
| 188 |
+
"which": which,
|
| 189 |
+
"preview": preview,
|
| 190 |
+
}, "B")
|
| 191 |
+
# #endregion
|
| 192 |
+
winner_obj = result
|
| 193 |
+
winner_text = preview
|
| 194 |
+
break
|
| 195 |
+
if winner_obj is not None:
|
| 196 |
+
break
|
| 197 |
+
|
| 198 |
+
for t in pending:
|
| 199 |
+
if not t.done():
|
| 200 |
+
t.cancel()
|
| 201 |
+
|
| 202 |
+
if winner_obj is not None:
|
| 203 |
+
return winner_obj
|
| 204 |
+
if last_exc is not None:
|
| 205 |
+
raise last_exc
|
| 206 |
+
raise RuntimeError("race produced no result and no exception")
|
| 207 |
|
| 208 |
async def generate(
|
| 209 |
self,
|
|
@@ -7,7 +7,7 @@
|
|
| 7 |
<meta name="theme-color" content="#000000" />
|
| 8 |
<meta
|
| 9 |
name="description"
|
| 10 |
-
content="
|
| 11 |
/>
|
| 12 |
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
| 13 |
<!--
|
|
@@ -24,7 +24,7 @@
|
|
| 24 |
work correctly both with client-side routing and a non-root public URL.
|
| 25 |
Learn how to configure a non-root public URL by running `npm run build`.
|
| 26 |
-->
|
| 27 |
-
<title>
|
| 28 |
</head>
|
| 29 |
<body>
|
| 30 |
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
|
|
| 7 |
<meta name="theme-color" content="#000000" />
|
| 8 |
<meta
|
| 9 |
name="description"
|
| 10 |
+
content="Cybersecurity Advisor — AI-powered security guidance from expert advisors"
|
| 11 |
/>
|
| 12 |
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
| 13 |
<!--
|
|
|
|
| 24 |
work correctly both with client-side routing and a non-root public URL.
|
| 25 |
Learn how to configure a non-root public URL by running `npm run build`.
|
| 26 |
-->
|
| 27 |
+
<title>Cybersecurity Advisor</title>
|
| 28 |
</head>
|
| 29 |
<body>
|
| 30 |
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
-
"short_name": "
|
| 3 |
-
"name": "
|
| 4 |
"icons": [
|
| 5 |
{
|
| 6 |
"src": "favicon.ico",
|
|
|
|
| 1 |
{
|
| 2 |
+
"short_name": "Cybersecurity Advisor",
|
| 3 |
+
"name": "Cybersecurity Advisor",
|
| 4 |
"icons": [
|
| 5 |
{
|
| 6 |
"src": "favicon.ico",
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// User Guide content for Cybersecurity Advisor
|
| 2 |
// Use {{appName}} as a placeholder — replaced at render time.
|
| 3 |
|
| 4 |
export const userGuideTopics = [
|
|
|
|
| 1 |
+
// User Guide content for Cybersecurity Advisor.
|
| 2 |
// Use {{appName}} as a placeholder — replaced at render time.
|
| 3 |
|
| 4 |
export const userGuideTopics = [
|
|
@@ -157,23 +157,19 @@
|
|
| 157 |
position: sticky;
|
| 158 |
top: 0;
|
| 159 |
z-index: 100;
|
| 160 |
-
background:
|
| 161 |
backdrop-filter: blur(20px);
|
| 162 |
-webkit-backdrop-filter: blur(20px);
|
| 163 |
-
border-bottom: 1px solid var(--
|
| 164 |
padding: 12px 24px;
|
| 165 |
display: grid;
|
| 166 |
grid-template-columns: 1fr auto 1fr;
|
| 167 |
align-items: center;
|
| 168 |
gap: 16px;
|
| 169 |
-
box-shadow:
|
| 170 |
flex-shrink: 0;
|
| 171 |
}
|
| 172 |
.canvas-page-with-sidebar .floating-header .header-right { justify-self: end; }
|
| 173 |
-
.canvas-page-with-sidebar[data-canvas-theme="light"] .floating-header {
|
| 174 |
-
background: rgba(255, 255, 255, 0.95);
|
| 175 |
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
| 176 |
-
}
|
| 177 |
|
| 178 |
.canvas-page-with-sidebar .header-left {
|
| 179 |
display: flex;
|
|
|
|
| 157 |
position: sticky;
|
| 158 |
top: 0;
|
| 159 |
z-index: 100;
|
| 160 |
+
background: var(--header-bg);
|
| 161 |
backdrop-filter: blur(20px);
|
| 162 |
-webkit-backdrop-filter: blur(20px);
|
| 163 |
+
border-bottom: 1px solid var(--header-border);
|
| 164 |
padding: 12px 24px;
|
| 165 |
display: grid;
|
| 166 |
grid-template-columns: 1fr auto 1fr;
|
| 167 |
align-items: center;
|
| 168 |
gap: 16px;
|
| 169 |
+
box-shadow: var(--header-shadow);
|
| 170 |
flex-shrink: 0;
|
| 171 |
}
|
| 172 |
.canvas-page-with-sidebar .floating-header .header-right { justify-self: end; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
.canvas-page-with-sidebar .header-left {
|
| 175 |
display: flex;
|
|
@@ -18,23 +18,18 @@
|
|
| 18 |
position: sticky;
|
| 19 |
top: 0;
|
| 20 |
z-index: 100;
|
| 21 |
-
background:
|
| 22 |
backdrop-filter: blur(20px);
|
| 23 |
-webkit-backdrop-filter: blur(20px);
|
| 24 |
-
border-bottom: 1px solid var(--
|
| 25 |
padding: 12px 24px;
|
| 26 |
display: flex;
|
| 27 |
align-items: center;
|
| 28 |
justify-content: space-between;
|
| 29 |
-
box-shadow:
|
| 30 |
flex-shrink: 0;
|
| 31 |
}
|
| 32 |
|
| 33 |
-
[data-theme="dark"] .floating-header {
|
| 34 |
-
background: rgba(31, 41, 55, 0.95);
|
| 35 |
-
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
.header-left {
|
| 39 |
display: flex;
|
| 40 |
align-items: center;
|
|
|
|
| 18 |
position: sticky;
|
| 19 |
top: 0;
|
| 20 |
z-index: 100;
|
| 21 |
+
background: var(--header-bg);
|
| 22 |
backdrop-filter: blur(20px);
|
| 23 |
-webkit-backdrop-filter: blur(20px);
|
| 24 |
+
border-bottom: 1px solid var(--header-border);
|
| 25 |
padding: 12px 24px;
|
| 26 |
display: flex;
|
| 27 |
align-items: center;
|
| 28 |
justify-content: space-between;
|
| 29 |
+
box-shadow: var(--header-shadow);
|
| 30 |
flex-shrink: 0;
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
.header-left {
|
| 34 |
display: flex;
|
| 35 |
align-items: center;
|
|
@@ -42,6 +42,11 @@
|
|
| 42 |
--input-border: #D1D5DB;
|
| 43 |
--input-focus: #6366F1;
|
| 44 |
--input-focus-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
:root[data-theme="dark"] {
|
|
@@ -87,6 +92,11 @@
|
|
| 87 |
--input-border: #4B5563;
|
| 88 |
--input-focus: #818CF8;
|
| 89 |
--input-focus-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
/* Theme Toggle Button */
|
|
|
|
| 42 |
--input-border: #D1D5DB;
|
| 43 |
--input-focus: #6366F1;
|
| 44 |
--input-focus-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
| 45 |
+
|
| 46 |
+
/* App header (floating bar) */
|
| 47 |
+
--header-bg: #EBF5FF;
|
| 48 |
+
--header-border: rgba(59, 130, 246, 0.2);
|
| 49 |
+
--header-shadow: 0 4px 20px rgba(37, 99, 235, 0.1);
|
| 50 |
}
|
| 51 |
|
| 52 |
:root[data-theme="dark"] {
|
|
|
|
| 92 |
--input-border: #4B5563;
|
| 93 |
--input-focus: #818CF8;
|
| 94 |
--input-focus-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
|
| 95 |
+
|
| 96 |
+
/* App header (floating bar) */
|
| 97 |
+
--header-bg: rgba(23, 37, 64, 0.97);
|
| 98 |
+
--header-border: rgba(96, 165, 250, 0.18);
|
| 99 |
+
--header-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
| 100 |
}
|
| 101 |
|
| 102 |
/* Theme Toggle Button */
|
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\components\canvas\canvasData.js")
|
| 4 |
+
c = p.read_text(encoding="utf-8")
|
| 5 |
+
|
| 6 |
+
start = c.index("export const INSIGHTS = [")
|
| 7 |
+
end = c.index("export const WIDGET_CATALOG")
|
| 8 |
+
|
| 9 |
+
insights = r'''export const INSIGHTS = [
|
| 10 |
+
{
|
| 11 |
+
id: 'i-progress',
|
| 12 |
+
title: 'Program progress',
|
| 13 |
+
icon: 'graph',
|
| 14 |
+
category: 'progress',
|
| 15 |
+
confidence: 82,
|
| 16 |
+
summary: 'Zero Trust Phase 2 is 78% complete. MFA enforced for workforce; service accounts and legacy VPN exceptions remain the main gaps before audit sampling.',
|
| 17 |
+
bullets: [
|
| 18 |
+
'Identity: <strong>MFA 94%</strong> workforce · service accounts in remediation',
|
| 19 |
+
'Network: micro-segmentation pilot on <strong>3 app tiers</strong>',
|
| 20 |
+
'<strong>Risk:</strong> 12 VPN exceptions still lack compensating controls',
|
| 21 |
+
],
|
| 22 |
+
pinned: true,
|
| 23 |
+
sources: 18,
|
| 24 |
+
updatedMinutesAgo: 5,
|
| 25 |
+
quotes: [
|
| 26 |
+
'"MFA rollout blocked on two legacy HR integrations." — IAM workstream notes',
|
| 27 |
+
'"Auditors will sample VPN exception register first." — GRC advisor chat',
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 'i-method',
|
| 32 |
+
title: 'Controls posture',
|
| 33 |
+
icon: 'flask',
|
| 34 |
+
category: 'theory',
|
| 35 |
+
confidence: 71,
|
| 36 |
+
summary: 'SOC 2 CC6/CC7 mappings are drafted. Detection use cases cover ransomware and cred theft; log retention and IR tabletop evidence are still thin.',
|
| 37 |
+
bullets: [
|
| 38 |
+
'Mapped: <strong>CC6.1–CC6.7</strong> access controls with Okta + AWS',
|
| 39 |
+
'Open: centralized logging retention proof for <strong>365 days</strong>',
|
| 40 |
+
'Open: tabletop scenario for <strong>ransomware + exfil</strong> not yet run',
|
| 41 |
+
],
|
| 42 |
+
sources: 14,
|
| 43 |
+
updatedMinutesAgo: 14,
|
| 44 |
+
quotes: [
|
| 45 |
+
'"Need SIEM retention screenshots before fieldwork." — compliance advisor',
|
| 46 |
+
'"Tabletop scheduled but not executed." — IR lead notes',
|
| 47 |
+
],
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
id: 'i-lit',
|
| 51 |
+
title: 'Threat landscape',
|
| 52 |
+
icon: 'book',
|
| 53 |
+
category: 'literature',
|
| 54 |
+
confidence: 76,
|
| 55 |
+
summary: 'Strong coverage of identity attacks, SaaS misconfigurations, and supply-chain risks for your stack. Weaker on OT exposure and insider threat playbooks.',
|
| 56 |
+
bullets: [
|
| 57 |
+
'<strong>Coverage:</strong> MITRE techniques for cloud identity & SaaS',
|
| 58 |
+
'<strong>Gap:</strong> limited intel on <strong>OAuth consent phishing</strong> variants',
|
| 59 |
+
'<strong>Gap:</strong> no formal insider-threat escalation path documented',
|
| 60 |
+
],
|
| 61 |
+
sources: 32,
|
| 62 |
+
updatedMinutesAgo: 28,
|
| 63 |
+
quotes: [
|
| 64 |
+
'"OAuth abuse is the fastest-moving thread in your sector." — threat intel advisor',
|
| 65 |
+
'"Insider playbook is a one-pager — not enough for audit." — GRC advisor',
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
id: 'i-questions',
|
| 70 |
+
title: 'Open security questions',
|
| 71 |
+
icon: 'sparkles',
|
| 72 |
+
category: 'theory',
|
| 73 |
+
confidence: 63,
|
| 74 |
+
summary: 'Three live threads. Q1 (scope of zero trust for contractors) gates architecture sign-off. Q2–Q3 affect detection engineering priorities.',
|
| 75 |
+
bullets: [
|
| 76 |
+
'<strong>Q1:</strong> Do contractors get full ZTNA or bastion-only access?',
|
| 77 |
+
'<strong>Q2:</strong> Which SIEM detections are in-scope for SOC 2 evidence?',
|
| 78 |
+
'<strong>Q3:</strong> Is customer data in EU regions in scope for DPA addendum?',
|
| 79 |
+
],
|
| 80 |
+
sources: 9,
|
| 81 |
+
updatedMinutesAgo: 41,
|
| 82 |
+
quotes: [
|
| 83 |
+
'"Contractor access model blocks network design." — architect advisor',
|
| 84 |
+
'"EU data residency may expand audit scope." — privacy advisor',
|
| 85 |
+
],
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
id: 'i-next',
|
| 89 |
+
title: 'Next steps',
|
| 90 |
+
icon: 'arrow',
|
| 91 |
+
category: 'action',
|
| 92 |
+
confidence: 85,
|
| 93 |
+
summary: 'Near-term actions tied to audit date and production cutover. Two items have slipped one sprint.',
|
| 94 |
+
bullets: [
|
| 95 |
+
'Close <strong>12 VPN exceptions</strong> or document compensating controls',
|
| 96 |
+
'Run ransomware tabletop & upload minutes to evidence locker',
|
| 97 |
+
'Ship <strong>5 high-fidelity detections</strong> to production SIEM',
|
| 98 |
+
'Finalize vendor SOC 2 bridge letter for subprocessors',
|
| 99 |
+
],
|
| 100 |
+
sources: 7,
|
| 101 |
+
updatedMinutesAgo: 9,
|
| 102 |
+
quotes: [
|
| 103 |
+
'"VPN exceptions are the #1 audit finding risk." — GRC advisor',
|
| 104 |
+
'"Detections without tuning will false-positive in week one." — SOC advisor',
|
| 105 |
+
],
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
id: 'i-blockers',
|
| 109 |
+
title: 'Blockers & risks',
|
| 110 |
+
icon: 'alert',
|
| 111 |
+
category: 'risk',
|
| 112 |
+
confidence: 74,
|
| 113 |
+
summary: 'One technical blocker (legacy logging), one governance blocker (exception approvals). Governance is the higher audit risk.',
|
| 114 |
+
bullets: [
|
| 115 |
+
'<strong>Technical:</strong> legacy app logs not reaching SIEM — 18% of prod traffic',
|
| 116 |
+
'<strong>Governance:</strong> exception approval SLA > 10 days — auditors will flag',
|
| 117 |
+
],
|
| 118 |
+
sources: 6,
|
| 119 |
+
updatedMinutesAgo: 20,
|
| 120 |
+
quotes: [
|
| 121 |
+
'"Without those logs you cannot prove detective controls." — detection engineer',
|
| 122 |
+
'"Exception backlog reads as control failure." — devil\'s advocate advisor',
|
| 123 |
+
],
|
| 124 |
+
},
|
| 125 |
+
];
|
| 126 |
+
|
| 127 |
+
'''
|
| 128 |
+
|
| 129 |
+
p.write_text(c[:start] + insights + c[end:], encoding="utf-8")
|
| 130 |
+
print("insights updated")
|
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\pages\ChatPage.js")
|
| 4 |
+
c = p.read_text(encoding="utf-8")
|
| 5 |
+
|
| 6 |
+
close_div = "</" + "div>"
|
| 7 |
+
|
| 8 |
+
needle = f""" placeholder={{
|
| 9 |
+
replyingTo
|
| 10 |
+
? `Reply to ${{replyingTo.advisorName}}...`
|
| 11 |
+
: chatPlaceholder
|
| 12 |
+
}}
|
| 13 |
+
/>
|
| 14 |
+
{close_div}
|
| 15 |
+
{close_div}
|
| 16 |
+
{close_div}
|
| 17 |
+
{close_div}
|
| 18 |
+
);
|
| 19 |
+
}};
|
| 20 |
+
|
| 21 |
+
export default ChatPage;
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
replacement = f""" placeholder={{
|
| 25 |
+
replyingTo
|
| 26 |
+
? `Reply to ${{replyingTo.advisorName}}...`
|
| 27 |
+
: chatPlaceholder
|
| 28 |
+
}}
|
| 29 |
+
showProfileButtons={{!userProfile || userProfile.completion_pct < 100}}
|
| 30 |
+
onOpenOnboarding={{() => setShowOnboarding(true)}}
|
| 31 |
+
onOpenProfileForm={{() => setShowProfileForm(true)}}
|
| 32 |
+
/>
|
| 33 |
+
{close_div}
|
| 34 |
+
{close_div}
|
| 35 |
+
{close_div}
|
| 36 |
+
|
| 37 |
+
{{showOnboarding && (
|
| 38 |
+
<OnboardingChat
|
| 39 |
+
authToken={{authToken}}
|
| 40 |
+
userName={{user?.firstName}}
|
| 41 |
+
onClose={{() => {{ setShowOnboarding(false); loadProfile(); }}}}
|
| 42 |
+
/>
|
| 43 |
+
)}}
|
| 44 |
+
|
| 45 |
+
{{showProfileForm && (
|
| 46 |
+
<ProfileWalkthrough
|
| 47 |
+
authToken={{authToken}}
|
| 48 |
+
existingProfile={{userProfile}}
|
| 49 |
+
onClose={{() => {{ setShowProfileForm(false); loadProfile(); }}}}
|
| 50 |
+
/>
|
| 51 |
+
)}}
|
| 52 |
+
|
| 53 |
+
{{showClearData && (
|
| 54 |
+
<ClearDataModal
|
| 55 |
+
authToken={{authToken}}
|
| 56 |
+
onClose={{() => setShowClearData(false)}}
|
| 57 |
+
onDataCleared={{({{ profile: clearedProfile, chats: clearedChats }}) => {{
|
| 58 |
+
if (clearedProfile) {{
|
| 59 |
+
setUserProfile(null);
|
| 60 |
+
loadProfile();
|
| 61 |
+
}}
|
| 62 |
+
if (clearedChats) {{
|
| 63 |
+
setMessages([]);
|
| 64 |
+
setCurrentSessionId(null);
|
| 65 |
+
setCurrentSessionTitle('');
|
| 66 |
+
handleNewChat();
|
| 67 |
+
}}
|
| 68 |
+
}}}}
|
| 69 |
+
/>
|
| 70 |
+
)}}
|
| 71 |
+
|
| 72 |
+
{{showAccount && (
|
| 73 |
+
<AccountModal
|
| 74 |
+
user={{user}}
|
| 75 |
+
authToken={{authToken}}
|
| 76 |
+
onClose={{() => setShowAccount(false)}}
|
| 77 |
+
onAccountUpdated={{(updated) => {{
|
| 78 |
+
if (user) {{
|
| 79 |
+
user.firstName = updated.firstName;
|
| 80 |
+
user.lastName = updated.lastName;
|
| 81 |
+
user.email = updated.email;
|
| 82 |
+
}}
|
| 83 |
+
}}}}
|
| 84 |
+
onAccountDeleted={{() => {{
|
| 85 |
+
setShowAccount(false);
|
| 86 |
+
onSignOut();
|
| 87 |
+
}}}}
|
| 88 |
+
/>
|
| 89 |
+
)}}
|
| 90 |
+
{close_div}
|
| 91 |
+
);
|
| 92 |
+
}};
|
| 93 |
+
|
| 94 |
+
export default ChatPage;
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
if needle not in c:
|
| 98 |
+
raise SystemExit("needle not found in ChatPage.js")
|
| 99 |
+
|
| 100 |
+
p.write_text(c.replace(needle, replacement, 1), encoding="utf-8")
|
| 101 |
+
print("ok")
|
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\components\MessageBubble.js")
|
| 4 |
+
c = p.read_text(encoding="utf-8")
|
| 5 |
+
|
| 6 |
+
start = c.index(" const avatarElement = (size = 40)")
|
| 7 |
+
end = c.index(" return (", start)
|
| 8 |
+
new = """ const avatarElement = (size = 44) => {
|
| 9 |
+
const iconSize = Math.round(size * 0.52);
|
| 10 |
+
return (
|
| 11 |
+
<div
|
| 12 |
+
className="advisor-message-avatar-ring"
|
| 13 |
+
style={{ width: size, height: size }}
|
| 14 |
+
>
|
| 15 |
+
{advisor.avatarUrl ? (
|
| 16 |
+
<img
|
| 17 |
+
src={advisor.avatarUrl}
|
| 18 |
+
alt={advisor.name || 'Advisor'}
|
| 19 |
+
/>
|
| 20 |
+
) : Icon ? (
|
| 21 |
+
<Icon
|
| 22 |
+
className="advisor-message-avatar-icon"
|
| 23 |
+
style={{
|
| 24 |
+
color: colors.color || 'var(--text-secondary)',
|
| 25 |
+
width: iconSize,
|
| 26 |
+
height: iconSize,
|
| 27 |
+
}}
|
| 28 |
+
/>
|
| 29 |
+
) : (
|
| 30 |
+
<span
|
| 31 |
+
className="advisor-message-avatar-initial"
|
| 32 |
+
style={{ color: colors.color || 'var(--text-secondary)', fontSize: iconSize }}
|
| 33 |
+
>
|
| 34 |
+
{advisor.name ? advisor.name.charAt(0) : 'A'}
|
| 35 |
+
</span>
|
| 36 |
+
)}
|
| 37 |
+
</motion.div>
|
| 38 |
+
);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
"""
|
| 42 |
+
new = new.replace("</motion.div>", "</" + "div>")
|
| 43 |
+
|
| 44 |
+
c = c[:start] + new + c[end:]
|
| 45 |
+
c = c.replace("{inlineAvatar && avatarElement(32)}", "{inlineAvatar && avatarElement(44)}", 1)
|
| 46 |
+
p.write_text(c, encoding="utf-8")
|
| 47 |
+
print("ok")
|
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
p = Path(r"C:\Users\dream\CCAI-Demo-Canvas-Upgrades\phd-advisor-frontend\src\components\Sidebar.js")
|
| 4 |
+
c = p.read_text(encoding="utf-8")
|
| 5 |
+
|
| 6 |
+
avatar_old = (
|
| 7 |
+
' <div className="user-avatar">\n'
|
| 8 |
+
' <User size={20} />\n'
|
| 9 |
+
' </div>'
|
| 10 |
+
)
|
| 11 |
+
avatar_new = (
|
| 12 |
+
' <motion.div\n'
|
| 13 |
+
' className="user-avatar"\n'
|
| 14 |
+
' onClick={() => onAvatarChange && setShowAvatarPicker(true)}\n'
|
| 15 |
+
' style={{\n'
|
| 16 |
+
" cursor: onAvatarChange ? 'pointer' : undefined,\n"
|
| 17 |
+
' backgroundColor: currentAvatar?.bg || undefined,\n'
|
| 18 |
+
' color: currentAvatar?.color || undefined,\n'
|
| 19 |
+
' }}\n'
|
| 20 |
+
" title={onAvatarChange ? 'Change avatar' : undefined}\n"
|
| 21 |
+
' >\n'
|
| 22 |
+
' <AvatarIcon size={20} />\n'
|
| 23 |
+
' </div>'
|
| 24 |
+
)
|
| 25 |
+
avatar_new = avatar_new.replace("<motion.div", "<div").replace("motion.div\n", "div\n")
|
| 26 |
+
|
| 27 |
+
menu_old = (
|
| 28 |
+
' {showUserMenu && (\n'
|
| 29 |
+
' <div className="user-menu">\n'
|
| 30 |
+
' <button className="user-menu-item">\n'
|
| 31 |
+
' <Settings size={16} />\n'
|
| 32 |
+
' <span>Settings</span>\n'
|
| 33 |
+
' </button>\n'
|
| 34 |
+
' <button className="user-menu-item sign-out" onClick={onSignOut}>\n'
|
| 35 |
+
' <LogOut size={16} />\n'
|
| 36 |
+
' <span>Sign Out</span>\n'
|
| 37 |
+
' </button>\n'
|
| 38 |
+
' </div>\n'
|
| 39 |
+
' )}'
|
| 40 |
+
)
|
| 41 |
+
menu_new = (
|
| 42 |
+
' {showUserMenu && (\n'
|
| 43 |
+
' <div className="user-menu">\n'
|
| 44 |
+
' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); setShowAvatarPicker(true); }}>\n'
|
| 45 |
+
' <User size={16} />\n'
|
| 46 |
+
' <span>Change Avatar</span>\n'
|
| 47 |
+
' </button>\n'
|
| 48 |
+
' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenProfile) onOpenProfile(); }}>\n'
|
| 49 |
+
' <UserCircle size={16} />\n'
|
| 50 |
+
' <span>Profile</span>\n'
|
| 51 |
+
' </button>\n'
|
| 52 |
+
' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenAccount) onOpenAccount(); }}>\n'
|
| 53 |
+
' <KeyRound size={16} />\n'
|
| 54 |
+
' <span>Account</span>\n'
|
| 55 |
+
' </button>\n'
|
| 56 |
+
' <button className="user-menu-item" onClick={() => { setShowUserMenu(false); if (onOpenClearData) onOpenClearData(); }}>\n'
|
| 57 |
+
' <DatabaseZap size={16} />\n'
|
| 58 |
+
' <span>Clear User Data</span>\n'
|
| 59 |
+
' </button>\n'
|
| 60 |
+
' <button className="user-menu-item sign-out" onClick={onSignOut}>\n'
|
| 61 |
+
' <LogOut size={16} />\n'
|
| 62 |
+
' <span>Sign Out</span>\n'
|
| 63 |
+
' </button>\n'
|
| 64 |
+
' </div>\n'
|
| 65 |
+
' )}'
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if avatar_old not in c:
|
| 69 |
+
raise SystemExit("avatar block not found")
|
| 70 |
+
c = c.replace(avatar_old, avatar_new, 1)
|
| 71 |
+
if menu_old not in c:
|
| 72 |
+
raise SystemExit("menu block not found")
|
| 73 |
+
c = c.replace(menu_old, menu_new, 1)
|
| 74 |
+
|
| 75 |
+
picker = """
|
| 76 |
+
{showAvatarPicker && (
|
| 77 |
+
<UserAvatarPicker
|
| 78 |
+
options={avatarOptions}
|
| 79 |
+
currentId={userAvatarId}
|
| 80 |
+
onSelect={(id) => { onAvatarChange?.(id); setShowAvatarPicker(false); }}
|
| 81 |
+
onClose={() => setShowAvatarPicker(false)}
|
| 82 |
+
/>
|
| 83 |
+
)}
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
if "UserAvatarPicker" not in c.split("export default")[0].split("return (")[-1]:
|
| 87 |
+
c = c.replace(
|
| 88 |
+
" {isMobileOpen && (",
|
| 89 |
+
picker + "\n {isMobileOpen && (",
|
| 90 |
+
1,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
p.write_text(c, encoding="utf-8")
|
| 94 |
+
print("Sidebar patched")
|