feat(chat): persona decides to buy/walk — auto-finish + tolerance (temper) + resume
The conversation now ENDS when the persona makes a decision (option C), not when the
trainee clicks a button:
- persona_id replies carry {reply, decision(none/buy/walk), mood}; when decision is
buy/walk the session auto-finishes (won/lost) with a debrief that reveals latent
details + per-turn 'turning points'.
- personas have a tolerance (1-5, 'temper'): impatient personas walk away fast after
poor answers (fed via internal.misses on mood<=-1); tough 'wrong text' cases can
still be won by a strong, gentle response (judge realism).
- trainee 'Finish' button removed; if they leave mid-chat an active session is resumed
via /chat/resume (continue, not restart). One-shot lock still enforced once decided.
- mock/tests updated: persona deciding buy -> send auto-finishes won.
Rebuilt dist.
This commit is contained in:
@@ -51,11 +51,48 @@ SCENARIOS = {
|
|||||||
|
|
||||||
def _scenario_config(scenario: str, persona: dict):
|
def _scenario_config(scenario: str, persona: dict):
|
||||||
cfg = SCENARIOS.get(scenario, SCENARIOS["social"])
|
cfg = SCENARIOS.get(scenario, SCENARIOS["social"])
|
||||||
# OR with persona's own initiation preference: f2f/call forces seller-first unless persona
|
|
||||||
# is strongly customer-initiated (we let scenario win, but keep persona special handling).
|
|
||||||
return cfg, cfg["init"]
|
return cfg, cfg["init"]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_abbrev_debrief(outcome: str, persona: dict, internal: dict) -> dict:
|
||||||
|
"""Build a lightweight debrief from the persona's own decision + per-turn signals
|
||||||
|
(no extra LLM judge call needed since the persona chose to buy/walk)."""
|
||||||
|
signals = internal.get("signals", [])
|
||||||
|
turning_points = [
|
||||||
|
f"รอบที่ {sig['turn']}: ลูกค้า{'เริ่มใจขึ้น' if sig['mood'] > 0 else 'เริ่มหงุดหงิด/ลังเล'}"
|
||||||
|
for sig in signals if sig.get("type") in ("annoy", "warm")
|
||||||
|
]
|
||||||
|
why = (
|
||||||
|
"ลูกค้าตัดสินใจซื้อ (แก้ปัญหาและยอมรับข้อเสนอแล้ว)"
|
||||||
|
if outcome == "won"
|
||||||
|
else "ลูกค้าตัดสินใจไม่ซื้อ — ยังไม่เห็นคุณค่าพอ หรือการตอบไม่ตรงความต้องการ"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"outcome": outcome,
|
||||||
|
"score": 60 if outcome == "won" else 25,
|
||||||
|
"pain": (persona.get("pains") or [{}])[0].get("description", "") if persona.get("pains") else "",
|
||||||
|
"why": why,
|
||||||
|
"failurePoints": [] if outcome == "won" else ["ปิดการขายไม่สำเร็จ", "ลูกค้าถอยก่อนตัดสินใจซื้อ"],
|
||||||
|
"coaching": (
|
||||||
|
["ทำได้ดีมาก — ลูกค้าปิดการขายกับคุณ"]
|
||||||
|
if outcome == "won"
|
||||||
|
else turning_points + ["ลองถามความต้องการให้ลึกกว่าเดิม", "รับมือข้อโต้แย้งให้ตรงจุด"]
|
||||||
|
),
|
||||||
|
"turning_points": turning_points,
|
||||||
|
"signals": signals,
|
||||||
|
"revealed_persona": {
|
||||||
|
"pains": persona.get("pains", []),
|
||||||
|
"income": persona.get("income", ""),
|
||||||
|
"personality": persona.get("personality", ""),
|
||||||
|
"budget": persona.get("budget", ""),
|
||||||
|
"negotiation_levers": persona.get("negotiation_levers", []),
|
||||||
|
"opener": persona.get("opener", ""),
|
||||||
|
"background": persona.get("background", ""),
|
||||||
|
"tolerance": persona.get("tolerance", 3),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_ready_group(s, gid: str) -> dict:
|
def _get_ready_group(s, gid: str) -> dict:
|
||||||
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
||||||
group = s["groups"].get_or_none(gid)
|
group = s["groups"].get_or_none(gid)
|
||||||
@@ -154,7 +191,7 @@ def send_message(gid: str, pid: str):
|
|||||||
|
|
||||||
sim = _sim(group, persona)
|
sim = _sim(group, persona)
|
||||||
try:
|
try:
|
||||||
reply = sim.persona_reply(
|
reply, meta = sim.persona_reply(
|
||||||
persona=persona,
|
persona=persona,
|
||||||
sales_kit=group.get("sales_kit") or {},
|
sales_kit=group.get("sales_kit") or {},
|
||||||
messages=messages,
|
messages=messages,
|
||||||
@@ -166,10 +203,43 @@ def send_message(gid: str, pid: str):
|
|||||||
raise ApiError(f"LLM error: {exc}", 500)
|
raise ApiError(f"LLM error: {exc}", 500)
|
||||||
messages.append({"role": "customer", "text": reply})
|
messages.append({"role": "customer", "text": reply})
|
||||||
|
|
||||||
# Per-turn tracking: count exchanged turns + nudge score down as the chat drags.
|
# Update internal state: track misses (poor answers) and mood trend.
|
||||||
internal = session.get("internal", {}) or {}
|
internal = session.get("internal", {}) or {}
|
||||||
internal.setdefault("turns", 0)
|
internal.setdefault("turns", 0)
|
||||||
internal["turns"] = internal.get("turns", 0) + 1
|
internal["turns"] = internal.get("turns", 0) + 1
|
||||||
|
internal["signals"] = internal.get("signals", [])
|
||||||
|
try:
|
||||||
|
mood = int(meta.get("mood", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mood = 0
|
||||||
|
# A clearly annoyed customer is a "miss" against the seller.
|
||||||
|
if mood <= -1:
|
||||||
|
internal["misses"] = internal.get("misses", 0) + 1
|
||||||
|
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "annoy"})
|
||||||
|
elif mood >= 1:
|
||||||
|
internal["signals"].append({"turn": internal["turns"], "mood": mood, "type": "warm"})
|
||||||
|
|
||||||
|
# Decision by the persona ends the session (one-shot lock).
|
||||||
|
decision = meta.get("decision")
|
||||||
|
if decision in ("buy", "walk"):
|
||||||
|
outcome = "won" if decision == "buy" else "lost"
|
||||||
|
debrief = _build_abbrev_debrief(outcome, persona, internal)
|
||||||
|
s["sessions"].update(
|
||||||
|
session["id"],
|
||||||
|
status="finished",
|
||||||
|
outcome=outcome,
|
||||||
|
messages=messages,
|
||||||
|
internal=internal,
|
||||||
|
debrief=debrief,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"reply": reply,
|
||||||
|
"messages": messages,
|
||||||
|
"finished": True,
|
||||||
|
"outcome": outcome,
|
||||||
|
"debrief": debrief,
|
||||||
|
"session": s["sessions"].get(session["id"]),
|
||||||
|
})
|
||||||
|
|
||||||
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||||
return jsonify({"reply": reply, "messages": messages})
|
return jsonify({"reply": reply, "messages": messages})
|
||||||
@@ -238,3 +308,16 @@ def get_session(sid: str):
|
|||||||
if not session or session.get("user_id") != current_user()["id"]:
|
if not session or session.get("user_id") != current_user()["id"]:
|
||||||
raise ApiError("session not found", 404)
|
raise ApiError("session not found", 404)
|
||||||
return jsonify({"session": session})
|
return jsonify({"session": session})
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.get("/<gid>/personas/<pid>/chat/resume")
|
||||||
|
@require_auth
|
||||||
|
@require_roles("user")
|
||||||
|
def resume_session(gid: str, pid: str):
|
||||||
|
"""Resume an active (unfinished) session for this persona so the trainee can continue."""
|
||||||
|
s = _stores()
|
||||||
|
actor = current_user()
|
||||||
|
session = s["sessions"].active_for_persona(actor["id"], pid)
|
||||||
|
if not session or session.get("group_id") != gid:
|
||||||
|
raise ApiError("no active session for this persona", 404)
|
||||||
|
return jsonify({"session": session, "scenario": session.get("scenario", "social")})
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ EACH persona MUST include ALL of these fields:
|
|||||||
- pains[] (LATENT)
|
- pains[] (LATENT)
|
||||||
- negotiation_levers[] (LATENT)
|
- negotiation_levers[] (LATENT)
|
||||||
- opener, special, difficulty, notes
|
- opener, special, difficulty, notes
|
||||||
|
- tolerance (1-5): how many irritant/poor answers you tolerate before you walk away ("heart"). IMPORTANT:
|
||||||
|
a temperamental/impatient persona has LOW tolerance (1-2, walks away fast after poor answers); a patient
|
||||||
|
one has HIGH (4-5). Avg is 3. Match tolerance to personality (e.g. a busy owner / abrupt personality = low).
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
|
1. DIVERSITY: 15 distinct people across age groups, occupations, incomes, lifestyles,
|
||||||
@@ -32,8 +35,9 @@ RULES:
|
|||||||
(what the seller must satisfy to resolve it).
|
(what the seller must satisfy to resolve it).
|
||||||
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
|
3. NEGOTIATION: every persona negotiates. negotiation_levers[] lists what they push on
|
||||||
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
|
(price reduction, freebies, delivery time for made-to-order, scope, payment terms, guarantee).
|
||||||
4. INITIATION MODE: pick per persona "customer" (they message first) or "seller" (seller must open
|
4. DECISION BEHAVIOR: when the persona decides to buy (after their pain is resolved + price accepted)
|
||||||
the sale - e.g. insurance/proactive). You may mix, but every persona picks one.
|
OR to walk away (after too many misses / rude / pushy / wrong), the persona STATES the decision in
|
||||||
|
ordinary dialogue (e.g. "ok I'll go with it" / "no thanks, forget it") — it does NOT announce it as meta.
|
||||||
5. CHANNEL: "facebook" or "line".
|
5. CHANNEL: "facebook" or "line".
|
||||||
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
|
6. ONE SPECIAL TIER-C PERSONA: special="wrong_text". They open looking ready to buy, then instantly
|
||||||
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
|
lose interest and want to end the chat (open='never mind, forget it'), yet still have a live pain.
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ naturally and it makes sense for a real customer to reveal them):
|
|||||||
- Your pains (some may be product-solvable, some NOT): {pains}
|
- Your pains (some may be product-solvable, some NOT): {pains}
|
||||||
- Your negotiation levers: {levers}
|
- Your negotiation levers: {levers}
|
||||||
- Your goal/mood: {goal}
|
- Your goal/mood: {goal}
|
||||||
|
- Your tolerance (TOLERANCE): you walk away after about {tolerance} irritating/off-point/pushy
|
||||||
|
answers. If the seller is repeatedly wrong, ignores your need, or is pushy, you feel fed up.
|
||||||
Initiation mode: {init_mode}. {special_instr}
|
Initiation mode: {init_mode}. {special_instr}
|
||||||
|
|
||||||
BEHAVIOR RULES:
|
BEHAVIOR RULES:
|
||||||
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate (price, freebies,
|
1. You do NOT buy easily. You stall, ask questions, compare, and negotiate.
|
||||||
delivery time, scope, payment).
|
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops.
|
||||||
2. If the seller is rude, pushy, ignores your need, or mis-diagnoses your pain, your trust drops
|
Past your tolerance, you SAY so plainly and end the chat (e.g. "Never mind, forget it" / "I'll
|
||||||
and you may refuse to continue / walk away — even if you wanted the product.
|
think about it elsewhere" / "ok, bye").
|
||||||
3. You reveal pains only when the seller asks good questions or builds trust. Do not dump your
|
3. When the seller genuinely resolves your real pain AND you accept the price, you DECIDE and SAY
|
||||||
pains unprompted.
|
plainly you'll take it (e.g. "ok, let's go with it" / "fine, send me the order").
|
||||||
4. Respond in natural, in-character chat style ({channel} style, casual for LINE).
|
4. You reveal pains only when the seller asks good questions or builds trust.
|
||||||
5. Stay in character; never mention that you are a simulation or an AI persona.
|
5. Respond in natural, in-character style.
|
||||||
|
6. Stay in character; never mention this is a simulation. When you decide (buy OR walk away), say it
|
||||||
|
naturally in-dialogue; do not narrate as meta.
|
||||||
|
|
||||||
Reply with a JSON object: {{"reply": "<your message>"}}
|
Reply ONLY with a JSON object:
|
||||||
|
{{"reply": "<your message>", "decision": "none" | "buy" | "walk", "mood": -2..2}}
|
||||||
|
- "decision": set "buy" ONLY when you clearly decided to purchase; "walk" ONLY when you clearly
|
||||||
|
decided NOT to purchase and are ending the chat; otherwise "none".
|
||||||
|
- "mood": -2 (very annoyed) .. +2 (very receptive), current feel about the seller.
|
||||||
Only output that JSON.
|
Only output that JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -87,13 +95,15 @@ class Simulator:
|
|||||||
internal: dict[str, Any],
|
internal: dict[str, Any],
|
||||||
scenario: str = "social",
|
scenario: str = "social",
|
||||||
scenario_adapt: str = "",
|
scenario_adapt: str = "",
|
||||||
) -> str:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
"""Return (reply_text, meta) where meta includes decision/mood from the persona."""
|
||||||
pains_txt = self._describe_pains(persona.get("pains", []))
|
pains_txt = self._describe_pains(persona.get("pains", []))
|
||||||
adapt = scenario_adapt or {
|
adapt = scenario_adapt or {
|
||||||
"social": "Chat style: short, casual, quick social-messaging replies.",
|
"social": "Chat style: short, casual, quick social-messaging replies.",
|
||||||
"f2f_call": "Style: natural, conversational like a live face-to-face or phone talk.",
|
"f2f_call": "Style: natural, conversational like a live face-to-face or phone talk.",
|
||||||
"recontact": "Style: casual messaging; you already know the product from 1-3 months ago.",
|
"recontact": "Style: casual messaging; you already know the product from 1-3 months ago.",
|
||||||
}.get(scenario, "")
|
}.get(scenario, "")
|
||||||
|
tolerance = int(persona.get("tolerance", 3) or 3)
|
||||||
system = CHAT_SYSTEM.format(
|
system = CHAT_SYSTEM.format(
|
||||||
name=persona.get("name", "Customer"),
|
name=persona.get("name", "Customer"),
|
||||||
tone=persona.get("communication_style", "natural, casual"),
|
tone=persona.get("communication_style", "natural, casual"),
|
||||||
@@ -109,20 +119,18 @@ class Simulator:
|
|||||||
pains=pains_txt,
|
pains=pains_txt,
|
||||||
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
|
levers=", ".join(persona.get("negotiation_levers", [])) or "price, delivery time",
|
||||||
goal=persona.get("goal", ""),
|
goal=persona.get("goal", ""),
|
||||||
|
tolerance=tolerance,
|
||||||
init_mode="you contacted the seller first (customer-initiated)"
|
init_mode="you contacted the seller first (customer-initiated)"
|
||||||
if persona.get("initiation_mode") == "customer"
|
if persona.get("initiation_mode") == "customer"
|
||||||
else "the seller opened the sale to you (you are a lead)",
|
else "the seller opened the sale to you (you are a lead)",
|
||||||
special_instr=self._special_instr(persona) + "\n" + adapt,
|
special_instr=self._special_instr(persona) + "\n" + adapt,
|
||||||
)
|
)
|
||||||
msgs = [{"role": "system", "content": system}]
|
msgs = [{"role": "system", "content": system}]
|
||||||
# send a compact recap of internal state to the persona ad
|
|
||||||
# (doesn't leak to trainee)
|
|
||||||
msgs.append({
|
msgs.append({
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "Internal state (for your role-play only): "
|
"content": "Internal state (for your role-play only): "
|
||||||
+ json.dumps(internal, ensure_ascii=False),
|
+ json.dumps(internal, ensure_ascii=False),
|
||||||
})
|
})
|
||||||
# Translate role 'system' transcript entries into a hidden system note for the LLM.
|
|
||||||
for m in messages[-30:]:
|
for m in messages[-30:]:
|
||||||
role = m.get("role")
|
role = m.get("role")
|
||||||
if role == "system":
|
if role == "system":
|
||||||
@@ -133,13 +141,19 @@ class Simulator:
|
|||||||
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
raise
|
raise
|
||||||
# extract {reply: ...}
|
# extract {reply, decision, mood}
|
||||||
|
meta: dict[str, Any] = {"decision": "none", "mood": 0}
|
||||||
try:
|
try:
|
||||||
data = json.loads(self._extract_json(resp))
|
data = json.loads(self._extract_json(resp))
|
||||||
reply = data.get("reply") or data.get("response") or str(resp)
|
reply = (data.get("reply") or data.get("response") or str(resp)).strip()
|
||||||
|
meta["decision"] = data.get("decision", "none")
|
||||||
|
try:
|
||||||
|
meta["mood"] = int(float(data.get("mood", 0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta["mood"] = 0
|
||||||
except Exception:
|
except Exception:
|
||||||
reply = resp
|
reply = resp.strip()
|
||||||
return reply.strip()
|
return reply, meta
|
||||||
|
|
||||||
# ── judge ──────────────────────────────────────────────────────────
|
# ── judge ──────────────────────────────────────────────────────────
|
||||||
def judge(
|
def judge(
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"opener": p.get("opener", ""),
|
"opener": p.get("opener", ""),
|
||||||
"special": p.get("special", ""), # e.g. "wrong_text" | ""
|
"special": p.get("special", ""), # e.g. "wrong_text" | ""
|
||||||
"difficulty": p.get("difficulty", 1), # 1..5
|
"difficulty": p.get("difficulty", 1), # 1..5
|
||||||
|
"tolerance": p.get("tolerance", 3), # misses before this persona walks away (temper)
|
||||||
"notes": p.get("notes", ""),
|
"notes": p.get("notes", ""),
|
||||||
}
|
}
|
||||||
# validate
|
# validate
|
||||||
|
|||||||
@@ -107,5 +107,10 @@ class MockLLM:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def complete_conversation(self, messages, **kw) -> str:
|
def complete_conversation(self, messages, **kw) -> str:
|
||||||
# persona chat: echo a short in-character reply
|
# persona chat: echo a short in-character reply with a decision.
|
||||||
return json.dumps({"reply": "I see. Tell me more about the price then."}, ensure_ascii=False)
|
# On the first send, the persona decides to buy (so E2E auto-finishes as won).
|
||||||
|
return json.dumps({
|
||||||
|
"reply": "I see. Tell me more about the price then.",
|
||||||
|
"decision": "buy",
|
||||||
|
"mood": 1,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|||||||
@@ -87,16 +87,15 @@ def main():
|
|||||||
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send",
|
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/send",
|
||||||
json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH)
|
json={"text": "Hi, I run a small noodle shop. Tell me about pricing."}, headers=UH)
|
||||||
assert r.status_code == 200, r.get_json()
|
assert r.status_code == 200, r.get_json()
|
||||||
assert r.get_json()["reply"]
|
body = r.get_json()
|
||||||
print("[ok] send message -> persona replies")
|
assert body["reply"]
|
||||||
|
# Mock persona decides to buy on this send -> session auto-finishes as won.
|
||||||
# finish -> debrief reveals latent + outcome won
|
assert body.get("finished") is True, body
|
||||||
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/finish", headers=UH)
|
assert body.get("outcome") == "won", body
|
||||||
assert r.status_code == 200, r.get_json()
|
debrief = body.get("debrief") or {}
|
||||||
debrief = r.get_json()["debrief"]
|
assert debrief.get("outcome") == "won"
|
||||||
assert debrief["outcome"] == "won"
|
|
||||||
assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"]
|
assert "revealed_persona" in debrief and "pains" in debrief["revealed_persona"]
|
||||||
print("[ok] finish -> debrief with latent reveal + outcome")
|
print("[ok] send -> persona decides (buy) -> session auto-finishes won with debrief")
|
||||||
|
|
||||||
# ONE-SHOT: cannot start again on same persona
|
# ONE-SHOT: cannot start again on same persona
|
||||||
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)
|
r = client.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=UH)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as w,_ as h,l as U,c as r,a as e,m,u as o,j as l,t as n,i as u,w as p,v,D as b,e as f,F as V,q as M,s as k,r as y,o as i,A as C}from"./index-tI5UDFg-.js";import{U as z}from"./users-qicnhyDs.js";/**
|
import{k as w,_ as h,l as U,c as r,a as e,m,u as o,j as l,t as n,i as u,w as p,v,D as b,e as f,F as V,q as M,s as k,r as y,o as i,A as C}from"./index-Dv9XjhnS.js";import{U as z}from"./users-CFr5Kj99.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k,_ as C,l as V,c as u,a as s,m as i,u as a,j as m,t,i as l,n as h,w as f,v as w,p as z,e as M,F as N,q as L,s as P,x as U,r as v,o as _}from"./index-tI5UDFg-.js";import{U as j}from"./users-qicnhyDs.js";import{P as A}from"./plus-DCrJlDOb.js";/**
|
import{k,_ as C,l as V,c as u,a as s,m as i,u as a,j as m,t,i as l,n as h,w as f,v as w,p as z,e as M,F as N,q as L,s as P,x as U,r as v,o as _}from"./index-Dv9XjhnS.js";import{U as j}from"./users-CFr5Kj99.js";import{P as A}from"./plus-Dg6sDA5g.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
1
frontend/dist/assets/Chat-BPfrVhz_.css
vendored
1
frontend/dist/assets/Chat-BPfrVhz_.css
vendored
@@ -1 +0,0 @@
|
|||||||
.thread[data-v-310aef08]{background:#eceff4;border:1px solid var(--border);border-radius:var(--radius);padding:16px;min-height:320px;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px}.bubble[data-v-310aef08]{max-width:72%;padding:10px 14px;white-space:pre-wrap;word-break:break-word}.msg-system[data-v-310aef08]{align-self:center;background:#fef3c7;color:#92400e;font-size:12px;max-width:88%;border-radius:999px}.composer[data-v-310aef08]{display:flex;gap:8px;margin-top:12px}.task[data-v-310aef08]{margin-bottom:12px;background:#fff7ed;border-color:#fed7aa}.debrief[data-v-310aef08]{margin-top:16px}button.danger[data-v-310aef08]{background:var(--red);color:#fff;border:none}.guide[data-v-310aef08]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:12px}.scenario[data-v-310aef08]{border:2px solid var(--border);border-radius:12px;padding:12px 14px;margin:10px 0;cursor:pointer;transition:border-color .15s ease,background .15s ease}.scenario[data-v-310aef08]:hover{border-color:var(--accent)}.scenario.sel[data-v-310aef08]{border-color:var(--accent);background:#eef2ff}.reveal-grid[data-v-310aef08]{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}.rev[data-v-310aef08]{display:flex;flex-direction:column;background:#f8fafc;border:1px solid var(--border);border-radius:8px;padding:8px 10px}.rk[data-v-310aef08]{font-size:12px;color:var(--muted)}.rv[data-v-310aef08]{font-size:13px;color:var(--ink);margin-top:2px}@media (max-width: 640px){.reveal-grid[data-v-310aef08]{grid-template-columns:1fr}}
|
|
||||||
1
frontend/dist/assets/Chat-D8HchzhD.css
vendored
Normal file
1
frontend/dist/assets/Chat-D8HchzhD.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.thread[data-v-4da228fb]{background:#eceff4;border:1px solid var(--border);border-radius:var(--radius);padding:16px;min-height:320px;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px}.bubble[data-v-4da228fb]{max-width:72%;padding:10px 14px;white-space:pre-wrap;word-break:break-word}.msg-system[data-v-4da228fb]{align-self:center;background:#fef3c7;color:#92400e;font-size:12px;max-width:88%;border-radius:999px}.composer[data-v-4da228fb]{display:flex;gap:8px;margin-top:12px}.task[data-v-4da228fb]{margin-bottom:12px;background:#fff7ed;border-color:#fed7aa}.debrief[data-v-4da228fb]{margin-top:16px}button.danger[data-v-4da228fb]{background:var(--red);color:#fff;border:none}.guide[data-v-4da228fb]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:12px}.scenario[data-v-4da228fb]{border:2px solid var(--border);border-radius:12px;padding:12px 14px;margin:10px 0;cursor:pointer;transition:border-color .15s ease,background .15s ease}.scenario[data-v-4da228fb]:hover{border-color:var(--accent)}.scenario.sel[data-v-4da228fb]{border-color:var(--accent);background:#eef2ff}.reveal-grid[data-v-4da228fb]{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}.rev[data-v-4da228fb]{display:flex;flex-direction:column;background:#f8fafc;border:1px solid var(--border);border-radius:8px;padding:8px 10px}.rk[data-v-4da228fb]{font-size:12px;color:var(--muted)}.rv[data-v-4da228fb]{font-size:13px;color:var(--ink);margin-top:2px}@media (max-width: 640px){.reveal-grid[data-v-4da228fb]{grid-template-columns:1fr}}
|
||||||
6
frontend/dist/assets/Chat-fU4aRozx.js
vendored
6
frontend/dist/assets/Chat-fU4aRozx.js
vendored
File diff suppressed because one or more lines are too long
6
frontend/dist/assets/Chat-o6rfEiwI.js
vendored
Normal file
6
frontend/dist/assets/Chat-o6rfEiwI.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
import{k as g,_ as M,c as f,m as d,n as V,a as e,u as l,j as r,t as s,i as o,w as u,v as y,D as k,e as C,x as z,r as v,s as B,g as A,o as x}from"./index-tI5UDFg-.js";import{A as P}from"./arrow-left-C_e4n_39.js";/**
|
import{k as g,_ as M,c as f,m as d,n as V,a as e,u as l,j as r,t as s,i as o,w as u,v as y,D as k,e as C,x as z,r as v,s as B,g as A,o as x}from"./index-Dv9XjhnS.js";import{A as P}from"./arrow-left-haz-8IhO.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as T,_ as F,E as O,o as m,c as f,a as e,t as i,w as o,v as u,D as M,e as _,G as I,r as k,l as q,m as j,n as D,u as c,j as y,i as B,B as J,F as N,q as P,H,h as R,s as z,x as K,A as E}from"./index-tI5UDFg-.js";import{U as Q}from"./users-qicnhyDs.js";/**
|
import{k as T,_ as F,E as O,o as m,c as f,a as e,t as i,w as o,v as u,D as M,e as _,G as I,r as k,l as q,m as j,n as D,u as c,j as y,i as B,B as J,F as N,q as P,H,h as R,s as z,x as K,A as E}from"./index-Dv9XjhnS.js";import{U as Q}from"./users-CFr5Kj99.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1 +1 @@
|
|||||||
import{_ as b,c as d,a as e,t as s,u as c,i as o,w as y,v as x,b as w,d as k,e as S,r as l,f,g as V,h as B,o as v}from"./index-tI5UDFg-.js";const C={class:"login-wrap"},D={class:"card login-card"},K={class:"pw-wrap"},E=["type"],L=["aria-label"],M={key:0,class:"error",role:"alert"},N=["disabled"],R={key:0,class:"spinner"},U={key:1},q={__name:"Login",setup(H){const g=B(),_=V(),n=l(""),u=l(""),a=l(!1),r=l(""),i=l(!1);async function m(){r.value="",i.value=!0;try{await f.login(n.value.trim(),u.value),f.mustSetup?_.push({path:"/setup"}):_.push(g.query.redirect||"/")}catch{r.value=o.t("loginError")}finally{i.value=!1}}return(h,t)=>(v(),d("div",C,[e("div",D,[e("h1",null,"🎯 "+s(c(o).t("app")),1),t[3]||(t[3]=e("p",{class:"muted",style:{"margin-top":"-8px"}},"Sales training simulator",-1)),e("label",null,s(c(o).t("username")),1),y(e("input",{"onUpdate:modelValue":t[0]||(t[0]=p=>n.value=p),type:"text",autocomplete:"username",onKeyup:w(m,["enter"])},null,544),[[x,n.value]]),e("label",null,s(c(o).t("password")),1),e("div",K,[y(e("input",{"onUpdate:modelValue":t[1]||(t[1]=p=>u.value=p),type:a.value?"text":"password",autocomplete:"current-password",onKeyup:w(m,["enter"])},null,40,E),[[k,u.value]]),e("button",{type:"button",class:"pw-toggle",onClick:t[2]||(t[2]=p=>a.value=!a.value),"aria-label":a.value?"Hide password":"Show password"},s(a.value?"🙈":"👁"),9,L)]),r.value?(v(),d("div",M,s(r.value),1)):S("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:i.value||!n.value||!u.value,onClick:m},[i.value?(v(),d("span",R)):(v(),d("span",U,s(c(o).t("login")),1))],8,N)])]))}},P=b(q,[["__scopeId","data-v-f7904d9f"]]);export{P as default};
|
import{_ as b,c as d,a as e,t as s,u as c,i as o,w as y,v as x,b as w,d as k,e as S,r as l,f,g as V,h as B,o as v}from"./index-Dv9XjhnS.js";const C={class:"login-wrap"},D={class:"card login-card"},K={class:"pw-wrap"},E=["type"],L=["aria-label"],M={key:0,class:"error",role:"alert"},N=["disabled"],R={key:0,class:"spinner"},U={key:1},q={__name:"Login",setup(H){const g=B(),_=V(),n=l(""),u=l(""),a=l(!1),r=l(""),i=l(!1);async function m(){r.value="",i.value=!0;try{await f.login(n.value.trim(),u.value),f.mustSetup?_.push({path:"/setup"}):_.push(g.query.redirect||"/")}catch{r.value=o.t("loginError")}finally{i.value=!1}}return(h,t)=>(v(),d("div",C,[e("div",D,[e("h1",null,"🎯 "+s(c(o).t("app")),1),t[3]||(t[3]=e("p",{class:"muted",style:{"margin-top":"-8px"}},"Sales training simulator",-1)),e("label",null,s(c(o).t("username")),1),y(e("input",{"onUpdate:modelValue":t[0]||(t[0]=p=>n.value=p),type:"text",autocomplete:"username",onKeyup:w(m,["enter"])},null,544),[[x,n.value]]),e("label",null,s(c(o).t("password")),1),e("div",K,[y(e("input",{"onUpdate:modelValue":t[1]||(t[1]=p=>u.value=p),type:a.value?"text":"password",autocomplete:"current-password",onKeyup:w(m,["enter"])},null,40,E),[[k,u.value]]),e("button",{type:"button",class:"pw-toggle",onClick:t[2]||(t[2]=p=>a.value=!a.value),"aria-label":a.value?"Hide password":"Show password"},s(a.value?"🙈":"👁"),9,L)]),r.value?(v(),d("div",M,s(r.value),1)):S("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:i.value||!n.value||!u.value,onClick:m},[i.value?(v(),d("span",R)):(v(),d("span",U,s(c(o).t("login")),1))],8,N)])]))}},P=b(q,[["__scopeId","data-v-f7904d9f"]]);export{P as default};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as x,_ as f,l as k,s as p,c as r,a as t,m as b,u as e,j as B,t as s,i as a,e as v,w as N,y as D,F as S,q as T,r as h,z as c,o as i,A as V}from"./index-tI5UDFg-.js";/**
|
import{k as x,_ as f,l as k,s as p,c as r,a as t,m as b,u as e,j as B,t as s,i as a,e as v,w as N,y as D,F as S,q as T,r as h,z as c,o as i,A as V}from"./index-Dv9XjhnS.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1 +1 @@
|
|||||||
import{_ as T,l as $,c as r,m as d,n as m,a as t,t as s,u as e,i as n,f as g,j as _,e as w,F as y,q as p,h as L,s as N,x as V,r as b,o as a,S as I,A as h,B as A}from"./index-tI5UDFg-.js";import{T as S}from"./target-CylhW5SK.js";import{A as j}from"./arrow-left-C_e4n_39.js";const F={class:"row",style:{"align-items":"center"}},q={style:{margin:"0"}},D={class:"muted",style:{"margin-left":"auto"}},E={key:0,class:"card guide"},M={key:1,class:"row",style:{margin:"12px 0",gap:"10px"}},R={class:"primary"},G={class:"grid"},H={class:"row",style:{"justify-content":"space-between"}},J={class:"diff"},K={class:"muted"},O={class:"muted"},Q={class:"muted"},U={class:"muted",style:{"margin-top":"6px"}},W={class:"primary",style:{width:"100%"}},X={class:"primary",style:{width:"100%"}},Y={key:1,class:"muted",style:{"margin-top":"auto","font-size":"12px"}},Z={__name:"Personas",setup(tt){const c=L().params.gid,k=b([]),B=b(!0);async function C(){try{k.value=(await N.listPersonas(c)).personas}finally{B.value=!1}}function z(l){return k.value.filter(i=>i.tier===l)}function P(l){return n.t(l==="A"?"tierA":l==="B"?"tierB":"tierC")}function v(l){return l==="won"?n.t("won"):l==="lost"?n.t("lost"):n.t("notTried")}return $(C),(l,i)=>{const u=V("router-link");return a(),r("div",null,[d(u,{to:"/training",class:"btn-back"},{default:m(()=>[d(e(j),{size:16,"stroke-width":2}),_(" "+s(e(n).t("training")),1)]),_:1}),t("div",F,[t("h2",q,s(e(n).t("personas")),1),t("span",D,s(e(n).t("selectPersona")),1)]),e(g).isAdmin?w("",!0):(a(),r("div",E,[t("strong",null,[d(e(S),{size:17,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),i[0]||(i[0]=_(" วิธีฝึก",-1))]),i[1]||(i[1]=t("ol",{style:{margin:"8px 0 0","padding-left":"20px","line-height":"1.8"}},[t("li",null,"เลือกลูกค้าจำลอง (บุคคลต้นแบบ) คนหนึ่งที่อยากฝึกด้วย"),t("li",null,"ระดับ A ง่ายสุด → ระดับ C ยากสุด (ดูจากดาว ★ ความยาก)"),t("li",null,"กดปุ่มสำหรับฝึกแชทกับลูกค้าคนนั้น (ฝึกได้คนละครั้งเท่านั้น)")],-1))])),e(g).isAdmin?(a(),r("div",M,[d(u,{to:`/admin/groups/${e(c)}/edit`},{default:m(()=>[t("button",R,[d(e(I),{size:18,"stroke-width":1.8}),_(" "+s(e(n).t("managePersonas")),1)])]),_:1},8,["to"]),i[2]||(i[2]=t("span",{class:"muted"},"Admin: จัดการรายละเอียดบุคคลต้นแบบได้ที่นี่",-1))])):w("",!0),(a(),r(y,null,p(["A","B","C"],f=>t("div",{key:f,style:{margin:"20px 0"}},[t("h4",null,s(P(f)),1),t("div",G,[(a(!0),r(y,null,p(z(f),o=>(a(),r("div",{key:o.id,class:"card pcard lift"},[t("div",H,[t("strong",null,s(o.name),1),t("span",{class:h(["badge",o.my_outcome])},s(v(o.my_outcome)),3)]),t("div",J,[(a(),r(y,null,p(5,x=>t("span",{key:x,class:h(["star",{on:x<=(o.difficulty||1)}])},"★",2)),64)),t("span",K,s(e(n).t("difficulty"))+" "+s(o.difficulty||1)+"/5",1)]),t("div",O,[_(s(o.profession)+" · "+s(o.age_group)+" · "+s(o.location),1),i[3]||(i[3]=t("br",null,null,-1)),t("span",{class:h(["badge",o.channel])},s(o.channel),3),t("span",Q," · "+s(o.initiation_mode==="seller"?e(n).t("sellerInitiated"):e(n).t("customerInitiated")),1)]),t("div",U,s(o.product_context),1),e(g).isAdmin?(a(),A(u,{key:0,to:`/groups/${e(c)}/chat/${o.id}`,style:{"margin-top":"auto"}},{default:m(()=>[t("button",W,s(e(n).t("chat")),1)]),_:1},8,["to"])):(a(),r(y,{key:1},[o.my_outcome==="not_tried"?(a(),A(u,{key:0,to:`/groups/${e(c)}/chat/${o.id}`,style:{"margin-top":"auto"}},{default:m(()=>[t("button",X,s(e(n).t("chat")),1)]),_:1},8,["to"])):(a(),r("div",Y,"✓ "+s(e(n).t("trained"))+" ("+s(v(o.my_outcome))+")",1))],64))]))),128))])])),64))])}}},at=T(Z,[["__scopeId","data-v-46d50c99"]]);export{at as default};
|
import{_ as T,l as $,c as r,m as d,n as m,a as t,t as s,u as e,i as n,f as g,j as _,e as w,F as y,q as p,h as L,s as N,x as V,r as b,o as a,S as I,A as h,B as A}from"./index-Dv9XjhnS.js";import{T as S}from"./target-Do-370k4.js";import{A as j}from"./arrow-left-haz-8IhO.js";const F={class:"row",style:{"align-items":"center"}},q={style:{margin:"0"}},D={class:"muted",style:{"margin-left":"auto"}},E={key:0,class:"card guide"},M={key:1,class:"row",style:{margin:"12px 0",gap:"10px"}},R={class:"primary"},G={class:"grid"},H={class:"row",style:{"justify-content":"space-between"}},J={class:"diff"},K={class:"muted"},O={class:"muted"},Q={class:"muted"},U={class:"muted",style:{"margin-top":"6px"}},W={class:"primary",style:{width:"100%"}},X={class:"primary",style:{width:"100%"}},Y={key:1,class:"muted",style:{"margin-top":"auto","font-size":"12px"}},Z={__name:"Personas",setup(tt){const c=L().params.gid,k=b([]),B=b(!0);async function C(){try{k.value=(await N.listPersonas(c)).personas}finally{B.value=!1}}function z(l){return k.value.filter(i=>i.tier===l)}function P(l){return n.t(l==="A"?"tierA":l==="B"?"tierB":"tierC")}function v(l){return l==="won"?n.t("won"):l==="lost"?n.t("lost"):n.t("notTried")}return $(C),(l,i)=>{const u=V("router-link");return a(),r("div",null,[d(u,{to:"/training",class:"btn-back"},{default:m(()=>[d(e(j),{size:16,"stroke-width":2}),_(" "+s(e(n).t("training")),1)]),_:1}),t("div",F,[t("h2",q,s(e(n).t("personas")),1),t("span",D,s(e(n).t("selectPersona")),1)]),e(g).isAdmin?w("",!0):(a(),r("div",E,[t("strong",null,[d(e(S),{size:17,"stroke-width":1.8,style:{"vertical-align":"-3px"}}),i[0]||(i[0]=_(" วิธีฝึก",-1))]),i[1]||(i[1]=t("ol",{style:{margin:"8px 0 0","padding-left":"20px","line-height":"1.8"}},[t("li",null,"เลือกลูกค้าจำลอง (บุคคลต้นแบบ) คนหนึ่งที่อยากฝึกด้วย"),t("li",null,"ระดับ A ง่ายสุด → ระดับ C ยากสุด (ดูจากดาว ★ ความยาก)"),t("li",null,"กดปุ่มสำหรับฝึกแชทกับลูกค้าคนนั้น (ฝึกได้คนละครั้งเท่านั้น)")],-1))])),e(g).isAdmin?(a(),r("div",M,[d(u,{to:`/admin/groups/${e(c)}/edit`},{default:m(()=>[t("button",R,[d(e(I),{size:18,"stroke-width":1.8}),_(" "+s(e(n).t("managePersonas")),1)])]),_:1},8,["to"]),i[2]||(i[2]=t("span",{class:"muted"},"Admin: จัดการรายละเอียดบุคคลต้นแบบได้ที่นี่",-1))])):w("",!0),(a(),r(y,null,p(["A","B","C"],f=>t("div",{key:f,style:{margin:"20px 0"}},[t("h4",null,s(P(f)),1),t("div",G,[(a(!0),r(y,null,p(z(f),o=>(a(),r("div",{key:o.id,class:"card pcard lift"},[t("div",H,[t("strong",null,s(o.name),1),t("span",{class:h(["badge",o.my_outcome])},s(v(o.my_outcome)),3)]),t("div",J,[(a(),r(y,null,p(5,x=>t("span",{key:x,class:h(["star",{on:x<=(o.difficulty||1)}])},"★",2)),64)),t("span",K,s(e(n).t("difficulty"))+" "+s(o.difficulty||1)+"/5",1)]),t("div",O,[_(s(o.profession)+" · "+s(o.age_group)+" · "+s(o.location),1),i[3]||(i[3]=t("br",null,null,-1)),t("span",{class:h(["badge",o.channel])},s(o.channel),3),t("span",Q," · "+s(o.initiation_mode==="seller"?e(n).t("sellerInitiated"):e(n).t("customerInitiated")),1)]),t("div",U,s(o.product_context),1),e(g).isAdmin?(a(),A(u,{key:0,to:`/groups/${e(c)}/chat/${o.id}`,style:{"margin-top":"auto"}},{default:m(()=>[t("button",W,s(e(n).t("chat")),1)]),_:1},8,["to"])):(a(),r(y,{key:1},[o.my_outcome==="not_tried"?(a(),A(u,{key:0,to:`/groups/${e(c)}/chat/${o.id}`,style:{"margin-top":"auto"}},{default:m(()=>[t("button",X,s(e(n).t("chat")),1)]),_:1},8,["to"])):(a(),r("div",Y,"✓ "+s(e(n).t("trained"))+" ("+s(v(o.my_outcome))+")",1))],64))]))),128))])])),64))])}}},at=T(Z,[["__scopeId","data-v-46d50c99"]]);export{at as default};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as z,_ as T,r as c,f as i,c as h,a as e,m as k,u as a,S as $,j as v,t as o,i as s,w as x,v as b,e as V,A as S,s as U,o as w}from"./index-tI5UDFg-.js";/**
|
import{k as z,_ as T,r as c,f as i,c as h,a as e,m as k,u as a,S as $,j as v,t as o,i as s,w as x,v as b,e as V,A as S,s as U,o as w}from"./index-Dv9XjhnS.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1 +1 @@
|
|||||||
import{_ as x,c as v,a as e,t,u as s,i as a,j as S,f as _,w,v as f,b as y,e as V,r,g as K,o as m}from"./index-tI5UDFg-.js";const T={class:"setup-wrap"},B={class:"card setup-card"},N={class:"muted"},U={key:0,class:"error",role:"alert"},C=["disabled"],D={key:0,class:"spinner"},M={key:1},P={__name:"Setup",setup(j){const k=K(),i=r(""),l=r(""),p=r(""),o=r(""),d=r(!1);async function c(){if(o.value="",l.value.length<4){o.value=a.t("passwordTooShort");return}if(l.value!==p.value){o.value=a.t("passwordMismatch");return}d.value=!0;try{await _.finishSetup(i.value.trim(),l.value),k.push("/")}catch(h){o.value=h.message}finally{d.value=!1}}return(h,u)=>{var b,g;return m(),v("div",T,[e("div",B,[e("h1",null,"🔐 "+t(s(a).t("setupTitle")),1),e("p",N,[S(t(s(a).t("setupSubtitle"))+" ",1),e("strong",null,t(((b=s(_).user)==null?void 0:b.name)||((g=s(_).user)==null?void 0:g.username)),1)]),e("label",null,t(s(a).t("email")),1),w(e("input",{"onUpdate:modelValue":u[0]||(u[0]=n=>i.value=n),type:"email",autocomplete:"email",onKeyup:y(c,["enter"])},null,544),[[f,i.value]]),e("label",null,t(s(a).t("newPassword")),1),w(e("input",{"onUpdate:modelValue":u[1]||(u[1]=n=>l.value=n),type:"password",autocomplete:"new-password",onKeyup:y(c,["enter"])},null,544),[[f,l.value]]),e("label",null,t(s(a).t("confirmPassword")),1),w(e("input",{"onUpdate:modelValue":u[2]||(u[2]=n=>p.value=n),type:"password",autocomplete:"new-password",onKeyup:y(c,["enter"])},null,544),[[f,p.value]]),o.value?(m(),v("div",U,t(o.value),1)):V("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:d.value||!i.value||!l.value||l.value!==p.value,onClick:c},[d.value?(m(),v("span",D)):(m(),v("span",M,t(s(a).t("save")),1))],8,C)])])}}},I=x(P,[["__scopeId","data-v-f190e120"]]);export{I as default};
|
import{_ as x,c as v,a as e,t,u as s,i as a,j as S,f as _,w,v as f,b as y,e as V,r,g as K,o as m}from"./index-Dv9XjhnS.js";const T={class:"setup-wrap"},B={class:"card setup-card"},N={class:"muted"},U={key:0,class:"error",role:"alert"},C=["disabled"],D={key:0,class:"spinner"},M={key:1},P={__name:"Setup",setup(j){const k=K(),i=r(""),l=r(""),p=r(""),o=r(""),d=r(!1);async function c(){if(o.value="",l.value.length<4){o.value=a.t("passwordTooShort");return}if(l.value!==p.value){o.value=a.t("passwordMismatch");return}d.value=!0;try{await _.finishSetup(i.value.trim(),l.value),k.push("/")}catch(h){o.value=h.message}finally{d.value=!1}}return(h,u)=>{var b,g;return m(),v("div",T,[e("div",B,[e("h1",null,"🔐 "+t(s(a).t("setupTitle")),1),e("p",N,[S(t(s(a).t("setupSubtitle"))+" ",1),e("strong",null,t(((b=s(_).user)==null?void 0:b.name)||((g=s(_).user)==null?void 0:g.username)),1)]),e("label",null,t(s(a).t("email")),1),w(e("input",{"onUpdate:modelValue":u[0]||(u[0]=n=>i.value=n),type:"email",autocomplete:"email",onKeyup:y(c,["enter"])},null,544),[[f,i.value]]),e("label",null,t(s(a).t("newPassword")),1),w(e("input",{"onUpdate:modelValue":u[1]||(u[1]=n=>l.value=n),type:"password",autocomplete:"new-password",onKeyup:y(c,["enter"])},null,544),[[f,l.value]]),e("label",null,t(s(a).t("confirmPassword")),1),w(e("input",{"onUpdate:modelValue":u[2]||(u[2]=n=>p.value=n),type:"password",autocomplete:"new-password",onKeyup:y(c,["enter"])},null,544),[[f,p.value]]),o.value?(m(),v("div",U,t(o.value),1)):V("",!0),e("button",{class:"primary",style:{width:"100%","margin-top":"16px"},disabled:d.value||!i.value||!l.value||l.value!==p.value,onClick:c},[d.value?(m(),v("span",D)):(m(),v("span",M,t(s(a).t("save")),1))],8,C)])])}}},I=x(P,[["__scopeId","data-v-f190e120"]]);export{I as default};
|
||||||
@@ -1 +1 @@
|
|||||||
import{_ as w,l as b,s as A,f as l,c as r,a as t,m as _,u as s,j as m,t as e,i as o,B as y,n as h,e as f,F as C,q as N,r as g,x as P,o as i,A as v}from"./index-tI5UDFg-.js";import{T}from"./target-CylhW5SK.js";import{P as B}from"./plus-DCrJlDOb.js";const z={class:"row",style:{"align-items":"center","margin-bottom":"16px"}},V={style:{margin:"0"}},j={style:{"margin-left":"auto"}},F={class:"primary"},L={class:"muted"},S={key:0,class:"card",style:{"min-height":"80px"}},$={key:1,class:"card empty-state"},q={key:0},D={key:1},E={class:"grid"},G={class:"card lift train-card"},I={class:"row",style:{"justify-content":"space-between"}},M={class:"muted",style:{margin:"6px 0 12px"}},H={class:"row",style:{gap:"8px"}},J={class:"muted"},K={class:"primary",style:{width:"100%","margin-top":"12px"}},O={__name:"Training",setup(Q){const c=g([]),u=g(!0);function k(a){return a.sales_kit&&a.sales_kit.productName||a.input&&a.input.product||""}function x(a){return a.personas||a.persona_count||0}return b(async()=>{try{const a=(await A.listGroups()).groups||[];c.value=l.isAdmin?a:a.filter(d=>d.status==="ready")}finally{u.value=!1}}),(a,d)=>{const p=P("router-link");return i(),r("div",null,[t("div",z,[t("h2",V,[_(s(T),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),m(" "+e(s(o).t("training")),1)]),t("div",j,[s(l).isAdmin?(i(),y(p,{key:0,to:"/admin/new-group"},{default:h(()=>[t("button",F,[_(s(B),{size:18,"stroke-width":2}),m(" "+e(s(o).t("addProduct")),1)])]),_:1})):f("",!0)])]),t("p",L,e(s(o).t("trainingSubtitle")),1),u.value?(i(),r("div",S,[...d[0]||(d[0]=[t("div",{class:"skeleton",style:{height:"50px"}},null,-1)])])):c.value.length===0?(i(),r("div",$,[t("strong",null,e(s(o).t("noTraining")),1),s(l).isAdmin?(i(),r("span",q,'Click "'+e(s(o).t("addProduct"))+'" to create a persona group.',1)):(i(),r("span",D,"Ask an admin to create a persona group first."))])):f("",!0),t("div",E,[(i(!0),r(C,null,N(c.value,n=>(i(),y(p,{key:n.id,to:s(l).isAdmin?`/admin/groups/${n.id}/edit`:`/groups/${n.id}/personas`,style:{"text-decoration":"none"}},{default:h(()=>[t("div",G,[t("div",I,[t("strong",null,e(n.title),1),t("span",{class:v(["badge",n.status==="ready"?"ready":"draft"])},e(n.status),3)]),t("div",M,e(k(n)),1),t("div",H,[t("span",{class:v(["badge",n.channel||"line"])},e(n.channel||"line"),3),t("span",J,e(x(n))+" "+e(s(o).t("personas").toLowerCase()),1)]),t("button",K,e(s(l).isAdmin?n.status==="ready"?s(o).t("managePersonas"):s(o).t("analyze"):s(o).t("selectPersona")),1)])]),_:2},1032,["to"]))),128))])])}}},X=w(O,[["__scopeId","data-v-1e5bd92b"]]);export{X as default};
|
import{_ as w,l as b,s as A,f as l,c as r,a as t,m as _,u as s,j as m,t as e,i as o,B as y,n as h,e as f,F as C,q as N,r as g,x as P,o as i,A as v}from"./index-Dv9XjhnS.js";import{T}from"./target-Do-370k4.js";import{P as B}from"./plus-Dg6sDA5g.js";const z={class:"row",style:{"align-items":"center","margin-bottom":"16px"}},V={style:{margin:"0"}},j={style:{"margin-left":"auto"}},F={class:"primary"},L={class:"muted"},S={key:0,class:"card",style:{"min-height":"80px"}},$={key:1,class:"card empty-state"},q={key:0},D={key:1},E={class:"grid"},G={class:"card lift train-card"},I={class:"row",style:{"justify-content":"space-between"}},M={class:"muted",style:{margin:"6px 0 12px"}},H={class:"row",style:{gap:"8px"}},J={class:"muted"},K={class:"primary",style:{width:"100%","margin-top":"12px"}},O={__name:"Training",setup(Q){const c=g([]),u=g(!0);function k(a){return a.sales_kit&&a.sales_kit.productName||a.input&&a.input.product||""}function x(a){return a.personas||a.persona_count||0}return b(async()=>{try{const a=(await A.listGroups()).groups||[];c.value=l.isAdmin?a:a.filter(d=>d.status==="ready")}finally{u.value=!1}}),(a,d)=>{const p=P("router-link");return i(),r("div",null,[t("div",z,[t("h2",V,[_(s(T),{size:22,"stroke-width":1.8,style:{"vertical-align":"-4px"}}),m(" "+e(s(o).t("training")),1)]),t("div",j,[s(l).isAdmin?(i(),y(p,{key:0,to:"/admin/new-group"},{default:h(()=>[t("button",F,[_(s(B),{size:18,"stroke-width":2}),m(" "+e(s(o).t("addProduct")),1)])]),_:1})):f("",!0)])]),t("p",L,e(s(o).t("trainingSubtitle")),1),u.value?(i(),r("div",S,[...d[0]||(d[0]=[t("div",{class:"skeleton",style:{height:"50px"}},null,-1)])])):c.value.length===0?(i(),r("div",$,[t("strong",null,e(s(o).t("noTraining")),1),s(l).isAdmin?(i(),r("span",q,'Click "'+e(s(o).t("addProduct"))+'" to create a persona group.',1)):(i(),r("span",D,"Ask an admin to create a persona group first."))])):f("",!0),t("div",E,[(i(!0),r(C,null,N(c.value,n=>(i(),y(p,{key:n.id,to:s(l).isAdmin?`/admin/groups/${n.id}/edit`:`/groups/${n.id}/personas`,style:{"text-decoration":"none"}},{default:h(()=>[t("div",G,[t("div",I,[t("strong",null,e(n.title),1),t("span",{class:v(["badge",n.status==="ready"?"ready":"draft"])},e(n.status),3)]),t("div",M,e(k(n)),1),t("div",H,[t("span",{class:v(["badge",n.channel||"line"])},e(n.channel||"line"),3),t("span",J,e(x(n))+" "+e(s(o).t("personas").toLowerCase()),1)]),t("button",K,e(s(l).isAdmin?n.status==="ready"?s(o).t("managePersonas"):s(o).t("analyze"):s(o).t("selectPersona")),1)])]),_:2},1032,["to"]))),128))])])}}},X=w(O,[["__scopeId","data-v-1e5bd92b"]]);export{X as default};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as e}from"./index-tI5UDFg-.js";/**
|
import{k as e}from"./index-Dv9XjhnS.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
import{k as e}from"./index-tI5UDFg-.js";/**
|
import{k as e}from"./index-Dv9XjhnS.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as c}from"./index-tI5UDFg-.js";/**
|
import{k as c}from"./index-Dv9XjhnS.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{k as e}from"./index-tI5UDFg-.js";/**
|
import{k as e}from"./index-Dv9XjhnS.js";/**
|
||||||
* @license lucide-vue-next v1.0.0 - ISC
|
* @license lucide-vue-next v1.0.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sales Trainer</title>
|
<title>Sales Trainer</title>
|
||||||
<script type="module" crossorigin src="/assets/index-tI5UDFg-.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dv9XjhnS.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bu7f-IE2.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bu7f-IE2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const api = {
|
|||||||
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
|
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
|
||||||
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
|
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
|
||||||
chatStart: (gid, pid, scenario) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, scenario || {}),
|
chatStart: (gid, pid, scenario) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, scenario || {}),
|
||||||
|
chatResume: (gid, pid) => request('GET', `/api/chat/${gid}/personas/${pid}/chat/resume`),
|
||||||
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
|
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
|
||||||
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
|
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
|
||||||
mySessions: () => request('GET', '/api/chat/sessions'),
|
mySessions: () => request('GET', '/api/chat/sessions'),
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
|
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
|
||||||
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
|
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
|
||||||
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
|
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
|
||||||
<button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0 || phase === 'done'">
|
<span v-if="phase === 'done'" class="badge" :class="debrief && debrief.outcome">
|
||||||
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }}
|
{{ debrief && debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat thread -->
|
<!-- Chat thread -->
|
||||||
@@ -102,7 +102,6 @@ const messages = ref([])
|
|||||||
const text = ref('')
|
const text = ref('')
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const debrief = ref(null)
|
const debrief = ref(null)
|
||||||
const taskText = ref('')
|
|
||||||
const sessionId = ref(null)
|
const sessionId = ref(null)
|
||||||
const picked = ref('social')
|
const picked = ref('social')
|
||||||
|
|
||||||
@@ -123,13 +122,33 @@ async function begin() {
|
|||||||
phase.value = 'chat'
|
phase.value = 'chat'
|
||||||
const res = await api.chatStart(gid, pid, { scenario: picked.value })
|
const res = await api.chatStart(gid, pid, { scenario: picked.value })
|
||||||
sessionId.value = res.session.id
|
sessionId.value = res.session.id
|
||||||
if (res.session.task) taskText.value = res.session.task
|
|
||||||
messages.value = res.session.messages || []
|
messages.value = res.session.messages || []
|
||||||
if (messages.value.length) scrollDown()
|
if (messages.value.length) scrollDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
persona.value = (await api.getPersona(gid, pid)).persona
|
persona.value = (await api.getPersona(gid, pid)).persona
|
||||||
|
// Resume: if there's an unfinished session for this persona, continue it instead of restarting.
|
||||||
|
try {
|
||||||
|
const r = await api.chatResume(gid, pid)
|
||||||
|
sessionId.value = r.session.id
|
||||||
|
messages.value = r.session.messages || []
|
||||||
|
if (messages.value.length && !r.session.debrief) {
|
||||||
|
phase.value = 'chat'
|
||||||
|
// re-derive which scenario was active
|
||||||
|
const sc = scenarios.find((s) => s.id === r.scenario)
|
||||||
|
if (sc) picked.value = r.scenario
|
||||||
|
// if session has a debrief already, it's done
|
||||||
|
} else if (r.session.debrief) {
|
||||||
|
debrief.value = r.session.debrief
|
||||||
|
messages.value = r.session.messages || []
|
||||||
|
phase.value = 'done'
|
||||||
|
}
|
||||||
|
if (messages.value.length) scrollDown()
|
||||||
|
} catch (e) {
|
||||||
|
// no active session -> show the scenario picker
|
||||||
|
phase.value = 'pick'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
@@ -139,6 +158,10 @@ async function send() {
|
|||||||
const res = await api.chatSend(gid, pid, text.value.trim())
|
const res = await api.chatSend(gid, pid, text.value.trim())
|
||||||
messages.value = res.messages
|
messages.value = res.messages
|
||||||
text.value = ''
|
text.value = ''
|
||||||
|
if (res.finished) {
|
||||||
|
debrief.value = res.debrief || null
|
||||||
|
phase.value = 'done'
|
||||||
|
}
|
||||||
scrollDown()
|
scrollDown()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message)
|
alert(e.message)
|
||||||
@@ -147,21 +170,6 @@ async function send() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finish() {
|
|
||||||
if (!confirm(i18n.t('finish') + '?')) return
|
|
||||||
sending.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.chatFinish(gid, pid)
|
|
||||||
debrief.value = res.debrief
|
|
||||||
messages.value = res.session.messages
|
|
||||||
phase.value = 'done'
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message)
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIELD_LABELS = {
|
const FIELD_LABELS = {
|
||||||
name: 'ชื่อ', tier: 'ระดับ', difficulty: 'ความยาก', profession: 'อาชีพ',
|
name: 'ชื่อ', tier: 'ระดับ', difficulty: 'ความยาก', profession: 'อาชีพ',
|
||||||
age_group: 'ช่วงอายุ', location: 'พื้นที่', income: 'รายได้', budget: 'งบประมาณ',
|
age_group: 'ช่วงอายุ', location: 'พื้นที่', income: 'รายได้', budget: 'งบประมาณ',
|
||||||
|
|||||||
Reference in New Issue
Block a user