-
-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathagent-screen.js
More file actions
3504 lines (3274 loc) · 150 KB
/
Copy pathagent-screen.js
File metadata and controls
3504 lines (3274 loc) · 150 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// /agent-screen — live agent screen viewer + workspace.
//
// URL: /agent-screen?agentId=<uuid>
//
// A full-bleed live "Screen" fills the stage; everything else is a floating,
// draggable, minimizable panel laid over it:
//
// • Avatar Cam — an offscreen Three.js render of the agent's avatar head,
// giving the agent presence. Drag, resize, minimize, hide.
// • Activity Log — what the agent narrated with each push. Filter by type,
// clear, drag, resize, minimize, hide.
// • Stream Stats — live FPS / frames / resolution / data / uptime, computed
// from the real SSE frame stream (advanced; hidden by default).
//
// Two viewing modes, both one keystroke away:
// • Default — screen + panels + task bar + header chrome.
// • Zen (Z) — hide ALL chrome for a clean screenshot: just the screen, and
// optionally the avatar cam. Built for sharing.
//
// Layout (panel positions, sizes, visibility, mode, fit) persists per browser.
import {
PerspectiveCamera,
WebGLRenderer,
Scene,
AmbientLight,
DirectionalLight,
Box3,
Quaternion,
} from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { clone as cloneSkinnedScene } from 'three/addons/utils/SkeletonUtils.js';
import { AnimationManager } from './animation-manager.js';
import { makeGltfRig, poseFromMannequinPreset } from './pose-rig.js';
import { matchPose, presetPoseById, POSE_QUICK_PICKS } from './pose-match.js';
import { StageShow } from './agent-screen-stage.js';
import { createAgentScreenClient } from './shared/agent-screen-client.js';
import { mountAgentReactions } from './agent-reactions.js';
import { handleTourFrame } from './agent-screen-tour.js';
import { buildRunCommand, buildRunCommandHtml, RUNTIME_LABELS } from './agent-screen-runcmd.js';
import { createNewsroomAnchor } from './agent-screen-anchor.js';
import { createTreasuryCockpit } from './agent-screen-treasury.js';
import { MirrorPanel } from './agent-screen-mirror.js';
import { createHireVisualizer } from './agent-screen-hire.js';
import { createReputationPanel } from './agent-screen-reputation.js';
import { createDiaryPanel } from './agent-screen-diary.js';
import {
parsePnlDelta, accumulatePnl, emptyPnlState, unrealizedTotalUsd, emoteForExit, formatSol, formatUsd,
} from './shared/trade-pnl.js';
import { classify, colorHex } from './activity-cinema.js';
import {
parseLaunchCommand, validateLaunchParams, narrate as narrateLaunch,
renderLaunchHud, truncMid,
} from './launch-director.js';
import { parseGrindCommand } from './vanity-grind-director.js';
import {
clampPrompt,
validatePrompt,
forgeStageNarration,
finalForgeFrame,
parseForgeFrame,
viewerLinkFor,
} from './shared/forge-frames.js';
import { agentAvatarGlb } from './shared/agent-3d.js';
import { getMeshoptDecoder } from './viewer/internal.js';
import { ScreenshotModal } from './components/screenshot-modal.js';
import { SentimentHeatmap3D } from './sentiment-heatmap-3d.js';
import { createHeatmapPoller, buildNarrationContext } from './sentiment-heatmap-data.js';
import { createPnlHud } from './agent-screen-pnl-hud.js';
import { createAmbientWorld, phaseLabel } from './agent-screen-world.js';
import { createDjScript } from './agent-screen-dj.js';
import { LipSyncAnalyser } from './lip-sync-analyser.js';
import { createVisemeDriver } from './runtime/lipsync.js';
import { classifyTaskInput, ensureSessionId } from './shared/ask-routing.js';
import { createCollabGraph } from './shared/collab-graph.js';
// One meshopt-aware loader, built once. Optimized agent avatars ship with
// EXT_meshopt_compression, so the decoder must be wired before the loader can
// parse a single bufferView — otherwise GLTFLoader throws on compressed files.
let _glbLoaderPromise = null;
function getAvatarLoader() {
if (!_glbLoaderPromise) {
_glbLoaderPromise = getMeshoptDecoder()
.then((decoder) => {
const loader = new GLTFLoader();
loader.setMeshoptDecoder(decoder);
return loader;
})
.catch(() => new GLTFLoader()); // uncompressed avatars still load
}
return _glbLoaderPromise;
}
const params = new URLSearchParams(location.search);
const agentId = params.get('agentId') || '';
const noAgentEl = document.getElementById('asc-no-agent');
const stageEl = document.getElementById('asc-stage');
const agentNameEl = document.getElementById('asc-agent-name');
const liveBadgeEl = document.getElementById('asc-live-badge');
const badgeTextEl = document.getElementById('asc-badge-text');
const backEl = document.getElementById('asc-back');
const agentLinkEl = document.getElementById('asc-agent-link');
const controlsEl = document.getElementById('asc-controls');
const fsBtn = document.getElementById('asc-fullscreen-btn');
const toastWrap = document.getElementById('asc-toast-wrap');
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
// ── transient toast ─────────────────────────────────────────────────────────
function toast(msg, ms = 2200) {
const el = document.createElement('div');
el.className = 'asc-toast';
el.textContent = msg;
toastWrap.appendChild(el);
setTimeout(() => {
el.classList.add('out');
setTimeout(() => el.remove(), 260);
}, ms);
}
if (!agentId) {
renderSetup();
} else {
noAgentEl.style.display = 'none';
boot(agentId);
}
// ── Worker Setup Panel ─────────────────────────────────────────────────────
// Rendered when no agentId is in the URL. Walks through:
// 1. Pick an agent (shows UUIDs)
// 2. Generate an API key (used as AGENT_JWT)
// 3. Copy the exact run command
async function renderSetup() {
document.title = 'Deploy to the wall · Agent Screen · three.ws';
agentNameEl.textContent = 'Deploy to wall';
liveBadgeEl.style.display = 'none';
controlsEl.style.display = 'none';
const container = noAgentEl;
container.className = 'ws-setup';
container.style.display = 'flex';
// ── wizard state (persists across re-renders for the session) ────────────
let selectedAgent = null; // { id, name }
let apiKey = null; // 'sk_live_...' — the real minted key, shown once
let activeTab = 'local'; // 'local' | 'docker' | 'bb'
let agentSearch = ''; // step-1 filter (overflow: 100s of agents)
let keyError = ''; // inline error from the mint call
// ── go-live detector state ───────────────────────────────────────────────
// liveState drives step 4. The SSE first-frame is ground truth for "live";
// the public directory check is a secondary signal that surfaces the most
// common silent failure (agent is private → never shows on the wall).
let liveState = 'idle'; // 'idle' | 'watching' | 'live'
let privateWarning = false; // agent not found in the public directory
const detector = { client: null, pollTimer: null, started: false };
// ── Check auth ─────────────────────────────────────────────────────────
let agents = [];
let isSignedIn = false;
try {
const r = await fetch('/api/agents', { credentials: 'include' });
if (r.ok) {
const j = await r.json();
agents = j.agents || j.data || [];
isSignedIn = true;
} else if (r.status === 401) {
isSignedIn = false;
}
} catch { /* network error — treat as signed-out */ }
// Command builders pull from the shared, unit-tested module so the copied
// command and the highlighted display can never drift. Placeholders are only
// shown before an agent/key exist; once both are real, so is the command.
const cmdOpts = () => ({
runtime: activeTab,
agentId: selectedAgent?.id || '<AGENT_ID>',
agentJwt: apiKey || '<AGENT_JWT>',
origin: location.origin,
});
function agentCardHTML(a) {
const initials = (a.name || '?').slice(0, 2).toUpperCase();
const thumbUrl = a.avatar_thumbnail_url || a.avatar_url || '';
const avatarEl = thumbUrl
? `<img class="ws-agent-avatar" src="${esc(thumbUrl)}" alt="" loading="lazy">`
: `<div class="ws-agent-avatar-placeholder">${esc(initials)}</div>`;
const shortId = (a.id || '').slice(0, 8) + '…';
return `<button class="ws-agent-card${selectedAgent?.id === a.id ? ' selected' : ''}" data-id="${esc(a.id)}" data-name="${esc(a.name || 'Agent')}">
${avatarEl}
<div class="ws-agent-info">
<div class="ws-agent-name">${esc(a.name || 'Agent')}</div>
<div class="ws-agent-uuid">${esc(shortId)}</div>
</div>
</button>`;
}
function filteredAgents() {
const q = agentSearch.trim().toLowerCase();
if (!q) return agents;
return agents.filter((a) =>
(a.name || '').toLowerCase().includes(q) || (a.id || '').toLowerCase().includes(q));
}
function agentGridHTML() {
const list = filteredAgents();
if (agents.length === 0) {
return `<div class="ws-agent-empty">
${isSignedIn
? `No agents yet. <a href="/agents/new">Create one →</a>`
: `<a href="/login">Sign in</a> to see your agents.`}
</div>`;
}
if (list.length === 0) {
return `<div class="ws-agent-empty">No agents match “${esc(agentSearch)}”.</div>`;
}
return list.map(agentCardHTML).join('');
}
function progressHTML() {
const currentStep = liveState === 'live' ? 4 : apiKey ? 3 : selectedAgent ? 2 : 1;
const labels = ['Pick agent', 'Generate key', 'Run command', 'Go live'];
return labels.map((label, i) => {
const n = i + 1;
const done = n < currentStep || (n === 4 && liveState === 'live');
const active = n === currentStep && !done;
return `<div class="ws-prog-node${done ? ' done' : active ? ' active' : ''}">
<span class="ws-prog-dot">${done ? '✓' : n}</span>
<span class="ws-prog-label">${label}</span>
</div>`;
}).join('<span class="ws-prog-line"></span>');
}
function goLiveHTML() {
const watchLink = selectedAgent ? `/agent-screen?agentId=${encodeURIComponent(selectedAgent.id)}` : '#';
const privateNote = privateWarning ? `
<div class="ws-golive-note">
This agent isn't in the public directory yet. Make it public so viewers can find it on the wall —
<a href="${selectedAgent ? `/agents/${encodeURIComponent(selectedAgent.id)}` : '/agents'}">agent settings →</a>
</div>` : '';
if (liveState === 'live') {
return `
<div class="ws-golive live">
<span class="ws-golive-check">✓</span>
<div>
<strong>You're live on the wall</strong>
<span>${esc(selectedAgent?.name || 'Your agent')} is broadcasting — it's now on the public live wall.</span>
</div>
</div>
<div class="ws-golive-actions">
<a class="ws-watch-link" href="/agents-live">See it on the wall →</a>
<a class="ws-btn ws-btn-ghost ws-golive-open" href="${watchLink}">Open your screen</a>
</div>`;
}
if (liveState === 'watching') {
return `
<div class="ws-golive watching">
<span class="ws-golive-pulse"></span>
<div>
<strong>Watching for your agent's first frame…</strong>
<span>Run the command above. The moment your caster pushes a frame, this flips to live.</span>
</div>
</div>
${privateNote}`;
}
return `<div class="ws-golive idle">
<span class="ws-golive-dot"></span>
<div><span>Generate a key and copy the command — go-live detection starts automatically.</span></div>
</div>`;
}
function render() {
const step1Done = !!selectedAgent;
const step2Done = !!apiKey;
const showSearch = agents.length > 8;
container.innerHTML = `
<div class="ws-setup-inner">
<div class="ws-setup-hero">
<h1>Deploy your agent to the wall</h1>
<p>Pick an agent, generate its key, copy one command — and watch it go live on the public wall.</p>
</div>
<div class="ws-progress" aria-hidden="true">${progressHTML()}</div>
${!isSignedIn ? `
<div class="ws-not-signed-in">
<span>⚠</span>
<span>You need to <a href="/login">sign in</a> to generate an API key and select an agent.</span>
</div>` : ''}
<!-- Step 1: Pick agent -->
<div class="ws-setup-step">
<div class="ws-setup-step-head">
<div class="ws-step-num${step1Done ? ' done' : ''}">${step1Done ? '✓' : '1'}</div>
<div>
<h3>${step1Done ? `Agent: ${esc(selectedAgent.name)}` : 'Select an agent'}</h3>
<p>${step1Done ? esc(selectedAgent.id) : 'Choose which agent will broadcast its screen'}</p>
</div>
${step1Done ? `<button class="ws-btn ws-btn-ghost" id="ws-change-agent" style="margin-left:auto;font-size:0.75rem;padding:0.3rem 0.6rem;">Change</button>` : ''}
</div>
${!step1Done ? `
<div class="ws-setup-step-body">
${showSearch ? `<input class="ws-agent-search" id="ws-agent-search" type="search" placeholder="Search your agents…" value="${esc(agentSearch)}" spellcheck="false">` : ''}
<div class="ws-agent-grid" id="ws-agent-grid">${agentGridHTML()}</div>
</div>` : ''}
</div>
<!-- Step 2: API key -->
<div class="ws-setup-step" ${!step1Done ? 'style="opacity:0.45;pointer-events:none"' : ''}>
<div class="ws-setup-step-head">
<div class="ws-step-num${step2Done ? ' done' : ''}">${step2Done ? '✓' : '2'}</div>
<div>
<h3>Generate an API key</h3>
<p>Used as <code style="font-size:0.78rem;color:#d4d4d8">AGENT_JWT</code> — authenticates the worker as you</p>
</div>
</div>
<div class="ws-setup-step-body">
<div class="ws-key-row">
<div class="ws-key-display${apiKey ? '' : ' placeholder'}" id="ws-key-display">
${apiKey ? esc(apiKey) : 'Click Generate to create a key'}
</div>
${apiKey
? `<button class="ws-btn ws-btn-copy" id="ws-copy-key">Copy</button>
<button class="ws-btn ws-btn-ghost" id="ws-regen-key" title="Generate a fresh key" style="font-size:0.78rem;">New</button>`
: `<button class="ws-btn ws-btn-primary" id="ws-gen-key" ${!isSignedIn ? 'disabled' : ''}>Generate</button>`
}
</div>
${keyError ? `<p class="ws-key-error">${esc(keyError)} <button class="ws-link-btn" id="ws-retry-key">Try again</button></p>` : ''}
${apiKey
? `<p class="ws-key-note"><strong>Save this key now.</strong> It won't be shown again. It grants access to your account — treat it like a password.</p>`
: `<p class="ws-key-note">Creates a new <code style="font-size:0.78rem">agents:write</code>-scoped API key. You can manage keys at <a href="/dashboard-next/developers" style="color:#d4d4d8">Developers →</a></p>`
}
</div>
</div>
<!-- Step 3: Run command -->
<div class="ws-setup-step" ${!step1Done ? 'style="opacity:0.45;pointer-events:none"' : ''}>
<div class="ws-setup-step-head">
<div class="ws-step-num${liveState === 'live' ? ' done' : ''}">${liveState === 'live' ? '✓' : '3'}</div>
<div>
<h3>Run the worker</h3>
<p>Copy and run in your terminal from the <code style="font-size:0.78rem;color:#d4d4d8">workers/agent-screen-worker/</code> directory</p>
</div>
</div>
<div class="ws-setup-step-body">
<div class="ws-cmd-tabs">
${['local', 'docker', 'bb'].map((t) => `<button class="ws-cmd-tab${activeTab === t ? ' active' : ''}" data-tab="${t}">${esc(RUNTIME_LABELS[t])}</button>`).join('')}
</div>
<div class="ws-cmd-block">
<button class="ws-btn ws-btn-copy ws-cmd-copy" id="ws-copy-cmd">Copy</button>
<pre id="ws-cmd-pre">${buildRunCommandHtml(cmdOpts())}</pre>
</div>
${activeTab === 'bb' ? `<p class="ws-key-note" style="margin-top:0.7rem">Get your Browserbase key + project ID at <a href="https://browserbase.com" target="_blank" rel="noopener" style="color:#d4d4d8">browserbase.com</a> — no Docker needed, the browser runs in their cloud.</p>` : ''}
<p class="ws-key-note" style="margin-top:0.7rem">The worker authenticates with your <code style="font-size:0.78rem">AGENT_JWT</code> alone — no other secret to set.</p>
</div>
</div>
<!-- Step 4: Go live -->
<div class="ws-setup-step" ${!step2Done ? 'style="opacity:0.45;pointer-events:none"' : ''}>
<div class="ws-setup-step-head">
<div class="ws-step-num${liveState === 'live' ? ' done' : ''}">${liveState === 'live' ? '✓' : '4'}</div>
<div>
<h3>${liveState === 'live' ? "You're live on the wall" : 'Go live'}</h3>
<p>${liveState === 'live' ? 'Your agent is broadcasting to viewers' : "We watch for your agent's first frame and confirm it's live"}</p>
</div>
</div>
<div class="ws-setup-step-body">
${goLiveHTML()}
<details class="ws-trouble">
<summary>Not appearing? Common fixes</summary>
<ul>
<li><strong>Worker not started</strong> — run the command above; frames land within seconds.</li>
<li><strong>Wrong directory</strong> — run it from <code>workers/agent-screen-worker/</code> after <code>npm install</code>.</li>
<li><strong>Key revoked or wrong</strong> — generate a fresh key in step 2 and recopy the command.</li>
<li><strong>Agent is private</strong> — public visibility is required to appear on <a href="/agents-live">/agents-live</a>.</li>
</ul>
</details>
</div>
</div>
</div>`;
bindAgentCards();
const searchEl = container.querySelector('#ws-agent-search');
if (searchEl) {
searchEl.addEventListener('input', () => {
agentSearch = searchEl.value;
const grid = container.querySelector('#ws-agent-grid');
if (grid) { grid.innerHTML = agentGridHTML(); bindAgentCards(); }
});
}
container.querySelector('#ws-change-agent')?.addEventListener('click', () => {
selectedAgent = null;
resetDetector();
render();
});
container.querySelector('#ws-gen-key')?.addEventListener('click', generateKey);
container.querySelector('#ws-regen-key')?.addEventListener('click', generateKey);
container.querySelector('#ws-retry-key')?.addEventListener('click', generateKey);
container.querySelector('#ws-copy-key')?.addEventListener('click', () => copyText(apiKey, 'ws-copy-key'));
container.querySelector('#ws-copy-cmd')?.addEventListener('click', () => copyText(buildRunCommand(cmdOpts()), 'ws-copy-cmd'));
container.querySelectorAll('.ws-cmd-tab').forEach((tab) => {
tab.addEventListener('click', () => { activeTab = tab.dataset.tab; render(); });
});
// Everything needed to go live exists → start watching for the first frame.
if (selectedAgent && apiKey) startGoLiveDetector();
}
function bindAgentCards() {
container.querySelectorAll('.ws-agent-card').forEach((card) => {
card.addEventListener('click', () => {
selectedAgent = { id: card.dataset.id, name: card.dataset.name };
resetDetector();
render();
});
});
}
async function generateKey() {
keyError = '';
const btn = container.querySelector('#ws-gen-key') || container.querySelector('#ws-regen-key');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ws-spinner"></span>'; }
try {
const csrf = await fetch('/api/csrf-token', { credentials: 'include' })
.then((r) => r.json())
.then((j) => j.data?.token || j.token || '')
.catch(() => '');
const r = await fetch('/api/api-keys', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
body: JSON.stringify({ name: `agent-screen:${selectedAgent?.name || 'worker'}`, scope: 'agents:write agents:read' }),
});
const j = await r.json().catch(() => ({}));
if (r.ok && (j.data?.token || j.token)) {
apiKey = j.data?.token || j.token;
resetDetector();
render();
} else if (r.status === 429) {
keyError = 'Rate limited — wait a moment before generating another key.';
render();
} else {
keyError = j.message || j.error_description || 'Could not generate a key. Check you are signed in and try again.';
render();
}
} catch {
keyError = 'Network error while generating the key — check your connection.';
render();
}
}
// ── go-live detector ─────────────────────────────────────────────────────
function startGoLiveDetector() {
if (detector.started || !selectedAgent || !apiKey) return;
detector.started = true;
liveState = 'watching';
detector.client = createAgentScreenClient(selectedAgent.id, {
onFrame() {
if (liveState === 'live') return;
liveState = 'live';
stopDetectorConnections();
render();
},
});
detector.client.connect();
// Secondary signal: is the agent indexed in the public directory? Surfaces
// the "agent is private" failure mode while we wait for the first frame.
checkDirectory();
detector.pollTimer = setInterval(checkDirectory, 8000);
render();
}
function stopDetectorConnections() {
detector.client?.disconnect();
detector.client = null;
if (detector.pollTimer) { clearInterval(detector.pollTimer); detector.pollTimer = null; }
}
function resetDetector() {
stopDetectorConnections();
detector.started = false;
liveState = 'idle';
privateWarning = false;
}
async function checkDirectory() {
if (!selectedAgent) return;
try {
const r = await fetch(`/api/agents/public?q=${encodeURIComponent(selectedAgent.name || '')}&limit=48`);
if (!r.ok) return;
const j = await r.json();
const list = j.agents || j.data || [];
const found = list.some((a) => a.id === selectedAgent.id);
if (privateWarning !== !found) {
privateWarning = !found;
if (liveState !== 'live') render();
}
} catch { /* non-fatal — the SSE frame is the authoritative signal */ }
}
async function copyText(text, btnId) {
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch {
// Clipboard API blocked (insecure context / permissions) → textarea fallback.
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.focus(); ta.select();
ok = document.execCommand('copy');
ta.remove();
} catch { ok = false; }
}
const el = container.querySelector(`#${btnId}`);
if (!el) return;
const orig = el.dataset.label || el.textContent;
el.dataset.label = orig;
el.textContent = ok ? 'Copied!' : 'Press ⌘C';
el.classList.toggle('copied', ok);
setTimeout(() => { el.textContent = orig; el.classList.remove('copied'); }, 1800);
}
window.addEventListener('beforeunload', stopDetectorConnections);
render();
}
// ── workspace layout persistence ─────────────────────────────────────────────
// Bumped v1 → v2 to retire the old six-panels-open layout. Returning visitors
// get the calm two-panel default once, then their re-arrangements persist again.
const LS_KEY = 'twx_asc_workspace_v2';
function defaultLayout() {
return {
zen: false,
zenCam: true, // keep the avatar cam visible in zen by default
fit: 'contain', // 'contain' | 'cover'
// Calm default: the avatar is the hero, so it owns the whole stage. Only two
// panels open on first load — Reputation and Activity Log — docked to a tidy
// right rail (reputation top, log bottom, no overlap). Everything else is one
// toolbar click away. Bumping LS_KEY resets returning visitors to this baseline.
panels: {
cam: { hidden: true, min: false, x: null, y: null, w: 260 },
log: { hidden: false, min: false, x: null, y: null, w: 320, h: 280 },
stats: { hidden: true, min: false, x: null, y: null, w: 218 },
treasury: { hidden: true, min: false, x: null, y: null, w: 344, h: null },
mirror: { hidden: true, min: false, x: null, y: null, w: 380, h: null },
stage: { hidden: true, min: false, x: null, y: null, w: 320, h: null },
heatmap: { hidden: true, min: false, x: null, y: null, w: 460, h: 340 },
hud: { hidden: true, min: false, x: null, y: null, w: 288, h: null },
diary: { hidden: true, min: false, x: null, y: null, w: 360, h: 332 },
hire: { hidden: true, min: false, x: null, y: null, w: 340, h: null },
reputation: { hidden: false, min: false, x: null, y: null, w: 320, h: 340 },
},
};
}
function loadLayout() {
const base = defaultLayout();
try {
const saved = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
base.zen = !!saved.zen;
base.zenCam = saved.zenCam !== false;
base.fit = saved.fit === 'cover' ? 'cover' : 'contain';
for (const k of Object.keys(base.panels)) {
if (saved.panels && saved.panels[k]) Object.assign(base.panels[k], saved.panels[k]);
}
} catch { /* corrupt or absent — defaults */ }
return base;
}
// Detect a Team Task command in the task bar. A lead agent splits the goal,
// delegates sub-tasks and hires teammates over x402 (api/agent-collab). Matches
// an explicit `team:` prefix or natural phrasings; returns the cleaned goal or
// null. The owner gate and hard spend cap live server-side — this only routes.
export function parseTeamCommand(raw) {
const text = String(raw == null ? '' : raw).trim();
if (!text) return null;
// Explicit prefix: "team: <goal>" / "team task: <goal>".
const prefix = text.match(/^team(?:\s+task)?\s*[:\-—]\s*(.+)$/is);
if (prefix && prefix[1].trim()) return prefix[1].trim();
// Natural phrasing: "assemble a team to …", "lead a team to …",
// "delegate to your team: …", "with your team, …".
const natural = text.match(
/^(?:assemble|lead|gather|rally|build)\s+(?:a|your|the)?\s*team\b[\s,:to-]*([\s\S]+)$/i,
) || text.match(/^delegate\s+to\s+(?:your|the)\s+team\b[\s,:-]*([\s\S]+)$/i);
if (natural && natural[1].trim()) return natural[1].trim();
return null;
}
// ─────────────────────────────────────────────────────────────────────────────
async function boot(id) {
stageEl.style.display = '';
const layout = loadLayout();
let saveTimer = null;
function saveLayout() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(layout)); } catch { /* quota — ignore */ }
}, 250);
}
// Build the stage DOM: full-bleed screen + floating panels + task bar.
stageEl.innerHTML = `
<div class="asc-screen-stage">
<canvas id="asc-screen-canvas"></canvas>
<!-- Newsroom Anchor lower-third (slides up on a bulletin) -->
<div class="asc-lt" id="asc-lowerthird" aria-live="polite">
<div class="asc-lt-bar"></div>
<div class="asc-lt-body">
<span class="asc-lt-eyebrow"><span class="asc-lt-dot"></span>MARKET ANCHOR</span>
<div class="asc-lt-headline" id="asc-lt-headline"></div>
<div class="asc-lt-note" id="asc-lt-note" style="display:none"></div>
</div>
<button class="asc-lt-mute" id="asc-lt-mute" type="button" aria-pressed="false" title="Unmute anchor — A">🔇</button>
</div>
<!-- One-tap unmute CTA (audio is muted by default) -->
<button class="asc-anchor-unmute" id="asc-anchor-unmute" type="button" style="display:none">
<span class="asc-anchor-unmute-icon">🔊</span>
<span>Tap to hear the anchor</span>
</button>
</div>
<div class="asc-screen-overlay" id="asc-screen-overlay">
<div class="pulse-ring"></div>
<p>Waiting for agent…</p>
<small>The agent will appear here once it starts broadcasting</small>
</div>
<!-- Avatar cam panel -->
<div class="asc-panel asc-panel--cam" id="asc-panel-cam" data-panel="cam">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Avatar Cam</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (C)">✕</button>
</div>
</div>
<div class="asc-panel-body asc-cam-body">
<div class="asc-webcam-bezel" id="asc-webcam-bezel">
<div class="asc-webcam-idle" id="asc-webcam-idle">Loading avatar…</div>
<div class="asc-webcam-badge" id="asc-webcam-badge" style="display:none">
<span class="cam-dot"></span>
<span id="asc-webcam-name">—</span>
</div>
</div>
<!-- Pose Studio Live: call out a pose, the avatar performs it -->
<div class="asc-pose" id="asc-pose">
<form class="asc-pose-form" id="asc-pose-form" autocomplete="off">
<input class="asc-pose-input" id="asc-pose-input" type="text" maxlength="120" spellcheck="false" placeholder="Pose the avatar… try “take a bow”">
<button class="asc-pose-go" id="asc-pose-go" type="submit" title="Perform pose">Pose</button>
</form>
<div class="asc-pose-chips" id="asc-pose-chips"></div>
<div class="asc-pose-hint" id="asc-pose-hint">Try: wave · bow · warrior</div>
</div>
</div>
<div class="asc-resize" data-resize="w"></div>
</div>
<!-- Activity log panel -->
<div class="asc-panel asc-panel--log" id="asc-panel-log" data-panel="log">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Activity Log</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (L)">✕</button>
</div>
</div>
<div class="asc-panel-body">
<div class="asc-log-tools">
<button class="asc-log-filter active" data-filter="all">All</button>
<button class="asc-log-filter" data-filter="trade">Trade</button>
<button class="asc-log-filter" data-filter="analysis">Analysis</button>
<button class="asc-log-filter" data-filter="activity">Activity</button>
<button class="asc-log-filter asc-log-clear" id="asc-log-clear" data-filter="">Clear</button>
</div>
<div id="asc-log">
<div class="asc-log-empty" id="asc-log-empty">No activity yet</div>
</div>
</div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Stream stats panel (advanced) -->
<div class="asc-panel asc-panel--stats" id="asc-panel-stats" data-panel="stats">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Stream Stats</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (I)">✕</button>
</div>
</div>
<div class="asc-panel-body">
<div class="asc-stats-grid">
<div class="asc-stat-row"><span class="asc-stat-key">Status</span><span class="asc-stat-val dim" id="st-status">—</span></div>
<div class="asc-stat-row"><span class="asc-stat-key">FPS</span><span class="asc-stat-val" id="st-fps">0.0</span></div>
<div class="asc-stat-row"><span class="asc-stat-key">Frames</span><span class="asc-stat-val" id="st-frames">0</span></div>
<div class="asc-stat-row"><span class="asc-stat-key">Resolution</span><span class="asc-stat-val" id="st-res">—</span></div>
<div class="asc-stat-row"><span class="asc-stat-key">Data</span><span class="asc-stat-val" id="st-data">0<span class="unit">KB</span></span></div>
<div class="asc-stat-row"><span class="asc-stat-key">Last frame</span><span class="asc-stat-val dim" id="st-age">—</span></div>
<div class="asc-stat-row"><span class="asc-stat-key">Uptime</span><span class="asc-stat-val dim" id="st-uptime">—</span></div>
</div>
</div>
</div>
<!-- Treasury cockpit panel -->
<div class="asc-panel asc-panel--treasury" id="asc-panel-treasury" data-panel="treasury">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Treasury</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (T)">✕</button>
</div>
</div>
<div class="asc-panel-body" id="asc-treasury-body"></div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Copy-trade mirror panel -->
<div class="asc-panel asc-panel--mirror" id="asc-panel-mirror" data-panel="mirror">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Mirror</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (M)">✕</button>
</div>
</div>
<div class="asc-panel-body" id="asc-mirror-body"></div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Sentiment heatmap panel -->
<div class="asc-panel asc-panel--heatmap" id="asc-panel-heatmap" data-panel="heatmap">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Sentiment Heatmap</span>
<div class="asc-panel-btns">
<button class="asc-hm-focus" id="asc-hm-focus" title="Center on $THREE">◎ $THREE</button>
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (H)">✕</button>
</div>
</div>
<div class="asc-panel-body asc-hm-body">
<canvas id="asc-heatmap-canvas"></canvas>
<div class="asc-hm-overlay" id="asc-hm-overlay">
<div class="pulse-ring"></div>
<p>Reading the market…</p>
</div>
<div class="asc-hm-stale" id="asc-hm-stale" hidden>
<span>stale — retrying</span>
<button id="asc-hm-retry">Retry</button>
</div>
<div class="asc-hm-tooltip" id="asc-hm-tooltip" hidden></div>
<div class="asc-hm-legend" id="asc-hm-legend">
<span class="asc-hm-legend-label">cold</span>
<span class="asc-hm-legend-ramp"></span>
<span class="asc-hm-legend-label">hot</span>
<span class="asc-hm-legend-meta" id="asc-hm-meta">—</span>
</div>
</div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Portfolio / PnL HUD panel -->
<div class="asc-panel asc-panel--hud" id="asc-panel-hud" data-panel="hud">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Portfolio</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (B)">✕</button>
</div>
</div>
<div class="asc-panel-body" id="asc-hud-body"></div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Live agent-to-agent hire visualizer (Moonshot 03) -->
<div class="asc-panel asc-panel--hire" id="asc-panel-hire" data-panel="hire">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Live Hire</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (R)">✕</button>
</div>
</div>
<div class="asc-panel-body" id="asc-hire-body"></div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Reputation arena panel (Moonshot 12): trust breakdown + the a2a-hire receipts that earned it -->
<div class="asc-panel asc-panel--reputation" id="asc-panel-reputation" data-panel="reputation">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Reputation</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (E)">✕</button>
</div>
</div>
<div class="asc-panel-body" id="asc-reputation-body"></div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Live stage show panel (Moonshot 08) -->
<div class="asc-panel asc-panel--stage" id="asc-panel-stage" data-panel="stage">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Live Show</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (G)">✕</button>
</div>
</div>
<div class="asc-panel-body asc-stage-body">
<div class="asc-stage-status">
<span class="asc-stage-dot is-ready" id="asc-stage-dot"></span>
<span class="asc-stage-state" id="asc-stage-state">Stage ready</span>
<span class="asc-stage-beat" id="asc-stage-beat"></span>
</div>
<div class="asc-stage-now" id="asc-stage-now">Press Start to bring the host on stage.</div>
<button class="asc-stage-start" id="asc-stage-start" type="button">▶ Start the show</button>
<div class="asc-stage-lead">
<div class="asc-stage-lead-head">Top tippers · $THREE</div>
<ol class="asc-stage-lead-list" id="asc-stage-lead-list"></ol>
</div>
<form class="asc-stage-ask" id="asc-stage-ask" autocomplete="off">
<input class="asc-stage-ask-input" id="asc-stage-ask-input" type="text" maxlength="240" placeholder="Ask the host a question…" spellcheck="false">
<button class="asc-stage-ask-send" type="submit" title="Ask the host">Ask</button>
</form>
<div class="asc-stage-ask-status" id="asc-stage-ask-status"></div>
</div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Memory Diary panel -->
<div class="asc-panel asc-panel--diary" id="asc-panel-diary" data-panel="diary">
<div class="asc-panel-head" data-drag>
<span class="asc-panel-grip">⠿</span>
<span class="asc-panel-title">Diary</span>
<div class="asc-panel-btns">
<button class="asc-panel-btn" data-act="min" title="Minimize">▁</button>
<button class="asc-panel-btn" data-act="close" title="Hide (D)">✕</button>
</div>
</div>
<div class="asc-panel-body" id="asc-diary-body"></div>
<div class="asc-resize" data-resize="wh"></div>
</div>
<!-- Task input -->
<div class="asc-task-bar" id="asc-task-bar">
<form class="asc-task-form" id="asc-task-form" autocomplete="off">
<button class="asc-task-mode" id="asc-task-mode" type="button" hidden aria-pressed="false" title="Asking live — click to queue a background task">Ask</button>
<input
class="asc-task-input"
id="asc-task-input"
type="text"
placeholder="Ask this agent anything…"
maxlength="1000"
spellcheck="false"
>
<button class="asc-task-forge-toggle" id="asc-forge-toggle" type="button" aria-pressed="false" aria-controls="asc-forge-form" title="Forge a 3D avatar from text — free">✦</button>
<button class="asc-task-voice" id="asc-task-voice" type="button" aria-pressed="false" title="Voice on — click to mute">🔊</button>
<button class="asc-task-send" id="asc-task-send" type="submit" title="Send">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5 8L1.5 1.5L5 8L1.5 14.5L14.5 8Z" fill="currentColor"/>
</svg>
</button>
</form>
<div class="asc-task-status" id="asc-task-status"></div>
<!-- Live Avatar Forge: type a prompt → watch a 3D avatar get built, rigged, animated -->
<form class="asc-forge-form" id="asc-forge-form" autocomplete="off">
<div class="asc-forge-icon" title="Free TRELLIS text→3D">✦</div>
<input
class="asc-forge-input"
id="asc-forge-input"
type="text"
placeholder="Forge an avatar — describe it (e.g. a glossy white robot mascot)"
maxlength="1000"
spellcheck="false"
>
<button class="asc-forge-btn" id="asc-forge-btn" type="submit" title="Forge a 3D avatar — free">
<span class="asc-forge-btn-label">Forge</span>
<span class="asc-forge-btn-spin" aria-hidden="true"></span>
</button>
</form>
<div class="asc-forge-result" id="asc-forge-result" hidden>
<a class="asc-forge-view" id="asc-forge-view" target="_blank" rel="noopener">Open in viewer ↗</a>
<button class="asc-forge-use" id="asc-forge-use" type="button">Use as agent avatar</button>
</div>
</div>
<!-- Zen exit hint -->
<div class="asc-zen-hint" id="asc-zen-hint">
<span>Zen mode · <kbd>C</kbd> cam · <kbd>Z</kbd> exit</span>
<button id="asc-zen-exit">Exit</button>
</div>
`;
const screenCanvas = document.getElementById('asc-screen-canvas');
const screenOverlay = document.getElementById('asc-screen-overlay');
const screenCtx = screenCanvas.getContext('2d');
const logEl = document.getElementById('asc-log');
let logEmpty = document.getElementById('asc-log-empty');
const webcamBezel = document.getElementById('asc-webcam-bezel');
const webcamIdle = document.getElementById('asc-webcam-idle');
const webcamBadge = document.getElementById('asc-webcam-badge');
const webcamName = document.getElementById('asc-webcam-name');
// ── resolve agent metadata ─────────────────────────────────────────────
let agentName = 'Agent';
let agentRecord = null; // freshest agent record, for the tip modal's wallet resolution
let avatarGlbUrl = null;
// Q&A concierge state: whether this viewer owns the agent (unlocks the
// queue-a-task mode) and the agent's configured TTS voice for spoken answers.
let isOwner = false;
let agentVoice = 'nova';
try {
// credentials:'include' so the server can tell whether this viewer owns the
// agent — owners get the ask⇄task toggle; everyone else can still ask.
const res = await fetch(`/api/agents/${encodeURIComponent(id)}`, { credentials: 'include' });
if (res.ok) {
const j = await res.json();
const agent = j.agent || j;
agentRecord = agent;
agentName = agent.name || 'Agent';
agentNameEl.textContent = agentName;
document.title = `${agentName} · Agent Screen · three.ws`;
webcamName.textContent = agentName;
backEl.href = `/agents/${id}`;
agentLinkEl.href = `/agents/${id}`;
agentLinkEl.innerHTML = `${esc(agentName)} →`;
isOwner = !!agent.is_owner;
if (agent.voice_id) agentVoice = agent.voice_id;
try {
avatarGlbUrl = await agentAvatarGlb(agent);
} catch { /* fall through to default */ }
}
} catch { /* non-fatal */ }
// A per-tab Q&A session id gives follow-up questions memory continuity.
const qaSessionId = ensureSessionId();
// ── avatar webcam ──────────────────────────────────────────────────────
const webcamRenderer = new WebGLRenderer({ antialias: true, alpha: true });
webcamRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
webcamRenderer.setClearColor(0x0a0d1a, 1);
const webcamScene = new Scene();
webcamScene.add(new AmbientLight(0xffffff, 0.7));
const sun = new DirectionalLight(0xffffff, 1.2);
sun.position.set(1.5, 2.5, 2);
webcamScene.add(sun);
const rim = new DirectionalLight(0x8090ff, 0.4);
rim.position.set(-2, 1, -1);
webcamScene.add(rim);
const webcamCamera = new PerspectiveCamera(38, 4 / 3, 0.01, 20);
webcamCamera.position.set(0, 1.62, 0.9);
webcamCamera.lookAt(0, 1.55, 0);
let webcamAnimManager = null;
let webcamAvatar = null;
let webcamRafId = null;
let anchor = null;
let webcamCanvas = null;
// Shared AudioContext for stage-show TTS playback + lip-sync analysis. Created
// lazily and resumed on a user gesture (the Start button) per autoplay policy.
let sharedAudioCtx = null;
function ensureAudioContext() {
try {
if (!sharedAudioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
sharedAudioCtx = new AC();
}
if (sharedAudioCtx.state === 'suspended') sharedAudioCtx.resume().catch(() => {});
return sharedAudioCtx;
} catch { return null; }
}