NeonClary commited on
Commit
b0ebe11
·
unverified ·
2 Parent(s): 7aadf4feb876f5

Merge pull request #2 from NeonClary/dev-cursor

Browse files
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
- StaticFiles(directory=str(_ADVISOR_PREVIEW_DIST), html=True),
120
  name="advisor-preview-static",
121
  )
122
 
123
  if _should_mount_static:
124
- app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="static")
 
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, greet them naturally (e.g. "Hi [Name]") 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. authorNamestudent / author credit
20
- 2. panelNamepanel or theme name
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
- - complete=true when authorName, panelName, 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.
 
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
- out = await generate_structured_reply(
51
  with_learner_context(SYSTEM, msg.learner_context),
52
- msg.history,
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"Gemini API error ({e.response.status_code}): {detail}",
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. panelNamepanel or theme name (ASK THIS FIRST so the project can be saved with a recognizable title)
20
+ 2. authorNamestudent / 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, greet them naturally (e.g. "Hi [Name]") 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,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. authorNamehuman author / how they want to be credited
21
- 2. advisorPersonaNamelabel for the AI advisor (e.g. Startup Mentor)
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
- - authorName, advisorPersonaName, roleTitle, personalityTagline, creativityLevel, expertiseSubjectArea, responseStyle, personalityTone, interactionGuidelines, customization
37
- Example: {"authorName":"","advisorPersonaName":"Campus buddy","roleTitle":"","personalityTagline":"","creativityLevel":"5","expertiseSubjectArea":"CS tutoring","responseStyle":"","personalityTone":"warm","interactionGuidelines":"","customization":""}
38
 
39
- If **authorName** is still empty and learner context does not already give a clear author name, ask for it before moving to **advisorPersonaName**."""
40
 
41
 
42
  @router.post("/message", response_model=ChatResponse)
43
  async def persona_message(msg: ChatMessage):
44
  try:
45
- out = await generate_structured_reply(
46
  with_learner_context(SYSTEM, msg.learner_context),
47
- msg.history,
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"Gemini 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
 
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. advisorPersonaNamelabel for the AI advisor (e.g. Startup Mentor) — ASK THIS FIRST so the project can be saved with a recognizable title
21
+ 2. authorNamehuman 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
- set DATA_DIR=%TEMP%\cu_student_test_data
 
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={`/persona/${personas[0].id}/summary`}>
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={`/persona/${p.id}/summary`}>
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={`/advisor/${advisorPanels[0].id}/summary`}>
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={`/advisor/${p.id}/summary`}>
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 { mergeAdvisorDraftFromChat } from '../lib/chatDraftMerge'
 
 
 
 
 
 
7
  import { buildLearnerContextForChat } from '../lib/learnerContext'
 
 
 
 
 
 
 
 
8
  import {
9
  createInitialMessageTtsState,
10
  MessageTtsControls,
11
  type MessageTtsSharedState,
12
  } from '../components/MessageTtsControls'
13
 
14
- type Msg = { role: 'user' | 'agent'; text: string }
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 [messages, setMessages] = useState<Msg[]>([])
27
- const [input, setInput] = useState('')
 
 
 
28
  const [loading, setLoading] = useState(false)
29
- const [progress, setProgress] = useState(0)
 
 
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
- try {
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 { localStorage.removeItem(LS); localStorage.removeItem('advisor_draft_v1') } catch { /* */ }
 
 
 
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 { mergePersonaDraftFromChat } from '../lib/chatDraftMerge'
 
 
 
 
 
 
7
  import { buildLearnerContextForChat } from '../lib/learnerContext'
 
 
 
 
 
 
 
 
8
  import {
9
  createInitialMessageTtsState,
10
  MessageTtsControls,
11
  type MessageTtsSharedState,
12
  } from '../components/MessageTtsControls'
13
 
14
- type Msg = { role: 'user' | 'agent'; text: string }
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 [messages, setMessages] = useState<Msg[]>([])
27
- const [input, setInput] = useState('')
 
 
 
28
  const [loading, setLoading] = useState(false)
29
- const [progress, setProgress] = useState(0)
 
 
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
- try {
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 { localStorage.removeItem(LS); localStorage.removeItem('persona_draft_v1') } catch { /* */ }
 
 
 
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)."