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:
Macky
2026-08-08 14:34:34 +07:00
parent bd6a7ffa32
commit aa2eb8dd37
28 changed files with 199 additions and 84 deletions

View File

@@ -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")})

View File

@@ -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.

View File

@@ -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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.

View File

@@ -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}}

View 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}}

File diff suppressed because one or more lines are too long

6
frontend/dist/assets/Chat-o6rfEiwI.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -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.

View File

@@ -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.

View File

@@ -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};

View File

@@ -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.

View File

@@ -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};

View File

@@ -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.

View File

@@ -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};

View File

@@ -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};

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>

View File

@@ -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'),

View File

@@ -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: 'งบประมาณ',