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:
@@ -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,62 +2,85 @@
|
||||
<div>
|
||||
<router-link :to="`/groups/${gid}/personas`" class="btn-back"><ArrowLeft :size="16" :stroke-width="2" /> {{ i18n.t('personas') }}</router-link>
|
||||
|
||||
<!-- How-to-train guide -->
|
||||
<div class="card guide">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="row" style="align-items:center;margin-bottom:12px">
|
||||
<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">
|
||||
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }}
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 }}
|
||||
<!-- 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>
|
||||
<p style="margin:6px 0 0;line-height:1.7">{{ i18n.t('chatGuideText') }}</p>
|
||||
</div>
|
||||
<div v-if="sending" class="bubble msg-customer muted">...</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div v-if="started && !debrief" 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">
|
||||
<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 class="row" style="align-items:center;margin-bottom:12px">
|
||||
<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 || phase === 'done'">
|
||||
<span v-if="sending" class="spinner" style="margin-right:4px"></span>{{ i18n.t('finish') }}
|
||||
</button>
|
||||
</div>
|
||||
<details>
|
||||
<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>
|
||||
|
||||
<!-- Chat thread -->
|
||||
<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="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>
|
||||
</details>
|
||||
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
|
||||
<details>
|
||||
<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>
|
||||
</template>
|
||||
@@ -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