bloby-bot 0.48.0 → 0.48.2
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/package.json +1 -1
- package/shared/config.ts +8 -0
- package/supervisor/channels/alexa.ts +219 -0
- package/supervisor/channels/manager.ts +188 -25
- package/supervisor/channels/types.ts +5 -3
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +41 -11
- package/supervisor/index.ts +703 -94
- package/workspace/skills/alexa/SKILL.md +281 -0
- package/workspace/skills/alexa/skill.json +15 -0
package/supervisor/index.ts
CHANGED
|
@@ -288,6 +288,17 @@ export async function startSupervisor() {
|
|
|
288
288
|
let currentStreamConvId: string | null = null;
|
|
289
289
|
let currentStreamBuffer = '';
|
|
290
290
|
|
|
291
|
+
// Workspace channel: SSE subscribers receive every chat event the dashboard widget WS
|
|
292
|
+
// clients receive. A subscriber is just a held-open HTTP response we write `data: ...\n\n`
|
|
293
|
+
// chunks to. Registered by `GET /api/agent/chat/stream`, used by `broadcastBloby` below.
|
|
294
|
+
interface ChatSubscriber {
|
|
295
|
+
id: string;
|
|
296
|
+
clientId?: string;
|
|
297
|
+
send: (type: string, data: any) => void;
|
|
298
|
+
close: () => void;
|
|
299
|
+
}
|
|
300
|
+
const chatSubscribers = new Set<ChatSubscriber>();
|
|
301
|
+
|
|
291
302
|
/** Call worker API endpoints (in-process via supervisor's own HTTP server) */
|
|
292
303
|
async function workerApi(apiPath: string, method = 'GET', body?: any) {
|
|
293
304
|
const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json', 'x-internal': internalSecret } };
|
|
@@ -296,12 +307,16 @@ export async function startSupervisor() {
|
|
|
296
307
|
return res.json();
|
|
297
308
|
}
|
|
298
309
|
|
|
299
|
-
/** Broadcast to all bloby
|
|
310
|
+
/** Broadcast to all bloby chat surfaces EXCEPT the sender WS. Workspace SSE subscribers
|
|
311
|
+
* always receive (sender is a WS, so no SSE collision). */
|
|
300
312
|
function broadcastBlobyExcept(sender: WebSocket, type: string, data: any) {
|
|
301
313
|
const msg = JSON.stringify({ type, data });
|
|
302
314
|
for (const client of blobyWss.clients) {
|
|
303
315
|
if (client !== sender && client.readyState === WebSocket.OPEN) client.send(msg);
|
|
304
316
|
}
|
|
317
|
+
for (const sub of chatSubscribers) {
|
|
318
|
+
try { sub.send(type, data); } catch {}
|
|
319
|
+
}
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
// ── Auth middleware ──
|
|
@@ -376,6 +391,7 @@ export async function startSupervisor() {
|
|
|
376
391
|
'POST /api/channels/whatsapp/pairing-code',
|
|
377
392
|
'POST /api/channels/whatsapp/react',
|
|
378
393
|
'POST /api/channels/send',
|
|
394
|
+
'POST /api/channels/alexa/handle',
|
|
379
395
|
];
|
|
380
396
|
|
|
381
397
|
function isExemptRoute(method: string, url: string): boolean {
|
|
@@ -813,6 +829,296 @@ ${!connected ? `<script>
|
|
|
813
829
|
return;
|
|
814
830
|
}
|
|
815
831
|
|
|
832
|
+
// POST /api/channels/alexa/handle — relay-forwarded Alexa utterance.
|
|
833
|
+
// Authed via x-bloby-alexa-secret (per-user shared secret minted at pair time).
|
|
834
|
+
// Returns plain text the relay then formats as Alexa SSML.
|
|
835
|
+
if (req.method === 'POST' && channelPath === '/api/channels/alexa/handle') {
|
|
836
|
+
let body = '';
|
|
837
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
838
|
+
req.on('end', async () => {
|
|
839
|
+
try {
|
|
840
|
+
const cfg = loadConfig();
|
|
841
|
+
const expected = cfg.channels?.alexa?.sharedSecret;
|
|
842
|
+
const provided = req.headers['x-bloby-alexa-secret'];
|
|
843
|
+
if (!expected || !provided || provided !== expected) {
|
|
844
|
+
res.writeHead(401);
|
|
845
|
+
res.end(JSON.stringify({ ok: false, error: 'alexa-auth-failed' }));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (!cfg.channels?.alexa?.enabled) {
|
|
849
|
+
res.writeHead(503);
|
|
850
|
+
res.end(JSON.stringify({ ok: false, error: 'alexa-disabled' }));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const { text, alexaUserId, sessionId, deviceId, locale, kind, apiEndpoint, apiAccessToken, requestId } = JSON.parse(body || '{}') as {
|
|
855
|
+
text?: string; alexaUserId?: string; sessionId?: string; deviceId?: string; locale?: string; kind?: string;
|
|
856
|
+
apiEndpoint?: string; apiAccessToken?: string; requestId?: string;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
// Launch / Stop / Help intents don't need to hit the agent — the relay
|
|
860
|
+
// can short-circuit, but we accept them here too for symmetry.
|
|
861
|
+
if (kind === 'launch') {
|
|
862
|
+
res.writeHead(200);
|
|
863
|
+
res.end(JSON.stringify({ ok: true, reply: 'Morphy here, what can I help with?', endSession: false }));
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (kind === 'stop' || kind === 'cancel') {
|
|
867
|
+
res.writeHead(200);
|
|
868
|
+
res.end(JSON.stringify({ ok: true, reply: 'Goodbye.', endSession: true }));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (!text || !alexaUserId) {
|
|
873
|
+
res.writeHead(400);
|
|
874
|
+
res.end(JSON.stringify({ ok: false, error: 'text and alexaUserId required' }));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
const reply = await channelManager.handleAlexaInbound({
|
|
880
|
+
text, alexaUserId, alexaSessionId: sessionId, deviceId, locale,
|
|
881
|
+
apiEndpoint, apiAccessToken, requestId,
|
|
882
|
+
});
|
|
883
|
+
res.writeHead(200);
|
|
884
|
+
res.end(JSON.stringify({ ok: true, reply, endSession: false }));
|
|
885
|
+
} catch (err: any) {
|
|
886
|
+
// Timeout or aborted turn — agent will still finish and the result lands
|
|
887
|
+
// in the shared conversation. The agent is responsible for any
|
|
888
|
+
// out-of-band notification (HA announce, chat, WhatsApp) — that's
|
|
889
|
+
// a skill concern, not a supervisor concern.
|
|
890
|
+
res.writeHead(200);
|
|
891
|
+
res.end(JSON.stringify({
|
|
892
|
+
ok: true,
|
|
893
|
+
reply: "I'll reply in your chat when ready.",
|
|
894
|
+
endSession: false,
|
|
895
|
+
overflow: true,
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
} catch (err: any) {
|
|
899
|
+
res.writeHead(500);
|
|
900
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// POST /api/channels/alexa/pair — dashboard mints a pairing code via the relay.
|
|
907
|
+
// Body: optional { ttlSeconds }. Returns: { code, expiresAt, sharedSecret }.
|
|
908
|
+
// We persist sharedSecret locally so future /handle calls can authenticate.
|
|
909
|
+
if (req.method === 'POST' && channelPath === '/api/channels/alexa/pair') {
|
|
910
|
+
(async () => {
|
|
911
|
+
try {
|
|
912
|
+
const cfg = loadConfig();
|
|
913
|
+
if (!cfg.relay?.token) {
|
|
914
|
+
res.writeHead(400);
|
|
915
|
+
res.end(JSON.stringify({ ok: false, error: 'no-relay-token' }));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
// The relay API always lives at api.bloby.bot — `cfg.relay.url` is the
|
|
919
|
+
// bot's public profile URL (e.g. https://bloby.bot/<handle>), not the API.
|
|
920
|
+
const resp = await fetch('https://api.bloby.bot/api/alexa/pair', {
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: {
|
|
923
|
+
'Content-Type': 'application/json',
|
|
924
|
+
Authorization: `Bearer ${cfg.relay.token}`,
|
|
925
|
+
},
|
|
926
|
+
body: '{}',
|
|
927
|
+
});
|
|
928
|
+
const data = await resp.json() as { code?: string; expiresAt?: string; sharedSecret?: string; error?: string };
|
|
929
|
+
if (!resp.ok || !data.code || !data.sharedSecret) {
|
|
930
|
+
res.writeHead(resp.status || 500);
|
|
931
|
+
res.end(JSON.stringify({ ok: false, error: data.error || 'pair-failed' }));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Persist the shared secret + mark channel enabled so handleInbound is gated.
|
|
936
|
+
if (!cfg.channels) cfg.channels = {};
|
|
937
|
+
cfg.channels.alexa = {
|
|
938
|
+
...(cfg.channels.alexa || {}),
|
|
939
|
+
enabled: true,
|
|
940
|
+
sharedSecret: data.sharedSecret,
|
|
941
|
+
};
|
|
942
|
+
saveConfig(cfg);
|
|
943
|
+
// Initialize the channel if it wasn't already running.
|
|
944
|
+
await channelManager.init().catch(() => {});
|
|
945
|
+
|
|
946
|
+
res.writeHead(200);
|
|
947
|
+
res.end(JSON.stringify({ ok: true, code: data.code, expiresAt: data.expiresAt }));
|
|
948
|
+
} catch (err: any) {
|
|
949
|
+
log.warn(`[alexa/pair] ${err.message}`);
|
|
950
|
+
res.writeHead(500);
|
|
951
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
952
|
+
}
|
|
953
|
+
})();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// GET /api/channels/alexa/pair-page — inline HTML page showing the code with countdown.
|
|
958
|
+
// The chat surface auto-converts URLs ending in /pair-page into a button (see MessageBubble).
|
|
959
|
+
if (req.method === 'GET' && channelPath === '/api/channels/alexa/pair-page') {
|
|
960
|
+
res.setHeader('Content-Type', 'text/html');
|
|
961
|
+
const alexaStatus = channelManager.getStatus('alexa');
|
|
962
|
+
const alreadyLinked = !!(alexaStatus?.info as any)?.linked;
|
|
963
|
+
const confettiHTML = Array.from({ length: 30 }, (_, i) => {
|
|
964
|
+
const colors = ['#04D1FE', '#AF27E3', '#FB4072', '#4ade80', '#facc15', '#818cf8'];
|
|
965
|
+
const color = colors[Math.floor(Math.random() * colors.length)];
|
|
966
|
+
const left = Math.random() * 100;
|
|
967
|
+
const delay = i * 0.04;
|
|
968
|
+
const drift = (Math.random() - 0.5) * 120;
|
|
969
|
+
const duration = 1.8 + Math.random() * 0.8;
|
|
970
|
+
return `<div class="confetti-dot" style="left:${left}%;background:${color};animation-delay:${delay}s;animation-duration:${duration}s;--drift:${drift}px"></div>`;
|
|
971
|
+
}).join('');
|
|
972
|
+
res.writeHead(200);
|
|
973
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect Alexa</title>
|
|
974
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
975
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
976
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
|
|
977
|
+
<style>
|
|
978
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
979
|
+
body{background:#212121;color:#f5f5f5;font-family:'Inter',system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100dvh;margin:0;overflow-x:hidden}
|
|
980
|
+
.container{display:flex;flex-direction:column;align-items:center;max-width:380px;width:100%;padding:20px}
|
|
981
|
+
|
|
982
|
+
.card{background:#2a2a2a;border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:28px 24px;width:100%;box-shadow:0 0 0 1px rgba(175,39,227,0.1),0 0 20px -5px rgba(175,39,227,0.15);animation:fade-up .5s ease-out both;text-align:center}
|
|
983
|
+
|
|
984
|
+
.header{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:18px}
|
|
985
|
+
.badge-alexa{display:inline-flex;align-items:center;gap:6px;background:#1a1a1a;border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:4px 10px;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:0.6px}
|
|
986
|
+
.badge-alexa::before{content:'';width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#04D1FE,#AF27E3);box-shadow:0 0 8px rgba(4,209,254,0.5)}
|
|
987
|
+
.title{font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:700;background:linear-gradient(135deg,#04D1FE,#AF27E3,#FB4072);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-top:6px}
|
|
988
|
+
.sub{font-size:13px;color:#999;line-height:1.6;margin-top:6px}
|
|
989
|
+
|
|
990
|
+
.code-block{margin:18px 0 6px;animation:fade-up .5s ease-out .15s both}
|
|
991
|
+
.code-label{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.8px;margin-bottom:8px}
|
|
992
|
+
.code-value{font-family:'Space Grotesk',monospace;font-size:36px;font-weight:700;letter-spacing:8px;background:linear-gradient(135deg,#04D1FE,#AF27E3);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;line-height:1.1}
|
|
993
|
+
.countdown{font-size:12px;color:#666;margin-top:10px;display:inline-flex;align-items:center;gap:6px}
|
|
994
|
+
.countdown.warn{color:#FB4072}
|
|
995
|
+
.countdown .dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 1.6s ease-in-out infinite;opacity:.6}
|
|
996
|
+
|
|
997
|
+
.quote-card{background:#1a1a1a;border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:14px 16px;margin:18px 0 6px;animation:fade-up .5s ease-out .25s both}
|
|
998
|
+
.quote-label{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.6px;margin-bottom:6px}
|
|
999
|
+
.quote{font-size:15px;color:#f5f5f5;line-height:1.5;font-style:italic}
|
|
1000
|
+
.quote .invocation{color:#04D1FE;font-style:normal;font-weight:600}
|
|
1001
|
+
.quote .num{background:linear-gradient(135deg,#04D1FE,#AF27E3);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-style:normal;font-weight:700;letter-spacing:2px}
|
|
1002
|
+
|
|
1003
|
+
.steps{text-align:left;margin-top:20px;animation:fade-up .5s ease-out .35s both}
|
|
1004
|
+
.steps-title{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.6px;margin-bottom:10px;text-align:center}
|
|
1005
|
+
.step{display:flex;gap:12px;font-size:13px;color:#bbb;line-height:1.5;padding:6px 0}
|
|
1006
|
+
.step-num{flex-shrink:0;width:22px;height:22px;border-radius:50%;background:#1a1a1a;border:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#888}
|
|
1007
|
+
.step b{color:#f5f5f5;font-weight:600}
|
|
1008
|
+
|
|
1009
|
+
.btn-row{display:flex;gap:10px;margin-top:20px;width:100%;animation:fade-up .5s ease-out .45s both}
|
|
1010
|
+
.btn{flex:1;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:10px;padding:11px 16px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s}
|
|
1011
|
+
.btn-primary{background:linear-gradient(135deg,#AF27E3,#FB4072);color:#fff}
|
|
1012
|
+
.btn-primary:hover{opacity:.9}
|
|
1013
|
+
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
|
|
1014
|
+
.btn-ghost{background:#1a1a1a;border:1px solid rgba(255,255,255,0.1);color:#999}
|
|
1015
|
+
.btn-ghost:hover{border-color:rgba(175,39,227,0.4);color:#f5f5f5}
|
|
1016
|
+
|
|
1017
|
+
.err{color:#FB4072;font-size:13px;margin-top:14px;min-height:1.2em}
|
|
1018
|
+
|
|
1019
|
+
.confetti-wrap{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden;z-index:0}
|
|
1020
|
+
.confetti-dot{position:absolute;width:8px;height:8px;border-radius:50%;top:-10px;animation:confetti-fall 2s ease-out forwards}
|
|
1021
|
+
@keyframes confetti-fall{0%{opacity:1;transform:translateY(0) translateX(0) rotate(0) scale(1)}100%{opacity:0;transform:translateY(100vh) translateX(var(--drift)) rotate(360deg) scale(.5)}}
|
|
1022
|
+
|
|
1023
|
+
.video-wrap{margin-bottom:8px;animation:pop-in .5s cubic-bezier(.34,1.56,.64,1) forwards}
|
|
1024
|
+
.video-wrap video{width:200px;object-fit:contain;pointer-events:none}
|
|
1025
|
+
@keyframes pop-in{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
|
|
1026
|
+
|
|
1027
|
+
.text-wrap{text-align:center;animation:fade-up .5s ease-out .3s both}
|
|
1028
|
+
.success-sub{font-size:14px;color:#999;line-height:1.5;margin-top:6px}
|
|
1029
|
+
|
|
1030
|
+
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
|
|
1031
|
+
@keyframes fade-up{0%{opacity:0;transform:translateY(10px)}100%{opacity:1;transform:translateY(0)}}
|
|
1032
|
+
</style></head><body>
|
|
1033
|
+
<div class="container" id="root">
|
|
1034
|
+
${alreadyLinked
|
|
1035
|
+
? `<div class="confetti-wrap">${confettiHTML}</div>
|
|
1036
|
+
<div class="video-wrap"><video autoplay muted playsinline><source src="/bloby_happy_reappearing.mov" type='video/mp4; codecs="hvc1"'><source src="/bloby_happy_reappearing.webm" type="video/webm"></video></div>
|
|
1037
|
+
<div class="text-wrap">
|
|
1038
|
+
<div class="title">Connected!</div>
|
|
1039
|
+
<p class="success-sub">Alexa is linked. Say <b style="color:#f5f5f5">"Alexa, open Morphy Agent"</b> to start a conversation, or <b style="color:#f5f5f5">"Alexa, tell Morphy Agent <command>"</b> for one-shots.</p>
|
|
1040
|
+
<p class="success-sub" style="margin-top:14px;font-size:12px;color:#666">You can close this page.</p>
|
|
1041
|
+
</div>`
|
|
1042
|
+
: `<div class="card" id="pendingCard">
|
|
1043
|
+
<div class="header">
|
|
1044
|
+
<span class="badge-alexa">Amazon Alexa</span>
|
|
1045
|
+
<div class="title">Link your Alexa</div>
|
|
1046
|
+
<p class="sub">Open the Alexa app and enable the <b style="color:#f5f5f5">Morphy Agent</b> skill, then say the phrase below.</p>
|
|
1047
|
+
</div>
|
|
1048
|
+
|
|
1049
|
+
<div class="code-block">
|
|
1050
|
+
<div class="code-label">Pairing code</div>
|
|
1051
|
+
<div class="code-value" id="code">— — — — — —</div>
|
|
1052
|
+
<div class="countdown" id="cd"><span class="dot"></span><span id="cdText">Generating code…</span></div>
|
|
1053
|
+
</div>
|
|
1054
|
+
|
|
1055
|
+
<div class="quote-card">
|
|
1056
|
+
<div class="quote-label">Say to Alexa</div>
|
|
1057
|
+
<div class="quote">"<span class="invocation">Alexa, ask Morphy Agent to link with code</span> <span class="num" id="codeSpoken">———</span>"</div>
|
|
1058
|
+
</div>
|
|
1059
|
+
|
|
1060
|
+
<div class="steps">
|
|
1061
|
+
<div class="steps-title">How to link</div>
|
|
1062
|
+
<div class="step"><div class="step-num">1</div><div>Open the <b>Alexa</b> app on your phone</div></div>
|
|
1063
|
+
<div class="step"><div class="step-num">2</div><div>Find and enable the <b>Morphy Agent</b> skill</div></div>
|
|
1064
|
+
<div class="step"><div class="step-num">3</div><div>Say the phrase above to your Alexa device</div></div>
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
<div class="btn-row">
|
|
1068
|
+
<button class="btn btn-ghost" onclick="mint()">New code</button>
|
|
1069
|
+
</div>
|
|
1070
|
+
<p class="err" id="err"></p>
|
|
1071
|
+
</div>`}
|
|
1072
|
+
</div>
|
|
1073
|
+
<script>
|
|
1074
|
+
${alreadyLinked ? `` : `
|
|
1075
|
+
let expiresAt=0,timer=null;
|
|
1076
|
+
function fmt(n){return String(n).padStart(2,'0')}
|
|
1077
|
+
function tick(){
|
|
1078
|
+
const s=Math.max(0,Math.round((expiresAt-Date.now())/1000));
|
|
1079
|
+
const cd=document.getElementById('cd');
|
|
1080
|
+
const txt=document.getElementById('cdText');
|
|
1081
|
+
if(s>0){
|
|
1082
|
+
txt.textContent='Expires in '+Math.floor(s/60)+':'+fmt(s%60);
|
|
1083
|
+
cd.classList.toggle('warn',s<60);
|
|
1084
|
+
}else{
|
|
1085
|
+
txt.textContent='Expired — generate a new code';
|
|
1086
|
+
cd.classList.add('warn');
|
|
1087
|
+
if(timer){clearInterval(timer);timer=null}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
async function mint(){
|
|
1091
|
+
document.getElementById('err').textContent='';
|
|
1092
|
+
document.getElementById('code').textContent='— — — — — —';
|
|
1093
|
+
document.getElementById('codeSpoken').textContent='———';
|
|
1094
|
+
document.getElementById('cdText').textContent='Generating code…';
|
|
1095
|
+
document.getElementById('cd').classList.remove('warn');
|
|
1096
|
+
try{
|
|
1097
|
+
const r=await fetch('/api/channels/alexa/pair',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
|
1098
|
+
const d=await r.json();
|
|
1099
|
+
if(!d.ok){document.getElementById('err').textContent=d.error||'Failed to mint code';return}
|
|
1100
|
+
document.getElementById('code').textContent=d.code.split('').join(' ');
|
|
1101
|
+
document.getElementById('codeSpoken').textContent=d.code;
|
|
1102
|
+
expiresAt=new Date(d.expiresAt).getTime();
|
|
1103
|
+
if(timer)clearInterval(timer);
|
|
1104
|
+
timer=setInterval(tick,1000);tick();
|
|
1105
|
+
}catch(e){document.getElementById('err').textContent=e.message}
|
|
1106
|
+
}
|
|
1107
|
+
mint();
|
|
1108
|
+
`}
|
|
1109
|
+
</script>
|
|
1110
|
+
</body></html>`);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// GET /api/channels/alexa/status — current alexa channel status
|
|
1115
|
+
if (req.method === 'GET' && channelPath === '/api/channels/alexa/status') {
|
|
1116
|
+
const status = channelManager.getStatus('alexa');
|
|
1117
|
+
res.writeHead(200);
|
|
1118
|
+
res.end(JSON.stringify(status || { channel: 'alexa', connected: false }));
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
816
1122
|
// Fallback for unknown channel routes
|
|
817
1123
|
res.writeHead(404);
|
|
818
1124
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
@@ -869,27 +1175,43 @@ ${!connected ? `<script>
|
|
|
869
1175
|
}
|
|
870
1176
|
|
|
871
1177
|
// ── Agent API — SDK gateway for workspace code ──
|
|
872
|
-
if (req.
|
|
1178
|
+
if (req.url?.startsWith('/api/agent/')) {
|
|
873
1179
|
const agentPath = req.url.split('?')[0];
|
|
874
|
-
res.setHeader('Content-Type', 'application/json');
|
|
875
|
-
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
876
1180
|
|
|
877
1181
|
// Localhost-only guard
|
|
878
1182
|
const remoteIp = req.socket.remoteAddress || '';
|
|
879
1183
|
if (remoteIp !== '127.0.0.1' && remoteIp !== '::1' && remoteIp !== '::ffff:127.0.0.1') {
|
|
1184
|
+
res.setHeader('Content-Type', 'application/json');
|
|
880
1185
|
res.writeHead(403);
|
|
881
1186
|
res.end(JSON.stringify({ ok: false, error: 'Agent API is localhost-only.' }));
|
|
882
1187
|
return;
|
|
883
1188
|
}
|
|
884
1189
|
|
|
885
|
-
// Auth: x-agent-secret header
|
|
886
|
-
|
|
1190
|
+
// Auth: x-agent-secret header (query param fallback for EventSource which cannot set headers)
|
|
1191
|
+
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
1192
|
+
const headerSecret = req.headers['x-agent-secret'];
|
|
1193
|
+
const querySecret = urlObj.searchParams.get('secret');
|
|
1194
|
+
if (headerSecret !== agentSecret && querySecret !== agentSecret) {
|
|
1195
|
+
res.setHeader('Content-Type', 'application/json');
|
|
887
1196
|
res.writeHead(401);
|
|
888
|
-
res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret
|
|
1197
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret.' }));
|
|
889
1198
|
return;
|
|
890
1199
|
}
|
|
891
1200
|
|
|
892
|
-
|
|
1201
|
+
const readJsonBody = () => new Promise<any>((resolve, reject) => {
|
|
1202
|
+
let body = '';
|
|
1203
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
1204
|
+
req.on('end', () => {
|
|
1205
|
+
try { resolve(body ? JSON.parse(body) : {}); }
|
|
1206
|
+
catch (err: any) { reject(err); }
|
|
1207
|
+
});
|
|
1208
|
+
req.on('error', reject);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// POST /api/agent/query — one-shot agent query (legacy AGENT-API.md endpoint)
|
|
1212
|
+
if (req.method === 'POST' && agentPath === '/api/agent/query') {
|
|
1213
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1214
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
893
1215
|
let body = '';
|
|
894
1216
|
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
895
1217
|
req.on('end', async () => {
|
|
@@ -897,7 +1219,6 @@ ${!connected ? `<script>
|
|
|
897
1219
|
const parsed = JSON.parse(body) as AgentQueryRequest;
|
|
898
1220
|
const result = await handleAgentQuery(parsed);
|
|
899
1221
|
|
|
900
|
-
// If the agent wrote files, restart backend + broadcast HMR
|
|
901
1222
|
if (result.usedFileTools) {
|
|
902
1223
|
resetBackendRestarts();
|
|
903
1224
|
stopBackend().then(() => spawnBackend(backendPort));
|
|
@@ -914,6 +1235,275 @@ ${!connected ? `<script>
|
|
|
914
1235
|
return;
|
|
915
1236
|
}
|
|
916
1237
|
|
|
1238
|
+
// ── Workspace channel: live-conversation mirror of the Bloby chat ──
|
|
1239
|
+
// The workspace is a chat surface like the dashboard widget. Everything users
|
|
1240
|
+
// see in the widget + WhatsApp is mirrored here; messages sent from workspace
|
|
1241
|
+
// run through the SAME orchestrator with the SAME memory/agents/recent history.
|
|
1242
|
+
|
|
1243
|
+
// GET /api/agent/chat/stream — Server-Sent Events, every chat event (bot:*, chat:*)
|
|
1244
|
+
if (req.method === 'GET' && agentPath === '/api/agent/chat/stream') {
|
|
1245
|
+
const clientId = urlObj.searchParams.get('clientId') || undefined;
|
|
1246
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1247
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
1248
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1249
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1250
|
+
res.writeHead(200);
|
|
1251
|
+
res.write(': connected\n\n');
|
|
1252
|
+
|
|
1253
|
+
const sub: ChatSubscriber = {
|
|
1254
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
1255
|
+
clientId,
|
|
1256
|
+
send: (type, data) => {
|
|
1257
|
+
if (res.writableEnded) return;
|
|
1258
|
+
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1259
|
+
},
|
|
1260
|
+
close: () => { try { res.end(); } catch {} },
|
|
1261
|
+
};
|
|
1262
|
+
chatSubscribers.add(sub);
|
|
1263
|
+
|
|
1264
|
+
// Replay current streaming state so a late-joining workspace catches up mid-turn
|
|
1265
|
+
if (agentQueryActive && currentStreamConvId) {
|
|
1266
|
+
sub.send('chat:state', {
|
|
1267
|
+
streaming: true,
|
|
1268
|
+
conversationId: currentStreamConvId,
|
|
1269
|
+
buffer: currentStreamBuffer,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const keepAlive = setInterval(() => {
|
|
1274
|
+
if (res.writableEnded) return;
|
|
1275
|
+
res.write(': ping\n\n');
|
|
1276
|
+
}, 25_000);
|
|
1277
|
+
|
|
1278
|
+
req.on('close', () => {
|
|
1279
|
+
clearInterval(keepAlive);
|
|
1280
|
+
chatSubscribers.delete(sub);
|
|
1281
|
+
});
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// GET /api/agent/chat/state — snapshot: convId + bot/user names + recent messages
|
|
1286
|
+
if (req.method === 'GET' && agentPath === '/api/agent/chat/state') {
|
|
1287
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1288
|
+
(async () => {
|
|
1289
|
+
try {
|
|
1290
|
+
const ctx = await workerApi('/api/context/current');
|
|
1291
|
+
const convId: string | undefined = ctx.conversationId;
|
|
1292
|
+
const [status, recentRaw] = await Promise.all([
|
|
1293
|
+
workerApi('/api/onboard/status'),
|
|
1294
|
+
convId ? workerApi(`/api/conversations/${convId}/messages/recent?limit=50`) : Promise.resolve([]),
|
|
1295
|
+
]);
|
|
1296
|
+
const messages = Array.isArray(recentRaw)
|
|
1297
|
+
? recentRaw
|
|
1298
|
+
.filter((m: any) => m.role === 'user' || m.role === 'assistant')
|
|
1299
|
+
.map((m: any) => ({ role: m.role, content: m.content, timestamp: m.created_at }))
|
|
1300
|
+
: [];
|
|
1301
|
+
res.writeHead(200);
|
|
1302
|
+
res.end(JSON.stringify({
|
|
1303
|
+
ok: true,
|
|
1304
|
+
conversationId: convId || null,
|
|
1305
|
+
agentName: status?.agentName || 'Bloby',
|
|
1306
|
+
userName: status?.userName || 'Human',
|
|
1307
|
+
streaming: agentQueryActive,
|
|
1308
|
+
streamConversationId: currentStreamConvId,
|
|
1309
|
+
streamBuffer: currentStreamBuffer,
|
|
1310
|
+
messages,
|
|
1311
|
+
}));
|
|
1312
|
+
} catch (err: any) {
|
|
1313
|
+
res.writeHead(500);
|
|
1314
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
1315
|
+
}
|
|
1316
|
+
})();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// POST /api/agent/chat/message — push a user message into the shared conversation.
|
|
1321
|
+
// Mirrors the chat-WS `user:message` flow: ensures live conversation, persists, pushes.
|
|
1322
|
+
if (req.method === 'POST' && agentPath === '/api/agent/chat/message') {
|
|
1323
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1324
|
+
(async () => {
|
|
1325
|
+
try {
|
|
1326
|
+
const body = await readJsonBody();
|
|
1327
|
+
const content: string = body.content;
|
|
1328
|
+
const clientId: string | undefined = body.clientId;
|
|
1329
|
+
if (!content || typeof content !== 'string') {
|
|
1330
|
+
res.writeHead(400);
|
|
1331
|
+
res.end(JSON.stringify({ ok: false, error: 'Missing "content".' }));
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const freshConfig = loadConfig();
|
|
1336
|
+
const provider = freshConfig.ai.provider;
|
|
1337
|
+
if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'pi') {
|
|
1338
|
+
res.writeHead(400);
|
|
1339
|
+
res.end(JSON.stringify({ ok: false, error: `Workspace chat requires a harnessed provider (got "${provider}").` }));
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
let savedFiles: SavedFile[] = [];
|
|
1344
|
+
if (Array.isArray(body.attachments) && body.attachments.length) {
|
|
1345
|
+
for (const att of body.attachments) {
|
|
1346
|
+
try { savedFiles.push(saveAttachment(att)); }
|
|
1347
|
+
catch (err: any) { log.warn(`[workspace-chat] attachment save: ${err.message}`); }
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
let convId: string;
|
|
1352
|
+
const ctx = await workerApi('/api/context/current');
|
|
1353
|
+
if (ctx.conversationId) {
|
|
1354
|
+
convId = ctx.conversationId;
|
|
1355
|
+
} else {
|
|
1356
|
+
const conv = await workerApi('/api/conversations', 'POST', { title: content.slice(0, 80), model: freshConfig.ai.model });
|
|
1357
|
+
convId = conv.id;
|
|
1358
|
+
await workerApi('/api/context/set', 'POST', { conversationId: convId });
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const meta: any = { model: freshConfig.ai.model, channel: 'workspace' };
|
|
1362
|
+
if (savedFiles.length) {
|
|
1363
|
+
meta.attachments = JSON.stringify(savedFiles.map((f) => ({
|
|
1364
|
+
type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath,
|
|
1365
|
+
})));
|
|
1366
|
+
}
|
|
1367
|
+
try {
|
|
1368
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1369
|
+
role: 'user', content, meta,
|
|
1370
|
+
});
|
|
1371
|
+
} catch (err: any) {
|
|
1372
|
+
log.warn(`[workspace-chat] DB persist error: ${err.message}`);
|
|
1373
|
+
broadcastBloby('chat:persist-error', { conversationId: convId, role: 'user', error: err.message });
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Echo user message to every OTHER surface (dashboard WS + other SSE subscribers).
|
|
1377
|
+
// The originating workspace subscriber renders its own message optimistically.
|
|
1378
|
+
broadcastBlobyExceptSubscriber(clientId, 'chat:sync', {
|
|
1379
|
+
conversationId: convId,
|
|
1380
|
+
message: { role: 'user', content, timestamp: new Date().toISOString() },
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
let botName = 'Bloby', humanName = 'Human';
|
|
1384
|
+
let recentMessages: RecentMessage[] = [];
|
|
1385
|
+
try {
|
|
1386
|
+
const [status, recentRaw] = await Promise.all([
|
|
1387
|
+
workerApi('/api/onboard/status'),
|
|
1388
|
+
workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
|
|
1389
|
+
]);
|
|
1390
|
+
botName = status.agentName || 'Bloby';
|
|
1391
|
+
humanName = status.userName || 'Human';
|
|
1392
|
+
if (Array.isArray(recentRaw)) {
|
|
1393
|
+
const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
|
|
1394
|
+
if (filtered.length > 0) {
|
|
1395
|
+
recentMessages = filtered.slice(0, -1).map((m: any) => ({
|
|
1396
|
+
role: m.role as 'user' | 'assistant',
|
|
1397
|
+
content: m.content,
|
|
1398
|
+
}));
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
} catch {}
|
|
1402
|
+
|
|
1403
|
+
if (!hasConversation(convId)) {
|
|
1404
|
+
log.info(`[workspace-chat] Starting new live conversation: ${convId}`);
|
|
1405
|
+
const waState = channelManager.createWaStreamState();
|
|
1406
|
+
await startConversation(
|
|
1407
|
+
convId,
|
|
1408
|
+
freshConfig.ai.model,
|
|
1409
|
+
createSharedChatOnMessage(convId, freshConfig.ai.model, botName, waState),
|
|
1410
|
+
{ botName, humanName },
|
|
1411
|
+
recentMessages,
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const agentAttachments = Array.isArray(body.attachments) && body.attachments.length
|
|
1416
|
+
? body.attachments
|
|
1417
|
+
: undefined;
|
|
1418
|
+
|
|
1419
|
+
// Mirror to WhatsApp self-chat if connected (same behaviour as the widget).
|
|
1420
|
+
const waStatus = channelManager.getStatus('whatsapp');
|
|
1421
|
+
const ownPhone = waStatus?.connected ? (waStatus.info?.phoneNumber as string | undefined) : undefined;
|
|
1422
|
+
const waMirrorTo = ownPhone ? `${ownPhone}@s.whatsapp.net` : undefined;
|
|
1423
|
+
|
|
1424
|
+
channelManager.pushWithRouting(
|
|
1425
|
+
convId,
|
|
1426
|
+
{ surface: 'workspace', waSendTo: waMirrorTo, isSelfChat: true },
|
|
1427
|
+
content,
|
|
1428
|
+
agentAttachments,
|
|
1429
|
+
savedFiles,
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
res.writeHead(200);
|
|
1433
|
+
res.end(JSON.stringify({ ok: true, conversationId: convId }));
|
|
1434
|
+
} catch (err: any) {
|
|
1435
|
+
log.warn(`[workspace-chat] message error: ${err.message}`);
|
|
1436
|
+
res.writeHead(500);
|
|
1437
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
1438
|
+
}
|
|
1439
|
+
})();
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// POST /api/agent/chat/stop — interrupt the current turn (mirrors user:stop)
|
|
1444
|
+
if (req.method === 'POST' && agentPath === '/api/agent/chat/stop') {
|
|
1445
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1446
|
+
(async () => {
|
|
1447
|
+
try {
|
|
1448
|
+
const ctx = await workerApi('/api/context/current');
|
|
1449
|
+
const convId: string | undefined = ctx.conversationId;
|
|
1450
|
+
if (convId && hasConversation(convId)) {
|
|
1451
|
+
log.info(`[workspace-chat] stop — ending live conversation ${convId}`);
|
|
1452
|
+
endConversation(convId);
|
|
1453
|
+
}
|
|
1454
|
+
res.writeHead(200);
|
|
1455
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1456
|
+
} catch (err: any) {
|
|
1457
|
+
res.writeHead(500);
|
|
1458
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
1459
|
+
}
|
|
1460
|
+
})();
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// POST /api/agent/chat/clear — clear context (mirrors user:clear-context)
|
|
1465
|
+
if (req.method === 'POST' && agentPath === '/api/agent/chat/clear') {
|
|
1466
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1467
|
+
(async () => {
|
|
1468
|
+
try {
|
|
1469
|
+
const ctx = await workerApi('/api/context/current');
|
|
1470
|
+
const convId: string | undefined = ctx.conversationId;
|
|
1471
|
+
if (convId && hasConversation(convId)) endConversation(convId);
|
|
1472
|
+
await workerApi('/api/context/clear', 'POST');
|
|
1473
|
+
broadcastBloby('chat:cleared', {});
|
|
1474
|
+
res.writeHead(200);
|
|
1475
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1476
|
+
} catch (err: any) {
|
|
1477
|
+
res.writeHead(500);
|
|
1478
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
1479
|
+
}
|
|
1480
|
+
})();
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// POST /api/agent/chat/whisper — transcribe audio via the same Whisper the widget uses
|
|
1485
|
+
if (req.method === 'POST' && agentPath === '/api/agent/chat/whisper') {
|
|
1486
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1487
|
+
(async () => {
|
|
1488
|
+
try {
|
|
1489
|
+
const body = await readJsonBody();
|
|
1490
|
+
if (!body.audio || typeof body.audio !== 'string') {
|
|
1491
|
+
res.writeHead(400);
|
|
1492
|
+
res.end(JSON.stringify({ ok: false, error: 'Missing "audio" (base64).' }));
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const result = await workerApi('/api/whisper/transcribe', 'POST', { audio: body.audio });
|
|
1496
|
+
res.writeHead(200);
|
|
1497
|
+
res.end(JSON.stringify({ ok: !result.error, ...result }));
|
|
1498
|
+
} catch (err: any) {
|
|
1499
|
+
res.writeHead(500);
|
|
1500
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
1501
|
+
}
|
|
1502
|
+
})();
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
res.setHeader('Content-Type', 'application/json');
|
|
917
1507
|
res.writeHead(404);
|
|
918
1508
|
res.end(JSON.stringify({ ok: false, error: 'Unknown agent endpoint.' }));
|
|
919
1509
|
return;
|
|
@@ -1122,12 +1712,105 @@ ${!connected ? `<script>
|
|
|
1122
1712
|
});
|
|
1123
1713
|
});
|
|
1124
1714
|
|
|
1125
|
-
/** Send a message to
|
|
1715
|
+
/** Send a message to every chat surface — dashboard widget WS clients AND workspace SSE
|
|
1716
|
+
* subscribers. The workspace mirror sees the exact same event stream the widget does. */
|
|
1126
1717
|
function broadcastBloby(type: string, data: any = {}) {
|
|
1127
1718
|
const msg = JSON.stringify({ type, data });
|
|
1128
1719
|
for (const client of blobyWss.clients) {
|
|
1129
1720
|
if (client.readyState === WebSocket.OPEN) client.send(msg);
|
|
1130
1721
|
}
|
|
1722
|
+
for (const sub of chatSubscribers) {
|
|
1723
|
+
try { sub.send(type, data); } catch {}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** Like broadcastBloby but skips the workspace SSE subscriber identified by clientId.
|
|
1728
|
+
* Used when the workspace itself originated the event — that subscriber renders the
|
|
1729
|
+
* event optimistically and shouldn't receive its own echo. */
|
|
1730
|
+
function broadcastBlobyExceptSubscriber(excludeClientId: string | undefined, type: string, data: any) {
|
|
1731
|
+
const msg = JSON.stringify({ type, data });
|
|
1732
|
+
for (const client of blobyWss.clients) {
|
|
1733
|
+
if (client.readyState === WebSocket.OPEN) client.send(msg);
|
|
1734
|
+
}
|
|
1735
|
+
for (const sub of chatSubscribers) {
|
|
1736
|
+
if (excludeClientId && sub.clientId === excludeClientId) continue;
|
|
1737
|
+
try { sub.send(type, data); } catch {}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** Build the onMessage handler used by EVERY surface that pushes into the shared live
|
|
1742
|
+
* conversation. Chat-WS, the workspace SSE channel, and (in spirit) the channel manager
|
|
1743
|
+
* all want the same wiring: track stream buffer for late joiners, route streaming text
|
|
1744
|
+
* via the manager's per-conversation routing FIFO, persist responses, defer backend
|
|
1745
|
+
* restarts, and broadcast every event to all chat surfaces.
|
|
1746
|
+
*
|
|
1747
|
+
* Factored so adding a new surface (here: workspace) cannot drift from the chat WS
|
|
1748
|
+
* behaviour — same buffer, same persistence, same restart timing. */
|
|
1749
|
+
function createSharedChatOnMessage(
|
|
1750
|
+
convId: string,
|
|
1751
|
+
model: string,
|
|
1752
|
+
botName: string,
|
|
1753
|
+
waState: ReturnType<typeof channelManager.createWaStreamState>,
|
|
1754
|
+
) {
|
|
1755
|
+
return async (type: string, eventData: any) => {
|
|
1756
|
+
if (type === 'bot:typing') {
|
|
1757
|
+
currentStreamConvId = convId;
|
|
1758
|
+
currentStreamBuffer = '';
|
|
1759
|
+
agentQueryActive = true;
|
|
1760
|
+
}
|
|
1761
|
+
if (type === 'bot:token' && eventData.token) {
|
|
1762
|
+
currentStreamBuffer += eventData.token;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
channelManager.routeWaStreamEvent(waState, type, eventData, botName);
|
|
1766
|
+
|
|
1767
|
+
if (type === 'bot:turn-complete') {
|
|
1768
|
+
log.info(`[orchestrator] ──── TURN COMPLETE ────`);
|
|
1769
|
+
log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
|
|
1770
|
+
agentQueryActive = false;
|
|
1771
|
+
currentStreamConvId = null;
|
|
1772
|
+
currentStreamBuffer = '';
|
|
1773
|
+
|
|
1774
|
+
if (eventData.usedFileTools || pendingBackendRestart) {
|
|
1775
|
+
log.info('[orchestrator] Restarting backend (file tools used)');
|
|
1776
|
+
pendingBackendRestart = false;
|
|
1777
|
+
if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
|
|
1778
|
+
resetBackendRestarts();
|
|
1779
|
+
stopBackend().then(() => spawnBackend(backendPort));
|
|
1780
|
+
}
|
|
1781
|
+
if (pendingUpdate) {
|
|
1782
|
+
pendingUpdate = false;
|
|
1783
|
+
log.info('[orchestrator] Ending conversation before update...');
|
|
1784
|
+
endConversation(convId);
|
|
1785
|
+
runDeferredUpdate();
|
|
1786
|
+
}
|
|
1787
|
+
broadcastBloby('bot:idle', { conversationId: convId });
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
if (type === 'bot:conversation-ended') {
|
|
1792
|
+
log.info(`[orchestrator] Conversation ended: ${convId}`);
|
|
1793
|
+
agentQueryActive = false;
|
|
1794
|
+
currentStreamConvId = null;
|
|
1795
|
+
currentStreamBuffer = '';
|
|
1796
|
+
channelManager.clearRoutes(convId);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
if (type === 'bot:response') {
|
|
1801
|
+
currentStreamBuffer = '';
|
|
1802
|
+
try {
|
|
1803
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1804
|
+
role: 'assistant', content: eventData.content, meta: { model },
|
|
1805
|
+
});
|
|
1806
|
+
} catch (err: any) {
|
|
1807
|
+
log.warn(`[bloby] DB persist bot response error: ${err.message}`);
|
|
1808
|
+
broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
broadcastBloby(type, eventData);
|
|
1813
|
+
};
|
|
1131
1814
|
}
|
|
1132
1815
|
|
|
1133
1816
|
blobyWss.on('connection', (ws) => {
|
|
@@ -1446,93 +2129,19 @@ ${!connected ? `<script>
|
|
|
1446
2129
|
log.info(`[orchestrator] Conv: ${convId}`);
|
|
1447
2130
|
log.info(`[orchestrator] Live conversation exists: ${hasConversation(convId)}`);
|
|
1448
2131
|
|
|
1449
|
-
// Start a live conversation if one doesn't exist
|
|
2132
|
+
// Start a live conversation if one doesn't exist. The onMessage handler is
|
|
2133
|
+
// shared with the workspace channel — both surfaces push into the same conv
|
|
2134
|
+
// and observe the same events.
|
|
1450
2135
|
if (!hasConversation(convId)) {
|
|
1451
2136
|
log.info(`[orchestrator] Starting new live conversation...`);
|
|
1452
|
-
|
|
1453
|
-
// Per-conversation WhatsApp streaming state. The manager owns the
|
|
1454
|
-
// routing decision: if a WhatsApp inbound message is the trigger
|
|
1455
|
-
// for this turn, it sends to that chat; otherwise it falls back to
|
|
1456
|
-
// the self-chat mirror (the user's own number).
|
|
1457
2137
|
const waState = channelManager.createWaStreamState();
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
if (type === 'bot:token' && eventData.token) {
|
|
1468
|
-
currentStreamBuffer += eventData.token;
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
// Route streaming text via the manager's per-conversation routing FIFO.
|
|
1472
|
-
// The destination was decided at pushWithRouting time — this is purely a
|
|
1473
|
-
// dispatcher and cannot bleed events to the wrong surface.
|
|
1474
|
-
channelManager.routeWaStreamEvent(waState, type, eventData, botName);
|
|
1475
|
-
|
|
1476
|
-
// Agent finished a turn — handle backend restart + notify client
|
|
1477
|
-
if (type === 'bot:turn-complete') {
|
|
1478
|
-
log.info(`[orchestrator] ──── TURN COMPLETE ────`);
|
|
1479
|
-
log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
|
|
1480
|
-
agentQueryActive = false;
|
|
1481
|
-
currentStreamConvId = null;
|
|
1482
|
-
currentStreamBuffer = '';
|
|
1483
|
-
|
|
1484
|
-
if (eventData.usedFileTools || pendingBackendRestart) {
|
|
1485
|
-
log.info('[orchestrator] Restarting backend (file tools used)');
|
|
1486
|
-
pendingBackendRestart = false;
|
|
1487
|
-
if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
|
|
1488
|
-
resetBackendRestarts();
|
|
1489
|
-
stopBackend().then(() => spawnBackend(backendPort));
|
|
1490
|
-
}
|
|
1491
|
-
if (pendingUpdate) {
|
|
1492
|
-
pendingUpdate = false;
|
|
1493
|
-
// End the live conversation before updating — SDK child processes
|
|
1494
|
-
// must be cleaned up or process.exit() may not terminate cleanly
|
|
1495
|
-
log.info('[orchestrator] Ending conversation before update...');
|
|
1496
|
-
endConversation(convId);
|
|
1497
|
-
runDeferredUpdate();
|
|
1498
|
-
}
|
|
1499
|
-
// Tell the client the agent is idle — streaming can stop
|
|
1500
|
-
broadcastBloby('bot:idle', { conversationId: convId });
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
// Conversation ended (query loop exited)
|
|
1505
|
-
if (type === 'bot:conversation-ended') {
|
|
1506
|
-
log.info(`[orchestrator] Conversation ended: ${convId}`);
|
|
1507
|
-
agentQueryActive = false;
|
|
1508
|
-
currentStreamConvId = null;
|
|
1509
|
-
currentStreamBuffer = '';
|
|
1510
|
-
channelManager.clearRoutes(convId);
|
|
1511
|
-
return;
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// Save assistant response to DB BEFORE broadcasting so a refresh
|
|
1515
|
-
// immediately after the bubble appears can't race the INSERT and lose
|
|
1516
|
-
// the message. addMessage() in worker/db.ts is self-healing —
|
|
1517
|
-
// it INSERT OR IGNOREs the parent conversation row first, so even an
|
|
1518
|
-
// orphan convId persists cleanly.
|
|
1519
|
-
if (type === 'bot:response') {
|
|
1520
|
-
currentStreamBuffer = '';
|
|
1521
|
-
try {
|
|
1522
|
-
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1523
|
-
role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
|
|
1524
|
-
});
|
|
1525
|
-
} catch (err: any) {
|
|
1526
|
-
log.warn(`[bloby] DB persist bot response error: ${err.message}`);
|
|
1527
|
-
// Tell clients the bubble they're about to see is not durable —
|
|
1528
|
-
// they can flag/retry rather than silently losing it on refresh.
|
|
1529
|
-
broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// Stream all events to every connected client
|
|
1534
|
-
broadcastBloby(type, eventData);
|
|
1535
|
-
}, { botName, humanName }, recentMessages);
|
|
2138
|
+
await startConversation(
|
|
2139
|
+
convId,
|
|
2140
|
+
freshConfig.ai.model,
|
|
2141
|
+
createSharedChatOnMessage(convId, freshConfig.ai.model, botName, waState),
|
|
2142
|
+
{ botName, humanName },
|
|
2143
|
+
recentMessages,
|
|
2144
|
+
);
|
|
1536
2145
|
}
|
|
1537
2146
|
|
|
1538
2147
|
// Push the user message into the live conversation with a pinned routing
|