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:
@@ -27,6 +27,35 @@ def _sim(group, persona):
|
||||
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:
|
||||
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
||||
group = s["groups"].get_or_none(gid)
|
||||
@@ -53,6 +82,12 @@ def start_session(gid: str, pid: str):
|
||||
if not persona:
|
||||
raise ApiError("persona not found", 404)
|
||||
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
|
||||
try:
|
||||
session = s["sessions"].create(
|
||||
@@ -60,27 +95,35 @@ def start_session(gid: str, pid: str):
|
||||
persona_name=persona.get("name", "?"),
|
||||
persona_meta={
|
||||
"tier": persona.get("tier"),
|
||||
"initiation_mode": persona.get("initiation_mode"),
|
||||
"initiation_mode": init_mode,
|
||||
"channel": persona.get("channel"),
|
||||
"scenario": scenario,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ApiError(str(exc), 400)
|
||||
|
||||
sim = _sim(group, persona)
|
||||
# Seller-initiated: give the trainee an opening task (no persona message yet).
|
||||
init_mode = persona.get("initiation_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."
|
||||
s["sessions"].update(session["id"], messages=[{"role": "customer", "text": opener}])
|
||||
else:
|
||||
s["sessions"].update(
|
||||
session["id"],
|
||||
task="The customer did NOT message first. You must open the sale — start the "
|
||||
"conversation with this lead (e.g. introduce yourself and engage with interest).",
|
||||
scenario=scenario,
|
||||
internal={**(session.get("internal") or {}), "turns": 0, "score": 50, "signals": []},
|
||||
)
|
||||
return jsonify({"session": s["sessions"].get(session["id"]), "initiation_mode": init_mode})
|
||||
sim = _sim(group, persona)
|
||||
# Seed messages: system note(s) then customer opener for customer-first scenarios.
|
||||
seeded = list(session.get("messages", []))
|
||||
if scenario_meta.get("system_preamble"):
|
||||
seeded.append({"role": "system", "text": scenario_meta["system_preamble"]})
|
||||
if init_mode == "customer":
|
||||
opener = persona.get("opener") or "Hi, I saw your product and had a question."
|
||||
seeded.append({"role": "customer", "text": opener})
|
||||
s["sessions"].update(session["id"], messages=seeded)
|
||||
sess = s["sessions"].get(session["id"])
|
||||
return jsonify({
|
||||
"session": sess,
|
||||
"initiation_mode": init_mode,
|
||||
"scenario": scenario,
|
||||
"scenario_meta": scenario_meta,
|
||||
})
|
||||
|
||||
|
||||
@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)
|
||||
messages = list(session.get("messages", []))
|
||||
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)
|
||||
try:
|
||||
@@ -114,12 +159,19 @@ def send_message(gid: str, pid: str):
|
||||
sales_kit=group.get("sales_kit") or {},
|
||||
messages=messages,
|
||||
internal=session.get("internal", {}),
|
||||
scenario=scenario,
|
||||
scenario_adapt=adapt,
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise ApiError(f"LLM error: {exc}", 500)
|
||||
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})
|
||||
|
||||
|
||||
@@ -139,7 +191,9 @@ def finish_session(gid: str, pid: str):
|
||||
sim = _sim(group, persona)
|
||||
messages = session.get("messages", [])
|
||||
try:
|
||||
verdict = sim.judge(persona=persona, messages=messages)
|
||||
verdict = sim.judge(
|
||||
persona=persona, messages=messages, internal=session.get("internal", {})
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise ApiError(f"LLM error: {exc}", 500)
|
||||
|
||||
|
||||
@@ -47,9 +47,18 @@ A sale is CLOSED only if BOTH:
|
||||
AND
|
||||
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:
|
||||
{
|
||||
"outcome": "won" | "lost",
|
||||
@@ -76,14 +85,21 @@ class Simulator:
|
||||
sales_kit: dict[str, Any],
|
||||
messages: list[dict[str, str]],
|
||||
internal: dict[str, Any],
|
||||
scenario: str = "social",
|
||||
scenario_adapt: str = "",
|
||||
) -> str:
|
||||
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(
|
||||
name=persona.get("name", "Customer"),
|
||||
tone=persona.get("communication_style", "natural, casual"),
|
||||
profession=persona.get("profession", "customer"),
|
||||
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", ""),
|
||||
personality=persona.get("personality", ""),
|
||||
lifestyle=persona.get("lifestyle", ""),
|
||||
@@ -96,7 +112,7 @@ class Simulator:
|
||||
init_mode="you contacted the seller first (customer-initiated)"
|
||||
if persona.get("initiation_mode") == "customer"
|
||||
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}]
|
||||
# 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): "
|
||||
+ 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:
|
||||
resp = self.llm.complete_conversation(msgs, temperature=0.7, max_tokens=400)
|
||||
except LLMError as exc:
|
||||
@@ -125,6 +147,7 @@ class Simulator:
|
||||
*,
|
||||
persona: dict[str, Any],
|
||||
messages: list[dict[str, str]],
|
||||
internal: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
persona_summary = json.dumps({
|
||||
"name": persona.get("name"),
|
||||
@@ -136,7 +159,16 @@ class Simulator:
|
||||
transcript = "\n".join(
|
||||
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:
|
||||
result = self.judge_llm.complete_json(
|
||||
JUDGE_SYSTEM, user_prompt, temperature=0.2, max_tokens=2000
|
||||
|
||||
@@ -79,7 +79,8 @@ def main():
|
||||
assert r.status_code == 200, r.get_json()
|
||||
session = r.get_json()["session"]
|
||||
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")
|
||||
|
||||
# send messages
|
||||
@@ -102,14 +103,15 @@ def main():
|
||||
assert r.status_code == 400, r.get_json()
|
||||
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")
|
||||
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()
|
||||
s2 = r.get_json()["session"]
|
||||
assert "task" in r.get_json() or "initiation_mode" in r.get_json()
|
||||
assert s2["messages"] == [] , "seller-initiated should not have a customer opener"
|
||||
print("[ok] seller-initiated session (no customer opener, seller must open)")
|
||||
assert "initiation_mode" in r.get_json()
|
||||
assert not any(m["role"] == "customer" for m in s2["messages"]), "f2f_call: no customer opener (seller must open)"
|
||||
print("[ok] seller-first (f2f/call) session — no customer opener, seller must open")
|
||||
|
||||
# board
|
||||
r = client.get("/api/me/board", headers=UH)
|
||||
|
||||
79
backend/scripts/test_scenario.py
Normal file
79
backend/scripts/test_scenario.py
Normal 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")
|
||||
@@ -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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1,4 +1,4 @@
|
||||
import{k,_ as C,l as V,c as u,a as s,m as i,u as a,j as m,t,i as l,n as h,w as f,v as w,p as z,e as M,F as N,q as L,s as P,x as U,r as v,o as _}from"./index-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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
1
frontend/dist/assets/Chat-BPfrVhz_.css
vendored
Normal file
1
frontend/dist/assets/Chat-BPfrVhz_.css
vendored
Normal 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}}
|
||||
6
frontend/dist/assets/Chat-Bo6laZZd.js
vendored
6
frontend/dist/assets/Chat-Bo6laZZd.js
vendored
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/Chat-Dq49zqgJ.css
vendored
1
frontend/dist/assets/Chat-Dq49zqgJ.css
vendored
@@ -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
6
frontend/dist/assets/Chat-fU4aRozx.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import{k as g,_ as M,c as f,m as d,n as V,a as e,u as l,j as r,t as s,i as o,w as u,v as y,D as k,e as C,x as z,r as v,s as B,g as A,o as x}from"./index-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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1,4 +1,4 @@
|
||||
import{k as T,_ as F,E as O,o as m,c as f,a as e,t as i,w as o,v as u,D as M,e as _,G as I,r as k,l as q,m as j,n as D,u as c,j as y,i as B,B as J,F as N,q as P,H,h as R,s as z,x as K,A as E}from"./index-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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1 +1 @@
|
||||
import{_ as b,c as d,a as e,t as s,u as c,i as o,w as y,v as x,b as w,d as k,e as S,r as l,f,g as V,h as B,o as v}from"./index-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};
|
||||
@@ -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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1 +1 @@
|
||||
import{_ as T,l as $,c as r,m as d,n as m,a as t,t as s,u as e,i as n,f as g,j as _,e as w,F as y,q as p,h as L,s as N,x as V,r as b,o as a,S as I,A as h,B as A}from"./index-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};
|
||||
@@ -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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1 +1 @@
|
||||
import{_ as x,c as v,a as e,t,u as s,i as a,j as S,f as _,w,v as f,b as y,e as V,r,g as K,o as m}from"./index-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};
|
||||
@@ -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};
|
||||
@@ -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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import{k as e}from"./index-C0Orcg9B.js";/**
|
||||
import{k as e}from"./index-tI5UDFg-.js";/**
|
||||
* @license lucide-vue-next v1.0.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -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
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const api = {
|
||||
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
|
||||
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
|
||||
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 }),
|
||||
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
|
||||
mySessions: () => request('GET', '/api/chat/sessions'),
|
||||
|
||||
@@ -66,6 +66,7 @@ const messages = {
|
||||
tierB: 'Tier B — Unsure',
|
||||
tierC: 'Tier C — Not interested but has pain',
|
||||
selectPersona: 'Select a persona to practice',
|
||||
chooseScenario: 'Choose a scenario',
|
||||
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.',
|
||||
chat: 'Chat',
|
||||
@@ -151,6 +152,7 @@ const messages = {
|
||||
tierB: 'ระดับ B — ยังไม่แน่ใจ',
|
||||
tierC: 'ระดับ C — ไม่สนใจแต่มีปัญหา',
|
||||
selectPersona: 'เลือกบุคคลต้นแบบเพื่อฝึก',
|
||||
chooseScenario: 'เลือกสถานการณ์',
|
||||
chatGuideTitle: 'วิธีฝึก',
|
||||
chatGuideText: 'คุณรับบทเป็นพนักงานขาย พูดคุยกับลูกค้าคนนี้เพื่อพยายามปิดการขายให้ได้ สอบถามความต้องการ แก้ไขปัญหาของลูกค้า และรับมือกับข้อโต้แย้ง เมื่อพอใจแล้วกด "สรุปผล" เพื่อดูคะแนนและข้อเสนอแนะ',
|
||||
chat: 'แชท',
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
<div>
|
||||
<router-link :to="`/groups/${gid}/personas`" class="btn-back"><ArrowLeft :size="16" :stroke-width="2" /> {{ i18n.t('personas') }}</router-link>
|
||||
|
||||
<!-- Scenario picker (before chat) -->
|
||||
<div v-if="phase === 'pick'" class="card">
|
||||
<h3 style="margin-top:0">🎬 {{ i18n.t('chooseScenario') }}</h3>
|
||||
<p class="muted" style="margin-top:0">เลือกสถานการณ์ที่อยากฝึกการขาย — แต่ละแบบกำหนดว่าใครทักก่อน และโทนการสนทนา</p>
|
||||
<div
|
||||
v-for="sc in scenarios"
|
||||
:key="sc.id"
|
||||
class="scenario"
|
||||
:class="{ sel: picked === sc.id }"
|
||||
@click="picked = sc.id"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Chat / results -->
|
||||
<div v-else>
|
||||
<!-- How-to-train guide -->
|
||||
<div class="card guide">
|
||||
<strong><Target :size="17" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('chatGuideTitle') }}</strong>
|
||||
@@ -12,33 +37,30 @@
|
||||
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
|
||||
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</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 || !!debrief">
|
||||
<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') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Seller-initiated task -->
|
||||
<div v-if="!started" class="card task">
|
||||
<strong>📣 {{ i18n.t('sellerInitiated') }}</strong>
|
||||
<div v-if="taskText">{{ taskText }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="thread" ref="thread">
|
||||
<div
|
||||
v-for="(m, i) in messages"
|
||||
:key="i"
|
||||
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="started && !debrief" class="composer">
|
||||
<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 overlay -->
|
||||
<div v-if="debrief" class="card debrief">
|
||||
<!-- 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>
|
||||
@@ -60,6 +82,7 @@
|
||||
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -74,13 +97,20 @@ const gid = route.params.gid
|
||||
const pid = route.params.pid
|
||||
|
||||
const persona = ref(null)
|
||||
const started = ref(false)
|
||||
const phase = ref('pick') // 'pick' | 'chat' | 'done'
|
||||
const messages = ref([])
|
||||
const text = ref('')
|
||||
const sending = ref(false)
|
||||
const debrief = ref(null)
|
||||
const taskText = ref('')
|
||||
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() {
|
||||
nextTick(() => {
|
||||
@@ -89,16 +119,17 @@ function scrollDown() {
|
||||
}
|
||||
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 () => {
|
||||
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() {
|
||||
@@ -123,6 +154,7 @@ async function finish() {
|
||||
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 {
|
||||
@@ -162,12 +194,15 @@ function fmt(v) {
|
||||
gap: 8px;
|
||||
}
|
||||
.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; }
|
||||
.task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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); }
|
||||
|
||||
Reference in New Issue
Block a user