Plant assistant mockup: operator, management, sales
Deploy test site / deploy (push) Successful in 24s

docs/plant-assistant.html (test site /demos/plant-assistant/): the
plant assistant on vinyl-ai as three roles see it. Operator at the C1
press kiosk in Thai or English with push to talk (non-fill rejects
explained from cycle rows, lot change and press hall humidity, the
stamper change procedure quoted from the approved version, a lot on
hold, a refused price question, a safety question answered word for
word); management at a desk (output and yield per press against the
plan basis, steam per record against scale.md, orders at risk, the
weekly note, payroll refused); sales on a phone (order status and ship
window, repress feasibility from archived mothers and the standby
calendar, repress leads, a test pressing status, fans' addresses
refused). Each answer carries numbered sources; the side panel shows
the tool calls (ERPNext as the asker's role, the PostgreSQL replica,
VictoriaMetrics, documents, event log) on premises with the internet
not used; action buttons create drafts only. Deep links
#role=<role>&q=<id>, light and dark theme. Invented data; names match
the factory console demo.

plant-assistant.md: mockup link; sales and customer service row in
"Who asks what"; sales drafts (quotation, CRM opportunity, message).
README, site index and site/build.sh link and publish the page.
This commit is contained in:
grabowski committed 2026-10-27 09:50:42 +07:00
1 parent 3d9bb9af53
commit 22f04ee9a1
5 files changed
+825 -3

No files matched your search

+795
View File
@@ -0,0 +1,795 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="description" content="CNX Vinyl plant assistant (vinyl-ai) mockup: operators, management and sales ask the plant in Thai or English; every answer shows the tools it used and its sources. Runs on premises. Invented data.">
<title>Plant assistant (vinyl-ai) · CNX Vinyl (mockup)</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root {
--bg: #0c0c0f; --bg2: #111116; --card: #16161c; --card2: #1e1e26; --line: #25252e; --stroke: #363642;
--ink: #f3f0e8; --mute: #a3a0ad; --faint: #6f6c79;
--acc: #ff6b3d; --acc-s: rgba(255,107,61,0.13); --onacc: #1d0c04;
--ok: #3ecf8e; --ok-s: rgba(62,207,142,0.12); --warn: #f5b941; --warn-s: rgba(245,185,65,0.12); --bad: #f2706b; --bad-s: rgba(242,112,107,0.13);
--inv: #96a2ff; --inv-s: rgba(150,162,255,0.12); --metal: #c39bff; --metal-s: rgba(195,155,255,0.12);
--glass: rgba(12,12,15,0.78); --shadow: 0 18px 50px rgba(0,0,0,0.45);
--bezel: #050507; --screen: #101015;
--mono: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", monospace;
color-scheme: dark;
}
html[data-theme=light] {
--bg: #f6f4ef; --bg2: #efebe3; --card: #ffffff; --card2: #f4f1ea; --line: #e8e3d9; --stroke: #d8d2c5;
--ink: #16151a; --mute: #65616d; --faint: #9a96a1;
--acc: #e4521f; --acc-s: rgba(228,82,31,0.09); --onacc: #ffffff;
--ok: #0f9468; --ok-s: rgba(15,148,104,0.09); --warn: #b5770f; --warn-s: rgba(181,119,15,0.1); --bad: #d0453f; --bad-s: rgba(208,69,63,0.09);
--inv: #4a55c8; --inv-s: rgba(74,85,200,0.08); --metal: #7c4fc4; --metal-s: rgba(124,79,196,0.08);
--glass: rgba(246,244,239,0.85); --shadow: 0 18px 44px rgba(60,40,10,0.1);
--bezel: #2a2830; --screen: #ffffff;
color-scheme: light;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 14.5px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; }
html[lang=th] body, .th, .device.kiosk { font-family: system-ui, -apple-system, "Segoe UI", "Leelawadee UI", "Noto Sans Thai", Thonburi, Tahoma, sans-serif; }
button { font: inherit; color: inherit; }
a { color: var(--acc); text-decoration: none; }
a:hover { text-decoration: underline; }
.mute { color: var(--mute); }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
h1 { font-size: 27px; line-height: 1.15; letter-spacing: -0.02em; margin: 4px 0 6px; font-weight: 740; }
h2 { font-size: 15px; margin: 0; font-weight: 680; }
.eyebrow { font-size: 11.5px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--acc); font-weight: 700; }
/* top bar */
.top { position: sticky; top: 0; z-index: 40; background: var(--glass); backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); border-bottom: 1px solid var(--line); }
.top .in { max-width: 1440px; margin: 0 auto; padding: 9px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15.5px; white-space: nowrap; }
.brand svg { width: 26px; height: 26px; flex: none; }
.pill { font-size: 10.5px; font-weight: 700; color: var(--mute); border: 1px solid var(--stroke); border-radius: 99px; padding: 1px 8px; letter-spacing: 0.08em; text-transform: uppercase; }
.vsw { display: flex; background: var(--card2); border: 1px solid var(--line); border-radius: 99px; padding: 3px; }
.vsw button { border: 0; background: transparent; padding: 5px 14px; border-radius: 99px; cursor: pointer; color: var(--mute); font-weight: 650; font-size: 13px; white-space: nowrap; }
.vsw button.on { background: var(--card); color: var(--ink); box-shadow: 0 1px 4px rgba(0,0,0,0.22); }
.ctl { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }
.seg { display: inline-flex; border: 1px solid var(--stroke); border-radius: 9px; overflow: hidden; }
.seg button { border: 0; background: transparent; padding: 4px 10px; font-size: 12.5px; cursor: pointer; color: var(--mute); }
.seg button + button { border-left: 1px solid var(--stroke); }
.seg button.on { background: var(--inv-s); color: var(--ink); font-weight: 700; }
.ib { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--stroke); background: var(--card); cursor: pointer; display: grid; place-items: center; font-size: 13px; flex: none; }
.ib:hover { border-color: var(--acc); }
.demobar { font-size: 12px; color: var(--mute); text-align: center; padding: 4px 16px; border-top: 1px solid var(--line); background: var(--bg2); }
.wrap { max-width: 1440px; margin: 0 auto; padding: 22px 20px 70px; }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: end; margin-bottom: 18px; }
.hero p { margin: 0; color: var(--mute); max-width: 860px; }
.chips-h { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
.hchip { font-size: 12px; border: 1px solid var(--line); background: var(--card); border-radius: 99px; padding: 3px 10px; white-space: nowrap; color: var(--mute); }
.hchip b { color: var(--ink); font-weight: 650; }
.grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 22px; align-items: start; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 16px; }
/* devices */
.devwrap { display: flex; flex-direction: column; gap: 14px; }
.device { position: relative; margin: 0 auto; width: 100%; }
.device.kiosk { background: var(--bezel); border-radius: 22px; padding: 16px 16px 26px; box-shadow: var(--shadow); }
.device.kiosk::after { content: "CNX · press-side kiosk"; position: absolute; left: 0; right: 0; bottom: 6px; text-align: center; font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: #6f6c79; }
.device.desk { background: var(--card); border: 1px solid var(--stroke); border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; }
.device.desk .bar { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background: var(--card2); border-bottom: 1px solid var(--line); font-size: 12px; color: var(--mute); }
.device.desk .bar i { width: 10px; height: 10px; border-radius: 50%; background: var(--stroke); display: inline-block; }
.device.desk .bar span { margin-left: 8px; font-family: var(--mono); }
.device.phone { max-width: 410px; background: var(--bezel); border-radius: 38px; padding: 14px 12px 18px; box-shadow: var(--shadow); }
.device.phone::before { content: ""; display: block; width: 110px; height: 20px; border-radius: 0 0 14px 14px; background: var(--bezel); margin: -14px auto 4px; }
.screen { background: var(--screen); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; height: 640px; }
.device.desk .screen { border-radius: 0; height: 640px; }
.device.phone .screen { border-radius: 26px; height: 700px; }
.ctx { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--card2); font-size: 12px; }
.ctx .k { font-size: 11.5px; padding: 2px 8px; border-radius: 99px; background: var(--card); border: 1px solid var(--line); white-space: nowrap; }
.ctx .k b { font-weight: 650; }
.ctx .k.role { background: var(--inv-s); border-color: transparent; color: var(--ink); }
.ctx .sp { margin-left: auto; }
.log { flex: 1; overflow-y: auto; padding: 14px 12px 8px; display: flex; flex-direction: column; gap: 12px; scroll-behavior: smooth; }
.empty { margin: auto; text-align: center; color: var(--mute); max-width: 360px; font-size: 13.5px; }
.empty .big { font-size: 34px; display: block; margin-bottom: 4px; }
.msg { display: flex; gap: 8px; align-items: flex-start; max-width: 100%; }
.msg.me { justify-content: flex-end; }
.bub { border-radius: 14px; padding: 9px 12px; max-width: 88%; min-width: 0; }
.me .bub { background: var(--acc); color: var(--onacc); border-bottom-right-radius: 4px; }
.me .bub .gloss { display: block; font-size: 12px; opacity: 0.8; margin-top: 2px; }
.me .bub .via { display: block; font-size: 11px; opacity: 0.75; margin-top: 3px; }
.ai .av { width: 28px; height: 28px; border-radius: 50%; background: var(--inv-s); color: var(--inv); display: grid; place-items: center; font-size: 11px; font-weight: 800; flex: none; font-family: var(--mono); }
.ai .bub { background: var(--card2); border: 1px solid var(--line); border-bottom-left-radius: 4px; width: 100%; }
.ai .bub > * + * { margin-top: 8px; }
.ai .bub p { margin: 0; }
.ai .bub ul, .ai .bub ol { margin: 0; padding-left: 20px; }
.ai .bub li + li { margin-top: 3px; }
.ref { display: inline-block; font-size: 10.5px; line-height: 1; padding: 2px 5px; border-radius: 5px; background: var(--inv-s); color: var(--inv); font-weight: 700; font-family: var(--mono); vertical-align: 1px; cursor: help; margin-left: 1px; }
.ref.hl, .src li.hl { outline: 2px solid var(--inv); }
.note { font-size: 12.5px; color: var(--warn); background: var(--warn-s); border-radius: 8px; padding: 6px 9px; }
.refuse { border-left: 3px solid var(--bad); background: var(--bad-s); border-radius: 8px; padding: 8px 10px; }
.refuse b { color: var(--bad); }
.safety { border: 1px solid var(--bad); border-radius: 10px; padding: 9px 11px; }
.safety .lab, .proc .lab { font-size: 11px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
.safety .lab { color: var(--bad); }
.safety q { display: block; font-weight: 650; margin: 4px 0; quotes: "\201C" "\201D"; }
.proc { border: 1px solid var(--line); background: var(--card); border-radius: 10px; padding: 9px 11px; }
.proc .lab { color: var(--ok); }
.proc ol { margin-top: 6px !important; }
.proc .foot { font-size: 12px; color: var(--mute); margin-top: 6px; }
.pics { display: flex; gap: 6px; margin-top: 6px; }
.pics span { width: 54px; height: 40px; border-radius: 6px; background: var(--card2); border: 1px dashed var(--stroke); display: grid; place-items: center; font-size: 10px; color: var(--faint); }
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 6px; }
.kv div { background: var(--card); border: 1px solid var(--line); border-radius: 9px; padding: 6px 9px; min-width: 0; }
.kv small { display: block; color: var(--mute); font-size: 11.5px; }
.kv b { font-size: 15px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.kv .bad b { color: var(--bad); } .kv .ok b { color: var(--ok); } .kv .warn b { color: var(--warn); }
.tbl { overflow-x: auto; }
.tbl table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
.tbl th, .tbl td { text-align: left; padding: 5px 7px; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl th { color: var(--mute); font-weight: 600; font-size: 11.5px; }
.tbl td.n { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.tbl td.bad { color: var(--bad); } .tbl td.ok { color: var(--ok); } .tbl td.warn { color: var(--warn); }
.chart svg { width: 100%; height: auto; display: block; }
.chart .cap { font-size: 11.5px; color: var(--mute); display: flex; gap: 12px; flex-wrap: wrap; }
.chart .cap i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
.acts { display: flex; flex-wrap: wrap; gap: 6px; }
.acts button { border: 1px solid var(--stroke); background: var(--card); border-radius: 9px; padding: 5px 10px; font-size: 12.5px; cursor: pointer; }
.acts button:hover:not(:disabled) { border-color: var(--acc); }
.acts button:disabled { opacity: 0.55; cursor: default; }
.draft { border: 1px dashed var(--warn); border-radius: 10px; padding: 8px 10px; background: var(--warn-s); font-size: 13px; }
.draft .lab { font-size: 11px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--warn); }
.draft dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; margin: 5px 0 4px; font-size: 12.5px; }
.draft dt { color: var(--mute); } .draft dd { margin: 0; }
.src { list-style: none; padding: 0 !important; margin: 0; font-size: 11.5px; color: var(--mute); border-top: 1px solid var(--line); padding-top: 6px !important; }
.src li { display: flex; gap: 6px; border-radius: 5px; padding: 1px 3px; }
.src li .ref { flex: none; cursor: default; }
.typing { display: inline-flex; gap: 4px; padding: 4px 2px; }
.typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--mute); animation: bl 1s infinite; }
.typing i:nth-child(2) { animation-delay: 0.15s; } .typing i:nth-child(3) { animation-delay: 0.3s; }
@keyframes bl { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
.fade { animation: fd 0.35s ease both; }
@keyframes fd { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.compose { border-top: 1px solid var(--line); padding: 9px 10px; display: flex; gap: 8px; align-items: center; background: var(--card2); }
.compose input { flex: 1; min-width: 0; border: 1px solid var(--stroke); background: var(--card); color: var(--ink); border-radius: 10px; padding: 8px 10px; font: inherit; font-size: 13.5px; }
.compose button { border: 0; border-radius: 10px; padding: 8px 12px; cursor: pointer; background: var(--acc); color: var(--onacc); font-weight: 700; }
.mic { width: 46px; height: 46px; border-radius: 50% !important; padding: 0 !important; display: grid; place-items: center; font-size: 19px; flex: none; }
.mic.rec { animation: pulse 0.9s infinite; background: var(--bad) !important; }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 var(--bad-s); } 50% { box-shadow: 0 0 0 9px var(--bad-s); } }
.kiosk .compose input { font-size: 15px; padding: 10px 12px; }
.kiosk .bub { font-size: 15px; }
.qs h2 { margin-bottom: 8px; }
.qs .list { display: flex; flex-wrap: wrap; gap: 7px; }
.qbtn { border: 1px solid var(--stroke); background: var(--card); border-radius: 12px; padding: 7px 11px; cursor: pointer; text-align: left; font-size: 13px; line-height: 1.35; max-width: 100%; }
.qbtn:hover:not(:disabled) { border-color: var(--acc); }
.qbtn:disabled { opacity: 0.5; cursor: default; }
.qbtn.done { border-color: var(--ok); }
.qbtn .tag { display: inline-block; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--mute); margin-right: 5px; }
.qbtn .tag.r { color: var(--bad); } .qbtn .tag.s { color: var(--warn); }
.qbtn .gl { display: block; font-size: 11.5px; color: var(--mute); }
.qs .tools { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; align-items: center; font-size: 12.5px; color: var(--mute); }
.lnk { border: 0; background: none; color: var(--acc); cursor: pointer; padding: 0; font-size: 12.5px; }
/* how it answered */
.how { position: sticky; top: calc(var(--toph, 90px) + 14px); padding: 14px 14px 12px; }
.how .hd { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.how .hd .mute { font-size: 12px; }
.arch svg { width: 100%; height: auto; display: block; }
.arch .nd rect { fill: var(--card2); stroke: var(--stroke); stroke-width: 1.2; transition: fill 0.25s, stroke 0.25s; }
.arch .nd text { fill: var(--ink); font-size: 11.5px; font-weight: 650; }
.arch .nd text.s { fill: var(--mute); font-size: 9.5px; font-weight: 500; }
.arch .nd.on rect { fill: var(--inv-s); stroke: var(--inv); stroke-width: 2; }
.arch .nd.model rect { stroke: var(--acc); }
.arch .nd.model.on rect { fill: var(--acc-s); stroke: var(--acc); }
.arch .nd.off rect { stroke-dasharray: 4 3; opacity: 0.6; }
.arch .nd.off text { fill: var(--faint); }
.arch .ed { stroke: var(--stroke); stroke-width: 1.5; fill: none; }
.arch .ed.on { stroke: var(--inv); stroke-width: 2.2; stroke-dasharray: 6 4; animation: flow 0.6s linear infinite; }
.arch .ed.x { stroke: var(--bad); stroke-dasharray: 3 3; opacity: 0.6; }
.arch .xm { stroke: var(--bad); stroke-width: 2; }
.arch .zone { fill: none; stroke: var(--ok); stroke-dasharray: 5 4; stroke-width: 1.2; opacity: 0.8; }
.arch .zt { fill: var(--ok); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; }
@keyframes flow { to { stroke-dashoffset: -20; } }
.trace { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 10px; }
.trace .meta { font-size: 12px; color: var(--mute); margin-bottom: 8px; }
.trace .meta b { color: var(--ink); font-weight: 650; }
.steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 470px; overflow-y: auto; }
.steps li { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 8px; font-size: 12.5px; }
.steps .ic { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; font-size: 11px; font-weight: 800; background: var(--card2); color: var(--mute); }
.steps li.run .ic { background: var(--inv-s); color: var(--inv); }
.steps li.ok .ic { background: var(--ok-s); color: var(--ok); }
.steps li.perm .ic { background: var(--warn-s); color: var(--warn); }
.steps li.deny .ic { background: var(--bad-s); color: var(--bad); }
.steps .tl { font-family: var(--mono); font-size: 12px; font-weight: 700; }
.steps .tl small { font-weight: 400; color: var(--mute); margin-left: 6px; }
.steps code { display: block; font-family: var(--mono); font-size: 11.5px; color: var(--mute); background: var(--card2); border-radius: 6px; padding: 4px 7px; margin-top: 3px; white-space: pre-wrap; word-break: break-word; }
.steps .out { color: var(--ink); margin-top: 2px; }
.tfoot { margin-top: 10px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.tfoot div { background: var(--card2); border-radius: 9px; padding: 6px 8px; font-size: 11.5px; color: var(--mute); }
.tfoot b { display: block; font-size: 15px; color: var(--ink); font-family: var(--mono); }
.rules { margin-top: 18px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.rules .card { padding: 12px 14px; font-size: 13px; color: var(--mute); }
.rules h3 { margin: 0 0 4px; font-size: 13.5px; color: var(--ink); }
footer { margin-top: 22px; font-size: 12px; color: var(--mute); }
@media (max-width: 1080px) {
.grid { grid-template-columns: minmax(0, 1fr); }
.how { position: static; }
.rules { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.wrap { padding: 14px 10px 60px; }
.top .in { padding: 8px 10px; gap: 6px 8px; }
.hero { grid-template-columns: minmax(0, 1fr); }
.chips-h { justify-content: flex-start; }
h1 { font-size: 23px; }
.device.kiosk { padding: 8px 8px 22px; border-radius: 16px; }
.device.phone { padding: 10px 6px 12px; border-radius: 30px; }
.screen, .device.desk .screen { height: 600px; }
.device.phone .screen { height: 640px; }
.rules { grid-template-columns: minmax(0, 1fr); }
.tfoot { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.vsw button { padding: 5px 10px; }
.bub { max-width: 94%; }
}
@media (prefers-reduced-motion: reduce) {
.fade, .arch .ed.on, .typing i, .mic.rec { animation: none !important; }
}
</style>
</head>
<body>
<header class="top" id="top">
<div class="in">
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="15" fill="#111" stroke="currentColor" stroke-opacity=".25"/><circle cx="16" cy="16" r="11" fill="none" stroke="#333" stroke-width=".6"/><circle cx="16" cy="16" r="8" fill="none" stroke="#333" stroke-width=".6"/><circle cx="16" cy="16" r="5" fill="#ff6b3d"/><circle cx="16" cy="16" r="1.2" fill="#111"/></svg>
<span>CNX Vinyl · Plant assistant</span><span class="pill">Mockup</span>
</div>
<div class="vsw" id="roleSw" role="tablist" aria-label="Who is asking">
<button type="button" data-role="operator" role="tab">Operator</button>
<button type="button" data-role="management" role="tab">Management</button>
<button type="button" data-role="sales" role="tab">Sales</button>
</div>
<div class="ctl">
<div class="seg" id="spdSw" title="Playback speed of the mockup">
<button type="button" data-spd="1">Normal</button><button type="button" data-spd="4">Fast</button>
</div>
<button type="button" class="ib" id="themeBtn">&#x2600;</button>
</div>
</div>
<div class="demobar">Mockup: questions, answers, IDs, figures and procedure texts are invented. Presses, shifts, the climate band and the plan figures quoted follow the plan documents. Nothing here is live.</div>
</header>
<main class="wrap">
<section class="hero">
<div>
<div class="eyebrow">vinyl-ai · ask the plant</div>
<h1>One assistant for the floor, the office and sales</h1>
<p>Operators ask at the press kiosk in Thai or English, management and sales at a desk or on a phone. It answers from the procedures, manuals and plan documents and from ERPNext and the machine data, shows every tool it used, sees only what the asker may see, drafts records but never submits them, and never touches a machine. Pick a role, then a question.</p>
</div>
<div class="chips-h">
<span class="hchip">Runs on <b>vinyl-ai</b>, on premises</span>
<span class="hchip">Model <b>Typhoon 2.5</b>, open weights</span>
<span class="hchip">Internet <b>not used</b></span>
</div>
</section>
<div class="grid">
<div class="devwrap">
<div class="device" id="device">
<div class="bar" id="devBar" hidden><i></i><i></i><i></i><span id="devUrl"></span></div>
<div class="screen">
<div class="ctx" id="ctx"></div>
<div class="log" id="log" aria-live="polite"></div>
<form class="compose" id="compose" autocomplete="off">
<button type="button" class="mic" id="mic" title="Push to talk: optional, Thai speech recognition on premises after a floor test (simulated here)" aria-label="Push to talk">&#x1F3A4;</button>
<input id="inp" type="text" aria-label="Ask a question">
<button type="submit" id="send">Ask</button>
</form>
</div>
</div>
<section class="card qs" style="padding:14px">
<h2 id="qsTitle">Questions</h2>
<div class="list" id="qList"></div>
<div class="tools"><button type="button" class="lnk" id="clearBtn">Clear the conversation</button><span id="qHint"></span></div>
</section>
</div>
<aside class="card how" id="how">
<div class="hd"><h2>How it answered</h2><span class="mute" id="howSub">Pick a question to see the tools it calls.</span></div>
<div class="arch" id="arch"></div>
<div class="trace">
<div class="meta" id="tMeta">No question yet.</div>
<ol class="steps" id="steps"></ol>
<div class="tfoot">
<div>Tool calls<b id="fCalls">0</b></div>
<div>Figures from memory<b id="fMem">0</b></div>
<div>Data sent off site<b id="fOff">0 B</b></div>
</div>
</div>
</aside>
</div>
<section class="rules">
<div class="card"><h3>Sources on every answer</h3>Each number comes from a query or a document it shows; with no source there is no answer.</div>
<div class="card"><h3>Only what the asker may see</h3>Every tool call runs with the asker's ERPNext role. Prices, customer contacts, payroll and fans' addresses stay out of reach where the role says so.</div>
<div class="card"><h3>Drafts, never actions</h3>It drafts a non-conformance, a maintenance request, a quotation or a reminder. A person reviews and submits. It never writes to a press or a recipe.</div>
<div class="card"><h3>Safety is quoted, not improvised</h3>Safety steps come word for word from the approved procedure with its version; without one it says so and stops.</div>
</section>
<footer>Mockup of the plan in the CNX Vinyl repository (docs/plant-assistant.md). Self-hosted on premises: the model, the document index and the tools run on the plant's own servers. Operator questions are logged to improve answers and procedures, not to rate the operator. Thai texts are a draft for review.</footer>
</main>
<script>
(function(){
"use strict";
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
/* ---------- roles ---------- */
const ROLES={
operator:{device:"kiosk", lang:"th", url:"",
ctx:[["role","Press operator"],["","<b>Press C1</b> · Classic"],["","Job card <b>JC-C1-0412</b>"],["","Somchai K. · badge 0417"],["","Shift A · Fri 30 Oct, 15:20"]],
title:"Operator at the press kiosk", hint:"Push the microphone to ask by voice, as an operator with gloves would.",
ph:{th:"ถามเป็นภาษาไทยหรืออังกฤษ…",en:"Ask in Thai or English…"},
empty:{th:"กดไมค์แล้วพูด หรือเลือกคำถามด้านล่าง",en:"Push the mic and speak, or pick a question below."}},
management:{device:"desk", lang:"en", url:"factory.lpn1.cnx.network/ask",
ctx:[["role","Management"],["","Plant manager"],["","Office desktop"],["","Fri 30 Oct, 15:20"]],
title:"Management at a desk", hint:"Answers combine ERPNext, the machine data and the plan documents.",
ph:{en:"Ask about output, yield, energy, orders…"}, empty:{en:"Ask about the plant: output, yield, energy, orders at risk."}},
sales:{device:"phone", lang:"en", url:"",
ctx:[["role","Sales and customer service"],["","On the phone, over the mesh"],["","Fri 30 Oct, 15:20"]],
title:"Sales on the phone", hint:"Sales sees orders, prices and the archive, never another customer's data in the wrong place or fans' addresses.",
ph:{en:"Ask about an order, a repress, a customer…"}, empty:{en:"Ask about an order, a repress or a customer."}}
};
/* ---------- architecture diagram ---------- */
const NODES={
ask:{x:10,y:14,w:150,h:46,t:"Kiosk · desk · phone",s:"asks in Thai or English"},
app:{x:10,y:96,w:150,h:46,t:"Factory app: Ask",s:"MCP tools, asker's role"},
model:{x:10,y:178,w:150,h:58,t:"vinyl-ai",s:"Typhoon 2.5, open weights",s2:"plans, calls tools, writes",model:1},
erp:{x:226,y:14,w:184,h:40,t:"ERPNext",s:"as the asker's role"},
sql:{x:226,y:62,w:184,h:40,t:"Machine data",s:"PostgreSQL replica, read-only"},
ts:{x:226,y:110,w:184,h:40,t:"Time series",s:"VictoriaMetrics, PromQL"},
doc:{x:226,y:158,w:184,h:40,t:"Documents",s:"procedures, manuals, plan"},
evt:{x:226,y:206,w:184,h:40,t:"Event log and alarms",s:"hash-chained, vmalert"},
net:{x:226,y:262,w:184,h:34,t:"Internet",s:"not used",off:1}
};
function archSVG(){
const W=420,H=304;let g="";
g+=`<rect class="zone" x="3" y="4" width="${W-6}" height="252" rx="12"/><text class="zt" x="12" y="274">ON PREMISES</text><text class="zt" x="12" y="288" style="font-weight:500;letter-spacing:0">vinyl-core, vinyl-ai, vinyl-backup</text>`;
const ed=(id,d)=>`<path class="ed" id="e-${id}" d="${d}"/>`;
g+=ed("ask","M85 60 V96")+ed("app","M85 142 V178");
["erp","sql","ts","doc","evt"].forEach(k=>{const n=NODES[k];g+=ed(k,`M160 207 C 195 207, 195 ${n.y+n.h/2}, 226 ${n.y+n.h/2}`);});
g+=`<path class="ed x" d="M160 225 C 195 225, 195 279, 226 279"/><path class="xm" d="M187 266 l10 10 m0 -10 l-10 10"/>`;
for(const k in NODES){const n=NODES[k];
g+=`<g class="nd${n.model?" model":""}${n.off?" off":""}" id="n-${k}"><rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="9"/>`+
`<text x="${n.x+10}" y="${n.y+(n.h>44?19:17)}">${esc(n.t)}</text><text class="s" x="${n.x+10}" y="${n.y+(n.h>44?33:31)}">${esc(n.s)}</text>`+
(n.s2?`<text class="s" x="${n.x+10}" y="${n.y+47}">${esc(n.s2)}</text>`:"")+`</g>`;}
$("#arch").innerHTML=`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="How vinyl-ai answers: the asker's device, the factory application, the model on vinyl-ai and its read-only tools, all on premises; the internet is not used">${g}</svg>`;
}
function lightNodes(ids){
$$(".arch .nd").forEach(n=>n.classList.remove("on"));$$(".arch .ed").forEach(e=>e.classList.remove("on"));
(ids||[]).forEach(id=>{const n=$("#n-"+id);if(n)n.classList.add("on");const e=$("#e-"+id);if(e)e.classList.add("on");
if(id==="app"||id==="model"){$("#e-ask").classList.add("on");$("#n-ask").classList.add("on");}
if(id==="model")$("#e-app").classList.add("on");});
}
/* ---------- charts ---------- */
const CH={
rejects(){ // C1 reject % per 15 min and press hall RH, 12:00 to 15:00
const rej=[0.7,0.9,0.6,0.8,0.7,0.9,0.8,0.8,3.2,4.4,4.1,3.9], rh=[51,52,52,53,54,56,61,63,63,64,63,63];
const W=420,H=150,L=30,R=30,T=10,B=24,pw=W-L-R,ph=H-T-B,n=rej.length,bw=pw/n;
const yR=v=>T+ph-(v/5)*ph, yH=v=>T+ph-((v-30)/40)*ph;
let s=`<rect x="${L}" y="${yH(60)}" width="${pw}" height="${yH(35)-yH(60)}" style="fill:var(--ok-s)"/>`;
s+=`<text x="${L+4}" y="${yH(60)+11}" style="fill:var(--ok);font-size:9.5px">plan band 35 to 60 % RH</text>`;
rej.forEach((v,i)=>{s+=`<rect x="${L+i*bw+3}" y="${yR(v)}" width="${bw-6}" height="${T+ph-yR(v)}" rx="2" style="fill:${v>2?"var(--bad)":"var(--stroke)"}"/>`;});
s+=`<polyline points="${rh.map((v,i)=>`${L+i*bw+bw/2},${yH(v)}`).join(" ")}" style="fill:none;stroke:var(--inv);stroke-width:2"/>`;
s+=`<line x1="${L+8*bw}" x2="${L+8*bw}" y1="${T}" y2="${T+ph}" style="stroke:var(--warn);stroke-dasharray:3 3"/><text x="${L+8*bw+4}" y="${T+9}" style="fill:var(--warn);font-size:9.5px">14:02 lot L-0412</text>`;
["12:00","13:00","14:00","15:00"].forEach((t,i)=>{s+=`<text x="${L+i*4*bw}" y="${H-7}" style="fill:var(--mute);font-size:9.5px" text-anchor="middle">${t}</text>`;});
[0,2,4].forEach(v=>{s+=`<text x="${L-5}" y="${yR(v)+3}" style="fill:var(--mute);font-size:9px" text-anchor="end">${v}%</text>`;});
[40,60].forEach(v=>{s+=`<text x="${W-R+5}" y="${yH(v)+3}" style="fill:var(--inv);font-size:9px">${v}%</text>`;});
return {svg:`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="C1 reject rate per 15 minutes and press hall humidity">${s}</svg>`,
cap:`<span><i style="background:var(--bad)"></i>Non-fill rejects, C1, % per 15 min</span><span><i style="background:var(--inv)"></i>Press hall humidity, % RH</span>`};
},
output(){ // good records this week vs plan basis
const P=[["D1-A",3840,2880,4480],["D1-B",3710,2880,4480],["C1",5410,6144,7168]];
const W=420,H=140,L=44,T=8,B=22,pw=W-L-12,ph=H-T-B,max=7600,bh=ph/P.length;
const x=v=>L+v/max*pw;let s="";
P.forEach((p,i)=>{const y=T+i*bh;
s+=`<rect x="${x(p[2])}" y="${y+4}" width="${x(p[3])-x(p[2])}" height="${bh-8}" rx="3" style="fill:var(--ok-s);stroke:var(--ok);stroke-dasharray:3 2"/>`;
s+=`<rect x="${L}" y="${y+bh/2-7}" width="${x(p[1])-L}" height="14" rx="3" style="fill:${p[1]<p[2]?"var(--bad)":"var(--inv)"}"/>`;
s+=`<text x="${L-6}" y="${y+bh/2+4}" text-anchor="end" style="fill:var(--ink);font-size:11px;font-weight:650">${p[0]}</text>`;
s+=`<text x="${x(p[1])+5}" y="${y+bh/2+4}" style="fill:var(--ink);font-size:10.5px">${p[1].toLocaleString("en-US")}</text>`;});
[0,2000,4000,6000].forEach(v=>{s+=`<text x="${x(v)}" y="${H-6}" text-anchor="middle" style="fill:var(--mute);font-size:9px">${v.toLocaleString("en-US")}</text>`;});
return {svg:`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Good records per press this week against the plan basis">${s}</svg>`,
cap:`<span><i style="background:var(--inv)"></i>Good records Mon to Fri</span><span><i style="background:var(--ok-s);outline:1px dashed var(--ok)"></i>Plan basis at 60 to 70 % effectiveness, 80 h</span>`};
},
steam(){
const D=[["Mon",1.95],["Tue",1.98],["Wed",2.40],["Thu",2.00],["Fri",2.02]];
const W=420,H=140,L=34,T=10,B=22,pw=W-L-10,ph=H-T-B,lo=1.4,hi=2.6,bw=pw/D.length;
const y=v=>T+ph-(v-lo)/(hi-lo)*ph;let s="";
s+=`<rect x="${L}" y="${y(1.88)}" width="${pw}" height="${y(1.81)-y(1.88)}" style="fill:var(--ok-s)"/><text x="${L+4}" y="${y(1.88)-4}" style="fill:var(--ok);font-size:9.5px">plan basis 1.8 to 1.9 kg</text>`;
D.forEach((d,i)=>{s+=`<rect x="${L+i*bw+10}" y="${y(d[1])}" width="${bw-20}" height="${T+ph-y(d[1])}" rx="3" style="fill:${d[1]>2.2?"var(--bad)":"var(--inv)"}"/>`;
s+=`<text x="${L+i*bw+bw/2}" y="${H-7}" text-anchor="middle" style="fill:var(--mute);font-size:9.5px">${d[0]}</text>`;
s+=`<text x="${L+i*bw+bw/2}" y="${y(d[1])-4}" text-anchor="middle" style="fill:var(--ink);font-size:10px">${d[1].toFixed(2)}</text>`;});
[1.5,2.0,2.5].forEach(v=>{s+=`<text x="${L-5}" y="${y(v)+3}" text-anchor="end" style="fill:var(--mute);font-size:9px">${v.toFixed(1)}</text>`;});
return {svg:`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Steam per good record per day this week against the plan basis">${s}</svg>`,
cap:`<span><i style="background:var(--inv)"></i>kg of steam per good record</span><span><i style="background:var(--ok-s)"></i>Plan basis at rated output (scale.md)</span>`};
}
};
/* ---------- scenarios ---------- */
const SC={
operator:[
{id:"o1",q:{th:"ตั้งแต่บ่ายสองโมง ของเสียแบบเนื้อไม่เต็ม (non-fill) เพิ่มขึ้น มีอะไรเปลี่ยนไปบ้าง",en:"Non-fill rejects are up since 14:00. What changed?"},voice:1,
steps:[
["model","understand",{th:"Thai question, station C1, job card JC-C1-0412",en:""},"Intent: explain a rise in one reject reason on this press since 14:00"],
["sql","machine_data.sql","SELECT bucket_15m, reason, count(*) FROM optical_result\n JOIN press_cycle USING (cycle_id)\nWHERE press='C1' AND t > '12:00' GROUP BY 1,2","Non-fill 0.8 % before 14:05, 4.1 % after (612 cycles)"],
["erp","erpnext.get","Stock Entry where t_warehouse = 'Hopper C1', today","STE-0918: lot L-0412 into hopper C1 at 14:02"],
["ts","metrics.promql","avg_over_time(zone_rh{zone=\"press-hall\"}[15m])","52 % at 13:30, 63 % from 13:40; plan band 35 to 60 %"],
["evt","eventlog.search","press=C1 AND kind=adjust AND rh>60 AND lot_change","12 Sep: zone 3 +3 °C by operator 0388, rejects down within 20 cycles"],
["doc","docs.search","\"extruder temperature change\" in Quality Procedures","QP-PRS-04 v3, step 6: changes over ±2 °C need the shift lead"],
["model","compose","figures only from the 5 results above","Answer in Thai; correlation labelled as such"]],
ans:[
{t:"p",th:"ของเสียแบบ non-fill ที่เครื่อง C1 เพิ่มจาก 0.8 % เป็น 4.1 % ตั้งแต่ 14:05 [1]",en:"Non-fill rejects on C1 rose from 0.8 % to 4.1 % from 14:05 [1]."},
{t:"chart",k:"rejects"},
{t:"p",th:"ก่อนหน้านั้นมีสองอย่างเปลี่ยน:",en:"Two things changed just before:"},
{t:"list",items:[{th:"ล็อตเม็ดพลาสติก L-0412 เข้าฮอปเปอร์เวลา 14:02 [2]",en:"Compound lot L-0412 went into the hopper at 14:02 [2]."},
{th:"ความชื้นในห้องกดขึ้นจาก 52 % เป็น 63 % ตั้งแต่ 13:40 เกินช่วงของแผน 35 ถึง 60 % [3]",en:"Press hall humidity rose from 52 % to 63 % from 13:40, above the plan's 35 to 60 % band [3]."}]},
{t:"note",th:"สองอย่างนี้เกิดพร้อมกัน เป็นความสัมพันธ์ ยังไม่ใช่สาเหตุที่ยืนยันแล้ว",en:"These happened together: a correlation, not a confirmed cause."},
{t:"p",th:"ครั้งล่าสุดที่ความชื้นเกิน 60 % พร้อมล็อตใหม่ (12 ก.ย.) ผู้ควบคุมเพิ่มอุณหภูมิโซน 3 ของเอ็กซ์ทรูเดอร์ 3 °C และของเสียลดลงภายใน 20 รอบ [4] ตามขั้นตอน QP-PRS-04 ข้อ 6 การปรับเกิน ±2 °C ต้องให้หัวหน้ากะอนุมัติ [5]",
en:"Last time humidity was above 60 % with a new lot (12 Sep), the operator raised extruder zone 3 by 3 °C and rejects fell within 20 cycles [4]. Per QP-PRS-04 step 6, a change over ±2 °C needs the shift lead's approval [5]."},
{t:"acts",items:[{th:"เรียกหัวหน้ากะ",en:"Call the shift lead",d:{type:"ToDo",name:"TODO-3381",f:[["For","Shift lead A"],["About","C1 non-fill 4.1 % since 14:05, zone 3 change needs approval"],["Link","JC-C1-0412"]]}},
{th:"ร่างรายงานความไม่สอดคล้อง",en:"Draft a non-conformance",d:{type:"Non Conformance",name:"NC-2026-0031",f:[["Procedure","QP-PRS-04 v3"],["Observed","Press hall RH 63 % (band 35 to 60 %), non-fill 4.1 % on C1"],["Job card","JC-C1-0412"]]}}]}],
src:["press_cycle and optical_result, C1, 12:00 to 15:00 (replica on vinyl-backup)","ERPNext Stock Entry STE-0918","VictoriaMetrics zone_rh, press hall","Event log, 12 Sep, adjustment by operator 0388","Quality Procedure QP-PRS-04 v3 (approved)"]},
{id:"o2",q:{th:"ขอขั้นตอนเปลี่ยนสแตมเปอร์ของเครื่อง Classic",en:"Show me the stamper change steps for the Classic."},voice:1,
steps:[
["model","understand","","Intent: procedure lookup for this press type"],
["doc","docs.search","\"stamper change\" asset=Classic status=approved","QP-PRS-07 v2, approved 14 Sep 2026 by the QC lead"],
["doc","docs.get","Asset C1 attachment: Classic manual, section stamper change","Pictures 1 to 4 from the vendor manual"],
["model","compose","quote steps word for word, no paraphrase of safety lines","Thai version of the approved procedure"]],
ans:[
{t:"proc",title:{th:"QP-PRS-07 ฉบับที่ 2 · อนุมัติแล้ว · ยกมาคำต่อคำ",en:"QP-PRS-07 version 2 · approved · quoted word for word"},
steps:[{th:"หยุดเครื่องเมื่อจบรอบ และตั้งเป็นโหมด Setup ที่หน้าจอ HMI",en:"Stop the press at the end of the cycle and set Setup mode on the HMI."},
{th:"ล็อกเอาต์: ปิดปั๊มไฮดรอลิก ใส่กุญแจที่สวิตช์ตัดไฟ แขวนป้ายชื่อของคุณ ปิดวาล์วไอน้ำเข้าแม่พิมพ์และแขวนป้าย",en:"Lock out: hydraulic pump off, your lock on the isolator, your name tag on it; steam valve to the mould closed and tagged."},
{th:"รอจนหน้าจอแสดงว่าแม่พิมพ์ปลอดภัยต่อการสัมผัส สวมถุงมือกันร้อน",en:"Wait until the HMI shows the mould safe to touch; wear heat gloves."},
{th:"สแกนสแตมเปอร์ตัวเก่าที่คีออส ถอดออก แล้ววางในช่องที่หน้าจอบอก",en:"Scan the old stamper at the kiosk, remove it, put it in the rack slot shown."},
{th:"สแกนสแตมเปอร์ตัวใหม่ คีออสจะตรวจว่าตรงกับใบงานและด้านของแผ่น",en:"Scan the new stamper; the kiosk checks it matches the job card and side."},
{th:"ติดตั้งและจัดศูนย์ตามภาพที่ 3",en:"Fit and centre it as in picture 3."},
{th:"ปลดล็อกย้อนลำดับ กดแผ่นแรกแล้วพักไว้ให้ QC ตรวจก่อน",en:"Remove the locks in reverse order; press the first record and hold it for QC."}],
foot:{th:"ถ้าขั้นตอนใดไม่ชัดเจน ให้หยุดและเรียกหัวหน้ากะ (ต่อ 21) [1] ภาพจากคู่มือเครื่อง [2]",en:"If any step is unclear, stop and call the shift lead (ext. 21) [1]. Pictures from the press manual [2]."},pics:4}],
src:["Quality Procedure QP-PRS-07 v2, approved 14 Sep 2026","ERPNext Asset C1, attached vendor manual"]},
{id:"o3",q:{th:"ล็อต L-0415 ปล่อยใช้งานได้หรือยัง",en:"Is compound lot L-0415 released?"},voice:1,
steps:[
["model","understand","","Intent: release status of a batch"],
["erp","erpnext.get","Batch L-0415 · Quality Inspection by reference","Status On hold; QI-0882 at 10:12 failed moisture"],
["erp","erpnext.get","Work Order WO-L-0415-dry","Re-dry started 13:30, planned end 17:30"],
["model","compose","","Short answer with the next step"]],
ans:[
{t:"kv",items:[["L-0415","On hold","bad"],["QI-0882, 10:12","Moisture above limit","bad"],["Re-dry","13:30 to ~17:30","warn"]]},
{t:"p",th:"ยังไม่ได้ ล็อต L-0415 ถูกพักไว้ ความชื้นที่จุดตรวจของแล็บเกินขีดจำกัด [1] งานอบซ้ำ WO-L-0415-dry เริ่ม 13:30 คาดว่าเสร็จประมาณ 17:30 [2] ใช้ล็อต L-0412 ต่อไปจนกว่าแล็บจะปล่อยล็อตใหม่",en:"Not yet. L-0415 is on hold: moisture at the lab gate was above the limit [1]. Re-dry job WO-L-0415-dry started 13:30 and should end about 17:30 [2]. Keep running L-0412 until the lab releases it."}],
src:["ERPNext Batch L-0415 and Quality Inspection QI-0882","ERPNext Work Order WO-L-0415-dry"]},
{id:"o4",kind:"r",q:{th:"ลูกค้า Mae Ping Collective จ่ายราคาเท่าไหร่",en:"What price is Mae Ping Collective paying?"},voice:0,
steps:[
["model","understand","","Intent: a customer's price"],
["erp","erpnext.get","Sales Order SO-1038 (as role Press operator)","Fields rate, amount, contact: not permitted for this role",1],
["model","compose","","Refuse, say why, say who can answer"]],
ans:[{t:"refuse",th:"<b>ไม่แสดงข้อมูลนี้</b> ราคาของลูกค้าไม่เปิดให้บทบาทพนักงานควบคุมเครื่อง [1] ถ้าจำเป็นต่องานของคุณ ให้ถามฝ่ายขาย",en:"<b>Not shown.</b> Customer prices are not visible to the Press operator role [1]. If you need it for your work, ask Sales."}],
src:["ERPNext role permissions: Press operator"]},
{id:"o5",kind:"s",q:{th:"ถ้าแผ่นติดในแม่พิมพ์ เปิดการ์ดแล้วดึงออกเร็ว ๆ ได้ไหม",en:"If a record sticks in the mould, can I open the guard and pull it out quickly?"},voice:1,
steps:[
["model","understand","","Intent: safety: reaching into the mould"],
["doc","docs.search","safety procedure, mould area, status=approved","QP-SAF-02 v3, step 1"],
["model","compose","safety rule: quote, do not paraphrase, name the responsible person","Quoted verbatim"]],
ans:[{t:"safety",lab:{th:"ความปลอดภัย · ยกมาคำต่อคำ",en:"Safety · quoted word for word"},
th:"<b>ไม่ได้</b> QP-SAF-02 ฉบับที่ 3 ข้อ 1: <q>ห้ามเอื้อมมือเข้าในพื้นที่แม่พิมพ์ขณะที่เครื่องยังไม่ได้ล็อกเอาต์ ไม่มีข้อยกเว้น</q> ทำตามขั้นตอนล็อกเอาต์ หรือเรียกหัวหน้ากะ ผู้รับผิดชอบ: หัวหน้าซ่อมบำรุง [1]",
en:"<b>No.</b> QP-SAF-02 version 3, step 1: <q>Never reach into the mould area unless the press is locked out. No exceptions.</q> Follow the lockout steps, or call the shift lead. Responsible: the maintenance lead [1]."},
{t:"acts",items:[{th:"แสดงขั้นตอนล็อกเอาต์",en:"Show the lockout steps",go:"o2"}]}],
src:["Quality Procedure QP-SAF-02 v3 (approved)"]}
],
management:[
{id:"m1",q:{en:"Output and yield per press this week, and why did the Classic drop?"},
steps:[
["model","understand","","Intent: weekly output and yield by press, explain the outlier"],
["erp","erpnext.report","Job Card summary by workstation, Mon 26 to Fri 30 Oct","Good, rejected and scrap per press"],
["erp","erpnext.get","Downtime Entry, workstation C1, this week","Label feed 1.8 h (Mon), hydraulic oil temperature 1.3 h (Wed), stamper changes 1.3 h"],
["sql","machine_data.sql","SELECT day, reason, count(*) FROM optical_result WHERE press='C1' GROUP BY 1,2","Non-fill episode Fri 14:05 onward"],
["doc","docs.search","plan basis: rates per press, effectiveness, hours","scale.md: Duplex 60 to 80/h a press, Classic ~128/h, 60 to 70 %"],
["model","compose","","Table and chart from the results; plan band computed for 80 h"]],
ans:[
{t:"tbl",head:["Press","Good","Yield","Downtime","Plan basis, 80 h"],rows:[["D1-A (Duplex)","3,840","96.1 %","1.2 h","2,880 to 4,480"],["D1-B (Duplex)","3,710","95.4 %","1.5 h","2,880 to 4,480"],["C1 (Classic)",{v:"5,410",c:"bad"},{v:"93.2 %",c:"warn"},{v:"4.4 h",c:"bad"},"6,144 to 7,168"],["Plant","12,960","94.7 %","7.1 h",""]],num:[1,2,3]},
{t:"chart",k:"output"},
{t:"p",en:"C1 is below its plan basis [1]. 4.4 hours of downtime explain most of it [2]: the label feed (1.8 h, Mon), a hydraulic oil temperature alarm (1.3 h, Wed) and stamper changes (1.3 h). This afternoon's non-fill episode cost the rest of the yield [3]. The Duplex presses are inside their band [4]."},
{t:"note",en:"The hydraulic oil temperature alarm is the second this month on C1."},
{t:"acts",items:[{en:"Draft a maintenance request",d:{type:"Maintenance Visit",name:"MV-2026-0058",f:[["Asset","C1 hydraulic unit"],["Reason","Oil temperature alarm, 2nd in October (Wed 28, 1.3 h)"],["Priority","Normal"]]}}]}],
src:["ERPNext Job Cards, Mon 26 to Fri 30 Oct","ERPNext Downtime Entries, C1","optical_result, C1 (replica)","Plan basis: scale.md, production capacity"]},
{id:"m2",q:{en:"Steam per record against the plan."},
steps:[
["model","understand","","Intent: compare an operating figure with the plan"],
["ts","metrics.promql","sum(increase(steam_flow_kg{boiler=~\"B-1|B-2\"}[1d]))","Steam per day, both boilers"],
["sql","machine_data.sql","SELECT day, count(*) FILTER (WHERE good) FROM press_cycle GROUP BY 1","Good records per day"],
["doc","docs.search","launch plant steam sizing","scale.md: 450 to 540 kg/h for 248 to 288 records/h rated"],
["model","compose","","Ratio per day, plan band, the outlier day"]],
ans:[
{t:"kv",items:[["Plan basis","1.8 to 1.9 kg",""],["This week","2.07 kg","warn"],["Wednesday","2.40 kg","bad"]]},
{t:"chart",k:"steam"},
{t:"p",en:"Steam per good record ran 2.07 kg this week against a plan basis of about 1.8 to 1.9 kg at rated output [1][2][3]. Wednesday stands out: C1 stood 1.3 hours with the hydraulic alarm while the boilers held the header at pressure, so the steam spread over fewer records."},
{t:"note",en:"The plan figure is at rated output; a week with downtime will sit above it. This is a comparison, not a fault finding."}],
src:["VictoriaMetrics steam_flow_kg, B-1 and B-2","press_cycle, good records per day (replica)","Plan: scale.md, launch plant steam 450 to 540 kg/h"]},
{id:"m3",q:{en:"Which orders are at risk of missing their date?"},
steps:[
["model","understand","","Intent: orders whose plan end passes the promised date"],
["erp","erpnext.report","Sales Orders open with delivery_date ≤ 30 Nov, work order plan","14 open orders, 2 at risk"],
["erp","erpnext.get","Artist Gate AG-1042-3 (test pressing)","Waiting on the customer since Mon 26 Oct"],
["erp","erpnext.get","Duplex schedule, next 10 working days","Booked until Fri 6 Nov"],
["model","compose","","Table with the cause and the decision each needs"]],
ans:[
{t:"tbl",head:["Order","Customer","Promised","Now likely","Why"],rows:[["CNX-1042","Lanna Dub Society, 7\" splatter 500","Fri 6 Nov",{v:"~Wed 11 Nov",c:"bad"},"Test pressing approval waiting 4 days"],["CNX-1051","Doi Suthep Sound, 12\" splatter 1,000","Fri 13 Nov",{v:"13 to 17 Nov",c:"warn"},"Duplex booked to 6 Nov; splatter runs only on the Duplex"]]},
{t:"p",en:"CNX-1042 holds if Lanna Dub Society approves by Monday [1][2]. CNX-1051 holds with one extra Duplex shift (Saturday 7 Nov) before its run [3]; that is a management decision, not something the assistant books."},
{t:"acts",items:[{en:"Draft a reminder to Lanna Dub Society",d:{type:"Communication",name:"COMM-2026-0412",f:[["To","Lanna Dub Society"],["About","Test pressing CNX-1042 waiting since 26 Oct; approve by Mon 2 Nov to keep 6 Nov"],["Status","Draft, sales to send"]]}}]}],
src:["ERPNext Sales Orders and Work Orders","ERPNext Artist Gate AG-1042-3","Duplex schedule (Work Orders on D1-A, D1-B)"]},
{id:"m4",q:{en:"Write this week's management note."},
steps:[
["model","understand","","Intent: weekly summary"],
["erp","erpnext.report","Job Cards, Downtime, Non Conformance, Sales Orders: this week","Totals and open items"],
["sql","machine_data.sql","stampers above 80 % of planned life","3 stampers"],
["ts","metrics.promql","zone excursions, energy and steam this week","1 humidity excursion, steam 2.07 kg/record"],
["model","compose","","Six lines, each linked to its source"]],
ans:[
{t:"list",items:[{en:"Output: 12,960 good records, yield 94.7 % [1]."},{en:"C1 below its plan basis: 4.4 h downtime, hydraulic alarm repeated; maintenance request drafted [1]."},{en:"Two orders at risk: CNX-1042 (customer approval), CNX-1051 (Duplex capacity) [2]."},{en:"Lot L-0415 on hold for moisture, re-dry today [3]."},{en:"Three stampers above 80 % of planned life; replacements in the plating plan [4]."},{en:"Press hall humidity above the 60 % band on Friday afternoon; steam 2.07 kg per record against 1.8 to 1.9 [5]."}]}],
src:["ERPNext Job Cards and Downtime Entries","ERPNext Sales Orders, Artist Gates","ERPNext Batch L-0415","Stamper play counts (Serial No) and cycle rows","VictoriaMetrics zone and steam series"]},
{id:"m5",kind:"r",q:{en:"Who earned the most overtime this month?"},
steps:[
["model","understand","","Intent: payroll and HR"],
["erp","erpnext.get","Salary Slip, overtime (as role Management)","Not available: HR and payroll are not connected to the assistant for any role",1],
["model","compose","","Refuse and point to the HR desk"]],
ans:[{t:"refuse",en:"<b>Not available here.</b> Payroll and HR records are not connected to the assistant for any role [1]. Use the HR reports in ERPNext."}],
src:["Assistant scope: HR and payroll excluded"]}
],
sales:[
{id:"s1",q:{en:"Where is order CNX-1038 for Mae Ping Collective, and when will it ship?"},
steps:[
["model","understand","","Intent: order status and ship date"],
["erp","erpnext.get","Sales Order SO-1038, Work Order, Job Card on D1-A","Pressing on D1-A since 13:10"],
["sql","machine_data.sql","SELECT count(*) FILTER (WHERE good) FROM press_cycle WHERE job_card='JC-D1A-0398'","214 good of 300"],
["doc","docs.search","finishing and dispatch duration","production-timeline.md: 3 to 5 working days"],
["model","compose","","Status, estimate, quoted window"]],
ans:[
{t:"kv",items:[["Stage","Pressing, D1-A",""],["Good so far","214 of 300","ok"],["Run ends","~16:40 today",""],["Ship window","Wed 4 to Fri 6 Nov",""]]},
{t:"p",en:"12\" marbled, 300 copies: pressing on D1-A since 13:10, 214 good so far [1][2]. At the current rate the run ends about 16:40 today; finishing, packing and dispatch take 3 to 5 working days [3], so the quoted window is Wed 4 to Fri 6 Nov."},
{t:"acts",items:[{en:"Draft an update to the customer",d:{type:"Communication",name:"COMM-2026-0413",f:[["To","Mae Ping Collective"],["About","CNX-1038 pressing today, ship window 4 to 6 Nov"],["Status","Draft, sales to send"]]}}]}],
src:["ERPNext SO-1038 and its Work Order","press_cycle, JC-D1A-0398 (replica)","production-timeline.md, finishing and dispatch"]},
{id:"s2",q:{en:"Can we take a 500-copy black 12\" repress for Suan Dok Records, delivered by 20 November?"},
steps:[
["model","understand","","Intent: feasibility of a repress by a date"],
["erp","erpnext.get","Serial No: mothers of SDR-012, sides A and B","Archived; 3 of ~10 stamper sets used on each"],
["erp","erpnext.get","Standby calendar: idle slots, C1, next 10 working days","Wed 4 and Thu 5 Nov, one shift each"],
["doc","docs.search","repress lead time from an archived mother","production-timeline.md: ~7 to 11 working days"],
["model","compose","","Yes or no with the dates and the margin"]],
ans:[
{t:"kv",items:[["Mothers","Archived, ~7 sets left","ok"],["Slot","C1, Wed 4 Nov","ok"],["Ready","~Tue 10 to Fri 13 Nov",""],["Margin to 20 Nov","1 week or more","ok"]]},
{t:"p",en:"Yes. Both mothers of SDR-012 are in the archive with about seven stamper sets left [1]. Black runs on the Classic, which has an idle shift on Wed 4 Nov [2]; 500 copies fit in it. A repress from an archived mother takes about 7 to 11 working days [3], so it is ready around 10 to 13 Nov, a week before the date."},
{t:"acts",items:[{en:"Draft a quotation",d:{type:"Quotation",name:"QTN-2026-0219",f:[["Customer","Suan Dok Records"],["Item","Repress SDR-012, 12\" black, 500"],["Price","from the customer's price list"],["Status","Draft, sales to review"]]}}]}],
src:["ERPNext Serial No, mothers of SDR-012","Standby calendar (Work Orders, C1)","production-timeline.md, repress path"]},
{id:"s3",q:{en:"Which customers have metal in the archive but no order for six months?"},
steps:[
["model","understand","","Intent: repress opportunities"],
["erp","erpnext.report","Serial No (mothers) by customer, last Sales Order date","3 customers match"],
["model","compose","","List with sets left, as leads for a person"]],
ans:[
{t:"tbl",head:["Customer","Title","Last order","Sets left"],rows:[["Ping River Tapes","PRT-007","May 2026","~8"],["Lanna Dub Society","LDS-003","Apr 2026","~6"],["Suan Dok Records","SDR-009","Mar 2026","~9"]],num:[3]},
{t:"p",en:"Three customers have archived mothers and no order since spring [1]. A repress for any of them skips cutting and plating and runs in about two weeks [2]."},
{t:"acts",items:[{en:"Draft three CRM opportunities",d:{type:"Opportunity",name:"OPP-2026-0071 to 0073",f:[["Customers","Ping River Tapes, Lanna Dub Society, Suan Dok Records"],["Type","Repress"],["Status","Draft, sales to qualify"]]}}]}],
src:["ERPNext Serial No and Sales Orders","production-timeline.md, repress path"]},
{id:"s4",q:{en:"What can I tell Lanna Dub Society about their test pressing?"},
steps:[
["model","understand","","Intent: gate status for a customer call"],
["erp","erpnext.get","Artist Gate AG-1042-3, Quality Inspection QI-1042-TP, Shipment","Shipped 22 Oct, delivered 24 Oct, waiting since 26 Oct"],
["model","compose","","Facts for the call"]],
ans:[
{t:"p",en:"Their 5 test pressings passed optical inspection and the listening check [1], shipped 22 Oct and were delivered 24 Oct [2]. Approval is open since 26 Oct. If they approve by Mon 2 Nov, the run keeps its Duplex slot and the 6 Nov ship date; later, it moves to about 11 Nov [3]."}],
src:["ERPNext Quality Inspection QI-1042-TP","ERPNext Shipment, carrier tracking","Duplex schedule"]},
{id:"s5",kind:"r",q:{en:"Send me the fans' addresses from Ping River Tapes' campaign."},
steps:[
["model","understand","","Intent: export personal data"],
["erp","erpnext.get","Customer and Address (campaign fans), as role Sales","Not permitted: fans' personal data, consent given only for the artist",1],
["model","compose","","Refuse with the rule"]],
ans:[{t:"refuse",en:"<b>Not shown.</b> Fans' names and addresses are personal data under the PDPA; they go only to the artist, and only where the fan ticked the consent box [1]. Shipping labels are printed at dispatch; nobody exports the list."}],
src:["Artist platform rules, personal data; ERPNext role permissions"]}
]};
/* ---------- state ---------- */
const S={role:"operator",lang:"th",spd:1,busy:false,n:0,played:{},ids:0};
const tx=o=>o==null?"":(typeof o==="string"?o:(o[S.lang]!=null&&o[S.lang]!==""?o[S.lang]:(o.en||"")));
const refs=s=>s.replace(/\[(\d)\]/g,'<span class="ref" data-n="$1">$1</span>');
const wait=ms=>new Promise(r=>setTimeout(r,ms/S.spd));
let token=0;
function setRole(r,keepHash){
if(!ROLES[r])r="operator";
token++;S.busy=false;S.role=r;S.lang=ROLES[r].lang;
$$("#roleSw button").forEach(b=>{b.classList.toggle("on",b.dataset.role===r);b.setAttribute("aria-selected",b.dataset.role===r);});
const R=ROLES[r],dev=$("#device");
dev.className="device "+R.device;
$("#devBar").hidden=R.device!=="desk";$("#devUrl").textContent=R.url;
$("#mic").hidden=r!=="operator";
let ctx=R.ctx.map(c=>`<span class="k${c[0]?" "+c[0]:""}">${c[1]}</span>`).join("");
if(r==="operator")ctx+=`<span class="sp"></span><span class="seg" id="langSw"><button type="button" data-l="th" class="th">ไทย</button><button type="button" data-l="en">EN</button></span>`;
$("#ctx").innerHTML=ctx;
if(r==="operator")$$("#langSw button").forEach(b=>{b.classList.toggle("on",b.dataset.l===S.lang);b.onclick=()=>{S.lang=b.dataset.l;$$("#langSw button").forEach(x=>x.classList.toggle("on",x.dataset.l===S.lang));rerender();};});
$("#qsTitle").textContent=R.title+": questions";$("#qHint").textContent=R.hint;
clearLog();renderQs();
if(!keepHash)setHash();
}
function renderQs(){
$("#qList").innerHTML=SC[S.role].map(q=>{
const tag=q.kind==="r"?'<span class="tag r">Refused</span>':q.kind==="s"?'<span class="tag s">Safety</span>':"";
const gl=S.role==="operator"?`<span class="gl">${esc(q.q.en)}</span>`:"";
const main=S.role==="operator"?`<span class="th">${esc(q.q.th)}</span>`:esc(q.q.en);
return `<button type="button" class="qbtn${S.played[q.id]?" done":""}" data-q="${q.id}"${S.busy?" disabled":""}>${tag}${main}${gl}</button>`;}).join("");
$$("#qList .qbtn").forEach(b=>b.onclick=()=>play(b.dataset.q));
}
function clearLog(){
const R=ROLES[S.role];
$("#log").innerHTML=`<div class="empty"><span class="big">${S.role==="operator"?"&#x1F3A4;":"&#x1F4AC;"}</span>${esc(tx(R.empty))}</div>`;
$("#inp").placeholder=tx(R.ph);
S.items=[];S.n=0;resetTrace();
}
function resetTrace(){
$("#steps").innerHTML="";$("#tMeta").textContent="No question yet.";$("#howSub").textContent="Pick a question to see the tools it calls.";
$("#fCalls").textContent="0";$("#fMem").textContent="0";$("#fOff").textContent="0 B";lightNodes([]);
}
/* ---------- rendering an answer ---------- */
function blockHTML(b,qid){
switch(b.t){
case "p": return `<p>${refs(tx(b))}</p>`;
case "note": return `<div class="note">${refs(tx(b))}</div>`;
case "list": return `<ul>${b.items.map(i=>`<li>${refs(tx(i))}</li>`).join("")}</ul>`;
case "refuse": return `<div class="refuse">${refs(tx(b))}</div>`;
case "safety": return `<div class="safety"><div class="lab">${esc(tx(b.lab))}</div>${refs(tx(b))}</div>`;
case "chart": {const c=CH[b.k]();return `<div class="chart">${c.svg}<div class="cap">${c.cap}</div></div>`;}
case "kv": return `<div class="kv">${b.items.map(i=>`<div class="${i[2]||""}"><small>${esc(i[0])}</small><b>${esc(i[1])}</b></div>`).join("")}</div>`;
case "tbl": return `<div class="tbl"><table><thead><tr>${b.head.map(h=>`<th>${esc(h)}</th>`).join("")}</tr></thead><tbody>${b.rows.map(r=>`<tr>${r.map((c,i)=>{const v=typeof c==="object"?c.v:c,cl=[(b.num||[]).includes(i)?"n":"",typeof c==="object"?c.c:""].filter(Boolean).join(" ");return `<td${cl?` class="${cl}"`:""}>${esc(v)}</td>`;}).join("")}</tr>`).join("")}</tbody></table></div>`;
case "proc": return `<div class="proc"><div class="lab">${esc(tx(b.title))}</div><ol>${b.steps.map(s=>`<li>${esc(tx(s))}</li>`).join("")}</ol>${b.pics?`<div class="pics">${Array.from({length:b.pics},(_,i)=>`<span>${S.lang==="th"?"ภาพ":"pic"} ${i+1}</span>`).join("")}</div>`:""}<div class="foot">${refs(tx(b.foot))}</div></div>`;
case "acts": return `<div class="acts">${b.items.map((a,i)=>`<button type="button" data-q="${qid}" data-a="${i}">${esc(tx(a))}</button>`).join("")}</div>`;
}
return "";
}
function answerHTML(q,drafts){
let h=q.ans.map(b=>blockHTML(b,q.id)).join("");
(drafts||[]).forEach(d=>{h+=draftHTML(d);});
h+=`<ol class="src">${q.src.map((s,i)=>`<li data-n="${i+1}"><span class="ref">${i+1}</span><span>${esc(s)}</span></li>`).join("")}</ol>`;
return h;
}
function draftHTML(d){
return `<div class="draft fade"><div class="lab">${S.lang==="th"&&S.role==="operator"?"ร่าง · ยังไม่ส่ง":"Draft · not submitted"}</div><div><b>${esc(d.type)} ${esc(d.name)}</b></div><dl>${d.f.map(f=>`<dt>${esc(f[0])}</dt><dd>${esc(f[1])}</dd>`).join("")}</dl><small class="mute">${S.lang==="th"&&S.role==="operator"?"ผู้ช่วยสร้างได้แค่ร่าง คนต้องตรวจและส่งใน ERPNext":"The assistant creates drafts only; a person reviews and submits it in ERPNext."}</small></div>`;
}
function userHTML(q,voice){
if(S.role==="operator"){
const main=S.lang==="th"?q.q.th:q.q.en, gl=S.lang==="th"?q.q.en:q.q.th;
return `<div class="bub"><span class="${S.lang==="th"?"th":""}">${esc(main)}</span><span class="gloss${S.lang==="th"?"":" th"}">${esc(gl)}</span>${voice?`<span class="via">${S.lang==="th"?"พูดผ่านไมค์ · แปลงเสียงบนเครื่อง":"spoken · transcribed on premises"}</span>`:""}</div>`;
}
return `<div class="bub">${esc(q.q.en)}</div>`;
}
function rerender(){ // language switch: re-render the log in place
$("#inp").placeholder=tx(ROLES[S.role].ph);
const e=$("#log .empty");if(e)e.innerHTML=`<span class="big">&#x1F3A4;</span>${esc(tx(ROLES[S.role].empty))}`;
(S.items||[]).forEach(it=>{
if(it.u)it.u.innerHTML=userHTML(it.q,it.voice);
if(it.a&&it.done)it.a.querySelector(".bub").innerHTML=answerHTML(it.q,it.drafts);
});
bindAnswers();
}
function bindAnswers(){
$$("#log .acts button").forEach(b=>{b.onclick=()=>doAction(b.dataset.q,+b.dataset.a,b);});
$$("#log .ref").forEach(r=>{const bub=r.closest(".bub");if(!bub)return;
r.onmouseenter=()=>{const li=bub.querySelector(`.src li[data-n="${r.dataset.n}"]`);if(li)li.classList.add("hl");};
r.onmouseleave=()=>bub.querySelectorAll(".src li.hl").forEach(x=>x.classList.remove("hl"));});
}
function doAction(qid,i,btn){
const q=find(qid);if(!q)return;const act=q.ans.find(b=>b.t==="acts").items[i];
if(act.go){play(act.go);return;}
const it=(S.items||[]).find(x=>x.q.id===qid);if(!it||!act.d)return;
if(it.drafts.some(d=>d.name===act.d.name))return;
it.drafts.push(act.d);btn.disabled=true;
$("#steps").innerHTML="";
$("#tMeta").innerHTML=`Action on the answer to: <b>${esc(tx(q.q))}</b>`;
$("#fCalls").textContent="0";
const bub=it.a.querySelector(".bub"),src=bub.querySelector(".src");
src.insertAdjacentHTML("beforebegin",draftHTML(act.d));
addStep(["erp","erpnext.create_draft",`${act.d.type} (docstatus 0), as role ${ROLES[S.role].ctx[0][1]}`,`${act.d.name} saved as a draft; submit stays with a person`],"ok");
$("#fCalls").textContent=String(+$("#fCalls").textContent+1);
lightNodes(["erp"]);scrollLog();
}
const find=id=>SC[S.role].find(q=>q.id===id);
function scrollLog(){const l=$("#log");l.scrollTop=l.scrollHeight;}
/* ---------- trace ---------- */
const ICON={model:"AI",erp:"E",sql:"DB",ts:"TS",doc:"D",evt:"EV"};
function addStep(st,cls){
const [node,tool,call,out,deny]=st;
const li=document.createElement("li");li.className=(deny?"deny":(cls||"ok"))+" fade";
const callTxt=typeof call==="object"?tx(call):call;
li.innerHTML=`<span class="ic">${ICON[node]||"?"}</span><div><div class="tl">${esc(tool)}<small>${node==="model"?"vinyl-ai":({erp:"ERPNext",sql:"replica",ts:"VictoriaMetrics",doc:"document index",evt:"event log"}[node])}</small></div>${callTxt?`<code>${esc(callTxt)}</code>`:""}<div class="out">${deny?"&#x26D4; ":""}${esc(out)}</div></div>`;
$("#steps").appendChild(li);const ol=$("#steps");ol.scrollTop=ol.scrollHeight;return li;
}
/* ---------- play ---------- */
async function play(id,opt){
opt=opt||{};const q=find(id);if(!q||S.busy)return;
const my=++token;S.busy=true;S.n++;renderQs();
const voice=S.role==="operator"&&(opt.voice||false);
const log=$("#log");const em=log.querySelector(".empty");if(em)em.remove();
const it={q,voice,drafts:[],done:false};S.items.push(it);
if(voice&&!opt.instant){const m=$("#mic");m.classList.add("rec");$("#inp").value=S.lang==="th"?"กำลังฟัง…":"Listening…";await wait(1300);m.classList.remove("rec");$("#inp").value="";if(my!==token)return;}
it.u=document.createElement("div");it.u.className="msg me fade";it.u.innerHTML=userHTML(q,voice);log.appendChild(it.u);
it.a=document.createElement("div");it.a.className="msg ai fade";it.a.innerHTML=`<span class="av">AI</span><div class="bub"><span class="typing"><i></i><i></i><i></i></span></div>`;log.appendChild(it.a);scrollLog();
$("#steps").innerHTML="";
const who=ROLES[S.role].ctx[0][1];
$("#tMeta").innerHTML=`Question ${S.n} · asked by <b>${esc(who)}</b>${S.role==="operator"?" at <b>C1</b>, badge 0417":""} · ${S.role==="operator"&&S.lang==="th"?"Thai":"English"}${voice?" · voice":""}`;
$("#howSub").textContent="Every call runs with the asker's role; nothing leaves the site.";
let calls=0;lightNodes(["app"]);
for(const st of q.steps){
if(my!==token)return;
lightNodes([st[0]]);
if(!opt.instant){const li=addStep(st,"run");await wait(st[0]==="model"?520:680);if(my!==token)return;li.className=(st[4]?"deny":"ok");}
else addStep(st,"ok");
if(st[0]!=="model")calls++;
$("#fCalls").textContent=String(calls);
}
if(my!==token)return;
lightNodes(["model"]);
if(!opt.instant)await wait(380);
if(my!==token)return;
it.done=true;it.a.querySelector(".bub").innerHTML=answerHTML(q,it.drafts);
$("#fMem").textContent="0";$("#fOff").textContent="0 B";
bindAnswers();scrollLog();
S.played[q.id]=1;S.busy=false;renderQs();lightNodes([]);
setHash(q.id);
}
/* ---------- compose and mic ---------- */
$("#compose").addEventListener("submit",e=>{
e.preventDefault();const v=$("#inp").value.trim();if(!v||S.busy)return;
const q=SC[S.role].find(x=>[x.q.en,x.q.th].filter(Boolean).some(s=>s.toLowerCase()===v.toLowerCase()));
$("#inp").value="";
if(q){play(q.id);return;}
const log=$("#log");const em=log.querySelector(".empty");if(em)em.remove();
log.insertAdjacentHTML("beforeend",`<div class="msg me fade"><div class="bub">${esc(v)}</div></div><div class="msg ai fade"><span class="av">AI</span><div class="bub"><p>${S.role==="operator"&&S.lang==="th"?"ในตัวอย่างนี้ ผู้ช่วยตอบได้เฉพาะคำถามด้านล่าง":"This mockup answers the suggested questions below only."}</p></div></div>`);scrollLog();
});
$("#mic").addEventListener("click",()=>{if(S.busy)return;const next=SC.operator.find(q=>q.voice&&!S.played[q.id])||SC.operator[0];play(next.id,{voice:true});});
$("#clearBtn").addEventListener("click",()=>{token++;S.busy=false;S.played={};S.n=0;clearLog();renderQs();setHash();});
/* ---------- controls, hash, theme ---------- */
$$("#roleSw button").forEach(b=>b.addEventListener("click",()=>{if(b.dataset.role!==S.role)setRole(b.dataset.role);}));
$$("#spdSw button").forEach(b=>b.addEventListener("click",()=>{S.spd=+b.dataset.spd;$$("#spdSw button").forEach(x=>x.classList.toggle("on",x===b));}));
$("#spdSw button").classList.add("on");
function setHash(qid){const h="#role="+S.role+(qid?"&q="+qid:"");if(location.hash!==h)history.replaceState(null,"",h);}
function fromHash(){
const p=new URLSearchParams(location.hash.replace(/^#/,""));const r=p.get("role")||"operator";
setRole(ROLES[r]?r:"operator",true);const q=p.get("q");
if(q&&find(q))play(q,{instant:true});else setHash();
}
window.addEventListener("hashchange",()=>{const p=new URLSearchParams(location.hash.replace(/^#/,""));if((p.get("role")||"operator")!==S.role||(p.get("q")&&!S.played[p.get("q")]))fromHash();});
(function(){
const b=$("#themeBtn"),de=document.documentElement;
function set(t,save){de.setAttribute("data-theme",t);if(save){try{localStorage.setItem("cnx-theme",t)}catch(e){}}b.textContent=t==="light"?"☾":"☀";b.title="Switch to "+(t==="light"?"dark":"light")+" mode";b.setAttribute("aria-label",b.title);}
set(de.getAttribute("data-theme")||"dark",false);
b.addEventListener("click",()=>{set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
const mq=window.matchMedia?matchMedia("(prefers-color-scheme: light)"):null;
if(mq&&mq.addEventListener)mq.addEventListener("change",e=>{let s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
})();
function topH(){document.documentElement.style.setProperty("--toph",$("#top").offsetHeight+"px");}
window.addEventListener("resize",topH);topH();
archSVG();fromHash();
window.__pa={S,SC,ROLES,play,setRole,find};
})();
</script>
</body>
</html>