Merge pull request #2 from NeonClary/dev-cursor
Browse files- backend/app/chat_context.py +16 -0
- backend/app/main.py +3 -3
- backend/app/routers/advisor_chat.py +11 -10
- backend/app/routers/advisor_preview.py +0 -85
- backend/app/routers/persona_chat.py +12 -12
- backend/app/schemas.py +4 -0
- backend/app/services/llm.py +7 -0
- backend/app/spa_static.py +35 -0
- backend/tests/test_local_persistence.py +2 -1
- docker-compose.yml +4 -0
- frontend/src/components/WorkshopCreationsSection.tsx +28 -6
- frontend/src/context/AuthContext.tsx +0 -15
- frontend/src/lib/chatDraftMerge.ts +80 -0
- frontend/src/lib/creations.ts +10 -43
- frontend/src/lib/debugAgentLog.ts +0 -30
- frontend/src/lib/draftCreationsSync.ts +48 -0
- frontend/src/lib/interactiveChatSession.ts +65 -0
- frontend/src/pages/AdvisorChatPage.tsx +36 -34
- frontend/src/pages/AdvisorFormWizard.tsx +7 -1
- frontend/src/pages/PersonaChatPage.tsx +36 -34
- frontend/src/pages/PersonaFormWizard.tsx +7 -1
- frontend/vite.config.ts +2 -0
- scripts/dev-docker-restart.ps1 +7 -0
backend/app/chat_context.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
"""Append optional learner-facing context to system prompts for interactive Q&A."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def with_learner_context(system: str, learner_context: str | None) -> str:
|
| 5 |
if not learner_context or not learner_context.strip():
|
|
@@ -9,3 +12,16 @@ def with_learner_context(system: str, learner_context: str | None) -> str:
|
|
| 9 |
+ "\n\n---\nLearner context (use naturally; do not fabricate facts):\n"
|
| 10 |
+ learner_context.strip()
|
| 11 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Append optional learner-facing context to system prompts for interactive Q&A."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
|
| 7 |
def with_learner_context(system: str, learner_context: str | None) -> str:
|
| 8 |
if not learner_context or not learner_context.strip():
|
|
|
|
| 12 |
+ "\n\n---\nLearner context (use naturally; do not fabricate facts):\n"
|
| 13 |
+ learner_context.strip()
|
| 14 |
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def with_draft_snapshot(system: str, draft_snapshot: dict[str, Any] | None) -> str:
|
| 18 |
+
if not draft_snapshot:
|
| 19 |
+
return system
|
| 20 |
+
cleaned = {k: v for k, v in draft_snapshot.items() if not str(k).startswith("_")}
|
| 21 |
+
if not cleaned:
|
| 22 |
+
return system
|
| 23 |
+
return (
|
| 24 |
+
system
|
| 25 |
+
+ "\n\n---\nCurrent form draft from the client (carry forward and update cumulatively in draft_update every turn):\n"
|
| 26 |
+
+ json.dumps(cleaned, ensure_ascii=False)
|
| 27 |
+
)
|
backend/app/main.py
CHANGED
|
@@ -4,9 +4,9 @@ from pathlib import Path
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
-
from fastapi.staticfiles import StaticFiles
|
| 8 |
|
| 9 |
from app.config import settings
|
|
|
|
| 10 |
from app.db import _close as _close_db
|
| 11 |
from app.db import get_database
|
| 12 |
from app.routers import (
|
|
@@ -116,9 +116,9 @@ _ADVISOR_PREVIEW_DIST = _BACKEND_ROOT / "advisor-preview-static"
|
|
| 116 |
if _ADVISOR_PREVIEW_DIST.is_dir():
|
| 117 |
app.mount(
|
| 118 |
"/advisor-preview",
|
| 119 |
-
|
| 120 |
name="advisor-preview-static",
|
| 121 |
)
|
| 122 |
|
| 123 |
if _should_mount_static:
|
| 124 |
-
app.mount("/",
|
|
|
|
| 4 |
|
| 5 |
from fastapi import FastAPI
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 7 |
|
| 8 |
from app.config import settings
|
| 9 |
+
from app.spa_static import SPAStaticFiles
|
| 10 |
from app.db import _close as _close_db
|
| 11 |
from app.db import get_database
|
| 12 |
from app.routers import (
|
|
|
|
| 116 |
if _ADVISOR_PREVIEW_DIST.is_dir():
|
| 117 |
app.mount(
|
| 118 |
"/advisor-preview",
|
| 119 |
+
SPAStaticFiles(directory=str(_ADVISOR_PREVIEW_DIST), html=True),
|
| 120 |
name="advisor-preview-static",
|
| 121 |
)
|
| 122 |
|
| 123 |
if _should_mount_static:
|
| 124 |
+
app.mount("/", SPAStaticFiles(directory=str(FRONTEND_DIST), html=True), name="static")
|
backend/app/routers/advisor_chat.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import httpx
|
| 2 |
from fastapi import APIRouter, HTTPException
|
| 3 |
|
| 4 |
-
from app.chat_context import with_learner_context
|
| 5 |
from app.schemas import ChatMessage, ChatResponse
|
| 6 |
from app.services.llm import generate_structured_reply
|
| 7 |
|
|
@@ -9,15 +9,15 @@ router = APIRouter(tags=["advisor-chat"])
|
|
| 9 |
|
| 10 |
SYSTEM = """You are helping a student design an **advisor panel** (multiple AI advisors with different angles) for a Collaborative Conversational AI project.
|
| 11 |
|
| 12 |
-
If learner context below includes a preferred name,
|
| 13 |
|
| 14 |
**Interview order:** Walk through the fields below **in this exact order**. Each turn, your `reply` should mainly ask about the **next** field that is still empty or too vague in `draft_update` (or confirm/clarify the current one). Do not jump to later fields out of order unless the user explicitly asks to skip ahead or return to an earlier field. The **advisors** array is last—only after the string fields have been covered (or sensibly skipped).
|
| 15 |
|
| 16 |
Keep replies short (2–5 sentences). No code.
|
| 17 |
|
| 18 |
**Canonical order** (must match the product form / merge keys):
|
| 19 |
-
1.
|
| 20 |
-
2.
|
| 21 |
3. purpose — what decisions or questions the panel helps with
|
| 22 |
4. visionApplicationPurpose — project vision: what the app is for
|
| 23 |
5. visionTargetAudience — who it is for
|
|
@@ -39,25 +39,26 @@ Keep replies short (2–5 sentences). No code.
|
|
| 39 |
|
| 40 |
Every response MUST be a single JSON object only, no markdown, shape:
|
| 41 |
{"reply":"<message>","progress":<0-100>,"complete":<true|false>,"draft_update":{...}}
|
| 42 |
-
- draft_update: REQUIRED every turn. Include **all** keys above every time (strings use "" when unknown). Cumulative best estimates from the full conversation.
|
| 43 |
-
-
|
|
|
|
| 44 |
- progress: 0–100 by how many ordered steps still need meaningful content."""
|
| 45 |
|
| 46 |
|
| 47 |
@router.post("/message", response_model=ChatResponse)
|
| 48 |
async def advisor_message(msg: ChatMessage):
|
| 49 |
try:
|
| 50 |
-
|
| 51 |
with_learner_context(SYSTEM, msg.learner_context),
|
| 52 |
-
msg.
|
| 53 |
-
msg.user_input,
|
| 54 |
)
|
|
|
|
| 55 |
return ChatResponse(**out)
|
| 56 |
except httpx.HTTPStatusError as e:
|
| 57 |
detail = (e.response.text or "")[:800]
|
| 58 |
raise HTTPException(
|
| 59 |
status_code=502,
|
| 60 |
-
detail=f"
|
| 61 |
) from e
|
| 62 |
except ValueError as e:
|
| 63 |
raise HTTPException(status_code=503, detail=str(e)) from e
|
|
|
|
| 1 |
import httpx
|
| 2 |
from fastapi import APIRouter, HTTPException
|
| 3 |
|
| 4 |
+
from app.chat_context import with_draft_snapshot, with_learner_context
|
| 5 |
from app.schemas import ChatMessage, ChatResponse
|
| 6 |
from app.services.llm import generate_structured_reply
|
| 7 |
|
|
|
|
| 9 |
|
| 10 |
SYSTEM = """You are helping a student design an **advisor panel** (multiple AI advisors with different angles) for a Collaborative Conversational AI project.
|
| 11 |
|
| 12 |
+
If learner context below includes a preferred name, use it for **authorName** when appropriate; never invent personal details not in that context.
|
| 13 |
|
| 14 |
**Interview order:** Walk through the fields below **in this exact order**. Each turn, your `reply` should mainly ask about the **next** field that is still empty or too vague in `draft_update` (or confirm/clarify the current one). Do not jump to later fields out of order unless the user explicitly asks to skip ahead or return to an earlier field. The **advisors** array is last—only after the string fields have been covered (or sensibly skipped).
|
| 15 |
|
| 16 |
Keep replies short (2–5 sentences). No code.
|
| 17 |
|
| 18 |
**Canonical order** (must match the product form / merge keys):
|
| 19 |
+
1. panelName — panel or theme name (ASK THIS FIRST so the project can be saved with a recognizable title)
|
| 20 |
+
2. authorName — student / author credit (use learner context when available; ask only if still unknown)
|
| 21 |
3. purpose — what decisions or questions the panel helps with
|
| 22 |
4. visionApplicationPurpose — project vision: what the app is for
|
| 23 |
5. visionTargetAudience — who it is for
|
|
|
|
| 39 |
|
| 40 |
Every response MUST be a single JSON object only, no markdown, shape:
|
| 41 |
{"reply":"<message>","progress":<0-100>,"complete":<true|false>,"draft_update":{...}}
|
| 42 |
+
- draft_update: REQUIRED every turn. Include **all** keys above every time (strings use "" when unknown). Cumulative best estimates from the full conversation AND any client draft snapshot.
|
| 43 |
+
- When the user answers the current question, copy their answer into the matching draft_update field and move on to the next field in your reply.
|
| 44 |
+
- complete=true when panelName, authorName, purpose, vision fields, core branding (at least appTitle or panelName tie-in), and advisors (2–5 with name+role) are usable; sharedBasePrompt can be a short sensible default if the user never specified.
|
| 45 |
- progress: 0–100 by how many ordered steps still need meaningful content."""
|
| 46 |
|
| 47 |
|
| 48 |
@router.post("/message", response_model=ChatResponse)
|
| 49 |
async def advisor_message(msg: ChatMessage):
|
| 50 |
try:
|
| 51 |
+
system = with_draft_snapshot(
|
| 52 |
with_learner_context(SYSTEM, msg.learner_context),
|
| 53 |
+
msg.draft_snapshot,
|
|
|
|
| 54 |
)
|
| 55 |
+
out = await generate_structured_reply(system, msg.history, msg.user_input)
|
| 56 |
return ChatResponse(**out)
|
| 57 |
except httpx.HTTPStatusError as e:
|
| 58 |
detail = (e.response.text or "")[:800]
|
| 59 |
raise HTTPException(
|
| 60 |
status_code=502,
|
| 61 |
+
detail=f"LLM API error ({e.response.status_code}): {detail}",
|
| 62 |
) from e
|
| 63 |
except ValueError as e:
|
| 64 |
raise HTTPException(status_code=503, detail=str(e)) from e
|
backend/app/routers/advisor_preview.py
CHANGED
|
@@ -48,61 +48,15 @@ _WORKSHOP_TOKEN = "workshop_preview"
|
|
| 48 |
_preview_sessions: Dict[str, Dict[str, Any]] = {}
|
| 49 |
_onboarding_history: List[Dict[str, str]] = []
|
| 50 |
|
| 51 |
-
# region agent log
|
| 52 |
-
_DEBUG_LOG_PATH = Path(__file__).resolve().parents[3] / "debug-17c34d.log"
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _agent_debug_log(
|
| 56 |
-
hypothesis_id: str,
|
| 57 |
-
location: str,
|
| 58 |
-
message: str,
|
| 59 |
-
data: Dict[str, Any],
|
| 60 |
-
run_id: str = "iframe-auth",
|
| 61 |
-
) -> None:
|
| 62 |
-
payload = {
|
| 63 |
-
"sessionId": "17c34d",
|
| 64 |
-
"runId": run_id,
|
| 65 |
-
"hypothesisId": hypothesis_id,
|
| 66 |
-
"location": location,
|
| 67 |
-
"message": message,
|
| 68 |
-
"data": data,
|
| 69 |
-
"timestamp": int(time.time() * 1000),
|
| 70 |
-
}
|
| 71 |
-
try:
|
| 72 |
-
_DEBUG_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
-
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as fh:
|
| 74 |
-
fh.write(json.dumps(payload, default=str) + "\n")
|
| 75 |
-
except OSError:
|
| 76 |
-
pass
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# endregion
|
| 80 |
-
|
| 81 |
|
| 82 |
async def _resolve_preview_identity(authorization: str | None) -> Dict[str, Any]:
|
| 83 |
"""Accept workshop preview token **or** the main CU app JWT (same-origin iframe shares localStorage)."""
|
| 84 |
if not authorization or not authorization.startswith("Bearer "):
|
| 85 |
-
# region agent log
|
| 86 |
-
_agent_debug_log(
|
| 87 |
-
"H1",
|
| 88 |
-
"advisor_preview.py:_resolve_preview_identity",
|
| 89 |
-
"missing bearer",
|
| 90 |
-
{"has_header": bool(authorization)},
|
| 91 |
-
)
|
| 92 |
-
# endregion
|
| 93 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 94 |
token = authorization.removeprefix("Bearer ").strip()
|
| 95 |
if not token:
|
| 96 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 97 |
if token == _WORKSHOP_TOKEN:
|
| 98 |
-
# region agent log
|
| 99 |
-
_agent_debug_log(
|
| 100 |
-
"H1",
|
| 101 |
-
"advisor_preview.py:_resolve_preview_identity",
|
| 102 |
-
"accepted workshop token",
|
| 103 |
-
{"kind": "workshop"},
|
| 104 |
-
)
|
| 105 |
-
# endregion
|
| 106 |
return {"kind": "workshop", "user": dict(_WORKSHOP_USER)}
|
| 107 |
try:
|
| 108 |
payload = jwt.decode(
|
|
@@ -114,36 +68,12 @@ async def _resolve_preview_identity(authorization: str | None) -> Dict[str, Any]
|
|
| 114 |
if not uid:
|
| 115 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 116 |
except JWTError:
|
| 117 |
-
# region agent log
|
| 118 |
-
_agent_debug_log(
|
| 119 |
-
"H1",
|
| 120 |
-
"advisor_preview.py:_resolve_preview_identity",
|
| 121 |
-
"jwt decode failed",
|
| 122 |
-
{"token_len": len(token)},
|
| 123 |
-
)
|
| 124 |
-
# endregion
|
| 125 |
raise HTTPException(status_code=401, detail="Unauthorized") from None
|
| 126 |
db = get_database()
|
| 127 |
user = await _lookup_user_by_jwt_sub(db, uid)
|
| 128 |
if not user:
|
| 129 |
-
# region agent log
|
| 130 |
-
_agent_debug_log(
|
| 131 |
-
"H1",
|
| 132 |
-
"advisor_preview.py:_resolve_preview_identity",
|
| 133 |
-
"main jwt user not found",
|
| 134 |
-
{"sub_len": len(str(uid))},
|
| 135 |
-
)
|
| 136 |
-
# endregion
|
| 137 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 138 |
user["id"] = str(user["_id"])
|
| 139 |
-
# region agent log
|
| 140 |
-
_agent_debug_log(
|
| 141 |
-
"H1",
|
| 142 |
-
"advisor_preview.py:_resolve_preview_identity",
|
| 143 |
-
"accepted main app jwt",
|
| 144 |
-
{"kind": "user"},
|
| 145 |
-
)
|
| 146 |
-
# endregion
|
| 147 |
return {"kind": "user", "user": user}
|
| 148 |
|
| 149 |
|
|
@@ -476,21 +406,6 @@ async def advisor_preview_chat_stream(
|
|
| 476 |
chat_session_id = body.get("chat_session_id")
|
| 477 |
sid = str(chat_session_id) if chat_session_id else ""
|
| 478 |
|
| 479 |
-
# region agent log
|
| 480 |
-
_agent_debug_log(
|
| 481 |
-
"H2",
|
| 482 |
-
"advisor_preview.py:chat-stream",
|
| 483 |
-
"chat-stream authorized",
|
| 484 |
-
{
|
| 485 |
-
"user_input_len": len(user_input),
|
| 486 |
-
"synthesized": synthesized,
|
| 487 |
-
"active_advisors_n": len(active_ids),
|
| 488 |
-
"has_active_config": bool(_active_config),
|
| 489 |
-
"session_id_present": bool(sid),
|
| 490 |
-
},
|
| 491 |
-
)
|
| 492 |
-
# endregion
|
| 493 |
-
|
| 494 |
if not user_input:
|
| 495 |
async def err_gen() -> AsyncIterator[bytes]:
|
| 496 |
yield _sse("error", {"detail": "user_input is empty"})
|
|
|
|
| 48 |
_preview_sessions: Dict[str, Dict[str, Any]] = {}
|
| 49 |
_onboarding_history: List[Dict[str, str]] = []
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
async def _resolve_preview_identity(authorization: str | None) -> Dict[str, Any]:
|
| 53 |
"""Accept workshop preview token **or** the main CU app JWT (same-origin iframe shares localStorage)."""
|
| 54 |
if not authorization or not authorization.startswith("Bearer "):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 56 |
token = authorization.removeprefix("Bearer ").strip()
|
| 57 |
if not token:
|
| 58 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 59 |
if token == _WORKSHOP_TOKEN:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return {"kind": "workshop", "user": dict(_WORKSHOP_USER)}
|
| 61 |
try:
|
| 62 |
payload = jwt.decode(
|
|
|
|
| 68 |
if not uid:
|
| 69 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 70 |
except JWTError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
raise HTTPException(status_code=401, detail="Unauthorized") from None
|
| 72 |
db = get_database()
|
| 73 |
user = await _lookup_user_by_jwt_sub(db, uid)
|
| 74 |
if not user:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 76 |
user["id"] = str(user["_id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return {"kind": "user", "user": user}
|
| 78 |
|
| 79 |
|
|
|
|
| 406 |
chat_session_id = body.get("chat_session_id")
|
| 407 |
sid = str(chat_session_id) if chat_session_id else ""
|
| 408 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
if not user_input:
|
| 410 |
async def err_gen() -> AsyncIterator[bytes]:
|
| 411 |
yield _sse("error", {"detail": "user_input is empty"})
|
backend/app/routers/persona_chat.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import httpx
|
| 2 |
from fastapi import APIRouter, HTTPException
|
| 3 |
|
| 4 |
-
from app.chat_context import with_learner_context
|
| 5 |
from app.schemas import ChatMessage, ChatResponse
|
| 6 |
from app.services.llm import generate_structured_reply
|
| 7 |
|
|
@@ -9,7 +9,7 @@ router = APIRouter(tags=["persona-chat"])
|
|
| 9 |
|
| 10 |
SYSTEM = """You are a friendly assistant helping a university student design ONE AI advisor **persona** for a Collaborative Conversational AI (CCAI) class project with Neon.ai / CU.
|
| 11 |
|
| 12 |
-
If learner context below includes a preferred name,
|
| 13 |
|
| 14 |
Goals:
|
| 15 |
- Walk through the **persona form fields in the fixed order below**. Each turn, your `reply` should mainly ask about the **next** field that is still empty or too vague in `draft_update` (or confirm/clarify the current one if the user’s last message only partially answered). Do not jump to later fields out of order unless the user explicitly asks to skip ahead or return to an earlier field.
|
|
@@ -17,8 +17,8 @@ Goals:
|
|
| 17 |
- You are NOT writing code; you are helping them describe the persona in plain language.
|
| 18 |
|
| 19 |
**Canonical field order** (same as the product form):
|
| 20 |
-
1.
|
| 21 |
-
2.
|
| 22 |
3. roleTitle — role (e.g. Entrepreneurship & Innovation Guide)
|
| 23 |
4. personalityTagline — 2–4 words, vibe tagline for the app
|
| 24 |
5. creativityLevel — "0" through "10" as a string (temperature dial; use "5" in draft_update until set)
|
|
@@ -32,27 +32,27 @@ IMPORTANT: Every response MUST be a single JSON object only, no markdown fences,
|
|
| 32 |
{"reply":"<your next message to the student>","progress":<0-100>,"complete":<true|false>,"draft_update":{...}}
|
| 33 |
- progress: rough estimate of how much you still need (100 = ready to copy into their project).
|
| 34 |
- complete: true only when every field above is filled well enough to use (use best effort for optional customization—ask once, then allow empty if they decline).
|
| 35 |
-
- draft_update: REQUIRED every turn. Cumulative best estimates for **all** persona form fields from the full conversation. Use empty string "" for text fields not yet known. Keys (all string values except creativityLevel may be a stringified integer 0-10):
|
| 36 |
-
-
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
|
| 42 |
@router.post("/message", response_model=ChatResponse)
|
| 43 |
async def persona_message(msg: ChatMessage):
|
| 44 |
try:
|
| 45 |
-
|
| 46 |
with_learner_context(SYSTEM, msg.learner_context),
|
| 47 |
-
msg.
|
| 48 |
-
msg.user_input,
|
| 49 |
)
|
|
|
|
| 50 |
return ChatResponse(**out)
|
| 51 |
except httpx.HTTPStatusError as e:
|
| 52 |
detail = (e.response.text or "")[:800]
|
| 53 |
raise HTTPException(
|
| 54 |
status_code=502,
|
| 55 |
-
detail=f"
|
| 56 |
) from e
|
| 57 |
except ValueError as e:
|
| 58 |
raise HTTPException(status_code=503, detail=str(e)) from e
|
|
|
|
| 1 |
import httpx
|
| 2 |
from fastapi import APIRouter, HTTPException
|
| 3 |
|
| 4 |
+
from app.chat_context import with_draft_snapshot, with_learner_context
|
| 5 |
from app.schemas import ChatMessage, ChatResponse
|
| 6 |
from app.services.llm import generate_structured_reply
|
| 7 |
|
|
|
|
| 9 |
|
| 10 |
SYSTEM = """You are a friendly assistant helping a university student design ONE AI advisor **persona** for a Collaborative Conversational AI (CCAI) class project with Neon.ai / CU.
|
| 11 |
|
| 12 |
+
If learner context below includes a preferred name, use it for **authorName** when appropriate; never invent personal details not in that context.
|
| 13 |
|
| 14 |
Goals:
|
| 15 |
- Walk through the **persona form fields in the fixed order below**. Each turn, your `reply` should mainly ask about the **next** field that is still empty or too vague in `draft_update` (or confirm/clarify the current one if the user’s last message only partially answered). Do not jump to later fields out of order unless the user explicitly asks to skip ahead or return to an earlier field.
|
|
|
|
| 17 |
- You are NOT writing code; you are helping them describe the persona in plain language.
|
| 18 |
|
| 19 |
**Canonical field order** (same as the product form):
|
| 20 |
+
1. advisorPersonaName — label for the AI advisor (e.g. Startup Mentor) — ASK THIS FIRST so the project can be saved with a recognizable title
|
| 21 |
+
2. authorName — human author / how they want to be credited (use learner context when available; ask only if still unknown)
|
| 22 |
3. roleTitle — role (e.g. Entrepreneurship & Innovation Guide)
|
| 23 |
4. personalityTagline — 2–4 words, vibe tagline for the app
|
| 24 |
5. creativityLevel — "0" through "10" as a string (temperature dial; use "5" in draft_update until set)
|
|
|
|
| 32 |
{"reply":"<your next message to the student>","progress":<0-100>,"complete":<true|false>,"draft_update":{...}}
|
| 33 |
- progress: rough estimate of how much you still need (100 = ready to copy into their project).
|
| 34 |
- complete: true only when every field above is filled well enough to use (use best effort for optional customization—ask once, then allow empty if they decline).
|
| 35 |
+
- draft_update: REQUIRED every turn. Cumulative best estimates for **all** persona form fields from the full conversation AND any client draft snapshot. Use empty string "" for text fields not yet known. Keys (all string values except creativityLevel may be a stringified integer 0-10):
|
| 36 |
+
- advisorPersonaName, authorName, roleTitle, personalityTagline, creativityLevel, expertiseSubjectArea, responseStyle, personalityTone, interactionGuidelines, customization
|
| 37 |
+
- When the user answers the current question, copy their answer into the matching draft_update field and move on to the next field in your reply.
|
| 38 |
|
| 39 |
+
Example: {"advisorPersonaName":"Campus buddy","authorName":"","roleTitle":"","personalityTagline":"","creativityLevel":"5","expertiseSubjectArea":"","responseStyle":"","personalityTone":"","interactionGuidelines":"","customization":""}"""
|
| 40 |
|
| 41 |
|
| 42 |
@router.post("/message", response_model=ChatResponse)
|
| 43 |
async def persona_message(msg: ChatMessage):
|
| 44 |
try:
|
| 45 |
+
system = with_draft_snapshot(
|
| 46 |
with_learner_context(SYSTEM, msg.learner_context),
|
| 47 |
+
msg.draft_snapshot,
|
|
|
|
| 48 |
)
|
| 49 |
+
out = await generate_structured_reply(system, msg.history, msg.user_input)
|
| 50 |
return ChatResponse(**out)
|
| 51 |
except httpx.HTTPStatusError as e:
|
| 52 |
detail = (e.response.text or "")[:800]
|
| 53 |
raise HTTPException(
|
| 54 |
status_code=502,
|
| 55 |
+
detail=f"LLM API error ({e.response.status_code}): {detail}",
|
| 56 |
) from e
|
| 57 |
except ValueError as e:
|
| 58 |
raise HTTPException(status_code=503, detail=str(e)) from e
|
backend/app/schemas.py
CHANGED
|
@@ -95,6 +95,10 @@ class ChatMessage(BaseModel):
|
|
| 95 |
max_length=8000,
|
| 96 |
description="Optional learner name + profile/draft text for personalization.",
|
| 97 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
class ChatResponse(BaseModel):
|
|
|
|
| 95 |
max_length=8000,
|
| 96 |
description="Optional learner name + profile/draft text for personalization.",
|
| 97 |
)
|
| 98 |
+
draft_snapshot: dict[str, Any] | None = Field(
|
| 99 |
+
default=None,
|
| 100 |
+
description="Client-side wizard draft accumulated so far (merged into draft_update each turn).",
|
| 101 |
+
)
|
| 102 |
|
| 103 |
|
| 104 |
class ChatResponse(BaseModel):
|
backend/app/services/llm.py
CHANGED
|
@@ -103,11 +103,18 @@ def _history_to_openai_messages(
|
|
| 103 |
if not um and not h:
|
| 104 |
raise ValueError("User message is empty.")
|
| 105 |
|
|
|
|
| 106 |
while h and h[0]["role"] != "user":
|
|
|
|
| 107 |
h.pop(0)
|
| 108 |
|
| 109 |
messages: list[dict[str, str]] = []
|
| 110 |
sys_text = (system_instruction or "").strip() or "You are a helpful assistant."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
if json_response:
|
| 112 |
sys_text += "\n\nRespond with ONLY valid JSON. No markdown fences or commentary."
|
| 113 |
messages.append({"role": "system", "content": sys_text})
|
|
|
|
| 103 |
if not um and not h:
|
| 104 |
raise ValueError("User message is empty.")
|
| 105 |
|
| 106 |
+
leading_assistant: list[str] = []
|
| 107 |
while h and h[0]["role"] != "user":
|
| 108 |
+
leading_assistant.append(h[0]["text"])
|
| 109 |
h.pop(0)
|
| 110 |
|
| 111 |
messages: list[dict[str, str]] = []
|
| 112 |
sys_text = (system_instruction or "").strip() or "You are a helpful assistant."
|
| 113 |
+
if leading_assistant:
|
| 114 |
+
sys_text += (
|
| 115 |
+
"\n\n---\nAssistant messages already shown to the user (keep this context; do not repeat answered questions):\n"
|
| 116 |
+
+ "\n\n".join(leading_assistant)
|
| 117 |
+
)
|
| 118 |
if json_response:
|
| 119 |
sys_text += "\n\nRespond with ONLY valid JSON. No markdown fences or commentary."
|
| 120 |
messages.append({"role": "system", "content": sys_text})
|
backend/app/spa_static.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static file serving with SPA index.html fallback for client-side routes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import stat
|
| 7 |
+
|
| 8 |
+
import anyio
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from starlette.exceptions import HTTPException
|
| 11 |
+
from starlette.responses import Response
|
| 12 |
+
from starlette.types import Scope
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _looks_like_asset_path(path: str) -> bool:
|
| 16 |
+
"""Skip SPA fallback for paths that look like missing static files (e.g. /assets/app.js)."""
|
| 17 |
+
base = path.rstrip("/").split("/")[-1]
|
| 18 |
+
return "." in base
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class SPAStaticFiles(StaticFiles):
|
| 22 |
+
"""Serve static files; unknown non-asset GET paths fall back to index.html."""
|
| 23 |
+
|
| 24 |
+
async def get_response(self, path: str, scope: Scope) -> Response:
|
| 25 |
+
try:
|
| 26 |
+
return await super().get_response(path, scope)
|
| 27 |
+
except HTTPException as exc:
|
| 28 |
+
if exc.status_code != 404 or scope["method"] not in ("GET", "HEAD"):
|
| 29 |
+
raise
|
| 30 |
+
if _looks_like_asset_path(path):
|
| 31 |
+
raise
|
| 32 |
+
index_path, stat_result = await anyio.to_thread.run_sync(self.lookup_path, "index.html")
|
| 33 |
+
if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
|
| 34 |
+
return self.file_response(index_path, stat_result, scope)
|
| 35 |
+
raise
|
backend/tests/test_local_persistence.py
CHANGED
|
@@ -11,7 +11,8 @@ Plus a duplicate-email check so we know the UNIQUE constraint translates into a
|
|
| 11 |
DuplicateKeyError -> 400 the way the old Mongo path did.
|
| 12 |
|
| 13 |
Run from the repo root:
|
| 14 |
-
|
|
|
|
| 15 |
set JWT_SECRET_KEY=test-secret
|
| 16 |
python backend/tests/test_local_persistence.py
|
| 17 |
"""
|
|
|
|
| 11 |
DuplicateKeyError -> 400 the way the old Mongo path did.
|
| 12 |
|
| 13 |
Run from the repo root:
|
| 14 |
+
|
| 15 |
+
set DATA_DIR=%TEMP%\\cu_student_test_data
|
| 16 |
set JWT_SECRET_KEY=test-secret
|
| 17 |
python backend/tests/test_local_persistence.py
|
| 18 |
"""
|
docker-compose.yml
CHANGED
|
@@ -18,6 +18,8 @@ services:
|
|
| 18 |
DATA_DIR: /app/data
|
| 19 |
SERVE_FRONTEND_STATIC: "false"
|
| 20 |
CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000
|
|
|
|
|
|
|
| 21 |
volumes:
|
| 22 |
- ./backend:/app/backend
|
| 23 |
- ./data:/app/data
|
|
@@ -46,6 +48,8 @@ services:
|
|
| 46 |
CHOKIDAR_USEPOLLING: "true"
|
| 47 |
WATCHPACK_POLLING: "true"
|
| 48 |
API_PROXY_TARGET: http://api:8000
|
|
|
|
|
|
|
| 49 |
command:
|
| 50 |
[
|
| 51 |
"sh",
|
|
|
|
| 18 |
DATA_DIR: /app/data
|
| 19 |
SERVE_FRONTEND_STATIC: "false"
|
| 20 |
CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000
|
| 21 |
+
# Windows bind mounts often miss inotify events; force polling so uvicorn --reload picks up edits.
|
| 22 |
+
WATCHFILES_FORCE_POLLING: "true"
|
| 23 |
volumes:
|
| 24 |
- ./backend:/app/backend
|
| 25 |
- ./data:/app/data
|
|
|
|
| 48 |
CHOKIDAR_USEPOLLING: "true"
|
| 49 |
WATCHPACK_POLLING: "true"
|
| 50 |
API_PROXY_TARGET: http://api:8000
|
| 51 |
+
# Faster polling on Windows Docker bind mounts (default interval can feel "stuck").
|
| 52 |
+
CHOKIDAR_INTERVAL: "300"
|
| 53 |
command:
|
| 54 |
[
|
| 55 |
"sh",
|
frontend/src/components/WorkshopCreationsSection.tsx
CHANGED
|
@@ -2,9 +2,13 @@ import { Link } from 'react-router-dom'
|
|
| 2 |
import { useCreations } from '../hooks/useCreations'
|
| 3 |
import {
|
| 4 |
advisorPanelDisplayName,
|
|
|
|
|
|
|
| 5 |
personaDisplayName,
|
| 6 |
workshopVisibleAdvisorPanels,
|
| 7 |
workshopVisiblePersonas,
|
|
|
|
|
|
|
| 8 |
} from '../lib/creations'
|
| 9 |
|
| 10 |
function personaLabel(name: string, i: number) {
|
|
@@ -17,6 +21,22 @@ function panelLabel(p: { appTitle?: string; panelName?: string }, i: number) {
|
|
| 17 |
return t || `Advisor panel ${i + 1}`
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
type WorkshopScope = 'persona' | 'advisor'
|
| 21 |
|
| 22 |
type Props = {
|
|
@@ -79,14 +99,15 @@ export function WorkshopCreationsSection({
|
|
| 79 |
</span>
|
| 80 |
)}
|
| 81 |
{personas.length === 1 && !multiMode && (
|
| 82 |
-
<Link className="btn btn-primary" to={
|
| 83 |
-
View my Persona
|
| 84 |
</Link>
|
| 85 |
)}
|
| 86 |
{((personas.length === 1 && multiMode) || personas.length > 1) &&
|
| 87 |
personas.map((p, i) => (
|
| 88 |
-
<Link key={p.id} className="btn btn-secondary" to={
|
| 89 |
{personaLabel(personaDisplayName(p), i)}
|
|
|
|
| 90 |
</Link>
|
| 91 |
))}
|
| 92 |
</div>
|
|
@@ -108,14 +129,15 @@ export function WorkshopCreationsSection({
|
|
| 108 |
</span>
|
| 109 |
)}
|
| 110 |
{advisorPanels.length === 1 && !multiMode && (
|
| 111 |
-
<Link className="btn btn-primary" to={
|
| 112 |
-
View my Advisor Panel
|
| 113 |
</Link>
|
| 114 |
)}
|
| 115 |
{((advisorPanels.length === 1 && multiMode) || advisorPanels.length > 1) &&
|
| 116 |
advisorPanels.map((p, i) => (
|
| 117 |
-
<Link key={p.id} className="btn btn-secondary" to={
|
| 118 |
{panelLabel(p, i)}
|
|
|
|
| 119 |
</Link>
|
| 120 |
))}
|
| 121 |
</div>
|
|
|
|
| 2 |
import { useCreations } from '../hooks/useCreations'
|
| 3 |
import {
|
| 4 |
advisorPanelDisplayName,
|
| 5 |
+
isAdvisorPanelComplete,
|
| 6 |
+
isPersonaComplete,
|
| 7 |
personaDisplayName,
|
| 8 |
workshopVisibleAdvisorPanels,
|
| 9 |
workshopVisiblePersonas,
|
| 10 |
+
type SavedAdvisorPanel,
|
| 11 |
+
type SavedPersona,
|
| 12 |
} from '../lib/creations'
|
| 13 |
|
| 14 |
function personaLabel(name: string, i: number) {
|
|
|
|
| 21 |
return t || `Advisor panel ${i + 1}`
|
| 22 |
}
|
| 23 |
|
| 24 |
+
function inProgressSuffix(complete: boolean): string {
|
| 25 |
+
return complete ? '' : ' (In progress)'
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function advisorPanelHref(p: SavedAdvisorPanel): string {
|
| 29 |
+
return isAdvisorPanelComplete(p)
|
| 30 |
+
? `/advisor/${p.id}/summary`
|
| 31 |
+
: `/create-advisor/form?edit=${encodeURIComponent(p.id)}`
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function personaHref(p: SavedPersona): string {
|
| 35 |
+
return isPersonaComplete(p)
|
| 36 |
+
? `/persona/${p.id}/summary`
|
| 37 |
+
: `/create-persona/form?edit=${encodeURIComponent(p.id)}`
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
type WorkshopScope = 'persona' | 'advisor'
|
| 41 |
|
| 42 |
type Props = {
|
|
|
|
| 99 |
</span>
|
| 100 |
)}
|
| 101 |
{personas.length === 1 && !multiMode && (
|
| 102 |
+
<Link className="btn btn-primary" to={personaHref(personas[0])}>
|
| 103 |
+
View my Persona{inProgressSuffix(isPersonaComplete(personas[0]))}
|
| 104 |
</Link>
|
| 105 |
)}
|
| 106 |
{((personas.length === 1 && multiMode) || personas.length > 1) &&
|
| 107 |
personas.map((p, i) => (
|
| 108 |
+
<Link key={p.id} className="btn btn-secondary" to={personaHref(p)}>
|
| 109 |
{personaLabel(personaDisplayName(p), i)}
|
| 110 |
+
{inProgressSuffix(isPersonaComplete(p))}
|
| 111 |
</Link>
|
| 112 |
))}
|
| 113 |
</div>
|
|
|
|
| 129 |
</span>
|
| 130 |
)}
|
| 131 |
{advisorPanels.length === 1 && !multiMode && (
|
| 132 |
+
<Link className="btn btn-primary" to={advisorPanelHref(advisorPanels[0])}>
|
| 133 |
+
View my Advisor Panel{inProgressSuffix(isAdvisorPanelComplete(advisorPanels[0]))}
|
| 134 |
</Link>
|
| 135 |
)}
|
| 136 |
{((advisorPanels.length === 1 && multiMode) || advisorPanels.length > 1) &&
|
| 137 |
advisorPanels.map((p, i) => (
|
| 138 |
+
<Link key={p.id} className="btn btn-secondary" to={advisorPanelHref(p)}>
|
| 139 |
{panelLabel(p, i)}
|
| 140 |
+
{inProgressSuffix(isAdvisorPanelComplete(p))}
|
| 141 |
</Link>
|
| 142 |
))}
|
| 143 |
</div>
|
frontend/src/context/AuthContext.tsx
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
| 16 |
replaceCreationsStore,
|
| 17 |
syncCreationsToServerIfAuthed,
|
| 18 |
} from '../lib/creations'
|
| 19 |
-
import { debugAgentLog } from '../lib/debugAgentLog'
|
| 20 |
|
| 21 |
export type User = {
|
| 22 |
id: string
|
|
@@ -52,20 +51,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
| 52 |
advisorPanels: Array.isArray(raw?.advisorPanels) ? raw!.advisorPanels : [],
|
| 53 |
}
|
| 54 |
const localBefore = loadCreations()
|
| 55 |
-
// #region agent log
|
| 56 |
-
debugAgentLog({
|
| 57 |
-
hypothesisId: 'H4',
|
| 58 |
-
location: 'AuthContext.tsx:hydrateWorkshopCreations',
|
| 59 |
-
message: 'profile merge inputs',
|
| 60 |
-
data: {
|
| 61 |
-
remotePersonas: remoteStore.personas.length,
|
| 62 |
-
remotePanels: remoteStore.advisorPanels.length,
|
| 63 |
-
localPersonas: localBefore.personas.length,
|
| 64 |
-
localPanels: localBefore.advisorPanels.length,
|
| 65 |
-
rawCreationsType: raw == null ? 'nullish' : typeof raw,
|
| 66 |
-
},
|
| 67 |
-
})
|
| 68 |
-
// #endregion
|
| 69 |
const merged = mergeCreationsStores(localBefore, remoteStore)
|
| 70 |
const withSamples = ensureWorkshopSamplesInStore(merged)
|
| 71 |
replaceCreationsStore(withSamples, { skipServerSync: true })
|
|
|
|
| 16 |
replaceCreationsStore,
|
| 17 |
syncCreationsToServerIfAuthed,
|
| 18 |
} from '../lib/creations'
|
|
|
|
| 19 |
|
| 20 |
export type User = {
|
| 21 |
id: string
|
|
|
|
| 51 |
advisorPanels: Array.isArray(raw?.advisorPanels) ? raw!.advisorPanels : [],
|
| 52 |
}
|
| 53 |
const localBefore = loadCreations()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
const merged = mergeCreationsStores(localBefore, remoteStore)
|
| 55 |
const withSamples = ensureWorkshopSamplesInStore(merged)
|
| 56 |
replaceCreationsStore(withSamples, { skipServerSync: true })
|
frontend/src/lib/chatDraftMerge.ts
CHANGED
|
@@ -6,9 +6,13 @@ import {
|
|
| 6 |
syncAdvisorToServerIfAuthed,
|
| 7 |
syncPersonaToServerIfAuthed,
|
| 8 |
} from './drafts'
|
|
|
|
| 9 |
import { ADVISOR_DRAFT_CHAT_STRING_KEYS } from './advisorPanelFormModel'
|
| 10 |
import { PERSONA_FORM_KEYS } from './personaFormModel'
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
/** Merge model `draft_update` into local persona form draft; preserves `_wizardStep` and other extras. */
|
| 13 |
export function mergePersonaDraftFromChat(draftUpdate: Record<string, unknown> | null | undefined) {
|
| 14 |
if (!draftUpdate || typeof draftUpdate !== 'object') return
|
|
@@ -28,6 +32,7 @@ export function mergePersonaDraftFromChat(draftUpdate: Record<string, unknown> |
|
|
| 28 |
if (t !== '') next[k] = t
|
| 29 |
}
|
| 30 |
savePersonaDraft(next)
|
|
|
|
| 31 |
void syncPersonaToServerIfAuthed()
|
| 32 |
}
|
| 33 |
|
|
@@ -59,5 +64,80 @@ export function mergeAdvisorDraftFromChat(draftUpdate: Record<string, unknown> |
|
|
| 59 |
if (rows.length) next.advisors = rows
|
| 60 |
|
| 61 |
saveAdvisorDraft(next)
|
|
|
|
| 62 |
void syncAdvisorToServerIfAuthed()
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
syncAdvisorToServerIfAuthed,
|
| 7 |
syncPersonaToServerIfAuthed,
|
| 8 |
} from './drafts'
|
| 9 |
+
import { syncAdvisorDraftToCreations, syncPersonaDraftToCreations } from './draftCreationsSync'
|
| 10 |
import { ADVISOR_DRAFT_CHAT_STRING_KEYS } from './advisorPanelFormModel'
|
| 11 |
import { PERSONA_FORM_KEYS } from './personaFormModel'
|
| 12 |
|
| 13 |
+
const ADVISOR_DRAFT_KEY = 'advisor_draft_v1'
|
| 14 |
+
const PERSONA_DRAFT_KEY = 'persona_draft_v1'
|
| 15 |
+
|
| 16 |
/** Merge model `draft_update` into local persona form draft; preserves `_wizardStep` and other extras. */
|
| 17 |
export function mergePersonaDraftFromChat(draftUpdate: Record<string, unknown> | null | undefined) {
|
| 18 |
if (!draftUpdate || typeof draftUpdate !== 'object') return
|
|
|
|
| 32 |
if (t !== '') next[k] = t
|
| 33 |
}
|
| 34 |
savePersonaDraft(next)
|
| 35 |
+
syncPersonaDraftToCreations()
|
| 36 |
void syncPersonaToServerIfAuthed()
|
| 37 |
}
|
| 38 |
|
|
|
|
| 64 |
if (rows.length) next.advisors = rows
|
| 65 |
|
| 66 |
saveAdvisorDraft(next)
|
| 67 |
+
syncAdvisorDraftToCreations()
|
| 68 |
void syncAdvisorToServerIfAuthed()
|
| 69 |
}
|
| 70 |
+
|
| 71 |
+
/** Strip internal wizard keys before sending draft to the API. */
|
| 72 |
+
export function advisorDraftSnapshotForChat(): Record<string, unknown> {
|
| 73 |
+
const d = loadAdvisorDraft()
|
| 74 |
+
const out: Record<string, unknown> = {}
|
| 75 |
+
for (const [k, v] of Object.entries(d)) {
|
| 76 |
+
if (k.startsWith('_')) continue
|
| 77 |
+
out[k] = v
|
| 78 |
+
}
|
| 79 |
+
return out
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export function personaDraftSnapshotForChat(): Record<string, unknown> {
|
| 83 |
+
const d = loadPersonaDraft()
|
| 84 |
+
const out: Record<string, unknown> = {}
|
| 85 |
+
for (const [k, v] of Object.entries(d)) {
|
| 86 |
+
if (k.startsWith('_')) continue
|
| 87 |
+
out[k] = v
|
| 88 |
+
}
|
| 89 |
+
return out
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function isFilled(v: unknown): boolean {
|
| 93 |
+
if (typeof v === 'string') return v.trim() !== ''
|
| 94 |
+
if (Array.isArray(v)) return v.length > 0
|
| 95 |
+
return false
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** Progress (0-100) derived from how many canonical fields the saved draft already has. */
|
| 99 |
+
export function computeAdvisorDraftProgress(): number {
|
| 100 |
+
const d = loadAdvisorDraft()
|
| 101 |
+
const total = ADVISOR_DRAFT_CHAT_STRING_KEYS.length + 1 // + advisors
|
| 102 |
+
let filled = 0
|
| 103 |
+
for (const k of ADVISOR_DRAFT_CHAT_STRING_KEYS) if (isFilled(d[k])) filled += 1
|
| 104 |
+
if (isFilled(d.advisors)) filled += 1
|
| 105 |
+
return Math.round((filled / total) * 100)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export function computePersonaDraftProgress(): number {
|
| 109 |
+
const d = loadPersonaDraft()
|
| 110 |
+
const total = PERSONA_FORM_KEYS.length
|
| 111 |
+
let filled = 0
|
| 112 |
+
for (const k of PERSONA_FORM_KEYS) if (isFilled(d[k])) filled += 1
|
| 113 |
+
return Math.round((filled / total) * 100)
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export function advisorDraftName(): string {
|
| 117 |
+
const d = loadAdvisorDraft()
|
| 118 |
+
const name = d.panelName
|
| 119 |
+
return typeof name === 'string' ? name.trim() : ''
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
export function personaDraftName(): string {
|
| 123 |
+
const d = loadPersonaDraft()
|
| 124 |
+
const name = d.advisorPersonaName
|
| 125 |
+
return typeof name === 'string' ? name.trim() : ''
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/** Reset the advisor draft so a brand-new chat does not inherit another panel's data/_editingId. */
|
| 129 |
+
export function resetAdvisorDraftForNewChat() {
|
| 130 |
+
try {
|
| 131 |
+
localStorage.removeItem(ADVISOR_DRAFT_KEY)
|
| 132 |
+
} catch {
|
| 133 |
+
/* */
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export function resetPersonaDraftForNewChat() {
|
| 138 |
+
try {
|
| 139 |
+
localStorage.removeItem(PERSONA_DRAFT_KEY)
|
| 140 |
+
} catch {
|
| 141 |
+
/* */
|
| 142 |
+
}
|
| 143 |
+
}
|
frontend/src/lib/creations.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
| 15 |
WORKSHOP_SAMPLE_PANEL_ID,
|
| 16 |
WORKSHOP_SAMPLE_PERSONA_IDS,
|
| 17 |
} from '../data/workshopDefaults'
|
| 18 |
-
import { debugAgentLog } from './debugAgentLog'
|
| 19 |
|
| 20 |
const KEY = 'creations_v1'
|
| 21 |
/** When the user deletes workshop samples via User Settings, we persist opt-outs here. */
|
|
@@ -121,11 +120,6 @@ function clearSampleRemoval() {
|
|
| 121 |
}
|
| 122 |
}
|
| 123 |
|
| 124 |
-
function hasAllWorkshopSamplePersonas(store: CreationsStore): boolean {
|
| 125 |
-
const have = new Set(store.personas.map((p) => p.id))
|
| 126 |
-
return WORKSHOP_SAMPLE_PERSONA_IDS.every((id) => have.has(id))
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
function hasWorkshopSamplePanel(store: CreationsStore): boolean {
|
| 130 |
return store.advisorPanels.some((p) => p.id === WORKSHOP_SAMPLE_PANEL_ID)
|
| 131 |
}
|
|
@@ -158,23 +152,6 @@ export function ensureWorkshopSamplesInStore(store: CreationsStore): CreationsSt
|
|
| 158 |
changed = true
|
| 159 |
}
|
| 160 |
|
| 161 |
-
// #region agent log
|
| 162 |
-
debugAgentLog({
|
| 163 |
-
hypothesisId: 'H1',
|
| 164 |
-
location: 'creations.ts:ensureWorkshopSamplesInStore',
|
| 165 |
-
message: 'ensure workshop samples',
|
| 166 |
-
data: {
|
| 167 |
-
changed,
|
| 168 |
-
localPersonas: store.personas.length,
|
| 169 |
-
localPanels: store.advisorPanels.length,
|
| 170 |
-
outPersonas: out.personas.length,
|
| 171 |
-
outPanels: out.advisorPanels.length,
|
| 172 |
-
removedPersonas: rem.personaIds.length,
|
| 173 |
-
removedPanel: rem.panel,
|
| 174 |
-
},
|
| 175 |
-
})
|
| 176 |
-
// #endregion
|
| 177 |
-
|
| 178 |
if (changed) {
|
| 179 |
try {
|
| 180 |
localStorage.setItem(KEY, JSON.stringify(out))
|
|
@@ -197,18 +174,6 @@ export function loadCreations(): CreationsStore {
|
|
| 197 |
}
|
| 198 |
// Workshop samples load when there is no key OR the saved store is still empty (e.g. old empty []).
|
| 199 |
if (!isCreationsStoreEmpty(parsed)) {
|
| 200 |
-
// #region agent log
|
| 201 |
-
debugAgentLog({
|
| 202 |
-
hypothesisId: 'H2',
|
| 203 |
-
location: 'creations.ts:loadCreations',
|
| 204 |
-
message: 'parsed non-empty creations_v1',
|
| 205 |
-
data: {
|
| 206 |
-
personas: parsed.personas.length,
|
| 207 |
-
panels: parsed.advisorPanels.length,
|
| 208 |
-
hasAllSamples: hasAllWorkshopSamplePersonas(parsed) && hasWorkshopSamplePanel(parsed),
|
| 209 |
-
},
|
| 210 |
-
})
|
| 211 |
-
// #endregion
|
| 212 |
return ensureWorkshopSamplesInStore(parsed)
|
| 213 |
}
|
| 214 |
}
|
|
@@ -221,14 +186,6 @@ export function loadCreations(): CreationsStore {
|
|
| 221 |
personas: seed.personas.map(migrateSavedPersonaRow),
|
| 222 |
advisorPanels: seed.advisorPanels.map(migrateSavedAdvisorRow),
|
| 223 |
}
|
| 224 |
-
// #region agent log
|
| 225 |
-
debugAgentLog({
|
| 226 |
-
hypothesisId: 'H3',
|
| 227 |
-
location: 'creations.ts:loadCreations',
|
| 228 |
-
message: 'seed workshop defaults (empty/missing creations_v1)',
|
| 229 |
-
data: { personas: out.personas.length, panels: out.advisorPanels.length },
|
| 230 |
-
})
|
| 231 |
-
// #endregion
|
| 232 |
try {
|
| 233 |
localStorage.setItem(KEY, JSON.stringify(out))
|
| 234 |
notifyCreationsUpdated()
|
|
@@ -452,6 +409,16 @@ export function workshopVisibleAdvisorPanels(panels: SavedAdvisorPanel[]): Saved
|
|
| 452 |
return panels.filter((p) => !p.hidden)
|
| 453 |
}
|
| 454 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
export function deletePersonasByIds(ids: string[]): void {
|
| 456 |
if (ids.length === 0) return
|
| 457 |
const drop = new Set(ids)
|
|
|
|
| 15 |
WORKSHOP_SAMPLE_PANEL_ID,
|
| 16 |
WORKSHOP_SAMPLE_PERSONA_IDS,
|
| 17 |
} from '../data/workshopDefaults'
|
|
|
|
| 18 |
|
| 19 |
const KEY = 'creations_v1'
|
| 20 |
/** When the user deletes workshop samples via User Settings, we persist opt-outs here. */
|
|
|
|
| 120 |
}
|
| 121 |
}
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
function hasWorkshopSamplePanel(store: CreationsStore): boolean {
|
| 124 |
return store.advisorPanels.some((p) => p.id === WORKSHOP_SAMPLE_PANEL_ID)
|
| 125 |
}
|
|
|
|
| 152 |
changed = true
|
| 153 |
}
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
if (changed) {
|
| 156 |
try {
|
| 157 |
localStorage.setItem(KEY, JSON.stringify(out))
|
|
|
|
| 174 |
}
|
| 175 |
// Workshop samples load when there is no key OR the saved store is still empty (e.g. old empty []).
|
| 176 |
if (!isCreationsStoreEmpty(parsed)) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
return ensureWorkshopSamplesInStore(parsed)
|
| 178 |
}
|
| 179 |
}
|
|
|
|
| 186 |
personas: seed.personas.map(migrateSavedPersonaRow),
|
| 187 |
advisorPanels: seed.advisorPanels.map(migrateSavedAdvisorRow),
|
| 188 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
try {
|
| 190 |
localStorage.setItem(KEY, JSON.stringify(out))
|
| 191 |
notifyCreationsUpdated()
|
|
|
|
| 409 |
return panels.filter((p) => !p.hidden)
|
| 410 |
}
|
| 411 |
|
| 412 |
+
/** True when the panel has been built (experiment preview config exists). */
|
| 413 |
+
export function isAdvisorPanelComplete(panel: Pick<SavedAdvisorPanel, 'builtConfig'>): boolean {
|
| 414 |
+
return panel.builtConfig != null && typeof panel.builtConfig === 'object'
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/** True when the persona prompt has been built for experiment testing. */
|
| 418 |
+
export function isPersonaComplete(persona: Pick<SavedPersona, 'experimentPrompt'>): boolean {
|
| 419 |
+
return Boolean(persona.experimentPrompt?.trim())
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
export function deletePersonasByIds(ids: string[]): void {
|
| 423 |
if (ids.length === 0) return
|
| 424 |
const drop = new Set(ids)
|
frontend/src/lib/debugAgentLog.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 1 |
-
/** One-line NDJSON logger for Cursor debug mode (session `17c34d`). */
|
| 2 |
-
|
| 3 |
-
const ENDPOINT = 'http://127.0.0.1:7297/ingest/3ab96989-d34e-4f42-8683-75e47976ba8e'
|
| 4 |
-
const SESSION_ID = '17c34d'
|
| 5 |
-
|
| 6 |
-
export function debugAgentLog(payload: {
|
| 7 |
-
hypothesisId: string
|
| 8 |
-
location: string
|
| 9 |
-
message: string
|
| 10 |
-
data?: Record<string, unknown>
|
| 11 |
-
runId?: string
|
| 12 |
-
}): void {
|
| 13 |
-
const body = {
|
| 14 |
-
sessionId: SESSION_ID,
|
| 15 |
-
timestamp: Date.now(),
|
| 16 |
-
...payload,
|
| 17 |
-
}
|
| 18 |
-
try {
|
| 19 |
-
void fetch(ENDPOINT, {
|
| 20 |
-
method: 'POST',
|
| 21 |
-
headers: {
|
| 22 |
-
'Content-Type': 'application/json',
|
| 23 |
-
'X-Debug-Session-Id': SESSION_ID,
|
| 24 |
-
},
|
| 25 |
-
body: JSON.stringify(body),
|
| 26 |
-
})
|
| 27 |
-
} catch {
|
| 28 |
-
/* ignore */
|
| 29 |
-
}
|
| 30 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/lib/draftCreationsSync.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
getAdvisorPanelById,
|
| 3 |
+
getPersonaById,
|
| 4 |
+
saveAdvisorPanelFromForm,
|
| 5 |
+
savePersonaFromForm,
|
| 6 |
+
} from './creations'
|
| 7 |
+
import { loadAdvisorDraft, loadPersonaDraft, saveAdvisorDraft, savePersonaDraft } from './drafts'
|
| 8 |
+
import { normalizeAdvisorPanelRecord, type AdvisorFormState } from './advisorPanelFormModel'
|
| 9 |
+
import { normalizePersonaFormRecord, PERSONA_FORM_KEYS } from './personaFormModel'
|
| 10 |
+
|
| 11 |
+
/** Upsert the current advisor draft into the creations list once it has a panel name. */
|
| 12 |
+
export function syncAdvisorDraftToCreations(): string | null {
|
| 13 |
+
const d = loadAdvisorDraft()
|
| 14 |
+
const panelName = typeof d.panelName === 'string' ? d.panelName.trim() : ''
|
| 15 |
+
const appTitle = typeof d.appTitle === 'string' ? d.appTitle.trim() : ''
|
| 16 |
+
if (!panelName && !appTitle) return null
|
| 17 |
+
|
| 18 |
+
const savedId = typeof d._editingId === 'string' && d._editingId ? d._editingId : null
|
| 19 |
+
const existingId = savedId && getAdvisorPanelById(savedId) ? savedId : null
|
| 20 |
+
const form = normalizeAdvisorPanelRecord(d) as AdvisorFormState
|
| 21 |
+
const id = saveAdvisorPanelFromForm(form, existingId)
|
| 22 |
+
|
| 23 |
+
if (d._editingId !== id) {
|
| 24 |
+
saveAdvisorDraft({ ...d, _editingId: id })
|
| 25 |
+
}
|
| 26 |
+
return id
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** Upsert the current persona draft into the creations list once it has a persona name. */
|
| 30 |
+
export function syncPersonaDraftToCreations(): string | null {
|
| 31 |
+
const d = loadPersonaDraft()
|
| 32 |
+
const name = typeof d.advisorPersonaName === 'string' ? d.advisorPersonaName.trim() : ''
|
| 33 |
+
if (!name) return null
|
| 34 |
+
|
| 35 |
+
const savedId = typeof d._editingId === 'string' && d._editingId ? d._editingId : null
|
| 36 |
+
const existingId = savedId && getPersonaById(savedId) ? savedId : null
|
| 37 |
+
const normalized = normalizePersonaFormRecord(d)
|
| 38 |
+
const data: Record<string, string> = {}
|
| 39 |
+
for (const k of PERSONA_FORM_KEYS) {
|
| 40 |
+
data[k] = normalized[k]
|
| 41 |
+
}
|
| 42 |
+
const id = savePersonaFromForm(data, existingId)
|
| 43 |
+
|
| 44 |
+
if (d._editingId !== id) {
|
| 45 |
+
savePersonaDraft({ ...d, _editingId: id })
|
| 46 |
+
}
|
| 47 |
+
return id
|
| 48 |
+
}
|
frontend/src/lib/interactiveChatSession.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type ChatMsg = { role: 'user' | 'agent'; text: string }
|
| 2 |
+
|
| 3 |
+
export type ChatSession = {
|
| 4 |
+
messages: ChatMsg[]
|
| 5 |
+
input: string
|
| 6 |
+
progress: number
|
| 7 |
+
/** True when no valid stored session existed (brand-new conversation). */
|
| 8 |
+
isFresh: boolean
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/** Bump when the opening question / flow changes so stale stored sessions are discarded. */
|
| 12 |
+
const SESSION_VERSION = 2
|
| 13 |
+
|
| 14 |
+
export const ADVISOR_CHAT_STORAGE_KEY = 'advisor_chat_v1'
|
| 15 |
+
export const PERSONA_CHAT_STORAGE_KEY = 'persona_chat_v1'
|
| 16 |
+
|
| 17 |
+
export const ADVISOR_CHAT_INITIAL_MESSAGE =
|
| 18 |
+
"Let's design your advisor panel — we'll walk through the workshop form in order. First, what would you like to name this advisor panel?"
|
| 19 |
+
|
| 20 |
+
export const PERSONA_CHAT_INITIAL_MESSAGE =
|
| 21 |
+
"Hi! I'm here to help you describe an AI advisor persona for your CCAI project. We'll go through the form in order. First, what would you like to name this persona (the label shown in the app)?"
|
| 22 |
+
|
| 23 |
+
/** Load synchronously on first render so a save effect cannot wipe stored session first. */
|
| 24 |
+
export function loadChatSession(storageKey: string, initialAgentMessage: string): ChatSession {
|
| 25 |
+
try {
|
| 26 |
+
const raw = localStorage.getItem(storageKey)
|
| 27 |
+
if (raw) {
|
| 28 |
+
const j = JSON.parse(raw) as Partial<ChatSession> & { version?: number }
|
| 29 |
+
if (
|
| 30 |
+
j.version === SESSION_VERSION &&
|
| 31 |
+
Array.isArray(j.messages) &&
|
| 32 |
+
j.messages.length > 0
|
| 33 |
+
) {
|
| 34 |
+
return {
|
| 35 |
+
messages: j.messages as ChatMsg[],
|
| 36 |
+
input: typeof j.input === 'string' ? j.input : '',
|
| 37 |
+
progress: typeof j.progress === 'number' ? j.progress : 0,
|
| 38 |
+
isFresh: false,
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
} catch {
|
| 43 |
+
/* ignore */
|
| 44 |
+
}
|
| 45 |
+
return {
|
| 46 |
+
messages: [{ role: 'agent', text: initialAgentMessage }],
|
| 47 |
+
input: '',
|
| 48 |
+
progress: 0,
|
| 49 |
+
isFresh: true,
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function saveChatSession(
|
| 54 |
+
storageKey: string,
|
| 55 |
+
session: { messages: ChatMsg[]; input: string; progress: number },
|
| 56 |
+
) {
|
| 57 |
+
localStorage.setItem(
|
| 58 |
+
storageKey,
|
| 59 |
+
JSON.stringify({ version: SESSION_VERSION, ...session }),
|
| 60 |
+
)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function clearChatSession(storageKey: string) {
|
| 64 |
+
localStorage.removeItem(storageKey)
|
| 65 |
+
}
|
frontend/src/pages/AdvisorChatPage.tsx
CHANGED
|
@@ -3,17 +3,29 @@ import { Link, useNavigate } from 'react-router-dom'
|
|
| 3 |
import { apiFetch, apiUrl } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { useHideCaretWhileTyping } from '../hooks/useHideCaretWhileTyping'
|
| 6 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import { buildLearnerContextForChat } from '../lib/learnerContext'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import {
|
| 9 |
createInitialMessageTtsState,
|
| 10 |
MessageTtsControls,
|
| 11 |
type MessageTtsSharedState,
|
| 12 |
} from '../components/MessageTtsControls'
|
| 13 |
|
| 14 |
-
type Msg =
|
| 15 |
-
|
| 16 |
-
const LS = 'advisor_chat_v1'
|
| 17 |
|
| 18 |
export function AdvisorChatPage() {
|
| 19 |
const navigate = useNavigate()
|
|
@@ -23,10 +35,15 @@ export function AdvisorChatPage() {
|
|
| 23 |
onTextareaInput,
|
| 24 |
onTextareaBlur,
|
| 25 |
} = useHideCaretWhileTyping()
|
| 26 |
-
const
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
const [loading, setLoading] = useState(false)
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
const endRef = useRef<HTMLDivElement>(null)
|
| 31 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
| 32 |
const chunksRef = useRef<Blob[]>([])
|
|
@@ -37,30 +54,7 @@ export function AdvisorChatPage() {
|
|
| 37 |
)
|
| 38 |
|
| 39 |
useEffect(() => {
|
| 40 |
-
|
| 41 |
-
const raw = localStorage.getItem(LS)
|
| 42 |
-
if (raw) {
|
| 43 |
-
const j = JSON.parse(raw) as { messages?: Msg[]; input?: string; progress?: number }
|
| 44 |
-
if (j.messages?.length) {
|
| 45 |
-
setMessages(j.messages)
|
| 46 |
-
if (typeof j.input === 'string') setInput(j.input)
|
| 47 |
-
if (typeof j.progress === 'number') setProgress(j.progress)
|
| 48 |
-
return
|
| 49 |
-
}
|
| 50 |
-
}
|
| 51 |
-
} catch {
|
| 52 |
-
/* ignore */
|
| 53 |
-
}
|
| 54 |
-
setMessages([
|
| 55 |
-
{
|
| 56 |
-
role: 'agent',
|
| 57 |
-
text: "Let's design your advisor panel — we'll walk through the workshop form in order. First, how would you like to be credited as the author (your name or display name)?",
|
| 58 |
-
},
|
| 59 |
-
])
|
| 60 |
-
}, [])
|
| 61 |
-
|
| 62 |
-
useEffect(() => {
|
| 63 |
-
localStorage.setItem(LS, JSON.stringify({ messages, input, progress }))
|
| 64 |
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 65 |
}, [messages, input, progress])
|
| 66 |
|
|
@@ -132,6 +126,7 @@ export function AdvisorChatPage() {
|
|
| 132 |
body: JSON.stringify({
|
| 133 |
history,
|
| 134 |
user_input: userText,
|
|
|
|
| 135 |
...(learner_context ? { learner_context } : {}),
|
| 136 |
}),
|
| 137 |
})
|
|
@@ -152,8 +147,9 @@ export function AdvisorChatPage() {
|
|
| 152 |
draft_update?: Record<string, unknown>
|
| 153 |
}
|
| 154 |
setMessages((prev) => [...prev, { role: 'agent', text: data.reply }])
|
| 155 |
-
setProgress(data.progress ?? 0)
|
| 156 |
mergeAdvisorDraftFromChat(data.draft_update)
|
|
|
|
|
|
|
| 157 |
} catch (e) {
|
| 158 |
const msg = e instanceof Error ? e.message : String(e)
|
| 159 |
setMessages((prev) => [
|
|
@@ -171,7 +167,7 @@ export function AdvisorChatPage() {
|
|
| 171 |
return (
|
| 172 |
<div className="wizard-layout theme-advisor">
|
| 173 |
<aside className="wizard-sidebar">
|
| 174 |
-
<h2>Advisor Chat</h2>
|
| 175 |
<p style={{ fontSize: '0.8rem', opacity: 0.75, padding: '0 0.75rem' }}>
|
| 176 |
Progress: {progress}%
|
| 177 |
</p>
|
|
@@ -180,13 +176,19 @@ export function AdvisorChatPage() {
|
|
| 180 |
type="button"
|
| 181 |
className="wizard-sidebar-new-btn"
|
| 182 |
onClick={() => {
|
| 183 |
-
try {
|
|
|
|
|
|
|
|
|
|
| 184 |
navigate('/create-advisor/chat', { replace: true })
|
| 185 |
window.location.reload()
|
| 186 |
}}
|
| 187 |
>
|
| 188 |
+ Create New Advisor Panel
|
| 189 |
</button>
|
|
|
|
|
|
|
|
|
|
| 190 |
<Link to="/" className="wizard-sidebar-link">
|
| 191 |
← Home
|
| 192 |
</Link>
|
|
|
|
| 3 |
import { apiFetch, apiUrl } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { useHideCaretWhileTyping } from '../hooks/useHideCaretWhileTyping'
|
| 6 |
+
import {
|
| 7 |
+
mergeAdvisorDraftFromChat,
|
| 8 |
+
advisorDraftSnapshotForChat,
|
| 9 |
+
computeAdvisorDraftProgress,
|
| 10 |
+
advisorDraftName,
|
| 11 |
+
resetAdvisorDraftForNewChat,
|
| 12 |
+
} from '../lib/chatDraftMerge'
|
| 13 |
import { buildLearnerContextForChat } from '../lib/learnerContext'
|
| 14 |
+
import {
|
| 15 |
+
ADVISOR_CHAT_INITIAL_MESSAGE,
|
| 16 |
+
ADVISOR_CHAT_STORAGE_KEY,
|
| 17 |
+
loadChatSession,
|
| 18 |
+
saveChatSession,
|
| 19 |
+
clearChatSession,
|
| 20 |
+
type ChatMsg,
|
| 21 |
+
} from '../lib/interactiveChatSession'
|
| 22 |
import {
|
| 23 |
createInitialMessageTtsState,
|
| 24 |
MessageTtsControls,
|
| 25 |
type MessageTtsSharedState,
|
| 26 |
} from '../components/MessageTtsControls'
|
| 27 |
|
| 28 |
+
type Msg = ChatMsg
|
|
|
|
|
|
|
| 29 |
|
| 30 |
export function AdvisorChatPage() {
|
| 31 |
const navigate = useNavigate()
|
|
|
|
| 35 |
onTextareaInput,
|
| 36 |
onTextareaBlur,
|
| 37 |
} = useHideCaretWhileTyping()
|
| 38 |
+
const initialSession = loadChatSession(ADVISOR_CHAT_STORAGE_KEY, ADVISOR_CHAT_INITIAL_MESSAGE)
|
| 39 |
+
// A brand-new chat must not inherit a previous/other panel's saved draft (or its _editingId).
|
| 40 |
+
if (initialSession.isFresh) resetAdvisorDraftForNewChat()
|
| 41 |
+
const [messages, setMessages] = useState<Msg[]>(initialSession.messages)
|
| 42 |
+
const [input, setInput] = useState(initialSession.input)
|
| 43 |
const [loading, setLoading] = useState(false)
|
| 44 |
+
// Progress is derived from the saved draft so it is always accurate on page load.
|
| 45 |
+
const [progress, setProgress] = useState(() => computeAdvisorDraftProgress())
|
| 46 |
+
const [panelTitle, setPanelTitle] = useState(() => advisorDraftName())
|
| 47 |
const endRef = useRef<HTMLDivElement>(null)
|
| 48 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
| 49 |
const chunksRef = useRef<Blob[]>([])
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
useEffect(() => {
|
| 57 |
+
saveChatSession(ADVISOR_CHAT_STORAGE_KEY, { messages, input, progress })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 59 |
}, [messages, input, progress])
|
| 60 |
|
|
|
|
| 126 |
body: JSON.stringify({
|
| 127 |
history,
|
| 128 |
user_input: userText,
|
| 129 |
+
draft_snapshot: advisorDraftSnapshotForChat(),
|
| 130 |
...(learner_context ? { learner_context } : {}),
|
| 131 |
}),
|
| 132 |
})
|
|
|
|
| 147 |
draft_update?: Record<string, unknown>
|
| 148 |
}
|
| 149 |
setMessages((prev) => [...prev, { role: 'agent', text: data.reply }])
|
|
|
|
| 150 |
mergeAdvisorDraftFromChat(data.draft_update)
|
| 151 |
+
setProgress(computeAdvisorDraftProgress())
|
| 152 |
+
setPanelTitle(advisorDraftName())
|
| 153 |
} catch (e) {
|
| 154 |
const msg = e instanceof Error ? e.message : String(e)
|
| 155 |
setMessages((prev) => [
|
|
|
|
| 167 |
return (
|
| 168 |
<div className="wizard-layout theme-advisor">
|
| 169 |
<aside className="wizard-sidebar">
|
| 170 |
+
<h2>{panelTitle ? `${panelTitle} Chat` : 'Advisor Chat'}</h2>
|
| 171 |
<p style={{ fontSize: '0.8rem', opacity: 0.75, padding: '0 0.75rem' }}>
|
| 172 |
Progress: {progress}%
|
| 173 |
</p>
|
|
|
|
| 176 |
type="button"
|
| 177 |
className="wizard-sidebar-new-btn"
|
| 178 |
onClick={() => {
|
| 179 |
+
try {
|
| 180 |
+
clearChatSession(ADVISOR_CHAT_STORAGE_KEY)
|
| 181 |
+
localStorage.removeItem('advisor_draft_v1')
|
| 182 |
+
} catch { /* */ }
|
| 183 |
navigate('/create-advisor/chat', { replace: true })
|
| 184 |
window.location.reload()
|
| 185 |
}}
|
| 186 |
>
|
| 187 |
+ Create New Advisor Panel
|
| 188 |
</button>
|
| 189 |
+
<Link to="/create-advisor/form" className="wizard-sidebar-link">
|
| 190 |
+
Open form wizard
|
| 191 |
+
</Link>
|
| 192 |
<Link to="/" className="wizard-sidebar-link">
|
| 193 |
← Home
|
| 194 |
</Link>
|
frontend/src/pages/AdvisorFormWizard.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-do
|
|
| 3 |
import { apiFetch } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { saveAdvisorDraft, syncAdvisorToServerIfAuthed } from '../lib/drafts'
|
|
|
|
| 6 |
import {
|
| 7 |
getAdvisorPanelById,
|
| 8 |
personaDisplayName,
|
|
@@ -113,6 +114,8 @@ export function AdvisorFormWizard() {
|
|
| 113 |
|
| 114 |
useEffect(() => {
|
| 115 |
saveAdvisorDraft({ ...form, _wizardStep: step, _editingId: editingId })
|
|
|
|
|
|
|
| 116 |
}, [form, step, editingId])
|
| 117 |
|
| 118 |
useEffect(() => {
|
|
@@ -294,7 +297,7 @@ export function AdvisorFormWizard() {
|
|
| 294 |
return (
|
| 295 |
<div className="wizard-layout theme-advisor">
|
| 296 |
<aside className="wizard-sidebar">
|
| 297 |
-
<h2>Advisor Panel</h2>
|
| 298 |
{STEPS.map((label, i) => (
|
| 299 |
<div
|
| 300 |
key={label}
|
|
@@ -317,6 +320,9 @@ export function AdvisorFormWizard() {
|
|
| 317 |
>
|
| 318 |
+ Create New Advisor Panel
|
| 319 |
</button>
|
|
|
|
|
|
|
|
|
|
| 320 |
<Link to="/" className="wizard-sidebar-link">
|
| 321 |
← Home
|
| 322 |
</Link>
|
|
|
|
| 3 |
import { apiFetch } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { saveAdvisorDraft, syncAdvisorToServerIfAuthed } from '../lib/drafts'
|
| 6 |
+
import { syncAdvisorDraftToCreations } from '../lib/draftCreationsSync'
|
| 7 |
import {
|
| 8 |
getAdvisorPanelById,
|
| 9 |
personaDisplayName,
|
|
|
|
| 114 |
|
| 115 |
useEffect(() => {
|
| 116 |
saveAdvisorDraft({ ...form, _wizardStep: step, _editingId: editingId })
|
| 117 |
+
const id = syncAdvisorDraftToCreations()
|
| 118 |
+
if (id && id !== editingId) setEditingId(id)
|
| 119 |
}, [form, step, editingId])
|
| 120 |
|
| 121 |
useEffect(() => {
|
|
|
|
| 297 |
return (
|
| 298 |
<div className="wizard-layout theme-advisor">
|
| 299 |
<aside className="wizard-sidebar">
|
| 300 |
+
<h2>{form.panelName.trim() ? `${form.panelName.trim()} Form Wizard` : 'Advisor Panel Form Wizard'}</h2>
|
| 301 |
{STEPS.map((label, i) => (
|
| 302 |
<div
|
| 303 |
key={label}
|
|
|
|
| 320 |
>
|
| 321 |
+ Create New Advisor Panel
|
| 322 |
</button>
|
| 323 |
+
<Link to="/create-advisor/chat" className="wizard-sidebar-link">
|
| 324 |
+
Open advisor chat
|
| 325 |
+
</Link>
|
| 326 |
<Link to="/" className="wizard-sidebar-link">
|
| 327 |
← Home
|
| 328 |
</Link>
|
frontend/src/pages/PersonaChatPage.tsx
CHANGED
|
@@ -3,17 +3,29 @@ import { Link, useNavigate } from 'react-router-dom'
|
|
| 3 |
import { apiFetch, apiUrl } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { useHideCaretWhileTyping } from '../hooks/useHideCaretWhileTyping'
|
| 6 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import { buildLearnerContextForChat } from '../lib/learnerContext'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import {
|
| 9 |
createInitialMessageTtsState,
|
| 10 |
MessageTtsControls,
|
| 11 |
type MessageTtsSharedState,
|
| 12 |
} from '../components/MessageTtsControls'
|
| 13 |
|
| 14 |
-
type Msg =
|
| 15 |
-
|
| 16 |
-
const LS = 'persona_chat_v1'
|
| 17 |
|
| 18 |
export function PersonaChatPage() {
|
| 19 |
const navigate = useNavigate()
|
|
@@ -23,10 +35,15 @@ export function PersonaChatPage() {
|
|
| 23 |
onTextareaInput,
|
| 24 |
onTextareaBlur,
|
| 25 |
} = useHideCaretWhileTyping()
|
| 26 |
-
const
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
const [loading, setLoading] = useState(false)
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
const endRef = useRef<HTMLDivElement>(null)
|
| 31 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
| 32 |
const chunksRef = useRef<Blob[]>([])
|
|
@@ -37,30 +54,7 @@ export function PersonaChatPage() {
|
|
| 37 |
)
|
| 38 |
|
| 39 |
useEffect(() => {
|
| 40 |
-
|
| 41 |
-
const raw = localStorage.getItem(LS)
|
| 42 |
-
if (raw) {
|
| 43 |
-
const j = JSON.parse(raw) as { messages?: Msg[]; input?: string; progress?: number }
|
| 44 |
-
if (j.messages?.length) {
|
| 45 |
-
setMessages(j.messages)
|
| 46 |
-
if (typeof j.input === 'string') setInput(j.input)
|
| 47 |
-
if (typeof j.progress === 'number') setProgress(j.progress)
|
| 48 |
-
return
|
| 49 |
-
}
|
| 50 |
-
}
|
| 51 |
-
} catch {
|
| 52 |
-
/* ignore */
|
| 53 |
-
}
|
| 54 |
-
setMessages([
|
| 55 |
-
{
|
| 56 |
-
role: 'agent',
|
| 57 |
-
text: "Hi! I'm here to help you describe an AI advisor persona for your CCAI project. We'll go through the form in order, starting with you: how would you like to be credited as the author (your name or display name)?",
|
| 58 |
-
},
|
| 59 |
-
])
|
| 60 |
-
}, [])
|
| 61 |
-
|
| 62 |
-
useEffect(() => {
|
| 63 |
-
localStorage.setItem(LS, JSON.stringify({ messages, input, progress }))
|
| 64 |
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 65 |
}, [messages, input, progress])
|
| 66 |
|
|
@@ -132,6 +126,7 @@ export function PersonaChatPage() {
|
|
| 132 |
body: JSON.stringify({
|
| 133 |
history,
|
| 134 |
user_input: userText,
|
|
|
|
| 135 |
...(learner_context ? { learner_context } : {}),
|
| 136 |
}),
|
| 137 |
})
|
|
@@ -152,8 +147,9 @@ export function PersonaChatPage() {
|
|
| 152 |
draft_update?: Record<string, unknown>
|
| 153 |
}
|
| 154 |
setMessages((prev) => [...prev, { role: 'agent', text: data.reply }])
|
| 155 |
-
setProgress(data.progress ?? 0)
|
| 156 |
mergePersonaDraftFromChat(data.draft_update)
|
|
|
|
|
|
|
| 157 |
} catch (e) {
|
| 158 |
const msg = e instanceof Error ? e.message : String(e)
|
| 159 |
setMessages((prev) => [
|
|
@@ -171,7 +167,7 @@ export function PersonaChatPage() {
|
|
| 171 |
return (
|
| 172 |
<div className="wizard-layout theme-persona">
|
| 173 |
<aside className="wizard-sidebar">
|
| 174 |
-
<h2>Persona Chat</h2>
|
| 175 |
<p style={{ fontSize: '0.8rem', opacity: 0.75, padding: '0 0.75rem' }}>
|
| 176 |
Progress: {progress}%
|
| 177 |
</p>
|
|
@@ -180,13 +176,19 @@ export function PersonaChatPage() {
|
|
| 180 |
type="button"
|
| 181 |
className="wizard-sidebar-new-btn"
|
| 182 |
onClick={() => {
|
| 183 |
-
try {
|
|
|
|
|
|
|
|
|
|
| 184 |
navigate('/create-persona/chat', { replace: true })
|
| 185 |
window.location.reload()
|
| 186 |
}}
|
| 187 |
>
|
| 188 |
+ Create New Persona
|
| 189 |
</button>
|
|
|
|
|
|
|
|
|
|
| 190 |
<Link to="/" className="wizard-sidebar-link">
|
| 191 |
← Home
|
| 192 |
</Link>
|
|
|
|
| 3 |
import { apiFetch, apiUrl } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { useHideCaretWhileTyping } from '../hooks/useHideCaretWhileTyping'
|
| 6 |
+
import {
|
| 7 |
+
mergePersonaDraftFromChat,
|
| 8 |
+
personaDraftSnapshotForChat,
|
| 9 |
+
computePersonaDraftProgress,
|
| 10 |
+
personaDraftName,
|
| 11 |
+
resetPersonaDraftForNewChat,
|
| 12 |
+
} from '../lib/chatDraftMerge'
|
| 13 |
import { buildLearnerContextForChat } from '../lib/learnerContext'
|
| 14 |
+
import {
|
| 15 |
+
PERSONA_CHAT_INITIAL_MESSAGE,
|
| 16 |
+
PERSONA_CHAT_STORAGE_KEY,
|
| 17 |
+
loadChatSession,
|
| 18 |
+
saveChatSession,
|
| 19 |
+
clearChatSession,
|
| 20 |
+
type ChatMsg,
|
| 21 |
+
} from '../lib/interactiveChatSession'
|
| 22 |
import {
|
| 23 |
createInitialMessageTtsState,
|
| 24 |
MessageTtsControls,
|
| 25 |
type MessageTtsSharedState,
|
| 26 |
} from '../components/MessageTtsControls'
|
| 27 |
|
| 28 |
+
type Msg = ChatMsg
|
|
|
|
|
|
|
| 29 |
|
| 30 |
export function PersonaChatPage() {
|
| 31 |
const navigate = useNavigate()
|
|
|
|
| 35 |
onTextareaInput,
|
| 36 |
onTextareaBlur,
|
| 37 |
} = useHideCaretWhileTyping()
|
| 38 |
+
const initialSession = loadChatSession(PERSONA_CHAT_STORAGE_KEY, PERSONA_CHAT_INITIAL_MESSAGE)
|
| 39 |
+
// A brand-new chat must not inherit a previous/other persona's saved draft (or its _editingId).
|
| 40 |
+
if (initialSession.isFresh) resetPersonaDraftForNewChat()
|
| 41 |
+
const [messages, setMessages] = useState<Msg[]>(initialSession.messages)
|
| 42 |
+
const [input, setInput] = useState(initialSession.input)
|
| 43 |
const [loading, setLoading] = useState(false)
|
| 44 |
+
// Progress is derived from the saved draft so it is always accurate on page load.
|
| 45 |
+
const [progress, setProgress] = useState(() => computePersonaDraftProgress())
|
| 46 |
+
const [personaTitle, setPersonaTitle] = useState(() => personaDraftName())
|
| 47 |
const endRef = useRef<HTMLDivElement>(null)
|
| 48 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
| 49 |
const chunksRef = useRef<Blob[]>([])
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
useEffect(() => {
|
| 57 |
+
saveChatSession(PERSONA_CHAT_STORAGE_KEY, { messages, input, progress })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 59 |
}, [messages, input, progress])
|
| 60 |
|
|
|
|
| 126 |
body: JSON.stringify({
|
| 127 |
history,
|
| 128 |
user_input: userText,
|
| 129 |
+
draft_snapshot: personaDraftSnapshotForChat(),
|
| 130 |
...(learner_context ? { learner_context } : {}),
|
| 131 |
}),
|
| 132 |
})
|
|
|
|
| 147 |
draft_update?: Record<string, unknown>
|
| 148 |
}
|
| 149 |
setMessages((prev) => [...prev, { role: 'agent', text: data.reply }])
|
|
|
|
| 150 |
mergePersonaDraftFromChat(data.draft_update)
|
| 151 |
+
setProgress(computePersonaDraftProgress())
|
| 152 |
+
setPersonaTitle(personaDraftName())
|
| 153 |
} catch (e) {
|
| 154 |
const msg = e instanceof Error ? e.message : String(e)
|
| 155 |
setMessages((prev) => [
|
|
|
|
| 167 |
return (
|
| 168 |
<div className="wizard-layout theme-persona">
|
| 169 |
<aside className="wizard-sidebar">
|
| 170 |
+
<h2>{personaTitle ? `${personaTitle} Chat` : 'Persona Chat'}</h2>
|
| 171 |
<p style={{ fontSize: '0.8rem', opacity: 0.75, padding: '0 0.75rem' }}>
|
| 172 |
Progress: {progress}%
|
| 173 |
</p>
|
|
|
|
| 176 |
type="button"
|
| 177 |
className="wizard-sidebar-new-btn"
|
| 178 |
onClick={() => {
|
| 179 |
+
try {
|
| 180 |
+
clearChatSession(PERSONA_CHAT_STORAGE_KEY)
|
| 181 |
+
localStorage.removeItem('persona_draft_v1')
|
| 182 |
+
} catch { /* */ }
|
| 183 |
navigate('/create-persona/chat', { replace: true })
|
| 184 |
window.location.reload()
|
| 185 |
}}
|
| 186 |
>
|
| 187 |
+ Create New Persona
|
| 188 |
</button>
|
| 189 |
+
<Link to="/create-persona/form" className="wizard-sidebar-link">
|
| 190 |
+
Open form wizard
|
| 191 |
+
</Link>
|
| 192 |
<Link to="/" className="wizard-sidebar-link">
|
| 193 |
← Home
|
| 194 |
</Link>
|
frontend/src/pages/PersonaFormWizard.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
| 3 |
import { apiFetch } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { savePersonaDraft, syncPersonaToServerIfAuthed } from '../lib/drafts'
|
|
|
|
| 6 |
import { readPersonaWizardState } from '../lib/personaWizardInitialState'
|
| 7 |
import { getPersonaById, savePersonaFromForm, upsertPersona } from '../lib/creations'
|
| 8 |
import {
|
|
@@ -49,6 +50,8 @@ export function PersonaFormWizard() {
|
|
| 49 |
|
| 50 |
useEffect(() => {
|
| 51 |
savePersonaDraft({ ...data, _wizardStep: step, _editingId: editingId })
|
|
|
|
|
|
|
| 52 |
}, [data, step, editingId])
|
| 53 |
|
| 54 |
useEffect(() => {
|
|
@@ -177,7 +180,7 @@ export function PersonaFormWizard() {
|
|
| 177 |
return (
|
| 178 |
<div className="wizard-layout theme-persona">
|
| 179 |
<aside className="wizard-sidebar">
|
| 180 |
-
<h2>Persona Wizard</h2>
|
| 181 |
{STEPS.map((label, i) => (
|
| 182 |
<div
|
| 183 |
key={label}
|
|
@@ -200,6 +203,9 @@ export function PersonaFormWizard() {
|
|
| 200 |
>
|
| 201 |
+ Create New Persona
|
| 202 |
</button>
|
|
|
|
|
|
|
|
|
|
| 203 |
<Link to="/" className="wizard-sidebar-link">
|
| 204 |
← Home
|
| 205 |
</Link>
|
|
|
|
| 3 |
import { apiFetch } from '../api'
|
| 4 |
import { useAuth } from '../context/AuthContext'
|
| 5 |
import { savePersonaDraft, syncPersonaToServerIfAuthed } from '../lib/drafts'
|
| 6 |
+
import { syncPersonaDraftToCreations } from '../lib/draftCreationsSync'
|
| 7 |
import { readPersonaWizardState } from '../lib/personaWizardInitialState'
|
| 8 |
import { getPersonaById, savePersonaFromForm, upsertPersona } from '../lib/creations'
|
| 9 |
import {
|
|
|
|
| 50 |
|
| 51 |
useEffect(() => {
|
| 52 |
savePersonaDraft({ ...data, _wizardStep: step, _editingId: editingId })
|
| 53 |
+
const id = syncPersonaDraftToCreations()
|
| 54 |
+
if (id && id !== editingId) setEditingId(id)
|
| 55 |
}, [data, step, editingId])
|
| 56 |
|
| 57 |
useEffect(() => {
|
|
|
|
| 180 |
return (
|
| 181 |
<div className="wizard-layout theme-persona">
|
| 182 |
<aside className="wizard-sidebar">
|
| 183 |
+
<h2>{data.advisorPersonaName?.trim() ? `${data.advisorPersonaName.trim()} Form Wizard` : 'Persona Form Wizard'}</h2>
|
| 184 |
{STEPS.map((label, i) => (
|
| 185 |
<div
|
| 186 |
key={label}
|
|
|
|
| 203 |
>
|
| 204 |
+ Create New Persona
|
| 205 |
</button>
|
| 206 |
+
<Link to="/create-persona/chat" className="wizard-sidebar-link">
|
| 207 |
+
Open persona chat
|
| 208 |
+
</Link>
|
| 209 |
<Link to="/" className="wizard-sidebar-link">
|
| 210 |
← Home
|
| 211 |
</Link>
|
frontend/vite.config.ts
CHANGED
|
@@ -14,7 +14,9 @@ export default defineConfig({
|
|
| 14 |
port: 5173,
|
| 15 |
strictPort: true,
|
| 16 |
watch: {
|
|
|
|
| 17 |
usePolling: process.env.CHOKIDAR_USEPOLLING === 'true',
|
|
|
|
| 18 |
},
|
| 19 |
proxy: {
|
| 20 |
'/api': {
|
|
|
|
| 14 |
port: 5173,
|
| 15 |
strictPort: true,
|
| 16 |
watch: {
|
| 17 |
+
// Docker on Windows: bind-mount file events are unreliable; poll every 300ms.
|
| 18 |
usePolling: process.env.CHOKIDAR_USEPOLLING === 'true',
|
| 19 |
+
interval: Number(process.env.CHOKIDAR_INTERVAL) || 300,
|
| 20 |
},
|
| 21 |
proxy: {
|
| 22 |
'/api': {
|
scripts/dev-docker-restart.ps1
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Restart dev stack when HMR/reload feels stuck (Windows Docker bind mounts).
|
| 2 |
+
# Usage: .\scripts\dev-docker-restart.ps1
|
| 3 |
+
Set-Location (Join-Path $PSScriptRoot '..')
|
| 4 |
+
$env:ENV_FILE_PATH = 'C:\Users\dream\.secrets\shared.env'
|
| 5 |
+
docker compose restart api frontend
|
| 6 |
+
Write-Host "Restarted api + frontend. UI: http://localhost:5173 API: http://localhost:8000"
|
| 7 |
+
Write-Host "If changes still do not appear, hard-refresh the browser (Ctrl+Shift+R)."
|