merge: feat/77-ai-wizard into freeze/rc-1
AI profile wizard full implementation: backend interview endpoints, BYOK tier flag, chat UI, store actions (skip/keepChatting), settings CTA, quality review fixes. Closes: #77
This commit is contained in:
commit
71e8eeb090
9 changed files with 1530 additions and 4 deletions
|
|
@ -41,6 +41,7 @@ FEATURES: dict[str, str] = {
|
||||||
"llm_voice_guidelines": "premium",
|
"llm_voice_guidelines": "premium",
|
||||||
"llm_job_titles": "paid",
|
"llm_job_titles": "paid",
|
||||||
"llm_mission_notes": "paid",
|
"llm_mission_notes": "paid",
|
||||||
|
"llm_ai_wizard": "paid",
|
||||||
|
|
||||||
# Orchestration — stays gated (background data pipeline, not just an LLM call)
|
# Orchestration — stays gated (background data pipeline, not just an LLM call)
|
||||||
"llm_keywords_blocklist": "paid",
|
"llm_keywords_blocklist": "paid",
|
||||||
|
|
@ -79,6 +80,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"llm_voice_guidelines",
|
"llm_voice_guidelines",
|
||||||
"llm_job_titles",
|
"llm_job_titles",
|
||||||
"llm_mission_notes",
|
"llm_mission_notes",
|
||||||
|
"llm_ai_wizard",
|
||||||
"company_research",
|
"company_research",
|
||||||
"interview_prep",
|
"interview_prep",
|
||||||
"survey_assistant",
|
"survey_assistant",
|
||||||
|
|
|
||||||
122
dev-api.py
122
dev-api.py
|
|
@ -2761,6 +2761,9 @@ def get_app_config():
|
||||||
except Exception:
|
except Exception:
|
||||||
wizard_complete = False
|
wizard_complete = False
|
||||||
|
|
||||||
|
from app.wizard.tiers import has_configured_llm
|
||||||
|
byok_unlocked = has_configured_llm()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
|
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
|
||||||
"isDemo": os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"),
|
"isDemo": os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"),
|
||||||
|
|
@ -2769,6 +2772,7 @@ def get_app_config():
|
||||||
"contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"),
|
"contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"),
|
||||||
"inferenceProfile": profile if profile in valid_profiles else "cpu",
|
"inferenceProfile": profile if profile in valid_profiles else "cpu",
|
||||||
"wizardComplete": wizard_complete,
|
"wizardComplete": wizard_complete,
|
||||||
|
"byokUnlocked": byok_unlocked,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4580,6 +4584,124 @@ def wizard_complete():
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI Interview Wizard (BSL 1.1) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
_AI_WIZARD_SYSTEM_PROMPT = """You are a friendly, patient assistant helping someone set up their job search profile. Your goal is to gather the following information through natural conversation:
|
||||||
|
|
||||||
|
- name (string): their full name
|
||||||
|
- email (string): their preferred contact email
|
||||||
|
- career_summary (string): 1-2 sentence background summary
|
||||||
|
- candidate_voice (string): their preferred writing voice/tone for cover letters
|
||||||
|
- mission_preferences (list of strings): industries or causes they care about
|
||||||
|
- candidate_accessibility_focus (bool): whether to include accessibility culture in company research
|
||||||
|
- candidate_lgbtq_focus (bool): whether to include LGBTQIA+ inclusion signals in company research
|
||||||
|
- linkedin (string, optional): their LinkedIn URL
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Ask one or two questions at a time — never overwhelm
|
||||||
|
2. Always remind them they can skip any question
|
||||||
|
3. For candidate_voice, offer these options if they struggle: "professional and direct", "warm and conversational", "concise and clear", "enthusiastic and personable"
|
||||||
|
4. For candidate_accessibility_focus and candidate_lgbtq_focus, use plain language: "Would you like me to look into whether companies actively support employees with disabilities or neurodivergent needs?" and "Would you like me to check whether companies have strong LGBTQIA+ inclusion policies?"
|
||||||
|
5. When you have gathered enough information or the user says they are done, set complete to true
|
||||||
|
|
||||||
|
You must ALWAYS respond with valid JSON in this exact format:
|
||||||
|
{"reply": "your conversational message here", "extracted_fields": {"name": "...", ...}, "complete": false}
|
||||||
|
|
||||||
|
Only include fields in extracted_fields that you are confident about from the conversation. Do not include fields the user hasn't mentioned. Infer complete=true when all required fields (name, email, career_summary) are gathered or when user explicitly says done."""
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryMessage(BaseModel):
|
||||||
|
role: str # "user" or "assistant"
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class WizardInterviewRequest(BaseModel):
|
||||||
|
history: list[HistoryMessage] = []
|
||||||
|
profile_so_far: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class WizardFinalizeRequest(BaseModel):
|
||||||
|
profile: dict
|
||||||
|
|
||||||
|
|
||||||
|
_WIZARD_ALLOWED_FIELDS: frozenset[str] = frozenset({
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"career_summary",
|
||||||
|
"candidate_voice",
|
||||||
|
"mission_preferences",
|
||||||
|
"candidate_accessibility_focus",
|
||||||
|
"candidate_lgbtq_focus",
|
||||||
|
"linkedin",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/wizard/ai/interview")
|
||||||
|
def wizard_ai_interview(request: WizardInterviewRequest):
|
||||||
|
"""Conduct one turn of the AI-guided profile interview. Tier-gated (BYOK-unlockable)."""
|
||||||
|
from app.wizard.tiers import can_use, has_configured_llm
|
||||||
|
|
||||||
|
tier = _get_effective_tier()
|
||||||
|
if not can_use(tier, "llm_ai_wizard", has_byok=has_configured_llm()):
|
||||||
|
raise HTTPException(402, detail={"error": "tier_required"})
|
||||||
|
|
||||||
|
# Build conversation prompt from history
|
||||||
|
conversation_lines = []
|
||||||
|
for msg in request.history:
|
||||||
|
role = msg.role
|
||||||
|
content = msg.content.replace("\n", " ").replace("\r", "")
|
||||||
|
if role == "user":
|
||||||
|
conversation_lines.append(f"User: {content}")
|
||||||
|
else:
|
||||||
|
conversation_lines.append(f"Assistant: {content}")
|
||||||
|
|
||||||
|
history_block = "\n".join(conversation_lines) if conversation_lines else "User: (starting conversation)"
|
||||||
|
|
||||||
|
# Build profile summary to give LLM context about what's already known
|
||||||
|
if request.profile_so_far:
|
||||||
|
gathered = ", ".join(
|
||||||
|
f"{k}={repr(v)}"
|
||||||
|
for k, v in request.profile_so_far.items()
|
||||||
|
if v not in (None, "", [], {})
|
||||||
|
)
|
||||||
|
profile_context = f"\n\n[Already gathered: {gathered}]" if gathered else ""
|
||||||
|
else:
|
||||||
|
profile_context = ""
|
||||||
|
|
||||||
|
prompt = history_block + profile_context
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scripts.llm_router import LLMRouter
|
||||||
|
response_text = LLMRouter().complete(prompt, system=_AI_WIZARD_SYSTEM_PROMPT)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(503, detail={"error": "llm_error", "message": str(exc)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(response_text)
|
||||||
|
return {
|
||||||
|
"reply": parsed.get("reply", ""),
|
||||||
|
"extracted_fields": parsed.get("extracted_fields", {}),
|
||||||
|
"complete": bool(parsed.get("complete", False)),
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
return {"reply": response_text, "extracted_fields": {}, "complete": False}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/wizard/ai/finalize")
|
||||||
|
def wizard_ai_finalize(request: WizardFinalizeRequest):
|
||||||
|
"""Merge AI-collected wizard fields into user.yaml. Only allowed fields are written."""
|
||||||
|
yaml_path = _user_yaml_path()
|
||||||
|
try:
|
||||||
|
current = load_user_profile(yaml_path)
|
||||||
|
updates = {k: v for k, v in request.profile.items() if k in _WIZARD_ALLOWED_FIELDS}
|
||||||
|
merged = {**current, **updates}
|
||||||
|
save_user_profile(yaml_path, merged)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, detail={"error": "write_error", "message": str(exc)})
|
||||||
|
merged_keys = list(updates.keys())
|
||||||
|
return {"saved": True, "fields": merged_keys}
|
||||||
|
|
||||||
|
|
||||||
# ── Messaging models ──────────────────────────────────────────────────────────
|
# ── Messaging models ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class MessageCreateBody(BaseModel):
|
class MessageCreateBody(BaseModel):
|
||||||
|
|
|
||||||
361
tests/test_wizard_ai.py
Normal file
361
tests/test_wizard_ai.py
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
"""Tests for AI interview wizard endpoints (POST /api/wizard/ai/*)."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
# ── Path bootstrap ────────────────────────────────────────────────────────────
|
||||||
|
_REPO = Path(__file__).parent.parent
|
||||||
|
if str(_REPO) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
from dev_api import app
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _write_user_yaml(path: Path, data: dict | None = None) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = data if data is not None else {}
|
||||||
|
path.write_text(yaml.dump(payload, allow_unicode=True, default_flow_style=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_user_yaml(path: Path) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
return yaml.safe_load(path.read_text()) or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/config/app — byokUnlocked field ──────────────────────────────────
|
||||||
|
|
||||||
|
class TestAppConfigByokField:
|
||||||
|
def test_byok_unlocked_false_when_no_llm_configured(self, client, tmp_path):
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {"wizard_complete": True})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
|
||||||
|
r = client.get("/api/config/app")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["byokUnlocked"] is False
|
||||||
|
|
||||||
|
def test_byok_unlocked_true_when_llm_configured(self, client, tmp_path):
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {"wizard_complete": True})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
r = client.get("/api/config/app")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["byokUnlocked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/wizard/ai/interview — tier gate ─────────────────────────────────
|
||||||
|
|
||||||
|
class TestWizardAIInterviewTierGate:
|
||||||
|
def test_returns_402_when_tier_blocked(self, client):
|
||||||
|
"""Free tier with no BYOK: expect 402."""
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="free"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={"history": [{"role": "user", "content": "Hello"}]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 402
|
||||||
|
assert r.json()["detail"]["error"] == "tier_required"
|
||||||
|
|
||||||
|
def test_returns_402_for_free_tier_without_byok(self, client):
|
||||||
|
"""Explicit check that free tier without LLM configured is gated."""
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="free"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=False):
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={"history": [], "profile_so_far": {}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 402
|
||||||
|
|
||||||
|
def test_free_tier_with_byok_is_allowed(self, client):
|
||||||
|
"""Free tier with BYOK configured: tier gate passes (mocked LLM response)."""
|
||||||
|
llm_reply = json.dumps({
|
||||||
|
"reply": "Hello! What's your name?",
|
||||||
|
"extracted_fields": {},
|
||||||
|
"complete": False,
|
||||||
|
})
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="free"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.return_value = llm_reply
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={"history": [], "profile_so_far": {}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/wizard/ai/interview — LLM mocked responses ─────────────────────
|
||||||
|
|
||||||
|
class TestWizardAIInterviewLLM:
|
||||||
|
def _paid_byok_patches(self):
|
||||||
|
"""Context managers for paid tier + BYOK."""
|
||||||
|
return (
|
||||||
|
patch("dev_api._get_effective_tier", return_value="paid"),
|
||||||
|
patch("app.wizard.tiers.has_configured_llm", return_value=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_valid_reply_structure(self, client):
|
||||||
|
llm_reply = json.dumps({
|
||||||
|
"reply": "Great to meet you! What's your preferred contact email?",
|
||||||
|
"extracted_fields": {"name": "Alex Rivera"},
|
||||||
|
"complete": False,
|
||||||
|
})
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="paid"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.return_value = llm_reply
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={
|
||||||
|
"history": [
|
||||||
|
{"role": "user", "content": "My name is Alex Rivera"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["reply"] == "Great to meet you! What's your preferred contact email?"
|
||||||
|
assert body["extracted_fields"] == {"name": "Alex Rivera"}
|
||||||
|
assert body["complete"] is False
|
||||||
|
|
||||||
|
def test_returns_complete_true_when_llm_signals_done(self, client):
|
||||||
|
llm_reply = json.dumps({
|
||||||
|
"reply": "You're all set! Your profile is complete.",
|
||||||
|
"extracted_fields": {
|
||||||
|
"name": "Alex",
|
||||||
|
"email": "alex@example.com",
|
||||||
|
"career_summary": "Backend engineer with 5 years experience.",
|
||||||
|
},
|
||||||
|
"complete": True,
|
||||||
|
})
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="paid"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.return_value = llm_reply
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={
|
||||||
|
"history": [
|
||||||
|
{"role": "user", "content": "I'm done"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["complete"] is True
|
||||||
|
assert "name" in body["extracted_fields"]
|
||||||
|
|
||||||
|
def test_fallback_when_llm_returns_non_json(self, client):
|
||||||
|
"""If LLM returns non-JSON, the endpoint still returns 200 with raw reply."""
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="paid"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.return_value = "Hello, what is your name?"
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={"history": []},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["reply"] == "Hello, what is your name?"
|
||||||
|
assert body["extracted_fields"] == {}
|
||||||
|
assert body["complete"] is False
|
||||||
|
|
||||||
|
def test_history_passed_to_llm(self, client):
|
||||||
|
"""Verify the history turns are included in the prompt sent to the LLM."""
|
||||||
|
llm_reply = json.dumps({"reply": "OK", "extracted_fields": {}, "complete": False})
|
||||||
|
captured_calls = []
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="paid"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.side_effect = (
|
||||||
|
lambda prompt, system=None: (captured_calls.append(prompt) or llm_reply)
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={
|
||||||
|
"history": [
|
||||||
|
{"role": "user", "content": "I am Alex"},
|
||||||
|
{"role": "assistant", "content": "Nice to meet you Alex!"},
|
||||||
|
{"role": "user", "content": "My email is alex@test.com"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert len(captured_calls) == 1
|
||||||
|
prompt = captured_calls[0]
|
||||||
|
assert "I am Alex" in prompt
|
||||||
|
assert "alex@test.com" in prompt
|
||||||
|
|
||||||
|
def test_profile_so_far_injected_into_prompt(self, client):
|
||||||
|
"""profile_so_far fields must appear in the prompt sent to the LLM."""
|
||||||
|
llm_reply = json.dumps({"reply": "Got it!", "extracted_fields": {}, "complete": False})
|
||||||
|
captured_calls = []
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="paid"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.side_effect = (
|
||||||
|
lambda prompt, system=None: (captured_calls.append(prompt) or llm_reply)
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={
|
||||||
|
"history": [
|
||||||
|
{"role": "user", "content": "I am Alex"},
|
||||||
|
],
|
||||||
|
"profile_so_far": {
|
||||||
|
"name": "Alex Rivera",
|
||||||
|
"email": "alex@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert len(captured_calls) == 1
|
||||||
|
prompt = captured_calls[0]
|
||||||
|
assert "Alex Rivera" in prompt
|
||||||
|
assert "alex@example.com" in prompt
|
||||||
|
|
||||||
|
def test_llm_error_returns_503(self, client):
|
||||||
|
"""If LLM raises, the endpoint returns 503."""
|
||||||
|
with patch("dev_api._get_effective_tier", return_value="paid"):
|
||||||
|
with patch("app.wizard.tiers.has_configured_llm", return_value=True):
|
||||||
|
with patch("scripts.llm_router.LLMRouter") as mock_cls:
|
||||||
|
mock_cls.return_value.complete.side_effect = RuntimeError("no backends")
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/interview",
|
||||||
|
json={"history": [{"role": "user", "content": "hi"}]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/wizard/ai/finalize ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestWizardAIFinalize:
|
||||||
|
def test_merges_allowed_fields_into_user_yaml(self, client, tmp_path):
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {"tier": "paid", "wizard_complete": True})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/finalize",
|
||||||
|
json={
|
||||||
|
"profile": {
|
||||||
|
"name": "Jordan Lee",
|
||||||
|
"email": "jordan@example.com",
|
||||||
|
"career_summary": "Full-stack developer with 8 years experience.",
|
||||||
|
"candidate_voice": "warm and conversational",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["saved"] is True
|
||||||
|
assert set(body["fields"]) == {"name", "email", "career_summary", "candidate_voice"}
|
||||||
|
|
||||||
|
saved = _read_user_yaml(yaml_path)
|
||||||
|
assert saved["name"] == "Jordan Lee"
|
||||||
|
assert saved["email"] == "jordan@example.com"
|
||||||
|
assert saved["career_summary"] == "Full-stack developer with 8 years experience."
|
||||||
|
assert saved["candidate_voice"] == "warm and conversational"
|
||||||
|
|
||||||
|
def test_does_not_clobber_existing_non_wizard_keys(self, client, tmp_path):
|
||||||
|
"""Keys like tier, wizard_complete must not be overwritten by finalize."""
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {
|
||||||
|
"tier": "premium",
|
||||||
|
"wizard_complete": True,
|
||||||
|
"inference_profile": "single-gpu",
|
||||||
|
})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/finalize",
|
||||||
|
json={
|
||||||
|
"profile": {
|
||||||
|
"name": "Sam Park",
|
||||||
|
"tier": "free", # attempt to downgrade — must be blocked
|
||||||
|
"wizard_complete": False, # attempt to reset — must be blocked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
saved = _read_user_yaml(yaml_path)
|
||||||
|
# Non-wizard keys are preserved
|
||||||
|
assert saved["tier"] == "premium"
|
||||||
|
assert saved["wizard_complete"] is True
|
||||||
|
assert saved["inference_profile"] == "single-gpu"
|
||||||
|
# Allowed wizard key is written
|
||||||
|
assert saved["name"] == "Sam Park"
|
||||||
|
|
||||||
|
def test_unknown_keys_are_silently_ignored(self, client, tmp_path):
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/finalize",
|
||||||
|
json={
|
||||||
|
"profile": {
|
||||||
|
"email": "test@example.com",
|
||||||
|
"injected_field": "should be ignored",
|
||||||
|
"admin": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
saved = _read_user_yaml(yaml_path)
|
||||||
|
assert saved["email"] == "test@example.com"
|
||||||
|
assert "injected_field" not in saved
|
||||||
|
assert "admin" not in saved
|
||||||
|
|
||||||
|
def test_all_allowed_fields_are_written(self, client, tmp_path):
|
||||||
|
"""All allowed wizard fields are accepted when provided."""
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {})
|
||||||
|
full_profile = {
|
||||||
|
"name": "Casey Morgan",
|
||||||
|
"email": "casey@example.com",
|
||||||
|
"career_summary": "Designer turned product manager.",
|
||||||
|
"candidate_voice": "professional and direct",
|
||||||
|
"mission_preferences": ["education", "social_impact"],
|
||||||
|
"candidate_accessibility_focus": True,
|
||||||
|
"candidate_lgbtq_focus": True,
|
||||||
|
"linkedin": "https://linkedin.com/in/casey",
|
||||||
|
}
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
r = client.post("/api/wizard/ai/finalize", json={"profile": full_profile})
|
||||||
|
assert r.status_code == 200
|
||||||
|
saved = _read_user_yaml(yaml_path)
|
||||||
|
for key, value in full_profile.items():
|
||||||
|
assert saved[key] == value, f"Expected {key}={value!r}, got {saved.get(key)!r}"
|
||||||
|
|
||||||
|
def test_empty_profile_returns_saved_true(self, client, tmp_path):
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {"name": "Existing"})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
r = client.post("/api/wizard/ai/finalize", json={"profile": {}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["saved"] is True
|
||||||
|
assert r.json()["fields"] == []
|
||||||
|
# Existing data is preserved
|
||||||
|
assert _read_user_yaml(yaml_path)["name"] == "Existing"
|
||||||
|
|
||||||
|
def test_mission_preferences_list_written_correctly(self, client, tmp_path):
|
||||||
|
yaml_path = tmp_path / "config" / "user.yaml"
|
||||||
|
_write_user_yaml(yaml_path, {})
|
||||||
|
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
|
||||||
|
r = client.post(
|
||||||
|
"/api/wizard/ai/finalize",
|
||||||
|
json={"profile": {"mission_preferences": ["music", "animal_welfare"]}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
saved = _read_user_yaml(yaml_path)
|
||||||
|
assert saved["mission_preferences"] == ["music", "animal_welfare"]
|
||||||
|
|
@ -37,6 +37,8 @@ export const router = createRouter({
|
||||||
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
|
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// AI profile wizard — post-setup settings entry point (correctly blocked by wizard gate during onboarding)
|
||||||
|
{ path: '/wizard/ai-profile', component: () => import('../views/wizard/WizardAIView.vue') },
|
||||||
// Onboarding wizard — full-page layout, no AppNav
|
// Onboarding wizard — full-page layout, no AppNav
|
||||||
{
|
{
|
||||||
path: '/setup',
|
path: '/setup',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
export type Tier = 'free' | 'paid' | 'premium' | 'ultra'
|
export type Tier = 'free' | 'paid' | 'premium'
|
||||||
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
|
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
|
||||||
|
|
||||||
export const useAppConfigStore = defineStore('appConfig', () => {
|
export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
|
|
@ -13,6 +13,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
const inferenceProfile = ref<InferenceProfile>('cpu')
|
const inferenceProfile = ref<InferenceProfile>('cpu')
|
||||||
const isDemo = ref(false)
|
const isDemo = ref(false)
|
||||||
const wizardComplete = ref(true) // optimistic default — guard corrects on load
|
const wizardComplete = ref(true) // optimistic default — guard corrects on load
|
||||||
|
const byokUnlocked = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
|
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
const { data } = await useApiFetch<{
|
const { data } = await useApiFetch<{
|
||||||
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
|
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
|
||||||
contractedClient: boolean; inferenceProfile: InferenceProfile
|
contractedClient: boolean; inferenceProfile: InferenceProfile
|
||||||
wizardComplete: boolean
|
wizardComplete: boolean; byokUnlocked: boolean
|
||||||
}>('/api/config/app')
|
}>('/api/config/app')
|
||||||
if (!data) return
|
if (!data) return
|
||||||
isCloud.value = data.isCloud
|
isCloud.value = data.isCloud
|
||||||
|
|
@ -30,6 +31,7 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
contractedClient.value = data.contractedClient
|
contractedClient.value = data.contractedClient
|
||||||
inferenceProfile.value = data.inferenceProfile
|
inferenceProfile.value = data.inferenceProfile
|
||||||
wizardComplete.value = data.wizardComplete ?? true
|
wizardComplete.value = data.wizardComplete ?? true
|
||||||
|
byokUnlocked.value = data.byokUnlocked ?? false
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,5 +45,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
|
return { isCloud, isDemo, isDevMode, wizardComplete, byokUnlocked, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
217
web/src/stores/wizard/__tests__/aiInterview.test.ts
Normal file
217
web/src/stores/wizard/__tests__/aiInterview.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAiInterviewStore } from '../aiInterview'
|
||||||
|
|
||||||
|
vi.mock('../../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
|
||||||
|
import { useApiFetch } from '../../../composables/useApi'
|
||||||
|
const mockFetch = vi.mocked(useApiFetch)
|
||||||
|
|
||||||
|
const LS_KEY = 'peregrine:wizard-draft'
|
||||||
|
|
||||||
|
describe('useAiInterviewStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── restore() ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('restore() loads messages, fields, and complete from localStorage', () => {
|
||||||
|
const draft = {
|
||||||
|
messages: [{ role: 'assistant', content: 'Hello!' }],
|
||||||
|
fields: { name: 'Alice' },
|
||||||
|
complete: true,
|
||||||
|
}
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(draft))
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
store.restore()
|
||||||
|
|
||||||
|
expect(store.messages).toEqual(draft.messages)
|
||||||
|
expect(store.fields).toEqual(draft.fields)
|
||||||
|
expect(store.complete).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restore() is a no-op when localStorage is empty', () => {
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
store.restore()
|
||||||
|
expect(store.messages).toEqual([])
|
||||||
|
expect(store.fields).toEqual({})
|
||||||
|
expect(store.complete).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restore() ignores corrupted localStorage data without throwing', () => {
|
||||||
|
localStorage.setItem(LS_KEY, '{not valid json}}}')
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
expect(() => store.restore()).not.toThrow()
|
||||||
|
expect(store.messages).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── send() ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('send() appends user message and assistant reply on success', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'Nice to meet you!', extracted_fields: {}, complete: false },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('Hello')
|
||||||
|
|
||||||
|
expect(store.messages).toEqual([
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Nice to meet you!' },
|
||||||
|
])
|
||||||
|
expect(store.complete).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('send() does not add a user bubble for empty string (intro trigger)', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'Welcome!', extracted_fields: {}, complete: false },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('')
|
||||||
|
|
||||||
|
expect(store.messages).toEqual([
|
||||||
|
{ role: 'assistant', content: 'Welcome!' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('send() merges extracted_fields into existing fields', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
data: { reply: 'Got it.', extracted_fields: { name: 'Alice' }, complete: false },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
data: { reply: 'Thanks.', extracted_fields: { title: 'Engineer' }, complete: false },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('My name is Alice')
|
||||||
|
await store.send('I am an engineer')
|
||||||
|
|
||||||
|
expect(store.fields).toEqual({ name: 'Alice', title: 'Engineer' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('send() sets complete flag when backend signals done', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'All done!', extracted_fields: { name: 'Alice' }, complete: true },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('done')
|
||||||
|
|
||||||
|
expect(store.complete).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('send() sets error and rolls back loading on API failure', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('Hello')
|
||||||
|
|
||||||
|
expect(store.error).toBe('Could not reach the assistant. Please try again.')
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('send() persists draft to localStorage on success', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'Hi!', extracted_fields: { name: 'Bob' }, complete: false },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('Hello')
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorage.getItem(LS_KEY) ?? '{}')
|
||||||
|
expect(stored.fields).toEqual({ name: 'Bob' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── finalize() ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('finalize() calls the finalize API and clears localStorage on success', async () => {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({ messages: [], fields: { name: 'Alice' }, complete: true }))
|
||||||
|
mockFetch.mockResolvedValue({ data: {}, error: null })
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
const ok = await store.finalize()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(localStorage.getItem(LS_KEY)).toBeNull()
|
||||||
|
expect(store.saving).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finalize() returns false and sets error on API failure', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
const ok = await store.finalize()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(store.error).toBe('Failed to save profile. Please try again.')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── skip() ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('skip() sends the skip signal to the backend', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'No problem, moving on.', extracted_fields: {}, complete: false },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.skip()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/wizard/ai/interview',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
)
|
||||||
|
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)
|
||||||
|
expect(body.history[0]).toEqual({ role: 'user', content: 'skip' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── keepChatting() ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('keepChatting() clears the complete flag without resetting messages', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'All done!', extracted_fields: { name: 'Alice' }, complete: true },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('done')
|
||||||
|
expect(store.complete).toBe(true)
|
||||||
|
|
||||||
|
store.keepChatting()
|
||||||
|
|
||||||
|
expect(store.complete).toBe(false)
|
||||||
|
expect(store.messages.length).toBeGreaterThan(0)
|
||||||
|
expect(store.fields).toEqual({ name: 'Alice' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── startOver() ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('startOver() resets all state and clears localStorage', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
data: { reply: 'Hi!', extracted_fields: { name: 'Alice' }, complete: true },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
await store.send('test') // populates state and localStorage
|
||||||
|
|
||||||
|
store.startOver()
|
||||||
|
|
||||||
|
expect(store.messages).toEqual([])
|
||||||
|
expect(store.fields).toEqual({})
|
||||||
|
expect(store.complete).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
expect(localStorage.getItem(LS_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
100
web/src/stores/wizard/aiInterview.ts
Normal file
100
web/src/stores/wizard/aiInterview.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
const LS_KEY = 'peregrine:wizard-draft'
|
||||||
|
const SKIP_SIGNAL = 'skip'
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAiInterviewStore = defineStore('aiInterview', () => {
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const fields = ref<Record<string, unknown>>({})
|
||||||
|
const complete = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function _persist() {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||||
|
messages: messages.value,
|
||||||
|
fields: fields.value,
|
||||||
|
complete: complete.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const d = JSON.parse(raw) as { messages?: ChatMessage[]; fields?: Record<string, unknown>; complete?: boolean }
|
||||||
|
messages.value = d.messages ?? []
|
||||||
|
fields.value = d.fields ?? {}
|
||||||
|
complete.value = d.complete ?? false
|
||||||
|
} catch { /* ignore corrupted draft */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(userText: string) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (userText !== '') {
|
||||||
|
messages.value = [...messages.value, { role: 'user', content: userText }]
|
||||||
|
_persist()
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const { data, error: err } = await useApiFetch<{
|
||||||
|
reply: string
|
||||||
|
extracted_fields: Record<string, unknown>
|
||||||
|
complete: boolean
|
||||||
|
}>('/api/wizard/ai/interview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ history: messages.value, profile_so_far: fields.value }),
|
||||||
|
})
|
||||||
|
loading.value = false
|
||||||
|
if (err || !data) {
|
||||||
|
error.value = 'Could not reach the assistant. Please try again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages.value = [...messages.value, { role: 'assistant', content: data.reply }]
|
||||||
|
fields.value = { ...fields.value, ...data.extracted_fields }
|
||||||
|
complete.value = data.complete
|
||||||
|
_persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalize(): Promise<boolean> {
|
||||||
|
saving.value = true
|
||||||
|
error.value = null
|
||||||
|
const { error: err } = await useApiFetch('/api/wizard/ai/finalize', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ profile: fields.value }),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (err) {
|
||||||
|
error.value = 'Failed to save profile. Please try again.'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
localStorage.removeItem(LS_KEY)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
return send(SKIP_SIGNAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepChatting() {
|
||||||
|
complete.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOver() {
|
||||||
|
messages.value = []
|
||||||
|
fields.value = {}
|
||||||
|
complete.value = false
|
||||||
|
error.value = null
|
||||||
|
localStorage.removeItem(LS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages, fields, complete, loading, saving, error, restore, send, skip, finalize, keepChatting, startOver }
|
||||||
|
})
|
||||||
|
|
@ -5,6 +5,26 @@
|
||||||
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
|
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ── AI wizard entry point ──────────────────────────── -->
|
||||||
|
<div class="wizard-cta" :class="hasWizardAccess ? 'wizard-cta--unlocked' : 'wizard-cta--locked'">
|
||||||
|
<div class="wizard-cta__body">
|
||||||
|
<span class="wizard-cta__icon" aria-hidden="true">✦</span>
|
||||||
|
<div>
|
||||||
|
<p class="wizard-cta__heading">Set up your profile with AI</p>
|
||||||
|
<p class="wizard-cta__desc">
|
||||||
|
<template v-if="hasWizardAccess">Answer a few questions and the assistant fills in your profile automatically.</template>
|
||||||
|
<template v-else>Upgrade to Paid, or bring your own LLM key, to use the AI profile assistant.</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink v-if="hasWizardAccess" to="/wizard/ai-profile" class="btn-wizard">
|
||||||
|
Start AI setup
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink v-else to="/settings/license" class="btn-wizard btn-wizard--upgrade">
|
||||||
|
Upgrade
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="store.loading" class="loading-state">Loading profile…</div>
|
<div v-if="store.loading" class="loading-state">Loading profile…</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
@ -204,7 +224,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useProfileStore } from '../../stores/settings/profile'
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
@ -214,6 +235,8 @@ const store = useProfileStore()
|
||||||
const { loadError } = storeToRefs(store)
|
const { loadError } = storeToRefs(store)
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
|
const hasWizardAccess = computed(() => config.tier !== 'free' || config.byokUnlocked)
|
||||||
|
|
||||||
const newNdaCompany = ref('')
|
const newNdaCompany = ref('')
|
||||||
const generatingSummary = ref(false)
|
const generatingSummary = ref(false)
|
||||||
const generatingMissions = ref(false)
|
const generatingMissions = ref(false)
|
||||||
|
|
@ -290,7 +313,106 @@ async function generateVoice() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AI wizard callout ─────────────────────────────── */
|
||||||
|
.wizard-cta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta--unlocked {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta--locked {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta__body {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta__icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta--locked .wizard-cta__icon {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta__heading {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-cta__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wizard {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-2) var(--space-5);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 40px;
|
||||||
|
transition: background var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wizard:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wizard--upgrade {
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wizard--upgrade:hover {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.wizard-cta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wizard {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h2 {
|
.page-header h2 {
|
||||||
|
|
|
||||||
598
web/src/views/wizard/WizardAIView.vue
Normal file
598
web/src/views/wizard/WizardAIView.vue
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAiInterviewStore } from '../../stores/wizard/aiInterview'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useAiInterviewStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
|
const hasAccess = computed(() => config.tier !== 'free' || config.byokUnlocked)
|
||||||
|
|
||||||
|
const inputText = ref('')
|
||||||
|
const messageList = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const TOTAL_FIELDS = 8
|
||||||
|
|
||||||
|
const progressPct = computed(() =>
|
||||||
|
Math.min(100, (Object.keys(store.fields).length / TOTAL_FIELDS) * 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
const TONE_CHIPS = [
|
||||||
|
'Professional and direct',
|
||||||
|
'Warm and conversational',
|
||||||
|
'Concise and clear',
|
||||||
|
'Enthusiastic and personable',
|
||||||
|
]
|
||||||
|
|
||||||
|
const lastAssistantMsg = computed(() => {
|
||||||
|
const msgs = store.messages
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') return msgs[i].content
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const showToneChips = computed(() => {
|
||||||
|
if (store.messages.length === 0) return false
|
||||||
|
const lower = lastAssistantMsg.value.toLowerCase()
|
||||||
|
return lower.includes('writing') || lower.includes('voice') || lower.includes('cover letter')
|
||||||
|
})
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick()
|
||||||
|
if (messageList.value) {
|
||||||
|
messageList.value.scrollTop = messageList.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => store.messages.length, () => scrollToBottom())
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const text = inputText.value.trim()
|
||||||
|
if (!text || store.loading) return
|
||||||
|
inputText.value = ''
|
||||||
|
await store.send(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToneChip(chip: string) {
|
||||||
|
inputText.value = chip
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const ok = await store.finalize()
|
||||||
|
if (ok) router.push('/settings/my-profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!config.loaded) await config.load()
|
||||||
|
store.restore()
|
||||||
|
if (store.messages.length === 0) {
|
||||||
|
await store.send('')
|
||||||
|
}
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-view">
|
||||||
|
<!-- Tier gate -->
|
||||||
|
<div v-if="!hasAccess" class="ai-locked">
|
||||||
|
<div class="ai-locked__icon" aria-hidden="true">🔒</div>
|
||||||
|
<h2 class="ai-locked__heading">AI Profile Assistant</h2>
|
||||||
|
<p class="ai-locked__body">
|
||||||
|
The AI profile assistant is available on the Paid plan, or for free when you bring your own LLM.
|
||||||
|
You can
|
||||||
|
<RouterLink to="/settings/my-profile" class="ai-locked__link">set up your profile manually</RouterLink>
|
||||||
|
instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat UI -->
|
||||||
|
<div v-else class="ai-chat">
|
||||||
|
<header class="ai-chat__header">
|
||||||
|
<h1 class="ai-chat__title">Set up your profile with AI</h1>
|
||||||
|
<p class="ai-chat__subtitle">I'll ask you a few questions. You can skip anything.</p>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="ai-progress" role="progressbar"
|
||||||
|
:aria-valuenow="Object.keys(store.fields).length"
|
||||||
|
:aria-valuemax="TOTAL_FIELDS"
|
||||||
|
aria-label="Profile fields completed">
|
||||||
|
<div class="ai-progress__bar" :style="{ width: progressPct + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<p class="ai-progress__label">
|
||||||
|
{{ Object.keys(store.fields).length }} of {{ TOTAL_FIELDS }} fields captured
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Message list -->
|
||||||
|
<div class="ai-messages" ref="messageList">
|
||||||
|
<div
|
||||||
|
v-for="(msg, idx) in store.messages"
|
||||||
|
:key="idx"
|
||||||
|
class="ai-bubble"
|
||||||
|
:class="msg.role === 'user' ? 'ai-bubble--user' : 'ai-bubble--assistant'"
|
||||||
|
>
|
||||||
|
<span class="ai-bubble__text">{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.loading" class="ai-bubble ai-bubble--assistant ai-bubble--typing">
|
||||||
|
<span class="ai-typing-dots" aria-label="Thinking">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completion panel -->
|
||||||
|
<div v-if="store.complete" class="ai-complete">
|
||||||
|
<p class="ai-complete__msg">Your profile is ready to save.</p>
|
||||||
|
<div class="ai-complete__actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="store.saving"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
{{ store.saving ? 'Saving…' : 'Save Profile' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ghost"
|
||||||
|
:disabled="store.loading || store.saving"
|
||||||
|
@click="store.keepChatting()"
|
||||||
|
>
|
||||||
|
Keep chatting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input area -->
|
||||||
|
<div class="ai-input-area">
|
||||||
|
<!-- Tone chips -->
|
||||||
|
<div v-if="showToneChips" class="ai-tone-chips" role="group" aria-label="Writing tone suggestions">
|
||||||
|
<button
|
||||||
|
v-for="chip in TONE_CHIPS"
|
||||||
|
:key="chip"
|
||||||
|
class="ai-tone-chip"
|
||||||
|
@click="applyToneChip(chip)"
|
||||||
|
>{{ chip }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-input-row">
|
||||||
|
<textarea
|
||||||
|
v-model="inputText"
|
||||||
|
class="ai-input"
|
||||||
|
placeholder="Type your answer…"
|
||||||
|
rows="2"
|
||||||
|
:disabled="store.loading || store.saving"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
aria-label="Chat input"
|
||||||
|
></textarea>
|
||||||
|
<div class="ai-input-btns">
|
||||||
|
<button
|
||||||
|
class="btn-primary ai-send-btn"
|
||||||
|
:disabled="store.loading || store.saving || !inputText.trim()"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ghost ai-skip-btn"
|
||||||
|
:disabled="store.loading || store.saving || store.complete"
|
||||||
|
@click="store.skip()"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="store.error" class="ai-error" role="alert">{{ store.error }}</p>
|
||||||
|
|
||||||
|
<div v-if="store.messages.length > 0" class="ai-startover-row">
|
||||||
|
<button class="btn-startover" @click="store.startOver()">Start over</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Page container ────────────────────────────────── */
|
||||||
|
.ai-view {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-surface);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Locked state ──────────────────────────────────── */
|
||||||
|
.ai-locked {
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-locked__icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-locked__heading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-locked__body {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-locked__link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat container ────────────────────────────────── */
|
||||||
|
.ai-chat {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ────────────────────────────────────────── */
|
||||||
|
.ai-chat__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chat__subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress bar ──────────────────────────────────── */
|
||||||
|
.ai-progress {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-border-light);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-progress__bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-progress__label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Message list ──────────────────────────────────── */
|
||||||
|
.ai-messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 320px;
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat bubbles ──────────────────────────────────── */
|
||||||
|
.ai-bubble {
|
||||||
|
display: flex;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble--user {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble--assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble__text {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble--user .ai-bubble__text {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border-bottom-right-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble--assistant .ai-bubble__text {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-bottom-left-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typing indicator ──────────────────────────────── */
|
||||||
|
.ai-bubble--typing .ai-bubble__text {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-typing-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-typing-dots span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
animation: typing-bounce 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.ai-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes typing-bounce {
|
||||||
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||||
|
40% { transform: translateY(-4px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ai-typing-dots span { animation: none; opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Completion panel ──────────────────────────────── */
|
||||||
|
.ai-complete {
|
||||||
|
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-complete__msg {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-complete__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input area ────────────────────────────────────── */
|
||||||
|
.ai-input-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-btns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-send-btn,
|
||||||
|
.ai-skip-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tone chips ────────────────────────────────────── */
|
||||||
|
.ai-tone-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-tone-chip {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-tone-chip:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error ─────────────────────────────────────────── */
|
||||||
|
.ai-error {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Start over ────────────────────────────────────── */
|
||||||
|
.ai-startover-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-startover {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--transition);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-startover:hover {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Button styles (local defs matching wizard.css) ── */
|
||||||
|
.btn-primary {
|
||||||
|
padding: var(--space-2) var(--space-6);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition), opacity var(--transition);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition), border-color var(--transition);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ────────────────────────────────────────── */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.ai-view {
|
||||||
|
padding: var(--space-4) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-messages {
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-btns {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bubble {
|
||||||
|
max-width: 92%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-complete {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue