@flow-engineer/flow 0.0.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +611 -0
- package/coding-agents/skill.md +353 -0
- package/coding-agents/test-skill.md +223 -0
- package/coding-agents/tools-skill.md +89 -0
- package/dist/flow-messaging/src/client.d.ts +40 -0
- package/dist/flow-messaging/src/client.js +142 -0
- package/dist/flow-messaging/src/index.d.ts +72 -0
- package/dist/flow-messaging/src/index.js +605 -0
- package/dist/simulators/src/index.d.ts +2 -0
- package/dist/simulators/src/index.js +2 -0
- package/dist/simulators/src/page.html +1245 -0
- package/dist/simulators/src/person.html +100 -0
- package/dist/simulators/src/platforms.d.ts +29 -0
- package/dist/simulators/src/platforms.js +72 -0
- package/dist/simulators/src/server.d.ts +201 -0
- package/dist/simulators/src/server.js +759 -0
- package/dist/src/agent.d.ts +504 -0
- package/dist/src/agent.js +818 -0
- package/dist/src/channels.d.ts +178 -0
- package/dist/src/channels.js +243 -0
- package/dist/src/conversation.d.ts +361 -0
- package/dist/src/conversation.js +1487 -0
- package/dist/src/db.d.ts +18 -0
- package/dist/src/db.js +57 -0
- package/dist/src/events.d.ts +17 -0
- package/dist/src/events.js +20 -0
- package/dist/src/http.d.ts +45 -0
- package/dist/src/http.js +269 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +11 -0
- package/dist/src/model.d.ts +21 -0
- package/dist/src/model.js +96 -0
- package/dist/src/names.d.ts +22 -0
- package/dist/src/names.js +43 -0
- package/dist/src/nudges.d.ts +23 -0
- package/dist/src/nudges.js +117 -0
- package/dist/src/postgres.d.ts +20 -0
- package/dist/src/postgres.js +62 -0
- package/dist/src/schedules.d.ts +60 -0
- package/dist/src/schedules.js +249 -0
- package/dist/src/skills.d.ts +12 -0
- package/dist/src/skills.js +69 -0
- package/dist/src/store.d.ts +214 -0
- package/dist/src/store.js +448 -0
- package/dist/src/talk.d.ts +57 -0
- package/dist/src/talk.js +166 -0
- package/dist/src/time.d.ts +71 -0
- package/dist/src/time.js +170 -0
- package/dist/src/tools.d.ts +43 -0
- package/dist/src/tools.js +26 -0
- package/dist/src/traces.d.ts +31 -0
- package/dist/src/traces.js +303 -0
- package/dist/src/types.d.ts +260 -0
- package/dist/src/types.js +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +580 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +117 -0
- package/dist/testing/src/check-endpoint.d.ts +31 -0
- package/dist/testing/src/check-endpoint.js +158 -0
- package/dist/testing/src/chinwag.d.ts +2 -0
- package/dist/testing/src/chinwag.js +6 -0
- package/dist/testing/src/cli.d.ts +2 -0
- package/dist/testing/src/cli.js +592 -0
- package/dist/testing/src/client.d.ts +117 -0
- package/dist/testing/src/client.js +956 -0
- package/dist/testing/src/flow-login.d.ts +143 -0
- package/dist/testing/src/flow-login.js +249 -0
- package/dist/testing/src/http-agent.d.ts +47 -0
- package/dist/testing/src/http-agent.js +155 -0
- package/dist/testing/src/index.d.ts +3 -0
- package/dist/testing/src/index.js +2 -0
- package/dist/testing/src/new.d.ts +13 -0
- package/dist/testing/src/new.js +147 -0
- package/dist/testing/src/print-run.d.ts +3 -0
- package/dist/testing/src/print-run.js +22 -0
- package/dist/testing/src/protocol.d.ts +159 -0
- package/dist/testing/src/protocol.js +28 -0
- package/dist/testing/src/report.d.ts +74 -0
- package/dist/testing/src/report.js +90 -0
- package/dist/testing/src/show.d.ts +36 -0
- package/dist/testing/src/show.js +280 -0
- package/dist/testing/src/suite.d.ts +87 -0
- package/dist/testing/src/suite.js +198 -0
- package/docs/agents.md +85 -0
- package/examples/cafe/agent/AGENTS.md +10 -0
- package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
- package/examples/cafe/src/agent.ts +43 -0
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/main.ts +30 -0
- package/examples/cafe/src/tools.ts +62 -0
- package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
- package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
- package/examples/cafe/tests/tools/get_menu.yaml +14 -0
- package/examples/cafe/tests/tools/place_order.yaml +29 -0
- package/examples/http-agent/server.mjs +32 -0
- package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
- package/flow-messaging/README.md +191 -0
- package/package.json +63 -8
- package/spec/agent-endpoint.openapi.yaml +148 -0
- package/spec/nudges.openapi.yaml +167 -0
- package/spec/request.schema.json +121 -0
- package/spec/response.schema.json +284 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{title}} · Flow Framework playground</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f4f4f1; --panel: #ffffff; --ink: #1c1c1a; --quiet: #6b6b66; --line: #e2e1dc; --accent: #2f6fed;
|
|
10
|
+
--good: #2e8b57; --warn: #b7791f; --fold: #8a5cf6; --bad: #c0392b;
|
|
11
|
+
}
|
|
12
|
+
@media (prefers-color-scheme: dark) {
|
|
13
|
+
:root { --bg: #161615; --panel: #1f1f1d; --ink: #ecebe6; --quiet: #9a9a94; --line: #33332f; }
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin: 0; font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--ink); }
|
|
17
|
+
button { font: inherit; }
|
|
18
|
+
.top { display: flex; align-items: center; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; }
|
|
19
|
+
.top h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
|
20
|
+
.top h1 small { color: var(--quiet); font-weight: 400; }
|
|
21
|
+
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
22
|
+
.tab { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--ink); cursor: pointer; }
|
|
23
|
+
.tab img { width: 16px; height: 16px; }
|
|
24
|
+
.tab small { color: var(--quiet); font-size: 11px; }
|
|
25
|
+
.tab[aria-pressed="true"] { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
26
|
+
.tab { position: relative; }
|
|
27
|
+
.tab .unread { position: absolute; top: -5px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #e5484d; color: #fff; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; }
|
|
28
|
+
.spacer { flex: 1; }
|
|
29
|
+
.ghost { padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--ink); cursor: pointer; }
|
|
30
|
+
select.ghost { max-width: 240px; }
|
|
31
|
+
.empty { grid-column: 1 / -1; padding: 32px; text-align: center; color: var(--quiet); }
|
|
32
|
+
.empty code { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 1px 6px; }
|
|
33
|
+
body:not(.play) .play-only, body.play .cloud-only { display: none; }
|
|
34
|
+
main { display: grid; grid-template-columns: minmax(0, 400px) minmax(0, 1fr); gap: 16px; padding: 16px; height: calc(100vh - 58px); }
|
|
35
|
+
@media (max-width: 820px) { main { grid-template-columns: 1fr; height: auto; } .side { height: 520px; } .phone { height: 680px; } }
|
|
36
|
+
|
|
37
|
+
/* The phone */
|
|
38
|
+
.phone { border-radius: 28px; overflow: hidden; display: flex; flex-direction: column; min-height: 0; border: 1px solid var(--line); box-shadow: 0 2px 10px rgba(0,0,0,.06); position: relative; }
|
|
39
|
+
.bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
|
|
40
|
+
.avatar { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; flex: none; }
|
|
41
|
+
.who { display: flex; flex-direction: column; min-width: 0; }
|
|
42
|
+
.who b { font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
43
|
+
.who span { font-size: 12.5px; opacity: .85; }
|
|
44
|
+
.thread { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 2px; }
|
|
45
|
+
.row { display: flex; flex-direction: column; max-width: 80%; position: relative; margin-top: 4px; }
|
|
46
|
+
.row.me { align-self: flex-end; align-items: flex-end; }
|
|
47
|
+
.row.them { align-self: flex-start; align-items: flex-start; }
|
|
48
|
+
.bubble { padding: 6px 9px 6px 10px; border-radius: 18px; white-space: pre-wrap; word-wrap: break-word; position: relative; min-width: 48px; }
|
|
49
|
+
.quote { border-left: 4px solid; border-radius: 6px; padding: 4px 8px; margin: 0 0 4px; font-size: 12.5px; background: rgba(0,0,0,.05); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
|
|
50
|
+
.quote b { display: block; font-size: 12px; }
|
|
51
|
+
.stamp { float: right; margin: 6px 0 -4px 10px; font-size: 11px; opacity: .6; white-space: nowrap; }
|
|
52
|
+
.ticks { font-size: 12px; letter-spacing: -3px; margin-left: 3px; }
|
|
53
|
+
.ticks.read { color: #53bdeb; opacity: 1; }
|
|
54
|
+
.meta { font-size: 10.5px; opacity: .65; margin: 2px 6px 0; }
|
|
55
|
+
.meta.carrier { font-style: italic; }
|
|
56
|
+
.reacts { display: flex; gap: 2px; margin-top: -6px; margin-bottom: 2px; z-index: 1; }
|
|
57
|
+
.react { background: #fff; border-radius: 999px; padding: 0 5px; font-size: 13px; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
|
|
58
|
+
.actions { position: absolute; top: 2px; display: none; gap: 2px; z-index: 2; }
|
|
59
|
+
.row.them .actions { right: -64px; } .row.me .actions { left: -64px; }
|
|
60
|
+
.row:hover .actions, .row:focus-within .actions { display: flex; }
|
|
61
|
+
.actions button { border: 0; background: rgba(0,0,0,.08); border-radius: 999px; width: 28px; height: 28px; cursor: pointer; font-size: 14px; color: #333; }
|
|
62
|
+
.picker { position: absolute; bottom: 100%; display: flex; gap: 2px; background: #fff; padding: 4px 6px; border-radius: 999px; box-shadow: 0 2px 10px rgba(0,0,0,.2); z-index: 3; }
|
|
63
|
+
.row.them .picker { left: 0; } .row.me .picker { right: 0; }
|
|
64
|
+
.picker button { border: 0; background: none; font-size: 20px; cursor: pointer; padding: 2px; color: #111; }
|
|
65
|
+
.picker button.word { font-size: 12px; padding: 4px 6px; border-radius: 999px; background: #f2f2f7; margin: 0 1px; }
|
|
66
|
+
.btns { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; width: 100%; min-width: 220px; }
|
|
67
|
+
.fields { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
|
68
|
+
.fields span { font-size: 11px; padding: 2px 7px; border-radius: 999px; border: 1px dashed currentColor; opacity: .75; }
|
|
69
|
+
.btns button { border: 0; padding: 8px; cursor: pointer; text-align: center; }
|
|
70
|
+
.tpl-name { font-size: 11.5px; opacity: .7; margin-bottom: 2px; }
|
|
71
|
+
.tpl-head { font-weight: 600; margin-bottom: 2px; }
|
|
72
|
+
.tpl-media { display: flex; align-items: center; justify-content: center; width: 260px; max-width: 100%; height: 140px; border-radius: 6px; margin: 0 0 4px; background: rgba(0,0,0,.06); font-size: 13px; opacity: .8; }
|
|
73
|
+
.tpl-foot { display: block; font-size: 12.5px; opacity: .6; margin-top: 2px; }
|
|
74
|
+
.photo { display: block; width: 260px; max-width: 100%; min-height: 140px; max-height: 320px; object-fit: cover; border-radius: 12px; margin: 0 0 4px; background: rgba(0,0,0,.06); }
|
|
75
|
+
/* A link in a custom UI's params, as that UI would show it (the agent's card rules). */
|
|
76
|
+
.linkcard { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; min-width: 220px; max-width: 280px; padding: 10px 12px; border-radius: 14px; border: 1px solid rgba(0,0,0,.1); background: #fff; color: #111; text-align: left; cursor: pointer; }
|
|
77
|
+
.linkcard b { font-size: 14px; color: #2f6fed; }
|
|
78
|
+
.linkcard.call b { color: #1e8e3e; }
|
|
79
|
+
.linkcard small { font-size: 11px; color: #6b6b66; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
80
|
+
.screen { position: absolute; inset: 0; z-index: 6; display: none; flex-direction: column; background: #fff; color: #111; }
|
|
81
|
+
.screen.on { display: flex; }
|
|
82
|
+
.screen .s-bar { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #eee; }
|
|
83
|
+
.screen .s-bar button { border: 0; background: none; font-size: 18px; cursor: pointer; }
|
|
84
|
+
.screen .s-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 20px; text-align: center; }
|
|
85
|
+
.screen code { font-size: 12px; word-break: break-all; color: #6b6b66; }
|
|
86
|
+
.screen.call { background: #1f2a24; color: #fff; }
|
|
87
|
+
.screen.call .s-bar { border-color: rgba(255,255,255,.1); } .screen.call .s-bar button { color: #fff; }
|
|
88
|
+
.screen.call .face { width: 96px; height: 96px; border-radius: 50%; background: #3c5a4a; display: grid; place-items: center; font-size: 40px; }
|
|
89
|
+
.screen.call code { color: #b9c9bf; }
|
|
90
|
+
.screen .end { border: 0; border-radius: 999px; padding: 12px 28px; background: #d93025; color: #fff; cursor: pointer; font-size: 15px; }
|
|
91
|
+
/* Debug mode: the agent's tool calls, inline where they happened; never shown otherwise. */
|
|
92
|
+
.toolcall { align-self: center; width: 92%; margin: 6px 0; padding: 8px 10px; border-radius: 10px; background: rgba(255,255,255,.92); color: #333; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: 0 0 0 1px rgba(0,0,0,.08); }
|
|
93
|
+
body:not(.debug) .toolcall { display: none; }
|
|
94
|
+
.toolcall .tc-head { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
|
95
|
+
.toolcall .tc-head b { font-weight: 600; }
|
|
96
|
+
.toolcall .st { color: #6b6b66; }
|
|
97
|
+
.toolcall .st.bg { color: #8a6d1f; } .toolcall .st.bad { color: #a33; }
|
|
98
|
+
.toolcall details { margin-top: 4px; } .toolcall summary { cursor: pointer; color: #6b6b66; }
|
|
99
|
+
.toolcall pre { margin: 4px 0 0; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto; background: #f6f6f4; padding: 6px 8px; border-radius: 6px; }
|
|
100
|
+
.toolcall ul { margin: 4px 0 0; padding-left: 16px; }
|
|
101
|
+
/* Embedded (a dashboard shows just the phone) and replay (a saved conversation, read only). */
|
|
102
|
+
.embed .top, .embed .side { display: none; }
|
|
103
|
+
.embed main { grid-template-columns: 1fr; height: 100vh; padding: 0; }
|
|
104
|
+
.embed .phone { border-radius: 0; border: 0; box-shadow: none; height: 100vh; }
|
|
105
|
+
.replay .compose, .replay .replying, .replay #reset { display: none; }
|
|
106
|
+
.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
|
|
107
|
+
.sysnote { align-self: center; font-size: 11.5px; padding: 4px 10px; border-radius: 8px; margin: 6px 0; max-width: 90%; text-align: center; }
|
|
108
|
+
.sysnote.bad { background: #fdecea; color: var(--bad); }
|
|
109
|
+
.dots { display: inline-flex; gap: 4px; padding: 10px 12px; border-radius: 18px; align-self: flex-start; align-items: center; margin-top: 4px; }
|
|
110
|
+
.dots i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .4; animation: blink 1.2s infinite; }
|
|
111
|
+
.dots i:nth-child(2) { animation-delay: .2s; } .dots i:nth-child(3) { animation-delay: .4s; }
|
|
112
|
+
.dots .status { font-size: 12.5px; margin-left: 6px; opacity: .85; }
|
|
113
|
+
@keyframes blink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: .9; } }
|
|
114
|
+
.replying { display: none; margin: 0 10px; padding: 6px 8px; border-left: 4px solid; border-radius: 6px; font-size: 12.5px; position: relative; }
|
|
115
|
+
.replying.on { display: block; }
|
|
116
|
+
.replying button { position: absolute; right: 4px; top: 2px; border: 0; background: none; cursor: pointer; font-size: 14px; }
|
|
117
|
+
.compose { display: flex; gap: 8px; padding: 8px 10px 10px; align-items: center; }
|
|
118
|
+
.compose input { flex: 1; padding: 9px 14px; border-radius: 999px; border: 1px solid rgba(0,0,0,.12); font: inherit; outline: none; background: #fff; color: #111; min-width: 0; }
|
|
119
|
+
.compose button { border: 0; border-radius: 999px; width: 38px; height: 38px; font-size: 16px; cursor: pointer; color: #fff; flex: none; }
|
|
120
|
+
.counter { font-size: 11px; opacity: .6; min-width: 44px; text-align: right; }
|
|
121
|
+
.compose .attach { cursor: pointer; font-size: 18px; opacity: .7; flex: none; }
|
|
122
|
+
.bubble .file { font-size: 13px; opacity: .85; }
|
|
123
|
+
.sheet { position: absolute; inset: auto 0 0 0; background: #fff; color: #111; border-radius: 16px 16px 0 0; box-shadow: 0 -4px 20px rgba(0,0,0,.2); padding: 8px 0 12px; display: none; max-height: 70%; overflow-y: auto; z-index: 5; }
|
|
124
|
+
.sheet.on { display: block; }
|
|
125
|
+
.sheet h3 { margin: 4px 16px 8px; font-size: 15px; text-align: center; }
|
|
126
|
+
.sheet label { display: flex; gap: 10px; align-items: center; padding: 10px 16px; border-top: 1px solid #f0f0f0; cursor: pointer; }
|
|
127
|
+
.sheet label small { display: block; color: #667781; }
|
|
128
|
+
.sheet .send { display: block; margin: 10px auto 0; border: 0; border-radius: 999px; padding: 10px 24px; background: #00a884; color: #fff; cursor: pointer; }
|
|
129
|
+
|
|
130
|
+
/* SMS (Android Messages) */
|
|
131
|
+
.p-sms { background: #fff; color: #111; }
|
|
132
|
+
.p-sms .bar { background: #fff; border-bottom: 1px solid #eee; }
|
|
133
|
+
.p-sms .avatar { background: #e8def8; color: #4a3c75; }
|
|
134
|
+
.p-sms .me .bubble { background: #d3e3fd; color: #041e49; }
|
|
135
|
+
.p-sms .them .bubble { background: #f1f3f4; color: #111; }
|
|
136
|
+
.p-sms .compose button { background: #0b57d0; }
|
|
137
|
+
.p-sms .sysnote:not(.bad) { color: #5f6368; }
|
|
138
|
+
|
|
139
|
+
/* WhatsApp */
|
|
140
|
+
.p-whatsapp { background: #efeae2; color: #111; }
|
|
141
|
+
.p-whatsapp .bar { background: #008069; color: #fff; }
|
|
142
|
+
.p-whatsapp .avatar { background: #dfe5e7; color: #54656f; }
|
|
143
|
+
.p-whatsapp .bubble { border-radius: 8px; box-shadow: 0 1px .5px rgba(11,20,26,.13); }
|
|
144
|
+
.p-whatsapp .me .bubble { background: #d9fdd3; }
|
|
145
|
+
.p-whatsapp .them .bubble { background: #fff; }
|
|
146
|
+
.p-whatsapp .quote { border-color: #06cf9c; }
|
|
147
|
+
.p-whatsapp .quote b { color: #06cf9c; }
|
|
148
|
+
.p-whatsapp .them .quote { border-color: #53bdeb; } .p-whatsapp .them .quote b { color: #027eb5; }
|
|
149
|
+
.p-whatsapp .btns button { background: #fff; color: #027eb5; border-radius: 8px; box-shadow: 0 1px .5px rgba(11,20,26,.13); }
|
|
150
|
+
.p-whatsapp .sysnote:not(.bad) { background: #fff7d6; color: #54656f; }
|
|
151
|
+
.p-whatsapp .replying { background: #f0f2f5; border-color: #06cf9c; color: #54656f; }
|
|
152
|
+
.p-whatsapp .compose { background: #f0f2f5; }
|
|
153
|
+
.p-whatsapp .compose button { background: #00a884; }
|
|
154
|
+
|
|
155
|
+
/* Not yet modelled in full */
|
|
156
|
+
.p-imessage { background: #fff; color: #111; }
|
|
157
|
+
.p-imessage .bar { background: #f6f6f6; border-bottom: 1px solid #e5e5e5; flex-direction: column; gap: 2px; }
|
|
158
|
+
.p-imessage .bar .who { align-items: center; }
|
|
159
|
+
.p-imessage .avatar { background: linear-gradient(#a5abb8, #858994); color: #fff; }
|
|
160
|
+
.p-imessage .me .bubble { background: #0a84ff; color: #fff; }
|
|
161
|
+
.p-imessage .them .bubble { background: #e9e9eb; color: #111; }
|
|
162
|
+
.p-imessage .dots { background: #e9e9eb; color: #555; }
|
|
163
|
+
.p-imessage .compose button { background: #0a84ff; }
|
|
164
|
+
.p-telegram { background: #c8dcea; color: #111; }
|
|
165
|
+
.p-telegram .bar { background: #fff; border-bottom: 1px solid #ddd; }
|
|
166
|
+
.p-telegram .avatar { background: #3390ec; color: #fff; }
|
|
167
|
+
.p-telegram .bubble { border-radius: 14px; }
|
|
168
|
+
.p-telegram .me .bubble { background: #e3fee0; }
|
|
169
|
+
.p-telegram .them .bubble { background: #fff; }
|
|
170
|
+
.p-telegram .compose { background: #fff; }
|
|
171
|
+
.p-telegram .compose button { background: #3390ec; }
|
|
172
|
+
.p-web { background: #fafaf8; color: #111; }
|
|
173
|
+
.p-web .bar { background: #fff; border-bottom: 1px solid #eee; }
|
|
174
|
+
.p-web .avatar { background: #2f6fed; color: #fff; }
|
|
175
|
+
.p-web .me .bubble { background: #2f6fed; color: #fff; }
|
|
176
|
+
.p-web .them .bubble { background: #ecebe6; color: #111; }
|
|
177
|
+
.p-web .dots { background: #ecebe6; color: #444; }
|
|
178
|
+
.p-web .compose button { background: #2f6fed; }
|
|
179
|
+
|
|
180
|
+
/* Side panel */
|
|
181
|
+
.side { display: flex; flex-direction: column; gap: 16px; min-height: 0; }
|
|
182
|
+
.side > .card:last-child { flex: 1; }
|
|
183
|
+
.backend { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 14px; }
|
|
184
|
+
[hidden] { display: none !important; }
|
|
185
|
+
.sidetabs { display: flex; gap: 4px; padding: 2px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; flex: none; }
|
|
186
|
+
.sidetabs button { flex: 1; border: 0; background: transparent; color: var(--quiet); padding: 7px 10px; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; position: relative; }
|
|
187
|
+
.sidetabs button[aria-selected="true"] { background: var(--bg); color: var(--ink); box-shadow: 0 1px 2px rgba(0,0,0,.08); }
|
|
188
|
+
.sidetabs .dot { position: absolute; top: 6px; right: 8px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
|
189
|
+
.side > .pane { flex: 1; }
|
|
190
|
+
#pPerson { overflow-y: auto; }
|
|
191
|
+
.sec { padding: 14px 16px; border-bottom: 1px solid var(--line); display: grid; gap: 10px; }
|
|
192
|
+
.sec:last-child { border-bottom: 0; }
|
|
193
|
+
.sec .label { font-size: 12px; font-weight: 600; color: var(--ink); }
|
|
194
|
+
.hint { font-weight: 400; color: var(--quiet); font-size: 12px; margin-left: 4px; }
|
|
195
|
+
.dim { color: var(--quiet); font-size: 13px; }
|
|
196
|
+
.now { grid-template-columns: 1fr auto; align-items: center; }
|
|
197
|
+
.big { font-size: 22px; font-weight: 600; letter-spacing: -.01em; margin-top: 2px; }
|
|
198
|
+
.pill { font-size: 12px; padding: 6px 10px; border-radius: 999px; border: 1px solid var(--line); max-width: 220px; text-align: center; }
|
|
199
|
+
.pill.quiet { background: #fff4e5; border-color: #f5c27a; color: #8a5300; }
|
|
200
|
+
.pill.open { background: #e9f7ef; border-color: #9fd9b4; color: #1f6b3d; }
|
|
201
|
+
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .pill.quiet { background: #3a2a10; color: #f5c27a; } :root:not([data-theme="light"]) .pill.open { background: #12301e; color: #9fd9b4; } }
|
|
202
|
+
.row2 { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
203
|
+
.sec input, .sec select, .sec textarea { font: inherit; font-size: 13px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); min-width: 0; }
|
|
204
|
+
.sec textarea { width: 100%; resize: vertical; }
|
|
205
|
+
.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; }
|
|
206
|
+
.grid2 label { display: grid; gap: 4px; font-size: 12px; color: var(--quiet); }
|
|
207
|
+
.check { display: flex; gap: 6px; align-items: center; font-size: 13px; }
|
|
208
|
+
details summary { cursor: pointer; font-size: 12px; color: var(--quiet); margin-bottom: 8px; }
|
|
209
|
+
details.sec > summary { margin: 0; color: var(--ink); }
|
|
210
|
+
details.sec:not([open]) { gap: 0; }
|
|
211
|
+
details.sec:not([open]) > :not(summary) { display: none; }
|
|
212
|
+
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; width: fit-content; }
|
|
213
|
+
.seg button { border: 0; background: transparent; color: var(--ink); padding: 6px 12px; font: inherit; font-size: 13px; cursor: pointer; }
|
|
214
|
+
.seg button + button { border-left: 1px solid var(--line); }
|
|
215
|
+
.seg button[aria-pressed="true"] { background: var(--accent); color: #fff; }
|
|
216
|
+
.primary { background: var(--accent); color: #fff; border: 0; border-radius: 8px; padding: 8px 14px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
217
|
+
.primary:disabled { opacity: .6; }
|
|
218
|
+
.items { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
219
|
+
.items li { border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; display: grid; gap: 3px; font-size: 13px; }
|
|
220
|
+
.items .tag { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--quiet); }
|
|
221
|
+
.items .tag.held { color: #b35c00; }
|
|
222
|
+
.items .what { white-space: pre-wrap; }
|
|
223
|
+
.items li.empty { border-style: dashed; color: var(--quiet); }
|
|
224
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; display: flex; flex-direction: column; min-height: 0; }
|
|
225
|
+
.card h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; }
|
|
226
|
+
.card h2 span { font-weight: 400; color: var(--quiet); }
|
|
227
|
+
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
|
228
|
+
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
229
|
+
th { color: var(--quiet); font-weight: 500; }
|
|
230
|
+
td.msg { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
|
|
231
|
+
.metrics { max-height: 220px; overflow-y: auto; }
|
|
232
|
+
.log { flex: 1; overflow-y: auto; padding: 6px 14px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
233
|
+
.log div { display: grid; grid-template-columns: 70px 1fr; gap: 8px; border-bottom: 1px dashed var(--line); padding: 2px 0; }
|
|
234
|
+
.log .t { color: var(--quiet); }
|
|
235
|
+
.tag { font-weight: 600; } .tag.inline { color: var(--good); } .tag.bg { color: var(--warn); } .tag.fold, .tag.ack { color: var(--fold); } .tag.bad { color: var(--bad); }
|
|
236
|
+
</style>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
<div class="top">
|
|
240
|
+
<h1>{{title}} <small>· Flow Framework playground</small></h1>
|
|
241
|
+
<div class="tabs" id="tabs" role="group" aria-label="Platform"></div>
|
|
242
|
+
<div class="spacer"></div>
|
|
243
|
+
<select class="ghost play-only" id="who" aria-label="Test person" title="Who you are: one of the agent's test people, the same person on every channel"></select>
|
|
244
|
+
<button class="ghost" id="debug" type="button" aria-pressed="false" title="Show the agent's tool calls in the conversation">Debug</button>
|
|
245
|
+
<button class="ghost cloud-only" id="reset" type="button">New conversation</button>
|
|
246
|
+
<button class="ghost play-only" id="clear" type="button" title="Forget this person: their history, schedules and everything the agent kept about them">Clear history</button>
|
|
247
|
+
</div>
|
|
248
|
+
<main>
|
|
249
|
+
<section class="phone" id="phone" aria-label="Simulator">
|
|
250
|
+
<div class="bar" id="bar">
|
|
251
|
+
<div class="avatar" id="avatar"></div>
|
|
252
|
+
<div class="who"><b id="name">{{title}}</b><span id="sub"></span></div>
|
|
253
|
+
</div>
|
|
254
|
+
<div class="thread" id="thread" aria-live="polite"></div>
|
|
255
|
+
<div class="replying" id="replying"><b>Replying to</b><div id="replyingText"></div><button type="button" id="cancelReply" aria-label="Cancel reply">✕</button></div>
|
|
256
|
+
<form class="compose" id="form">
|
|
257
|
+
<label class="attach" title="Send a photo, voice note or PDF (the message box is its caption)">📎<input type="file" id="file" accept="image/*,audio/*,application/pdf" hidden></label>
|
|
258
|
+
<input id="text" autocomplete="off" placeholder="Message" aria-label="Message">
|
|
259
|
+
<span class="counter" id="counter"></span>
|
|
260
|
+
<button type="submit" aria-label="Send">➤</button>
|
|
261
|
+
</form>
|
|
262
|
+
<div class="sheet" id="sheet" role="dialog" aria-label="Choose an option"></div>
|
|
263
|
+
<div class="screen" id="screen" role="dialog" aria-label="Opened link"></div>
|
|
264
|
+
</section>
|
|
265
|
+
<div class="side">
|
|
266
|
+
<nav class="sidetabs" role="tablist" aria-label="Side panel">
|
|
267
|
+
<button type="button" role="tab" data-pane="pPerson" id="tabPerson" hidden>Person</button>
|
|
268
|
+
<button type="button" role="tab" data-pane="pTurns">Turns</button>
|
|
269
|
+
<button type="button" role="tab" data-pane="pTrace">Trace <span class="dot" id="traceDot" hidden></span></button>
|
|
270
|
+
</nav>
|
|
271
|
+
<section class="card pane" id="pPerson" role="tabpanel" hidden>
|
|
272
|
+
<div class="sec now">
|
|
273
|
+
<div>
|
|
274
|
+
<div class="label">Their time</div>
|
|
275
|
+
<div class="big" id="theirTime">–</div>
|
|
276
|
+
<div class="dim" id="theirZone"></div>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="pill" id="quietPill" hidden></div>
|
|
279
|
+
</div>
|
|
280
|
+
<details class="sec" id="clockSec" hidden>
|
|
281
|
+
<summary class="label">Move the clock <span class="hint">reminders and held nudges come due as it moves</span></summary>
|
|
282
|
+
<div class="row2">
|
|
283
|
+
<button type="button" class="ghost" data-advance="900000">+15 min</button>
|
|
284
|
+
<button type="button" class="ghost" data-advance="3600000">+1 hour</button>
|
|
285
|
+
<button type="button" class="ghost" data-advance="86400000">+1 day</button>
|
|
286
|
+
<button type="button" class="ghost" id="toQuietEnd" title="Jump to when their quiet hours end">To end of quiet hours</button>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="row2"><input type="datetime-local" id="clockTo" aria-label="Move the clock to (your time)"><button type="button" class="ghost" id="clockGo">Go to this time</button><span class="hint">your time</span></div>
|
|
289
|
+
</details>
|
|
290
|
+
<div class="sec">
|
|
291
|
+
<div class="label">Waiting to go</div>
|
|
292
|
+
<ul class="items" id="waiting"></ul>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="sec" id="nudgeSec" hidden>
|
|
295
|
+
<div class="label">Send a nudge <span class="hint">as your backend would, through the API</span></div>
|
|
296
|
+
<div class="seg" role="radiogroup" aria-label="Kind of nudge">
|
|
297
|
+
<button type="button" data-kind="prompt" aria-pressed="true" title="The agent writes it in its own words">Prompt</button>
|
|
298
|
+
<button type="button" data-kind="text" aria-pressed="false" title="Sent exactly as written">Exact text</button>
|
|
299
|
+
<button type="button" data-kind="template" aria-pressed="false" title="A WhatsApp template you set up with Meta">Template</button>
|
|
300
|
+
</div>
|
|
301
|
+
<textarea id="nText" rows="3" placeholder="What the agent should tell them, e.g. Their order 1042 shipped"></textarea>
|
|
302
|
+
<div class="row2" id="nTemplateRow" hidden>
|
|
303
|
+
<select id="nTemplate" aria-label="Template"></select>
|
|
304
|
+
<input id="nValues" placeholder="values for {{1}}, {{2}}… comma separated" aria-label="Template body values">
|
|
305
|
+
</div>
|
|
306
|
+
<label class="check"><input type="checkbox" id="nQuiet" checked> Wait for their quiet hours to end</label>
|
|
307
|
+
<details>
|
|
308
|
+
<summary>More options</summary>
|
|
309
|
+
<div class="grid2">
|
|
310
|
+
<label>Send at <span class="hint">your time</span><input type="datetime-local" id="nAt"></label>
|
|
311
|
+
<label>Drop if still held after <span class="hint">minutes</span><input id="nExpires" inputmode="numeric" placeholder="1440"></label>
|
|
312
|
+
<label>Idempotency key<input id="nKey" placeholder="order-1042-shipped"></label>
|
|
313
|
+
</div>
|
|
314
|
+
</details>
|
|
315
|
+
<div class="row2"><button type="button" class="primary" id="nSend">Send nudge</button><span class="hint" id="nResult"></span></div>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="sec">
|
|
318
|
+
<div class="label">Contact settings</div>
|
|
319
|
+
<div class="dim" id="contactLine"></div>
|
|
320
|
+
<div class="grid2">
|
|
321
|
+
<label>Time zone<span class="row2"><input id="tz" placeholder="America/New_York" list="zones"><button type="button" class="ghost" id="tzSet">Save</button></span></label>
|
|
322
|
+
<label>Quiet hours<span class="row2"><input id="qFrom" placeholder="22:00" size="5" aria-label="From"> – <input id="qTo" placeholder="07:00" size="5" aria-label="To"><button type="button" class="ghost" id="qSet">Save</button></span></label>
|
|
323
|
+
</div>
|
|
324
|
+
<div class="row2"><button type="button" class="ghost" id="qOff">No quiet hours for them</button><button type="button" class="ghost" id="qDefault">Use the agent's</button></div>
|
|
325
|
+
<datalist id="zones"><option value="America/New_York"><option value="America/Chicago"><option value="America/Denver"><option value="America/Los_Angeles"><option value="Europe/London"><option value="Asia/Kolkata"><option value="Asia/Singapore"><option value="Australia/Sydney"></datalist>
|
|
326
|
+
</div>
|
|
327
|
+
</section>
|
|
328
|
+
<section class="card pane" id="pTurns" role="tabpanel" hidden>
|
|
329
|
+
<h2>Per turn <span id="status"></span></h2>
|
|
330
|
+
<div class="metrics">
|
|
331
|
+
<table>
|
|
332
|
+
<thead><tr><th>You sent</th><th>Acknowledged</th><th>Last message</th><th>Model calls</th></tr></thead>
|
|
333
|
+
<tbody id="turns"></tbody>
|
|
334
|
+
</table>
|
|
335
|
+
</div>
|
|
336
|
+
</section>
|
|
337
|
+
<section class="card pane" id="pTrace" role="tabpanel" hidden>
|
|
338
|
+
<h2>Trace</h2>
|
|
339
|
+
<div class="log" id="log"></div>
|
|
340
|
+
</section>
|
|
341
|
+
</div>
|
|
342
|
+
</main>
|
|
343
|
+
<script>
|
|
344
|
+
const BOOT = "{{boot}}";
|
|
345
|
+
// Where the simulators are mounted ("" on their own port, "/simulator" inside the test cloud).
|
|
346
|
+
const BASE = "{{base}}";
|
|
347
|
+
// The playground plays the agent's test people on its own channels; the test cloud, generated people on any platform.
|
|
348
|
+
const PLAY = "{{mode}}" === "playground";
|
|
349
|
+
if (PLAY) document.body.classList.add("play");
|
|
350
|
+
const params = new URLSearchParams(location.search);
|
|
351
|
+
// ?replay=<url of a saved run> shows that conversation read only; ?embed=1 shows only the phone.
|
|
352
|
+
const REPLAY = params.get("replay");
|
|
353
|
+
let replayData = null;
|
|
354
|
+
if (REPLAY) document.body.classList.add("replay");
|
|
355
|
+
if (params.get("embed") === "1") document.body.classList.add("embed");
|
|
356
|
+
const remember = (k, v) => { if (!params.has("embed")) { try { localStorage.setItem(k, v); } catch {} } };
|
|
357
|
+
const recall = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
|
358
|
+
let person = params.get("person") || (PLAY ? recall("chinwag.person") : null) || "demo";
|
|
359
|
+
let people = []; // the playground: the agent's test people
|
|
360
|
+
let channels = []; // the playground: the agent's channels, each with the platform that plays it
|
|
361
|
+
let title = document.getElementById("name").textContent;
|
|
362
|
+
const $ = (id) => document.getElementById(id);
|
|
363
|
+
const TAPBACKS = [["❤️", "Loved"], ["👍", "Liked"], ["👎", "Disliked"], ["😂", "Laughed at"], ["‼️", "Emphasized"], ["❓", "Questioned"]];
|
|
364
|
+
let platforms = [];
|
|
365
|
+
let cards = [];
|
|
366
|
+
let current = null;
|
|
367
|
+
let source = null;
|
|
368
|
+
let typingEl = null;
|
|
369
|
+
let statusText = null;
|
|
370
|
+
let turns = [];
|
|
371
|
+
let replyTo = null; // { id, text, fromAgent }
|
|
372
|
+
const rows = new Map(); // message id → row element
|
|
373
|
+
|
|
374
|
+
const where = () => PLAY ? { channel: current.channel, person } : { platform: current.id, person };
|
|
375
|
+
const query = () => "?" + new URLSearchParams(where()).toString();
|
|
376
|
+
const fmt = (ms) => ms == null ? "…" : ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
|
377
|
+
const clock = (at) => new Date(at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
378
|
+
const subtitle = () => ({ sms: person, whatsapp: "online", imessage: "iMessage", telegram: "bot", web: "Usually replies instantly" })[current.id];
|
|
379
|
+
|
|
380
|
+
// WhatsApp's own formatting: *bold*, _italic_, ~strike~, ```mono```.
|
|
381
|
+
function whatsappHtml(text) {
|
|
382
|
+
const esc = text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
383
|
+
return esc
|
|
384
|
+
.replace(/```([\s\S]+?)```/g, "<code>$1</code>")
|
|
385
|
+
.replace(/(^|[\s(])\*(\S(?:[^*\n]*\S)?)\*(?=[\s).,!?:;]|$)/g, "$1<b>$2</b>")
|
|
386
|
+
.replace(/(^|[\s(])_(\S(?:[^_\n]*\S)?)_(?=[\s).,!?:;]|$)/g, "$1<i>$2</i>")
|
|
387
|
+
.replace(/(^|[\s(])~(\S(?:[^~\n]*\S)?)~(?=[\s).,!?:;]|$)/g, "$1<s>$2</s>");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function setPlatform(id) {
|
|
391
|
+
if (PLAY) {
|
|
392
|
+
const ch = channels.find((c) => c.name === id) || channels[0];
|
|
393
|
+
current = { ...ch.platform, channel: ch.name };
|
|
394
|
+
remember("chinwag.channel", ch.name);
|
|
395
|
+
} else {
|
|
396
|
+
current = platforms.find((p) => p.id === id) || platforms[0];
|
|
397
|
+
if (!REPLAY) remember("chinwag.platform", current.id);
|
|
398
|
+
}
|
|
399
|
+
const tab = PLAY ? current.channel : current.id;
|
|
400
|
+
for (const b of document.querySelectorAll(".tab")) b.setAttribute("aria-pressed", String(b.dataset.id === tab));
|
|
401
|
+
void refreshPerson();
|
|
402
|
+
$("phone").className = "phone p-" + current.id;
|
|
403
|
+
$("avatar").textContent = title.trim().charAt(0).toUpperCase();
|
|
404
|
+
$("sub").textContent = subtitle();
|
|
405
|
+
$("thread").innerHTML = "";
|
|
406
|
+
$("log").innerHTML = "";
|
|
407
|
+
rows.clear();
|
|
408
|
+
turns = [];
|
|
409
|
+
renderTurns();
|
|
410
|
+
typingEl = null;
|
|
411
|
+
statusText = null;
|
|
412
|
+
setReply(null);
|
|
413
|
+
$("sheet").classList.remove("on");
|
|
414
|
+
$("text").placeholder = current.id === "sms" ? "Text message" : "Message";
|
|
415
|
+
updateCounter();
|
|
416
|
+
if (!current.complete) sysnote("This simulator doesn't model every feature of the real platform yet.");
|
|
417
|
+
connect();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function sysnote(text, bad) {
|
|
421
|
+
const el = document.createElement("div");
|
|
422
|
+
el.className = "sysnote" + (bad ? " bad" : "");
|
|
423
|
+
el.textContent = text;
|
|
424
|
+
insert(el);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function insert(el) {
|
|
428
|
+
const thread = $("thread");
|
|
429
|
+
thread.insertBefore(el, typingEl && typingEl.isConnected ? typingEl : null);
|
|
430
|
+
thread.scrollTop = thread.scrollHeight;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function quoteEl(id) {
|
|
434
|
+
const target = rows.get(id);
|
|
435
|
+
if (!target) return null;
|
|
436
|
+
const q = document.createElement("div");
|
|
437
|
+
q.className = "quote";
|
|
438
|
+
const who = document.createElement("b");
|
|
439
|
+
who.textContent = target.classList.contains("me") ? "You" : title;
|
|
440
|
+
q.append(who, document.createTextNode(target.dataset.text));
|
|
441
|
+
return q;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function addMessage(line) {
|
|
445
|
+
const mine = line.kind === "in";
|
|
446
|
+
const row = document.createElement("div");
|
|
447
|
+
row.className = "row " + (mine ? "me" : "them");
|
|
448
|
+
row.dataset.text = line.text;
|
|
449
|
+
row.dataset.at = String(line.at);
|
|
450
|
+
if (line.id) { row.dataset.id = line.id; rows.set(line.id, row); }
|
|
451
|
+
const b = document.createElement("div");
|
|
452
|
+
b.className = "bubble";
|
|
453
|
+
if (line.replyTo) { const q = quoteEl(line.replyTo); if (q) b.appendChild(q); }
|
|
454
|
+
for (const a of line.attachments || []) {
|
|
455
|
+
const f = document.createElement("div");
|
|
456
|
+
f.className = "file";
|
|
457
|
+
f.textContent = "📎 " + (a.name || { image: "Photo", audio: "Voice note", document: "Document" }[a.kind]);
|
|
458
|
+
b.appendChild(f);
|
|
459
|
+
}
|
|
460
|
+
if (line.image) {
|
|
461
|
+
// A photo, the text under it its caption. Loaded from its URL, as the platform would fetch it.
|
|
462
|
+
const img = document.createElement("img");
|
|
463
|
+
img.className = "photo"; img.src = line.image.url; img.alt = "Photo"; img.loading = "lazy"; img.referrerPolicy = "no-referrer";
|
|
464
|
+
img.title = line.image.url;
|
|
465
|
+
b.appendChild(img);
|
|
466
|
+
}
|
|
467
|
+
// A WhatsApp template. Declared (it has its buttons, even none): header, filled body, footer,
|
|
468
|
+
// buttons, as WhatsApp shows it. Not declared: its name over the values it was sent with.
|
|
469
|
+
const tpl = line.template;
|
|
470
|
+
const declared = tpl && Array.isArray(tpl.buttons);
|
|
471
|
+
if (tpl) {
|
|
472
|
+
b.title = `Template ${tpl.name}`;
|
|
473
|
+
if (!declared) { const n = document.createElement("div"); n.className = "tpl-name"; n.textContent = `Template “${tpl.name}”`; b.appendChild(n); }
|
|
474
|
+
const h = tpl.header;
|
|
475
|
+
if (h && h.kind === "text") { const el = document.createElement("div"); el.className = "tpl-head"; el.textContent = h.text; b.appendChild(el); }
|
|
476
|
+
else if (h && h.kind === "image" && h.url && h.url.startsWith("https://")) {
|
|
477
|
+
const img = document.createElement("img");
|
|
478
|
+
img.className = "photo"; img.src = h.url; img.alt = "Photo"; img.loading = "lazy"; img.referrerPolicy = "no-referrer"; img.title = h.url;
|
|
479
|
+
b.appendChild(img);
|
|
480
|
+
} else if (h) {
|
|
481
|
+
const el = document.createElement("div");
|
|
482
|
+
el.className = "tpl-media";
|
|
483
|
+
el.textContent = { image: "🖼 Photo", video: "▶ Video", document: "📄 Document" }[h.kind] || h.kind;
|
|
484
|
+
if (h.url || h.fileId) el.title = h.url || h.fileId;
|
|
485
|
+
b.appendChild(el);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const body = document.createElement("span");
|
|
489
|
+
if (current.formatting === "whatsapp") body.innerHTML = whatsappHtml(line.text);
|
|
490
|
+
else body.textContent = line.text;
|
|
491
|
+
b.appendChild(body);
|
|
492
|
+
if (tpl && tpl.footer) { const f = document.createElement("span"); f.className = "tpl-foot"; f.textContent = tpl.footer; b.appendChild(f); }
|
|
493
|
+
if (current.id === "whatsapp") {
|
|
494
|
+
const stamp = document.createElement("span");
|
|
495
|
+
stamp.className = "stamp";
|
|
496
|
+
stamp.textContent = clock(line.at);
|
|
497
|
+
if (mine) { const t = document.createElement("span"); t.className = "ticks"; t.textContent = line.id ? "✓✓" : "✓"; stamp.appendChild(t); }
|
|
498
|
+
b.appendChild(stamp);
|
|
499
|
+
}
|
|
500
|
+
row.appendChild(b);
|
|
501
|
+
if (declared && tpl.buttons.length) {
|
|
502
|
+
// A quick reply comes back as their tap, with its payload; a URL or phone button opens, as a link button does.
|
|
503
|
+
const btns = document.createElement("div");
|
|
504
|
+
btns.className = "btns";
|
|
505
|
+
for (const t of tpl.buttons) {
|
|
506
|
+
const btn = document.createElement("button");
|
|
507
|
+
btn.type = "button";
|
|
508
|
+
if (t.type === "quick_reply") {
|
|
509
|
+
btn.textContent = "↩ " + t.text;
|
|
510
|
+
btn.addEventListener("click", () => send(t.text, { choice: { id: t.payload, title: t.text }, replyTo: line.id }));
|
|
511
|
+
} else {
|
|
512
|
+
const url = t.type === "url" ? t.url : "tel:" + t.phone;
|
|
513
|
+
btn.textContent = (t.type === "url" ? "↗ " : "📞 ") + t.text;
|
|
514
|
+
btn.title = url;
|
|
515
|
+
btn.addEventListener("click", () => openScreen(cards.find((c) => url.includes(c.match)) || { label: t.text, kind: "page" }, url));
|
|
516
|
+
}
|
|
517
|
+
btns.appendChild(btn);
|
|
518
|
+
}
|
|
519
|
+
row.appendChild(btns);
|
|
520
|
+
}
|
|
521
|
+
if (line.buttons) {
|
|
522
|
+
const btns = document.createElement("div");
|
|
523
|
+
btns.className = "btns";
|
|
524
|
+
for (const label of line.buttons) {
|
|
525
|
+
const btn = document.createElement("button");
|
|
526
|
+
btn.type = "button";
|
|
527
|
+
btn.textContent = "↩ " + label;
|
|
528
|
+
btn.addEventListener("click", () => send(label, { choice: { title: label }, replyTo: line.id }));
|
|
529
|
+
btns.appendChild(btn);
|
|
530
|
+
}
|
|
531
|
+
row.appendChild(btns);
|
|
532
|
+
}
|
|
533
|
+
if (line.list) {
|
|
534
|
+
const btns = document.createElement("div");
|
|
535
|
+
btns.className = "btns";
|
|
536
|
+
const btn = document.createElement("button");
|
|
537
|
+
btn.type = "button";
|
|
538
|
+
btn.textContent = "☰ " + line.list.button;
|
|
539
|
+
btn.addEventListener("click", () => openList(line));
|
|
540
|
+
btns.appendChild(btn);
|
|
541
|
+
row.appendChild(btns);
|
|
542
|
+
}
|
|
543
|
+
if (line.link) {
|
|
544
|
+
// A button that opens a page (WhatsApp's call-to-action URL button). Nothing is really opened.
|
|
545
|
+
const btns = document.createElement("div");
|
|
546
|
+
btns.className = "btns";
|
|
547
|
+
const btn = document.createElement("button");
|
|
548
|
+
btn.type = "button";
|
|
549
|
+
btn.textContent = "↗ " + line.link.label;
|
|
550
|
+
btn.title = line.link.url;
|
|
551
|
+
const url = line.link.url;
|
|
552
|
+
btn.addEventListener("click", () => openScreen(cards.find((c) => url.includes(c.match)) || { label: line.link.label, kind: "page" }, url));
|
|
553
|
+
btns.appendChild(btn);
|
|
554
|
+
row.appendChild(btns);
|
|
555
|
+
}
|
|
556
|
+
if (line.fields) {
|
|
557
|
+
// The channel's own params: a link shows as the app would show it (a card), anything else as a chip.
|
|
558
|
+
const fields = document.createElement("div");
|
|
559
|
+
fields.className = "fields";
|
|
560
|
+
for (const [name, value] of Object.entries(line.fields)) {
|
|
561
|
+
const url = typeof value === "string" ? value : value && typeof value.url === "string" ? value.url : null;
|
|
562
|
+
if (url && /^[a-z][a-z0-9+.-]*:/i.test(url)) { row.appendChild(linkCard(name, url)); continue; }
|
|
563
|
+
const chip = document.createElement("span");
|
|
564
|
+
const shown = value && typeof value === "object" ? (value.screen ?? value.url ?? JSON.stringify(value)) : String(value);
|
|
565
|
+
chip.textContent = `→ ${name}: ${shown}`;
|
|
566
|
+
chip.title = JSON.stringify(value);
|
|
567
|
+
fields.appendChild(chip);
|
|
568
|
+
}
|
|
569
|
+
if (fields.children.length) row.appendChild(fields);
|
|
570
|
+
}
|
|
571
|
+
const reacts = document.createElement("div");
|
|
572
|
+
reacts.className = "reacts";
|
|
573
|
+
row.appendChild(reacts);
|
|
574
|
+
if (current.id !== "whatsapp") {
|
|
575
|
+
const meta = document.createElement("div");
|
|
576
|
+
meta.className = "meta" + (line.carrier ? " carrier" : "");
|
|
577
|
+
let text = clock(line.at);
|
|
578
|
+
if (line.sms && (line.sms.segments > 1 || line.sms.encoding === "Unicode")) text += ` · ${line.sms.segments} segment${line.sms.segments > 1 ? "s" : ""}, ${line.sms.encoding}`;
|
|
579
|
+
if (line.carrier) text += " · sent by the carrier";
|
|
580
|
+
meta.textContent = text;
|
|
581
|
+
row.appendChild(meta);
|
|
582
|
+
}
|
|
583
|
+
addActions(row, line);
|
|
584
|
+
insert(row);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function linkCard(name, url) {
|
|
588
|
+
const rule = cards.find((c) => url.includes(c.match)) || { label: "Open in app", kind: "page" };
|
|
589
|
+
const card = document.createElement("button");
|
|
590
|
+
card.type = "button";
|
|
591
|
+
card.className = "linkcard" + (rule.kind === "call" ? " call" : "");
|
|
592
|
+
card.title = `${name}: ${url}`;
|
|
593
|
+
const label = document.createElement("b");
|
|
594
|
+
label.textContent = (rule.kind === "call" ? "📹 " : "") + rule.label + " ›";
|
|
595
|
+
const small = document.createElement("small");
|
|
596
|
+
small.textContent = `${name} · ${url}`;
|
|
597
|
+
card.append(label, small);
|
|
598
|
+
card.addEventListener("click", () => openScreen(rule, url));
|
|
599
|
+
return card;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// What opening a link shows. Nothing is really opened: this is the app's own screen, played.
|
|
603
|
+
function openScreen(rule, url) {
|
|
604
|
+
const screen = $("screen");
|
|
605
|
+
screen.innerHTML = "";
|
|
606
|
+
screen.className = "screen on" + (rule.kind === "call" ? " call" : "");
|
|
607
|
+
const bar = document.createElement("div");
|
|
608
|
+
bar.className = "s-bar";
|
|
609
|
+
const back = document.createElement("button");
|
|
610
|
+
back.type = "button"; back.textContent = "‹"; back.setAttribute("aria-label", "Back");
|
|
611
|
+
const name = document.createElement("b");
|
|
612
|
+
name.textContent = rule.label;
|
|
613
|
+
bar.append(back, name);
|
|
614
|
+
const body = document.createElement("div");
|
|
615
|
+
body.className = "s-body";
|
|
616
|
+
const close = () => { clearInterval(timer); screen.className = "screen"; };
|
|
617
|
+
let timer = null;
|
|
618
|
+
if (rule.kind === "call") {
|
|
619
|
+
const face = document.createElement("div");
|
|
620
|
+
face.className = "face"; face.textContent = "👩⚕️";
|
|
621
|
+
const state = document.createElement("div");
|
|
622
|
+
state.textContent = "Connecting…";
|
|
623
|
+
const started = Date.now();
|
|
624
|
+
timer = setInterval(() => {
|
|
625
|
+
const s = Math.floor((Date.now() - started) / 1000);
|
|
626
|
+
state.textContent = s < 2 ? "Connecting…" : `In call · ${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
627
|
+
}, 500);
|
|
628
|
+
const end = document.createElement("button");
|
|
629
|
+
end.type = "button"; end.className = "end"; end.textContent = "End call";
|
|
630
|
+
end.addEventListener("click", close);
|
|
631
|
+
body.append(face, state, end);
|
|
632
|
+
} else {
|
|
633
|
+
const text = document.createElement("div");
|
|
634
|
+
text.textContent = `The app opens this screen.`;
|
|
635
|
+
body.appendChild(text);
|
|
636
|
+
}
|
|
637
|
+
const code = document.createElement("code");
|
|
638
|
+
code.textContent = url;
|
|
639
|
+
body.appendChild(code);
|
|
640
|
+
back.addEventListener("click", close);
|
|
641
|
+
screen.append(bar, body);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function addActions(row, line) {
|
|
645
|
+
const mine = line.kind === "in";
|
|
646
|
+
const actions = document.createElement("div");
|
|
647
|
+
actions.className = "actions";
|
|
648
|
+
if (current.replies && line.id) {
|
|
649
|
+
const reply = document.createElement("button");
|
|
650
|
+
reply.type = "button"; reply.title = "Reply"; reply.textContent = "↩";
|
|
651
|
+
reply.addEventListener("click", () => setReply({ id: line.id, text: line.text, fromAgent: !mine }));
|
|
652
|
+
actions.appendChild(reply);
|
|
653
|
+
}
|
|
654
|
+
if (!mine && current.personReactions && line.id) {
|
|
655
|
+
const react = document.createElement("button");
|
|
656
|
+
react.type = "button"; react.title = "React"; react.textContent = "☺";
|
|
657
|
+
react.addEventListener("click", () => togglePicker(row, current.personReactions.map((e) => [e, e]), (emoji) => {
|
|
658
|
+
fetch(BASE + "/api/reactions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...where(), messageId: line.id, emoji }) });
|
|
659
|
+
}));
|
|
660
|
+
actions.appendChild(react);
|
|
661
|
+
}
|
|
662
|
+
if (!mine && current.tapbacksAsText) {
|
|
663
|
+
const tap = document.createElement("button");
|
|
664
|
+
tap.type = "button"; tap.title = "Tapback from an iPhone (arrives as a text)"; tap.textContent = "❤︎";
|
|
665
|
+
tap.addEventListener("click", () => togglePicker(row, TAPBACKS, (_e, word) => send(`${word} “${line.text}”`), true));
|
|
666
|
+
actions.appendChild(tap);
|
|
667
|
+
}
|
|
668
|
+
if (actions.children.length) row.appendChild(actions);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function togglePicker(row, options, pick, words) {
|
|
672
|
+
const open = row.querySelector(".picker");
|
|
673
|
+
document.querySelectorAll(".picker").forEach((p) => p.remove());
|
|
674
|
+
if (open) return;
|
|
675
|
+
const picker = document.createElement("div");
|
|
676
|
+
picker.className = "picker";
|
|
677
|
+
for (const [emoji, word] of options) {
|
|
678
|
+
const b = document.createElement("button");
|
|
679
|
+
b.type = "button";
|
|
680
|
+
b.textContent = emoji;
|
|
681
|
+
b.title = word;
|
|
682
|
+
b.addEventListener("click", () => { picker.remove(); pick(emoji, word); });
|
|
683
|
+
picker.appendChild(b);
|
|
684
|
+
}
|
|
685
|
+
row.appendChild(picker);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function openList(line) {
|
|
689
|
+
const sheet = $("sheet");
|
|
690
|
+
sheet.innerHTML = "";
|
|
691
|
+
const h = document.createElement("h3");
|
|
692
|
+
h.textContent = line.list.button;
|
|
693
|
+
sheet.appendChild(h);
|
|
694
|
+
let picked = null;
|
|
695
|
+
for (const r of line.list.rows) {
|
|
696
|
+
const label = document.createElement("label");
|
|
697
|
+
const radio = document.createElement("input");
|
|
698
|
+
radio.type = "radio"; radio.name = "row";
|
|
699
|
+
radio.addEventListener("change", () => { picked = r; });
|
|
700
|
+
const text = document.createElement("div");
|
|
701
|
+
text.textContent = r.title;
|
|
702
|
+
if (r.description) { const s = document.createElement("small"); s.textContent = r.description; text.appendChild(s); }
|
|
703
|
+
label.append(radio, text);
|
|
704
|
+
sheet.appendChild(label);
|
|
705
|
+
}
|
|
706
|
+
const go = document.createElement("button");
|
|
707
|
+
go.type = "button"; go.className = "send"; go.textContent = "Send";
|
|
708
|
+
go.addEventListener("click", () => { if (!picked) return; sheet.classList.remove("on"); send(picked.title, { choice: { id: picked.id, title: picked.title }, replyTo: line.id }); });
|
|
709
|
+
sheet.appendChild(go);
|
|
710
|
+
sheet.classList.add("on");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function setReply(r) {
|
|
714
|
+
replyTo = r;
|
|
715
|
+
$("replying").classList.toggle("on", !!r);
|
|
716
|
+
$("replyingText").textContent = r ? r.text : "";
|
|
717
|
+
if (r) $("text").focus();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function addReaction(line) {
|
|
721
|
+
const row = rows.get(line.messageId);
|
|
722
|
+
if (!row) return;
|
|
723
|
+
const pill = document.createElement("span");
|
|
724
|
+
pill.className = "react";
|
|
725
|
+
pill.textContent = line.emoji;
|
|
726
|
+
row.querySelector(".reacts").appendChild(pill);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function markRead(ids) {
|
|
730
|
+
for (const id of ids) {
|
|
731
|
+
const t = rows.get(id)?.querySelector(".ticks");
|
|
732
|
+
if (t) { t.textContent = "✓✓"; t.classList.add("read"); }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function showTyping(on) {
|
|
737
|
+
if (!current.typing) return;
|
|
738
|
+
if (current.id === "whatsapp" || current.id === "telegram") { $("sub").textContent = on ? "typing…" : subtitle(); return; }
|
|
739
|
+
if (on) {
|
|
740
|
+
if (!typingEl) {
|
|
741
|
+
typingEl = document.createElement("div");
|
|
742
|
+
typingEl.className = "dots";
|
|
743
|
+
typingEl.innerHTML = "<i></i><i></i><i></i><span class='status'></span>";
|
|
744
|
+
}
|
|
745
|
+
typingEl.querySelector(".status").textContent = statusText || "";
|
|
746
|
+
$("thread").appendChild(typingEl);
|
|
747
|
+
$("thread").scrollTop = $("thread").scrollHeight;
|
|
748
|
+
} else if (typingEl) typingEl.remove();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function showStatus(text) {
|
|
752
|
+
statusText = text;
|
|
753
|
+
if (text) showTyping(true);
|
|
754
|
+
else if (typingEl) typingEl.querySelector(".status").textContent = "";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Per-turn metrics: a turn starts with a message from the person; a double text sent before any
|
|
758
|
+
// acknowledgement joins it, and timings count from their latest message.
|
|
759
|
+
function startTurn(text, at) {
|
|
760
|
+
const open = turns.at(-1);
|
|
761
|
+
if (open && open.ack == null) { open.texts.push(text); open.from = at; }
|
|
762
|
+
else turns.push({ texts: [text], from: at, ack: null, done: null, calls: 0 });
|
|
763
|
+
renderTurns();
|
|
764
|
+
}
|
|
765
|
+
function onSeen(kind, at) {
|
|
766
|
+
const t = turns.at(-1);
|
|
767
|
+
if (!t) return;
|
|
768
|
+
if (t.ack == null) t.ack = at - t.from;
|
|
769
|
+
if (kind === "message") t.done = at - t.from;
|
|
770
|
+
renderTurns();
|
|
771
|
+
}
|
|
772
|
+
function renderTurns() {
|
|
773
|
+
$("turns").innerHTML = "";
|
|
774
|
+
for (const t of turns) {
|
|
775
|
+
const tr = document.createElement("tr");
|
|
776
|
+
for (const [i, v] of [t.texts.join(" / "), fmt(t.ack), fmt(t.done), String(t.calls)].entries()) {
|
|
777
|
+
const td = document.createElement("td");
|
|
778
|
+
if (i === 0) td.className = "msg";
|
|
779
|
+
td.textContent = v;
|
|
780
|
+
tr.appendChild(td);
|
|
781
|
+
}
|
|
782
|
+
$("turns").appendChild(tr);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const toolEls = new Map();
|
|
787
|
+
function addTool(line) {
|
|
788
|
+
const el = document.createElement("div");
|
|
789
|
+
el.className = "toolcall";
|
|
790
|
+
toolEls.set(line.callId, el);
|
|
791
|
+
drawTool(el, line);
|
|
792
|
+
insert(el);
|
|
793
|
+
}
|
|
794
|
+
function updateTool(line) {
|
|
795
|
+
const el = toolEls.get(line.callId);
|
|
796
|
+
if (el) drawTool(el, line);
|
|
797
|
+
}
|
|
798
|
+
function drawTool(el, line) {
|
|
799
|
+
el.innerHTML = "";
|
|
800
|
+
const head = document.createElement("div");
|
|
801
|
+
head.className = "tc-head";
|
|
802
|
+
const name = document.createElement("b");
|
|
803
|
+
name.textContent = "🔧 " + line.tool;
|
|
804
|
+
const st = document.createElement("span");
|
|
805
|
+
st.className = "st" + (line.status === "background" ? " bg" : line.status === "failed" ? " bad" : "");
|
|
806
|
+
st.textContent = { running: "running…", background: `still running after ${fmt(line.ms)}, moved to the background`, done: `answered in ${fmt(line.ms)}`, failed: `failed after ${fmt(line.ms)}` }[line.status] || line.status;
|
|
807
|
+
head.append(name, st);
|
|
808
|
+
if (line.played) { const p = document.createElement("span"); p.className = "st"; p.textContent = `· played as “${line.played}”`; head.appendChild(p); }
|
|
809
|
+
el.appendChild(head);
|
|
810
|
+
const block = (label, value, open) => {
|
|
811
|
+
const d = document.createElement("details");
|
|
812
|
+
if (open) d.open = true;
|
|
813
|
+
const sm = document.createElement("summary");
|
|
814
|
+
sm.textContent = label;
|
|
815
|
+
const pre = document.createElement("pre");
|
|
816
|
+
let text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
817
|
+
try { if (typeof value === "string") text = JSON.stringify(JSON.parse(value), null, 2); } catch {}
|
|
818
|
+
pre.textContent = text;
|
|
819
|
+
d.append(sm, pre);
|
|
820
|
+
el.appendChild(d);
|
|
821
|
+
};
|
|
822
|
+
block("input", line.input ?? {}, false);
|
|
823
|
+
if (line.output !== undefined) block(line.status === "failed" ? "error" : "output", line.output, false);
|
|
824
|
+
if (line.changes && line.changes.length) {
|
|
825
|
+
const ul = document.createElement("ul");
|
|
826
|
+
for (const c of line.changes) { const li = document.createElement("li"); li.textContent = c; ul.appendChild(li); }
|
|
827
|
+
el.appendChild(ul);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function setDebug(on) {
|
|
831
|
+
document.body.classList.toggle("debug", on);
|
|
832
|
+
$("debug").setAttribute("aria-pressed", String(on));
|
|
833
|
+
try { localStorage.setItem("chinwag.debug", on ? "1" : "0"); } catch {}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function showLine(line, live) {
|
|
837
|
+
document.querySelector("#thread .empty")?.remove();
|
|
838
|
+
if (line.kind === "tool") { addTool(line); return; }
|
|
839
|
+
if (line.kind === "in") { addMessage(line); startTurn(line.text, line.at); }
|
|
840
|
+
else if (line.kind === "out") { addMessage(line); if (!line.carrier) onSeen("message", line.at); }
|
|
841
|
+
else if (line.kind === "reaction") { addReaction(line); if (line.by === "agent") onSeen("reaction", line.at); }
|
|
842
|
+
else if (line.kind === "read") markRead(line.messageIds);
|
|
843
|
+
else if (line.kind === "undelivered") sysnote(`Not delivered: “${line.text.slice(0, 60)}” (${line.reason})`, true);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function trace(e) {
|
|
847
|
+
const t = turns.at(-1);
|
|
848
|
+
if (t && (e.type === "model.call" || e.type === "model.restarted")) { t.calls++; renderTurns(); }
|
|
849
|
+
let tag = "", cls = "", text = e.type;
|
|
850
|
+
if (e.type === "tool.end") { tag = "inline"; cls = "inline"; text = `${e.tool} answered in ${e.ms} ms`; }
|
|
851
|
+
else if (e.type === "tool.detached") { tag = "background"; cls = "bg"; text = `${e.tool} still running after ${e.ms} ms (${e.task})`; }
|
|
852
|
+
else if (e.type === "task.done") { tag = "done"; cls = "bg"; text = `${e.tool} finished after ${e.ms} ms (${e.task})`; }
|
|
853
|
+
else if (e.type === "model.restarted") { tag = "folded"; cls = "fold"; text = "new message arrived, step restarted with it"; }
|
|
854
|
+
else if (e.type === "ack.required") { tag = "held"; cls = "ack"; text = `${e.held.join(", ")} held until they're acknowledged`; }
|
|
855
|
+
else if (e.type === "model.call") { text = `model ${e.ms} ms` + (e.toolCalls.length ? ` → ${e.toolCalls.join(", ")}` : " → (nothing)"); }
|
|
856
|
+
else if (e.type === "message.in") { text = `in [${e.messageId}]: ${e.text}`; }
|
|
857
|
+
else if (e.type === "message.out") { text = `out: ${e.text}` + (e.buttons ? ` [${e.buttons.join(" | ")}]` : "") + (e.list ? ` [list: ${e.list.rows.length} rows]` : "") + (e.link ? ` [link: ${e.link.label} → ${e.link.url}]` : "") + (e.image ? ` [image: ${e.image.url}]` : "") + (e.fields ? ` [${Object.keys(e.fields).join(", ")}]` : ""); }
|
|
858
|
+
else if (e.type === "reaction") { text = `reacted ${e.emoji} to ${e.messageId}`; }
|
|
859
|
+
else if (e.type === "reaction.in") { text = `they reacted ${e.emoji}`; }
|
|
860
|
+
else if (e.type === "status") { text = `status: ${e.text}`; }
|
|
861
|
+
else if (e.type === "text.hidden") { text = `(not shown) ${e.text}`; }
|
|
862
|
+
else if (e.type === "skill.loaded") { text = `loaded skill ${e.skill}`; }
|
|
863
|
+
else if (e.type === "turn.end") { text = `turn done in ${e.ms} ms`; }
|
|
864
|
+
else if (e.type === "tool.start") { text = `${e.tool} started`; }
|
|
865
|
+
else if (e.type === "message.undelivered") { tag = "not delivered"; cls = "bad"; text = e.reason; }
|
|
866
|
+
// In the thread too, so it shows with Debug off; handle() shows each numbered event once.
|
|
867
|
+
else if (e.type === "model.error") { tag = "model error"; cls = "bad"; text = e.error; sysnote(`Model error: ${e.error}`, true); }
|
|
868
|
+
logRow(e.at, tag, cls, text);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// A Backend button's run, on every open page: what it returned, or why it failed.
|
|
872
|
+
function actionDone(d) {
|
|
873
|
+
if (d.ok) logRow(d.at, "playground", "inline", d.label + (d.result ? ` → ${d.result}` : ""));
|
|
874
|
+
else logRow(d.at, "playground failed", "bad", `${d.label}: ${d.error}`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function logRow(at, tag, cls, text) {
|
|
878
|
+
const row = document.createElement("div");
|
|
879
|
+
const ts = document.createElement("span");
|
|
880
|
+
ts.className = "t";
|
|
881
|
+
ts.textContent = new Date(at).toLocaleTimeString([], { hour12: false });
|
|
882
|
+
const what = document.createElement("span");
|
|
883
|
+
if (tag) { const b = document.createElement("span"); b.className = "tag " + cls; b.textContent = tag + " "; what.appendChild(b); }
|
|
884
|
+
what.appendChild(document.createTextNode(text));
|
|
885
|
+
row.append(ts, what);
|
|
886
|
+
$("log").appendChild(row);
|
|
887
|
+
$("log").scrollTop = $("log").scrollHeight;
|
|
888
|
+
if ($("pTrace").hidden) $("traceDot").hidden = false;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// The page hears the agent's events on a live stream or, where the stream never arrives (some
|
|
892
|
+
// tunnels hold it back), by asking for them again and again. The events are numbered: `lastSeq`
|
|
893
|
+
// is the last one shown, so none shows twice, and `opened` ends the loops of an older connect().
|
|
894
|
+
let lastSeq = 0;
|
|
895
|
+
let opened = 0;
|
|
896
|
+
let polling = false;
|
|
897
|
+
|
|
898
|
+
async function connect() {
|
|
899
|
+
const mine = ++opened;
|
|
900
|
+
if (source) { source.close(); source = null; }
|
|
901
|
+
if (replayData) { showReplay(replayData); return; }
|
|
902
|
+
const { lines, seq } = await (await fetch(BASE + "/api/transcript" + query())).json();
|
|
903
|
+
if (mine !== opened) return;
|
|
904
|
+
for (const l of lines) showLine(l, false);
|
|
905
|
+
if (!lines.length) {
|
|
906
|
+
const empty = document.createElement("p");
|
|
907
|
+
empty.className = "empty";
|
|
908
|
+
empty.textContent = "This channel has no messages yet.";
|
|
909
|
+
$("thread").appendChild(empty);
|
|
910
|
+
}
|
|
911
|
+
lastSeq = seq;
|
|
912
|
+
if (polling) { poll(mine); return; }
|
|
913
|
+
let heard = false;
|
|
914
|
+
const fallBack = () => {
|
|
915
|
+
if (heard || mine !== opened) return;
|
|
916
|
+
clearTimeout(timer);
|
|
917
|
+
source.close(); source = null;
|
|
918
|
+
polling = true;
|
|
919
|
+
poll(mine);
|
|
920
|
+
};
|
|
921
|
+
const timer = setTimeout(fallBack, 3000);
|
|
922
|
+
source = new EventSource(BASE + "/api/events" + query() + "&after=" + seq);
|
|
923
|
+
source.onerror = fallBack;
|
|
924
|
+
source.onmessage = (ev) => {
|
|
925
|
+
const d = JSON.parse(ev.data);
|
|
926
|
+
if (d.type === "hello") { heard = true; clearTimeout(timer); }
|
|
927
|
+
handle(d);
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function poll(mine) {
|
|
932
|
+
let wait = 500;
|
|
933
|
+
while (mine === opened) {
|
|
934
|
+
try {
|
|
935
|
+
const res = await fetch(BASE + "/api/poll" + query() + "&after=" + lastSeq);
|
|
936
|
+
if (!res.ok) throw new Error("poll " + res.status);
|
|
937
|
+
const { boot, seq, events } = await res.json();
|
|
938
|
+
if (mine !== opened) return;
|
|
939
|
+
handle({ type: "hello", boot });
|
|
940
|
+
for (const d of events) { handle(d); if (mine !== opened) return; }
|
|
941
|
+
lastSeq = Math.max(lastSeq, seq);
|
|
942
|
+
wait = 500;
|
|
943
|
+
} catch {
|
|
944
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
945
|
+
wait = Math.min(wait * 2, 8000);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function handle(d) {
|
|
951
|
+
if (typeof d.seq === "number") { if (d.seq <= lastSeq) return; lastSeq = d.seq; }
|
|
952
|
+
if (d.type === "hello") { if (d.boot !== BOOT) location.reload(); }
|
|
953
|
+
else if (d.type === "line") showLine(d.line, true);
|
|
954
|
+
else if (d.type === "lineUpdate") updateTool(d.line);
|
|
955
|
+
else if (d.type === "id") giveId(d.at, d.messageId);
|
|
956
|
+
else if (d.type === "status") { if (current.status) { showStatus(d.text); if (d.text) onSeen("status", d.at); } }
|
|
957
|
+
else if (d.type === "typing") showTyping(d.on);
|
|
958
|
+
else if (d.type === "trace") trace(d.event);
|
|
959
|
+
else if (d.type === "action") actionDone(d);
|
|
960
|
+
else if (d.type === "reset") setPlatform(PLAY ? current.channel : current.id);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** A saved run: its transcript, and (cloud tests) its simulated tool calls with what they played and changed. */
|
|
964
|
+
function showReplay(data) {
|
|
965
|
+
sysnote("Replay of a saved conversation");
|
|
966
|
+
let lines = data.transcript || data.lines || [];
|
|
967
|
+
if (Array.isArray(data.toolCalls) && data.toolCalls.length) {
|
|
968
|
+
lines = lines.filter((l) => l.kind !== "tool").concat(data.toolCalls.map((c, i) => ({
|
|
969
|
+
kind: "tool", callId: `replay-${i}`, tool: c.tool, input: c.input, at: c.at,
|
|
970
|
+
status: c.ok ? "done" : "failed", ms: c.latencyMs, output: c.ok ? c.output : c.error, played: c.kind, changes: c.changes,
|
|
971
|
+
})));
|
|
972
|
+
}
|
|
973
|
+
lines.sort((a, b) => a.at - b.at);
|
|
974
|
+
for (const l of lines) showLine(l, false);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function send(text, extra = {}) {
|
|
978
|
+
const body = { ...where(), text, ...extra };
|
|
979
|
+
if (!body.replyTo && replyTo) body.replyTo = replyTo.id;
|
|
980
|
+
setReply(null);
|
|
981
|
+
await fetch(BASE + "/api/messages", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// The person's message is shown at once; the agent's id for it (for ticks, replies and
|
|
985
|
+
// reactions) arrives a moment later.
|
|
986
|
+
function giveId(at, id) {
|
|
987
|
+
const row = [...document.querySelectorAll(".row.me")].reverse().find((r) => r.dataset.at === String(at) && !r.dataset.id);
|
|
988
|
+
if (!row || !id) return;
|
|
989
|
+
row.dataset.id = id;
|
|
990
|
+
rows.set(id, row);
|
|
991
|
+
const ticks = row.querySelector(".ticks");
|
|
992
|
+
if (ticks && !ticks.classList.contains("read")) ticks.textContent = "✓✓";
|
|
993
|
+
row.querySelector(".actions")?.remove();
|
|
994
|
+
addActions(row, { kind: "in", id, text: row.dataset.text });
|
|
995
|
+
}
|
|
996
|
+
function updateCounter() {
|
|
997
|
+
const text = $("text").value;
|
|
998
|
+
if (current?.id !== "sms" || !text) { $("counter").textContent = ""; return; }
|
|
999
|
+
const gsm = /^[\x20-\x7E\n\r£¥èéùìòÇØøÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ¡ÄÖÑܧ¿äöñüà€]*$/.test(text);
|
|
1000
|
+
const len = [...text].length, single = gsm ? 160 : 70, multi = gsm ? 153 : 67;
|
|
1001
|
+
const segs = len <= single ? 1 : Math.ceil(len / multi);
|
|
1002
|
+
const left = (len <= single ? single : segs * multi) - len;
|
|
1003
|
+
$("counter").textContent = segs > 1 ? `${left}/${segs}` : String(left);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function refreshStatus() {
|
|
1007
|
+
try {
|
|
1008
|
+
const s = await (await fetch(BASE + "/api/status")).json();
|
|
1009
|
+
$("status").textContent = s.busy ? `busy · ${s.backgroundTasks} in background` : `idle ${Math.round(s.idleForMs / 1000)} s`;
|
|
1010
|
+
} catch {}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
$("form").addEventListener("submit", (e) => {
|
|
1014
|
+
e.preventDefault();
|
|
1015
|
+
const text = $("text").value.trim();
|
|
1016
|
+
if (!text) return;
|
|
1017
|
+
$("text").value = "";
|
|
1018
|
+
updateCounter();
|
|
1019
|
+
send(text);
|
|
1020
|
+
});
|
|
1021
|
+
$("text").addEventListener("input", updateCounter);
|
|
1022
|
+
// A file goes with whatever is in the message box as its caption.
|
|
1023
|
+
$("file").addEventListener("change", () => {
|
|
1024
|
+
const file = $("file").files[0];
|
|
1025
|
+
$("file").value = "";
|
|
1026
|
+
if (!file) return;
|
|
1027
|
+
const reader = new FileReader();
|
|
1028
|
+
reader.onload = () => {
|
|
1029
|
+
const kind = file.type.startsWith("image/") ? "image" : file.type.startsWith("audio/") ? "audio" : "document";
|
|
1030
|
+
const data = String(reader.result).split(",")[1] || "";
|
|
1031
|
+
const caption = $("text").value.trim();
|
|
1032
|
+
$("text").value = "";
|
|
1033
|
+
updateCounter();
|
|
1034
|
+
send(caption, { attachments: [{ kind, mime: file.type || "application/octet-stream", name: file.name, data }] });
|
|
1035
|
+
};
|
|
1036
|
+
reader.readAsDataURL(file);
|
|
1037
|
+
});
|
|
1038
|
+
$("cancelReply").addEventListener("click", () => setReply(null));
|
|
1039
|
+
$("reset").addEventListener("click", () => fetch(BASE + "/api/reset" + query(), { method: "POST" }));
|
|
1040
|
+
$("clear").addEventListener("click", () => {
|
|
1041
|
+
const who = people.find((p) => p.phone === person);
|
|
1042
|
+
if (who && confirm(`Forget ${who.name}? Their history, schedules and everything the agent kept about them go, on every channel.`)) {
|
|
1043
|
+
fetch(BASE + "/api/clear?" + new URLSearchParams({ person }).toString(), { method: "POST" });
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
document.addEventListener("click", (e) => {
|
|
1047
|
+
if (!e.target.closest(".picker") && !e.target.closest(".actions")) document.querySelectorAll(".picker").forEach((p) => p.remove());
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
function tabFor(id, label, p) {
|
|
1051
|
+
const b = document.createElement("button");
|
|
1052
|
+
b.className = "tab";
|
|
1053
|
+
b.type = "button";
|
|
1054
|
+
b.dataset.id = id;
|
|
1055
|
+
const img = document.createElement("img");
|
|
1056
|
+
img.src = `https://cdn.simpleicons.org/${p.icon}`;
|
|
1057
|
+
img.alt = "";
|
|
1058
|
+
b.append(img, document.createTextNode(label));
|
|
1059
|
+
if (!p.complete) { const s = document.createElement("small"); s.textContent = "partial"; b.appendChild(s); }
|
|
1060
|
+
const badge = document.createElement("span"); badge.className = "unread"; badge.hidden = true; badge.setAttribute("aria-label", "new message"); b.appendChild(badge);
|
|
1061
|
+
b.addEventListener("click", () => setPlatform(id));
|
|
1062
|
+
$("tabs").appendChild(b);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// The playground with nothing to play: say what the agent's author has to declare.
|
|
1066
|
+
function empty(html) {
|
|
1067
|
+
document.querySelector("main").innerHTML = `<div class="empty">${html}</div>`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// The side panel's tabs: Person (what is set and waiting for this person, the clock, nudges),
|
|
1071
|
+
// the author's Backend buttons, per-turn timings and the trace.
|
|
1072
|
+
function showPane(id) {
|
|
1073
|
+
for (const b of document.querySelectorAll(".sidetabs button")) b.setAttribute("aria-selected", String(b.dataset.pane === id));
|
|
1074
|
+
for (const pane of document.querySelectorAll(".pane")) pane.hidden = pane.id !== id;
|
|
1075
|
+
if (id === "pTrace") $("traceDot").hidden = true;
|
|
1076
|
+
remember("chinwag.pane", id);
|
|
1077
|
+
}
|
|
1078
|
+
for (const b of document.querySelectorAll(".sidetabs button")) b.addEventListener("click", () => showPane(b.dataset.pane));
|
|
1079
|
+
|
|
1080
|
+
let panel = { person: false, nudge: false, clock: false };
|
|
1081
|
+
let templates = [];
|
|
1082
|
+
let quietEndsAt = null;
|
|
1083
|
+
let nudgeKind = "prompt";
|
|
1084
|
+
const post = async (path, body) => {
|
|
1085
|
+
const res = await fetch(BASE + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...where(), ...body }) });
|
|
1086
|
+
return { ok: res.ok, data: await res.json().catch(() => ({})) };
|
|
1087
|
+
};
|
|
1088
|
+
const at = (ms, zone, opts = {}) => new Date(ms).toLocaleString("en-US", { timeZone: zone || undefined, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", ...opts });
|
|
1089
|
+
const city = (zone) => (zone ? zone.split("/").pop().replaceAll("_", " ") : "");
|
|
1090
|
+
const short = (text, n = 140) => (text.length > n ? text.slice(0, n - 1) + "…" : text);
|
|
1091
|
+
function item(tag, cls, what, meta) {
|
|
1092
|
+
const li = document.createElement("li");
|
|
1093
|
+
const t = document.createElement("span"); t.className = "tag" + (cls ? " " + cls : ""); t.textContent = tag;
|
|
1094
|
+
const w = document.createElement("span"); w.className = "what"; w.textContent = short(what); w.title = what;
|
|
1095
|
+
li.append(t, w);
|
|
1096
|
+
if (meta) { const m = document.createElement("span"); m.className = "dim"; m.textContent = meta; li.append(m); }
|
|
1097
|
+
return li;
|
|
1098
|
+
}
|
|
1099
|
+
async function refreshPerson() {
|
|
1100
|
+
if (!PLAY || !panel.person || !current) return;
|
|
1101
|
+
const res = await fetch(BASE + "/api/person" + query());
|
|
1102
|
+
if (!res.ok) return;
|
|
1103
|
+
const { now, contact, outbox, lastOut = {} } = await res.json();
|
|
1104
|
+
markUnread(lastOut);
|
|
1105
|
+
const zone = contact?.timeZone;
|
|
1106
|
+
$("theirTime").textContent = zone ? at(now, zone) : at(now);
|
|
1107
|
+
$("theirZone").textContent = !contact ? "They have not written to the agent yet, so nudges to them are refused (unknown_person)."
|
|
1108
|
+
: zone ? `${city(zone)} time, set by ${contact.timeZoneSource === "person" ? "them" : "the business"}`
|
|
1109
|
+
: "Your time: their time zone is not known, so quiet hours don't apply to them";
|
|
1110
|
+
const pill = $("quietPill");
|
|
1111
|
+
quietEndsAt = null;
|
|
1112
|
+
for (const h of outbox.held) if (h.holdUntil && (!quietEndsAt || h.holdUntil < quietEndsAt)) quietEndsAt = h.holdUntil;
|
|
1113
|
+
if (contact?.quietHours) {
|
|
1114
|
+
pill.hidden = false;
|
|
1115
|
+
const inQuiet = quietEndsAt !== null && quietEndsAt > now;
|
|
1116
|
+
pill.className = "pill " + (inQuiet ? "quiet" : "open");
|
|
1117
|
+
pill.textContent = `Quiet hours ${contact.quietHours.from}–${contact.quietHours.to}` + (inQuiet ? ` · nudges wait until ${at(quietEndsAt, zone, { weekday: undefined, month: undefined, day: undefined })}` : "");
|
|
1118
|
+
pill.title = contact.quietHoursSource === "person" ? "Their own quiet hours" : "The agent's quiet hours";
|
|
1119
|
+
} else pill.hidden = true;
|
|
1120
|
+
const rules = [];
|
|
1121
|
+
if (contact) {
|
|
1122
|
+
rules.push(contact.quietHours ? `Quiet hours ${contact.quietHours.from}–${contact.quietHours.to} (${contact.quietHoursSource === "person" ? "their own" : "the agent's"})` : contact.quietHoursSource === "no_time_zone" ? "Quiet hours don't apply: no time zone" : "No quiet hours");
|
|
1123
|
+
if (contact.stop) rules.push("Texted STOP: nothing the business starts goes");
|
|
1124
|
+
for (const p of contact.pauses) rules.push(`Paused until ${p.until ? at(p.until - 1, zone) : "?"}`);
|
|
1125
|
+
for (const t of contact.topics) rules.push(`Not about: ${t.rule}`);
|
|
1126
|
+
}
|
|
1127
|
+
$("contactLine").textContent = rules.join(" · ");
|
|
1128
|
+
const list = [];
|
|
1129
|
+
for (const h of outbox.held) {
|
|
1130
|
+
const until = h.reason === "window_closed" ? "until they reply (WhatsApp window closed)" : `until ${at(h.holdUntil, zone)}`;
|
|
1131
|
+
list.push(item(`Held ${h.kind} nudge · goes on ${h.channel}`, "held", h.what, `${until}${h.expiresAt ? ` · dropped if still held at ${at(h.expiresAt, zone)}` : ""}`));
|
|
1132
|
+
}
|
|
1133
|
+
for (const sc of outbox.schedules) list.push(item(sc.origin === "person" ? "Their reminder" : "Scheduled by the business", "", sc.message, `${sc.summary}${sc.nextRunAt ? ` · next ${at(sc.nextRunAt, sc.timeZone)}` : ""}${sc.respectsQuietHours ? " · waits for quiet hours" : ""}`));
|
|
1134
|
+
if (!list.length) { const li = document.createElement("li"); li.className = "empty"; li.textContent = "Nothing scheduled or held."; list.push(li); }
|
|
1135
|
+
$("waiting").replaceChildren(...list);
|
|
1136
|
+
$("toQuietEnd").disabled = !quietEndsAt;
|
|
1137
|
+
}
|
|
1138
|
+
// A new message on a channel not shown: a badge on its tab, until it is opened.
|
|
1139
|
+
function markUnread(lastOut) {
|
|
1140
|
+
const key = (ch) => `chinwag.seen.${person}.${ch}`;
|
|
1141
|
+
for (const b of document.querySelectorAll(".tab")) {
|
|
1142
|
+
const ch = b.dataset.id;
|
|
1143
|
+
const times = lastOut[ch] ?? [];
|
|
1144
|
+
const last = times.at(-1) ?? 0;
|
|
1145
|
+
const badge = b.querySelector(".unread");
|
|
1146
|
+
if (!badge) continue;
|
|
1147
|
+
const stored = recall(key(ch));
|
|
1148
|
+
if (ch === current.channel || stored === null) { remember(key(ch), String(last)); badge.hidden = true; continue; }
|
|
1149
|
+
const unread = times.filter((t) => t > Number(stored)).length;
|
|
1150
|
+
badge.hidden = unread === 0;
|
|
1151
|
+
badge.textContent = unread > 9 ? "9+" : String(unread);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function setNudgeKind(kind) {
|
|
1156
|
+
nudgeKind = kind;
|
|
1157
|
+
for (const b of document.querySelectorAll(".seg button")) b.setAttribute("aria-pressed", String(b.dataset.kind === kind));
|
|
1158
|
+
$("nTemplateRow").hidden = kind !== "template";
|
|
1159
|
+
$("nText").hidden = kind === "template";
|
|
1160
|
+
$("nText").placeholder = kind === "text" ? "Sent exactly as written, e.g. Your code is 4417" : "What the agent should tell them, e.g. Their order 1042 shipped";
|
|
1161
|
+
}
|
|
1162
|
+
function setUpPersonPanel() {
|
|
1163
|
+
if (!PLAY || !panel.person) return;
|
|
1164
|
+
$("tabPerson").hidden = false;
|
|
1165
|
+
if (panel.clock) {
|
|
1166
|
+
$("clockSec").hidden = false;
|
|
1167
|
+
for (const b of document.querySelectorAll("[data-advance]")) b.addEventListener("click", async () => { await post("/api/clock", { advanceMs: Number(b.dataset.advance) }); refreshPerson(); });
|
|
1168
|
+
$("toQuietEnd").addEventListener("click", async () => { if (quietEndsAt) await post("/api/clock", { to: quietEndsAt + 1000 }); refreshPerson(); });
|
|
1169
|
+
$("clockGo").addEventListener("click", async () => { const v = $("clockTo").value; if (v) await post("/api/clock", { to: new Date(v).getTime() }); refreshPerson(); });
|
|
1170
|
+
}
|
|
1171
|
+
$("tzSet").addEventListener("click", async () => { await post("/api/time-zone", { time_zone: $("tz").value.trim() }); refreshPerson(); });
|
|
1172
|
+
$("qSet").addEventListener("click", async () => { await post("/api/quiet-hours", { quiet_hours: { from: $("qFrom").value.trim(), to: $("qTo").value.trim() } }); refreshPerson(); });
|
|
1173
|
+
$("qOff").addEventListener("click", async () => { await post("/api/quiet-hours", { quiet_hours: "off" }); refreshPerson(); });
|
|
1174
|
+
$("qDefault").addEventListener("click", async () => { await post("/api/quiet-hours", { quiet_hours: null }); refreshPerson(); });
|
|
1175
|
+
if (panel.nudge) {
|
|
1176
|
+
$("nudgeSec").hidden = false;
|
|
1177
|
+
for (const t of templates) { const o = document.createElement("option"); o.value = t.name; o.textContent = t.name; o.title = t.body; $("nTemplate").appendChild(o); }
|
|
1178
|
+
for (const b of document.querySelectorAll(".seg button")) {
|
|
1179
|
+
b.disabled = b.dataset.kind === "template" && !templates.length;
|
|
1180
|
+
if (b.disabled) b.title = "This agent declares no WhatsApp templates";
|
|
1181
|
+
b.addEventListener("click", () => setNudgeKind(b.dataset.kind));
|
|
1182
|
+
}
|
|
1183
|
+
$("nSend").addEventListener("click", async () => {
|
|
1184
|
+
const text = $("nText").value.trim();
|
|
1185
|
+
const body = { kind: nudgeKind, ...(nudgeKind === "prompt" ? { prompt: text } : nudgeKind === "text" ? { text } : { template: $("nTemplate").value, body: $("nValues").value.split(",").map((v) => v.trim()).filter(Boolean) }) };
|
|
1186
|
+
if ($("nAt").value) body.at = new Date($("nAt").value).getTime();
|
|
1187
|
+
if (!$("nQuiet").checked) body.respect_quiet_hours = false;
|
|
1188
|
+
const mins = Number($("nExpires").value);
|
|
1189
|
+
if (mins > 0) body.expires_at = Date.now() + mins * 60_000;
|
|
1190
|
+
if ($("nKey").value.trim()) body.idempotency_key = $("nKey").value.trim();
|
|
1191
|
+
$("nSend").disabled = true;
|
|
1192
|
+
$("nResult").textContent = "";
|
|
1193
|
+
try {
|
|
1194
|
+
const { ok, data } = await post("/api/nudge", body);
|
|
1195
|
+
$("nResult").textContent = ok ? `${data.status}${data.reason ? ` (${data.reason})` : ""}` : (data.error || "failed");
|
|
1196
|
+
if (ok && nudgeKind !== "template") $("nText").value = "";
|
|
1197
|
+
} finally { $("nSend").disabled = false; refreshPerson(); }
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
setInterval(refreshPerson, 3000);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
(async () => {
|
|
1204
|
+
({ platforms, cards, people = [], channels = [], templates = [], panel = panel } = await (await fetch(BASE + "/api/platforms")).json());
|
|
1205
|
+
if (PLAY) {
|
|
1206
|
+
if (!channels.length) return empty("This agent has no channels. Declare them in <code>createAgent({ channels: [...] })</code> (whatsapp(), sms(), customUI()) to test it here.");
|
|
1207
|
+
if (!people.length) return empty("This agent has no test people. Declare who may be played here in <code>createAgent({ testPeople: [{ name, phone }] })</code>.");
|
|
1208
|
+
if (!people.some((p) => p.phone === person)) person = people[0].phone;
|
|
1209
|
+
remember("chinwag.person", person);
|
|
1210
|
+
for (const p of people) {
|
|
1211
|
+
const o = document.createElement("option");
|
|
1212
|
+
o.value = p.phone;
|
|
1213
|
+
o.textContent = `${p.name} · ${p.phone}`;
|
|
1214
|
+
$("who").appendChild(o);
|
|
1215
|
+
}
|
|
1216
|
+
$("who").value = person;
|
|
1217
|
+
$("who").addEventListener("change", () => { person = $("who").value; remember("chinwag.person", person); setPlatform(current.channel); });
|
|
1218
|
+
for (const c of channels) tabFor(c.name, c.name, c.platform);
|
|
1219
|
+
setUpPersonPanel();
|
|
1220
|
+
const saved = recall("chinwag.pane");
|
|
1221
|
+
showPane(saved && !document.querySelector(`[data-pane="${saved}"]`)?.hidden ? saved : panel.person ? "pPerson" : "pTrace");
|
|
1222
|
+
} else {
|
|
1223
|
+
for (const p of platforms) tabFor(p.id, p.name, p);
|
|
1224
|
+
showPane("pTrace");
|
|
1225
|
+
}
|
|
1226
|
+
let debug = params.get("debug") === "1";
|
|
1227
|
+
if (!params.has("debug")) { try { debug = localStorage.getItem("chinwag.debug") === "1"; } catch {} }
|
|
1228
|
+
setDebug(debug);
|
|
1229
|
+
$("debug").addEventListener("click", () => setDebug(!document.body.classList.contains("debug")));
|
|
1230
|
+
if (REPLAY) {
|
|
1231
|
+
replayData = await (await fetch(REPLAY)).json();
|
|
1232
|
+
// The agent's own name in the replay's header, when the saved run has one.
|
|
1233
|
+
if (replayData.agentTitle) { title = replayData.agentTitle; $("name").textContent = title; document.title = `${title} · replay`; }
|
|
1234
|
+
for (const b of document.querySelectorAll(".tab")) b.disabled = true;
|
|
1235
|
+
setPlatform(replayData.channel || replayData.platform || "sms");
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const saved = PLAY ? params.get("channel") || recall("chinwag.channel") : params.get("platform") || recall("chinwag.platform");
|
|
1239
|
+
setPlatform(saved || (PLAY ? channels[0].name : "sms"));
|
|
1240
|
+
setInterval(refreshStatus, 1000);
|
|
1241
|
+
refreshStatus();
|
|
1242
|
+
})();
|
|
1243
|
+
</script>
|
|
1244
|
+
</body>
|
|
1245
|
+
</html>
|