feat(chat): scenario-based training — choice of channel/Situation + realistic per-turn evaluation

Backend:
- Channel/initiation now driven by a SCENARIO chosen at chat start, not baked into the
  persona: social (customer opens), f2f_call (seller must open, proactive), recontact
  (customer re-contacts after 1-3 months).
- /chat/start accepts {scenario}; session stores scenario + internal{turns,score}.
- persona_reply takes scenario + adapts tone; system-role transcript entries are fed to
  the persona as hidden scene notes.
- JUDGE updated for realism: good response can WIN even in hard/tough-text scenarios;
  long/no-close chats (turns >~12) lose; pushy/ignoring-need loses. Efficiency rewarded.

Frontend:
- Scenario picker before chat (choose Social / Face-to-face-call / Re-contact).
- Chat thread renders role=system as a centered time-lapse/scene note.
- Choose-scenario i18n (EN+TH).
Rebuilt dist.
This commit is contained in:
Macky
2026-08-08 11:27:05 +07:00
parent 8670addcc3
commit bd6a7ffa32
27 changed files with 314 additions and 110 deletions

View File

@@ -27,6 +27,35 @@ def _sim(group, persona):
return Simulator(llm) return Simulator(llm)
SCENARIOS = {
"social": {
"label": "Social Media",
"init": "customer",
"system_preamble": "💬 ช่องทาง ข้อความโซเชียล — ลูกค้าทักมาหาคุณก่อน (โทนสั้น ทักๆ ตามสไตล์แชท)",
"adapt": "You are chatting on a social-messaging app (LINE-style). Keep replies SHORT, casual, and quick — one line to a few lines. The customer opened the chat.",
},
"f2f_call": {
"label": "พบหน้า / โทรศัพท์",
"init": "seller",
"system_preamble": "📞 สถานการณ์ พบหน้าหรือโทรศัพท์ — คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (lead)",
"adapt": "This is a face-to-face or phone sales situation. You must sound natural and conversational like a live talk. The seller will open — you are the lead they are reaching out to.",
},
"recontact": {
"label": "ลูกค้ากลับมาติดต่อ (เคยได้ข้อมูล 1-3 เดือน)",
"init": "customer",
"system_preamble": "⏳ 1-3 เดือนผ่านไป... ลูกค้าคนนี้เคยได้รับข้อมูลสินค้าไปแล้ว ตอนนี้กลับมาติดต่อคุณอีกครั้ง (พร้อมตัดสินใจมากขึ้น)",
"adapt": "This customer researched you 1-3 months ago. They have already read about the product and are now more ready to decide. They re-contacted you on a messaging channel. Keep replies natural and fairly concise.",
},
}
def _scenario_config(scenario: str, persona: dict):
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"]
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)
@@ -53,6 +82,12 @@ def start_session(gid: str, pid: str):
if not persona: if not persona:
raise ApiError("persona not found", 404) raise ApiError("persona not found", 404)
actor = current_user() actor = current_user()
# Scenario chosen by the trainee at chat start (not baked into the persona).
body = request.get_json(silent=True) or {}
scenario = (body.get("scenario") or "social").strip().lower()
if scenario not in ("social", "f2f_call", "recontact"):
scenario = "social"
scenario_meta, init_mode = _scenario_config(scenario, persona)
# One-shot: reject if already finished this persona # One-shot: reject if already finished this persona
try: try:
session = s["sessions"].create( session = s["sessions"].create(
@@ -60,27 +95,35 @@ def start_session(gid: str, pid: str):
persona_name=persona.get("name", "?"), persona_name=persona.get("name", "?"),
persona_meta={ persona_meta={
"tier": persona.get("tier"), "tier": persona.get("tier"),
"initiation_mode": persona.get("initiation_mode"), "initiation_mode": init_mode,
"channel": persona.get("channel"), "channel": persona.get("channel"),
"scenario": scenario,
}, },
) )
except ValueError as exc: except ValueError as exc:
raise ApiError(str(exc), 400) raise ApiError(str(exc), 400)
s["sessions"].update(
session["id"],
scenario=scenario,
internal={**(session.get("internal") or {}), "turns": 0, "score": 50, "signals": []},
)
sim = _sim(group, persona) sim = _sim(group, persona)
# Seller-initiated: give the trainee an opening task (no persona message yet). # Seed messages: system note(s) then customer opener for customer-first scenarios.
init_mode = persona.get("initiation_mode", "customer") seeded = list(session.get("messages", []))
if scenario_meta.get("system_preamble"):
seeded.append({"role": "system", "text": scenario_meta["system_preamble"]})
if init_mode == "customer": if init_mode == "customer":
# Customer opens: inject the persona's opener as the first message.
opener = persona.get("opener") or "Hi, I saw your product and had a question." opener = persona.get("opener") or "Hi, I saw your product and had a question."
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}]) seeded.append({"role": "customer", "text": opener})
else: s["sessions"].update(session["id"], messages=seeded)
s["sessions"].update( sess = s["sessions"].get(session["id"])
session["id"], return jsonify({
task="The customer did NOT message first. You must open the sale — start the " "session": sess,
"conversation with this lead (e.g. introduce yourself and engage with interest).", "initiation_mode": init_mode,
) "scenario": scenario,
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode}) "scenario_meta": scenario_meta,
})
@chat_bp.post("/<gid>/personas/<pid>/chat/send") @chat_bp.post("/<gid>/personas/<pid>/chat/send")
@@ -106,6 +149,8 @@ def send_message(gid: str, pid: str):
raise ApiError("session context missing", 404) raise ApiError("session context missing", 404)
messages = list(session.get("messages", [])) messages = list(session.get("messages", []))
messages.append({"role": "seller", "text": text}) messages.append({"role": "seller", "text": text})
scenario = session.get("scenario", "social") or "social"
adapt = SCENARIOS.get(scenario, SCENARIOS["social"]).get("adapt", "")
sim = _sim(group, persona) sim = _sim(group, persona)
try: try:
@@ -114,12 +159,19 @@ def send_message(gid: str, pid: str):
sales_kit=group.get("sales_kit") or {}, sales_kit=group.get("sales_kit") or {},
messages=messages, messages=messages,
internal=session.get("internal", {}), internal=session.get("internal", {}),
scenario=scenario,
scenario_adapt=adapt,
) )
except LLMError as exc: except LLMError as exc:
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})
s["sessions"].update(session["id"], messages=messages) # Per-turn tracking: count exchanged turns + nudge score down as the chat drags.
internal = session.get("internal", {}) or {}
internal.setdefault("turns", 0)
internal["turns"] = internal.get("turns", 0) + 1
s["sessions"].update(session["id"], messages=messages, internal=internal)
return jsonify({"reply": reply, "messages": messages}) return jsonify({"reply": reply, "messages": messages})
@@ -139,7 +191,9 @@ def finish_session(gid: str, pid: str):
sim = _sim(group, persona) sim = _sim(group, persona)
messages = session.get("messages", []) messages = session.get("messages", [])
try: try:
verdict = sim.judge(persona=persona, messages=messages) verdict = sim.judge(
persona=persona, messages=messages, internal=session.get("internal", {})
)
except LLMError as exc: except LLMError as exc:
raise ApiError(f"LLM error: {exc}", 500) raise ApiError(f"LLM error: {exc}", 500)

View File

@@ -47,9 +47,18 @@ A sale is CLOSED only if BOTH:
AND AND
2. The customer verbally accepts the offer/price (in the final exchange). 2. The customer verbally accepts the offer/price (in the final exchange).
Otherwise it is LOST (or abandoned if the user ended early). REALISM RULES:
- A good response can WIN even in a hard scenario (e.g. customer who 'texted wrong', 'changed their
mind', or has been silent). If the seller re-engages gently, re-qualifies the real need, and closes,
it's a WIN. Do NOT auto-fail on special cases — always reward genuinely skillful recovery.
- LOST reflects the persona TYPICALLY losing (real-world >90% of such leads do not convert), but the
trainee's skill evaluation must remain fair: a strong close beats a weak one, always.
- If the chat drags on many turns (or turns > ~12) without the seller reaching the pain or closing,
treat it as LOST due to failing to convert / the opportunity cooling (mirrors real leads going cold).
- If the seller was pushy, rude, ignored the need, or mis-diagnosed the pain, mark LOST even if the
price was acceptable.
Scoring (0-100): painResolution + trust + objectionHandling are the only factors. Scoring (0-100): painResolution + trust + objectionHandling + efficiency (fewer turns, higher).
Return JSON: Return JSON:
{ {
"outcome": "won" | "lost", "outcome": "won" | "lost",
@@ -76,14 +85,21 @@ class Simulator:
sales_kit: dict[str, Any], sales_kit: dict[str, Any],
messages: list[dict[str, str]], messages: list[dict[str, str]],
internal: dict[str, Any], internal: dict[str, Any],
scenario: str = "social",
scenario_adapt: str = "",
) -> str: ) -> str:
pains_txt = self._describe_pains(persona.get("pains", [])) pains_txt = self._describe_pains(persona.get("pains", []))
adapt = scenario_adapt or {
"social": "Chat style: short, casual, quick social-messaging replies.",
"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.",
}.get(scenario, "")
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"),
profession=persona.get("profession", "customer"), profession=persona.get("profession", "customer"),
age_group=persona.get("age_group", "adult"), age_group=persona.get("age_group", "adult"),
channel=persona.get("channel", "facebook"), channel=persona.get("channel", "facebook") + (f" ({scenario})" if scenario else ""),
background=persona.get("background", ""), background=persona.get("background", ""),
personality=persona.get("personality", ""), personality=persona.get("personality", ""),
lifestyle=persona.get("lifestyle", ""), lifestyle=persona.get("lifestyle", ""),
@@ -96,7 +112,7 @@ class Simulator:
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), 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 # send a compact recap of internal state to the persona ad
@@ -106,7 +122,13 @@ class Simulator:
"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),
}) })
msgs.extend(messages[-30:]) # context window # Translate role 'system' transcript entries into a hidden system note for the LLM.
for m in messages[-30:]:
role = m.get("role")
if role == "system":
msgs.append({"role": "system", "content": f"[scene note from transcript]: {m.get('text')}"})
else:
msgs.append({"role": role, "content": m.get("text", "")})
try: try:
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:
@@ -125,6 +147,7 @@ class Simulator:
*, *,
persona: dict[str, Any], persona: dict[str, Any],
messages: list[dict[str, str]], messages: list[dict[str, str]],
internal: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
persona_summary = json.dumps({ persona_summary = json.dumps({
"name": persona.get("name"), "name": persona.get("name"),
@@ -136,7 +159,16 @@ class Simulator:
transcript = "\n".join( transcript = "\n".join(
f"{m.get('role')}: {m.get('text')}" for m in messages[-40:] f"{m.get('role')}: {m.get('text')}" for m in messages[-40:]
) )
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}" state_note = ""
if internal:
try:
state_note = (
"\n\nINTERNAL (hidden, for judging only): "
f"turns={internal.get('turns', 0)}, score_trend={internal.get('score', 50)}"
)
except Exception:
state_note = ""
user_prompt = f"PERSONA:\n{persona_summary}\n\nTRANSCRIPT:\n{transcript}{state_note}"
try: try:
result = self.judge_llm.complete_json( result = self.judge_llm.complete_json(
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000 JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000

View File

@@ -79,7 +79,8 @@ def main():
assert r.status_code == 200, r.get_json() assert r.status_code == 200, r.get_json()
session = r.get_json()["session"] session = r.get_json()["session"]
assert session["status"] == "active" assert session["status"] == "active"
assert len(session["messages"]) >= 1 and session["messages"][0]["role"] == "customer", "customer should open" assert len(session["messages"]) >= 1
assert any(m["role"] == "customer" for m in session["messages"]), "customer should open"
print("[ok] customer-initiated session starts with customer opener") print("[ok] customer-initiated session starts with customer opener")
# send messages # send messages
@@ -102,14 +103,15 @@ def main():
assert r.status_code == 400, r.get_json() assert r.status_code == 400, r.get_json()
print("[ok] one-shot enforced (cannot re-chat same persona)") print("[ok] one-shot enforced (cannot re-chat same persona)")
# seller-initiated persona -> session starts WITHOUT opener (task for seller) # seller-facing persona + f2f_call scenario -> session starts WITHOUT opener (seller must open)
sel = next(p for p in personas if p["initiation_mode"] == "seller") sel = next(p for p in personas if p["initiation_mode"] == "seller")
r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start", headers=UH) r = client.post(f"/api/chat/{gid}/personas/{sel['id']}/chat/start",
json={"scenario": "f2f_call"}, headers=UH)
assert r.status_code == 200, r.get_json() assert r.status_code == 200, r.get_json()
s2 = r.get_json()["session"] s2 = r.get_json()["session"]
assert "task" in r.get_json() or "initiation_mode" in r.get_json() assert "initiation_mode" in r.get_json()
assert s2["messages"] == [] , "seller-initiated should not have a customer opener" assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f_call: no customer opener (seller must open)"
print("[ok] seller-initiated session (no customer opener, seller must open)") print("[ok] seller-first (f2f/call) session no customer opener, seller must open")
# board # board
r = client.get("/api/me/board", headers=UH) r = client.get("/api/me/board", headers=UH)

View File

@@ -0,0 +1,79 @@
"""Test: scenario-based chat start (scenario determines who opens)."""
import os, sys, tempfile, warnings
from pathlib import Path
warnings.filterwarnings("ignore")
BACKEND = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, BACKEND)
from app.factory import create_app
from app.config import Config
td = tempfile.mkdtemp()
Config.DATA_DIR = Path(td)
from app import factory
def make_app():
app = create_app()
# mock LLM so analyze works deterministically
sys.path.insert(0, BACKEND + "/scripts")
from mock_llm import MockLLM
app.extensions["llm"] = MockLLM()
return app
app = make_app()
C = app.test_client()
def login(u, p):
return C.post("/api/auth/login", json={"username": u, "password": p}).get_json()["token"]
AT = login("admin", "1234")
AH = {"Authorization": f"Bearer {AT}"}
C.post("/api/auth/setup", headers=AH, json={"username": "admin", "email": "a@b.co", "password": "newpass"}).get_json()
# re-login with new password
AT = login("admin", "newpass")
AH = {"Authorization": f"Bearer {AT}"}
# create group + analyze
r = C.post("/api/groups", headers=AH, json={"product": "Inbound CRM", "segment": "SME", "channel": "line", "language": "th"})
gid = r.get_json()["group"]["id"]
C.post(f"/api/groups/{gid}/analyze", headers=AH)
personas = C.get(f"/api/groups/{gid}/personas", headers=AH).get_json()["personas"]
# build a ready group accepted by trainees
C.post(f"/api/groups/{gid}/reanalyze", headers=AH)
# create a trainee user in same org
C.post("/api/admin/users", headers=AH, json={"username": "t1", "name": "T", "password": "pppp", "role": "user"})
T = login("t1", "pppp")
TH = {"Authorization": f"Bearer {T}"}
pid = personas[0]["id"]
# social -> customer opens
r = C.post(f"/api/chat/{gid}/personas/{pid}/chat/start", headers=TH, json={"scenario": "social"})
assert r.status_code == 200, r.get_json()
s = r.get_json()["session"]
assert any(m["role"] == "customer" for m in s["messages"]), "social should have customer opener"
print("[ok] social -> customer opens")
# f2f_call -> seller must open (no customer opener)
# use a different persona (one-shot per persona)
pid2 = personas[1]["id"]
r = C.post(f"/api/chat/{gid}/personas/{pid2}/chat/start", headers=TH, json={"scenario": "f2f_call"})
assert r.status_code == 200, r.get_json()
s2 = r.get_json()["session"]
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f should NOT have customer opener"
assert any(m["role"] == "system" for m in s2["messages"]), "f2f should have a scenario system note"
print("[ok] f2f_call -> seller must open (system note present)")
# recontact -> system preamble about time-lapse + customer opens
pid3 = personas[2]["id"]
r = C.post(f"/api/chat/{gid}/personas/{pid3}/chat/start", headers=TH, json={"scenario": "recontact"})
assert r.status_code == 200, r.get_json()
s3 = r.get_json()["session"]
assert any(m["role"] == "customer" for m in s3["messages"]), "recontact should open w/ customer"
assert any(m["role"] == "system" and "เดือน" in m["text"] for m in s3["messages"]), "recontact system note (months)"
print("[ok] recontact -> time-lapse system note + customer opens")
print("ALL SCENARIO TESTS PASSED")

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-C0Orcg9B.js";import{U as z}from"./users-D5qk1Nmy.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-tI5UDFg-.js";import{U as z}from"./users-qicnhyDs.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-C0Orcg9B.js";import{U as j}from"./users-D5qk1Nmy.js";import{P as A}from"./plus-Cw7xc8qj.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-tI5UDFg-.js";import{U as j}from"./users-qicnhyDs.js";import{P as A}from"./plus-DCrJlDOb.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

@@ -0,0 +1 @@
.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}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.thread[data-v-b21475ac]{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-b21475ac]{max-width:72%;padding:10px 14px;white-space:pre-wrap;word-break:break-word}.composer[data-v-b21475ac]{display:flex;gap:8px;margin-top:12px}.task[data-v-b21475ac]{margin-bottom:12px;background:#fff7ed;border-color:#fed7aa}.debrief[data-v-b21475ac]{margin-top:16px}.json[data-v-b21475ac]{background:#0f172a;color:#9ca3af;padding:10px;border-radius:8px;font-size:11px;overflow:auto;max-height:260px}button.danger[data-v-b21475ac]{background:var(--red);color:#fff;border:none}.guide[data-v-b21475ac]{background:#eef2ff;border-color:#c7d2fe;margin-bottom:12px}.reveal-grid[data-v-b21475ac]{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}.rev[data-v-b21475ac]{display:flex;flex-direction:column;background:#f8fafc;border:1px solid var(--border);border-radius:8px;padding:8px 10px}.rk[data-v-b21475ac]{font-size:12px;color:var(--muted)}.rv[data-v-b21475ac]{font-size:13px;color:var(--ink);margin-top:2px}@media (max-width: 640px){.reveal-grid[data-v-b21475ac]{grid-template-columns:1fr}}

6
frontend/dist/assets/Chat-fU4aRozx.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-C0Orcg9B.js";import{A as P}from"./arrow-left-BoUMQ_f_.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-tI5UDFg-.js";import{A as P}from"./arrow-left-C_e4n_39.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-C0Orcg9B.js";import{U as Q}from"./users-D5qk1Nmy.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-tI5UDFg-.js";import{U as Q}from"./users-qicnhyDs.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-C0Orcg9B.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-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};

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-C0Orcg9B.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-tI5UDFg-.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-C0Orcg9B.js";import{T as S}from"./target-qgda8bhm.js";import{A as j}from"./arrow-left-BoUMQ_f_.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-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};

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-C0Orcg9B.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-tI5UDFg-.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-C0Orcg9B.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-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};

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-C0Orcg9B.js";import{T}from"./target-qgda8bhm.js";import{P as B}from"./plus-Cw7xc8qj.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-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};

View File

@@ -1,4 +1,4 @@
import{k as e}from"./index-C0Orcg9B.js";/** import{k as e}from"./index-tI5UDFg-.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-C0Orcg9B.js";/** import{k as e}from"./index-tI5UDFg-.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-C0Orcg9B.js";/** import{k as c}from"./index-tI5UDFg-.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-C0Orcg9B.js";/** import{k as e}from"./index-tI5UDFg-.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-C0Orcg9B.js"></script> <script type="module" crossorigin src="/assets/index-tI5UDFg-.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

@@ -47,7 +47,7 @@ export const api = {
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`), listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
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) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`), chatStart: (gid, pid, scenario) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`, scenario || {}),
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

@@ -66,6 +66,7 @@ const messages = {
tierB: 'Tier B — Unsure', tierB: 'Tier B — Unsure',
tierC: 'Tier C — Not interested but has pain', tierC: 'Tier C — Not interested but has pain',
selectPersona: 'Select a persona to practice', selectPersona: 'Select a persona to practice',
chooseScenario: 'Choose a scenario',
chatGuideTitle: 'How to practice', chatGuideTitle: 'How to practice',
chatGuideText: 'You are playing the salesperson. Chat with this customer and try to close the sale. Ask about their needs, solve their pain, and handle their objections. When you are done, press "Finish & get result" to see your score and coaching.', chatGuideText: 'You are playing the salesperson. Chat with this customer and try to close the sale. Ask about their needs, solve their pain, and handle their objections. When you are done, press "Finish & get result" to see your score and coaching.',
chat: 'Chat', chat: 'Chat',
@@ -151,6 +152,7 @@ const messages = {
tierB: 'ระดับ B — ยังไม่แน่ใจ', tierB: 'ระดับ B — ยังไม่แน่ใจ',
tierC: 'ระดับ C — ไม่สนใจแต่มีปัญหา', tierC: 'ระดับ C — ไม่สนใจแต่มีปัญหา',
selectPersona: 'เลือกบุคคลต้นแบบเพื่อฝึก', selectPersona: 'เลือกบุคคลต้นแบบเพื่อฝึก',
chooseScenario: 'เลือกสถานการณ์',
chatGuideTitle: 'วิธีฝึก', chatGuideTitle: 'วิธีฝึก',
chatGuideText: 'คุณรับบทเป็นพนักงานขาย พูดคุยกับลูกค้าคนนี้เพื่อพยายามปิดการขายให้ได้ สอบถามความต้องการ แก้ไขปัญหาของลูกค้า และรับมือกับข้อโต้แย้ง เมื่อพอใจแล้วกด "สรุปผล" เพื่อดูคะแนนและข้อเสนอแนะ', chatGuideText: 'คุณรับบทเป็นพนักงานขาย พูดคุยกับลูกค้าคนนี้เพื่อพยายามปิดการขายให้ได้ สอบถามความต้องการ แก้ไขปัญหาของลูกค้า และรับมือกับข้อโต้แย้ง เมื่อพอใจแล้วกด "สรุปผล" เพื่อดูคะแนนและข้อเสนอแนะ',
chat: 'แชท', chat: 'แชท',

View File

@@ -2,62 +2,85 @@
<div> <div>
<router-link :to="`/groups/${gid}/personas`" class="btn-back"><ArrowLeft :size="16" :stroke-width="2" /> {{ i18n.t('personas') }}</router-link> <router-link :to="`/groups/${gid}/personas`" class="btn-back"><ArrowLeft :size="16" :stroke-width="2" /> {{ i18n.t('personas') }}</router-link>
<!-- How-to-train guide --> <!-- Scenario picker (before chat) -->
<div class="card guide"> <div v-if="phase === 'pick'" class="card">
<strong><Target :size="17" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('chatGuideTitle') }}</strong> <h3 style="margin-top:0">🎬 {{ i18n.t('chooseScenario') }}</h3>
<p style="margin:6px 0 0;line-height:1.7">{{ i18n.t('chatGuideText') }}</p> <p class="muted" style="margin-top:0">เลอกสถานการณอยากฝกการขาย แตละแบบกำหนดวาใครทกกอน และโทนการสนทนา</p>
</div> <div
v-for="sc in scenarios"
<div class="row" style="align-items:center;margin-bottom:12px"> :key="sc.id"
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2> class="scenario"
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span> :class="{ sel: picked === sc.id }"
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span> @click="picked = sc.id"
<button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0 || !!debrief"> >
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }} <div class="row" style="align-items:center">
<strong>{{ sc.emoji }} {{ sc.label }}</strong>
<span v-if="sc.init === 'customer'" class="badge line">ลูกค้าทักก่อน</span>
<span v-else class="badge facebook">ณตองทกกอน (เชงร)</span>
</div>
<div class="muted" style="margin-top:6px">{{ sc.desc }}</div>
</div>
<button class="primary" style="margin-top:16px;width:100%" :disabled="!picked" @click="begin">
{{ i18n.t('start') }}
</button> </button>
</div> </div>
<!-- Seller-initiated task --> <!-- Chat / results -->
<div v-if="!started" class="card task"> <div v-else>
<strong>📣 {{ i18n.t('sellerInitiated') }}</strong> <!-- How-to-train guide -->
<div v-if="taskText">{{ taskText }}</div> <div class="card guide">
</div> <strong><Target :size="17" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('chatGuideTitle') }}</strong>
<p style="margin:6px 0 0;line-height:1.7">{{ i18n.t('chatGuideText') }}</p>
<!-- Chat thread -->
<div class="thread" v-if="started" ref="thread">
<div v-for="(m, i) in messages" :key="i" class="bubble" :class="m.role === 'seller' ? 'msg-seller' : 'msg-customer'">
{{ m.text }}
</div> </div>
<div v-if="sending" class="bubble msg-customer muted">...</div>
</div>
<!-- Input --> <div class="row" style="align-items:center;margin-bottom:12px">
<div v-if="started && !debrief" class="composer"> <h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" /> <span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
<button class="primary" @click="send" :disabled="sending || !text.trim()">{{ i18n.t('send') }}</button> <span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
</div> <button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0 || phase === 'done'">
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }}
<!-- Debrief overlay --> </button>
<div v-if="debrief" class="card debrief">
<h3>{{ i18n.t('debrief') }}</h3>
<p><span class="badge" :class="debrief.outcome">{{ debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
{{ i18n.t('score') }}: <strong>{{ debrief.score }}</strong></p>
<p><strong>{{ i18n.t('pain') }}:</strong> {{ debrief.pain || '—' }}</p>
<p><strong>{{ i18n.t('why') }}:</strong> {{ debrief.why }}</p>
<div v-if="debrief.coaching && debrief.coaching.length">
<strong>Coaching:</strong>
<ul><li v-for="(c, i) in debrief.coaching" :key="i">{{ c }}</li></ul>
</div> </div>
<details>
<summary><Search :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> {{ i18n.t('reveal') }}</summary> <!-- Chat thread -->
<div class="reveal-grid" v-if="debrief.revealed_persona"> <div class="thread" ref="thread">
<div v-for="(v, k) in debrief.revealed_persona" :key="k" class="rev"> <div
<span class="rk">{{ fieldLabel(k) }}</span> v-for="(m, i) in messages"
<span class="rv">{{ fmt(v) }}</span> :key="i"
</div> class="bubble"
:class="m.role === 'seller' ? 'msg-seller' : m.role === 'system' ? 'msg-system' : 'msg-customer'"
>{{ m.text }}</div>
<div v-if="sending" class="bubble msg-customer muted">...</div>
</div>
<!-- Input -->
<div v-if="phase === 'chat'" class="composer">
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" />
<button class="primary" @click="send" :disabled="sending || !text.trim()">{{ i18n.t('send') }}</button>
</div>
<!-- Debrief -->
<div v-if="phase === 'done' && debrief" class="card debrief">
<h3>{{ i18n.t('debrief') }}</h3>
<p><span class="badge" :class="debrief.outcome">{{ debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
{{ i18n.t('score') }}: <strong>{{ debrief.score }}</strong></p>
<p><strong>{{ i18n.t('pain') }}:</strong> {{ debrief.pain || '—' }}</p>
<p><strong>{{ i18n.t('why') }}:</strong> {{ debrief.why }}</p>
<div v-if="debrief.coaching && debrief.coaching.length">
<strong>Coaching:</strong>
<ul><li v-for="(c, i) in debrief.coaching" :key="i">{{ c }}</li></ul>
</div> </div>
</details> <details>
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link> <summary><Search :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> {{ i18n.t('reveal') }}</summary>
<div class="reveal-grid" v-if="debrief.revealed_persona">
<div v-for="(v, k) in debrief.revealed_persona" :key="k" class="rev">
<span class="rk">{{ fieldLabel(k) }}</span>
<span class="rv">{{ fmt(v) }}</span>
</div>
</div>
</details>
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -74,13 +97,20 @@ const gid = route.params.gid
const pid = route.params.pid const pid = route.params.pid
const persona = ref(null) const persona = ref(null)
const started = ref(false) const phase = ref('pick') // 'pick' | 'chat' | 'done'
const messages = ref([]) 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 taskText = ref('')
const sessionId = ref(null) const sessionId = ref(null)
const picked = ref('social')
const scenarios = [
{ id: 'social', emoji: '💬', init: 'customer', label: 'Social Media (แชท)', desc: 'ลูกค้าทักมาหาคุณก่อน — โทนสั้น ทักๆ ตามสไตล์แชท' },
{ id: 'f2f_call', emoji: '📞', init: 'seller', label: 'พบหน้า / โทรศัพท์', desc: 'คุณต้องเป็นฝ่ายเปิดการสนทนาเชิงรุกกับลีด (ผู้ฝึกทักก่อน)' },
{ id: 'recontact', emoji: '⏳', init: 'customer', label: 'ลูกค้ากลับมาติดต่อ (1-3 เดือน)', desc: 'เคยได้รับข้อมูลไปแล้ว ตอนนี้กลับมาติดต่อ พร้อมตัดสินใจมากขึ้น' },
]
function scrollDown() { function scrollDown() {
nextTick(() => { nextTick(() => {
@@ -89,16 +119,17 @@ function scrollDown() {
} }
const thread = ref(null) const thread = ref(null)
async function begin() {
phase.value = 'chat'
const res = await api.chatStart(gid, pid, { scenario: picked.value })
sessionId.value = res.session.id
if (res.session.task) taskText.value = res.session.task
messages.value = res.session.messages || []
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
const res = await api.chatStart(gid, pid)
sessionId.value = res.session.id
if (res.session.task) {
taskText.value = res.session.task
}
messages.value = res.session.messages || []
started.value = true
if (messages.value.length) scrollDown()
}) })
async function send() { async function send() {
@@ -123,6 +154,7 @@ async function finish() {
const res = await api.chatFinish(gid, pid) const res = await api.chatFinish(gid, pid)
debrief.value = res.debrief debrief.value = res.debrief
messages.value = res.session.messages messages.value = res.session.messages
phase.value = 'done'
} catch (e) { } catch (e) {
alert(e.message) alert(e.message)
} finally { } finally {
@@ -162,12 +194,15 @@ function fmt(v) {
gap: 8px; gap: 8px;
} }
.bubble { max-width: 72%; padding: 10px 14px; white-space: pre-wrap; word-break: break-word; } .bubble { max-width: 72%; padding: 10px 14px; white-space: pre-wrap; word-break: break-word; }
.msg-system { align-self: center; background: #fef3c7; color: #92400e; font-size: 12px; max-width: 88%; border-radius: 999px; }
.composer { display: flex; gap: 8px; margin-top: 12px; } .composer { display: flex; gap: 8px; margin-top: 12px; }
.task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; } .task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; }
.debrief { margin-top: 16px; } .debrief { margin-top: 16px; }
.json { background: #0f172a; color: #9ca3af; padding: 10px; border-radius: 8px; font-size: 11px; overflow: auto; max-height: 260px; }
button.danger { background: var(--red); color: #fff; border: none; } button.danger { background: var(--red); color: #fff; border: none; }
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 12px; } .guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 12px; }
.scenario { 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:hover { border-color: var(--accent); }
.scenario.sel { border-color: var(--accent); background: #eef2ff; }
.reveal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; } .reveal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.rev { display: flex; flex-direction: column; background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; } .rev { display: flex; flex-direction: column; background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.rk { font-size: 12px; color: var(--muted); } .rk { font-size: 12px; color: var(--muted); }