@xfxstudio/claworld 2026.6.10-testing.5 → 2026.6.26-testing.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.
@@ -1,1547 +0,0 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import { pathToFileURL } from 'node:url';
5
- import { buildRuntimeAuthHeaders } from './account-identity.js';
6
- import {
7
- resolveConversationViewerGatewayBaseUrl,
8
- resolveConversationViewerOpenClawRoot,
9
- resolveConversationViewerRoot,
10
- } from './conversation-viewer-env.js';
11
- import { loadCurrentConfig } from './register-tooling.js';
12
- import { normalizeRelayHttpBaseUrl } from '../runtime/http-boundary.js';
13
-
14
- export const CONVERSATION_VIEWER_ROUTE_PREFIX = '/plugins/claworld/conversation-viewers/';
15
- export const CONVERSATION_VIEWER_SCHEMA = 'claworld.conversationViewer.v1';
16
- export {
17
- resolveConversationViewerGatewayBaseUrl,
18
- resolveConversationViewerOpenClawRoot,
19
- resolveConversationViewerRoot,
20
- } from './conversation-viewer-env.js';
21
-
22
- function normalizeText(value, fallback = null) {
23
- if (value == null) return fallback;
24
- const normalized = String(value).trim();
25
- return normalized || fallback;
26
- }
27
-
28
- function normalizeObject(value, fallback = null) {
29
- if (!value || typeof value !== 'object' || Array.isArray(value)) return fallback;
30
- return value;
31
- }
32
-
33
- function cloneJsonObject(value, fallback = null) {
34
- if (!value || typeof value !== 'object') return fallback;
35
- try {
36
- return JSON.parse(JSON.stringify(value));
37
- } catch {
38
- return fallback;
39
- }
40
- }
41
-
42
- function safeJsonForScript(value) {
43
- return JSON.stringify(value).replace(/</g, '\\u003c');
44
- }
45
-
46
- function normalizeRouteId(value) {
47
- const normalized = normalizeText(value, null);
48
- return normalized && /^[a-zA-Z0-9_-]+$/.test(normalized) ? normalized : null;
49
- }
50
-
51
- function resolveRequest(input = {}) {
52
- return normalizeObject(input.chatRequest, null)
53
- || normalizeObject(input.request, null)
54
- || (normalizeText(input.chatRequestId || input.requestId, null) ? input : null);
55
- }
56
-
57
- function resolveChat(input = {}) {
58
- return normalizeObject(input.chat, null)
59
- || normalizeObject(input.conversationChat, null)
60
- || null;
61
- }
62
-
63
- function resolveKickoff(input = {}, request = null) {
64
- return normalizeObject(input.kickoff, null)
65
- || normalizeObject(input.request?.kickoff, null)
66
- || normalizeObject(request?.kickoff, null)
67
- || null;
68
- }
69
-
70
- function resolveConversation(input = {}, chat = null) {
71
- return normalizeObject(input.conversation, null)
72
- || normalizeObject(chat?.conversation, null)
73
- || null;
74
- }
75
-
76
- function resolveCounterparty(input = {}, request = null, chat = null) {
77
- return normalizeObject(chat?.counterparty, null)
78
- || normalizeObject(request?.counterparty, null)
79
- || normalizeObject(input.counterparty, null)
80
- || null;
81
- }
82
-
83
- export function resolveConversationViewerTarget(input = {}, {
84
- accountId = null,
85
- viewerAgentId = null,
86
- role = null,
87
- source = null,
88
- } = {}) {
89
- const payload = normalizeObject(input, {}) || {};
90
- const request = resolveRequest(payload);
91
- const chat = resolveChat(payload);
92
- const kickoff = resolveKickoff(payload, request);
93
- const conversation = resolveConversation(payload, chat);
94
- const counterparty = resolveCounterparty(payload, request, chat);
95
- const chatRequestId = normalizeText(
96
- request?.chatRequestId,
97
- normalizeText(request?.requestId, normalizeText(chat?.chatRequestId, normalizeText(payload.chatRequestId, null))),
98
- );
99
- const conversationKey = normalizeText(
100
- kickoff?.conversationKey,
101
- normalizeText(
102
- chat?.conversationKey,
103
- normalizeText(conversation?.conversationKey, normalizeText(payload.conversationKey, null)),
104
- ),
105
- );
106
- const localSessionKey = normalizeText(
107
- kickoff?.localSessionKey,
108
- normalizeText(
109
- kickoff?.sessionKey,
110
- normalizeText(chat?.localSessionKey, normalizeText(chat?.sessionKey, normalizeText(conversation?.sessionKey, null))),
111
- ),
112
- );
113
- if (!chatRequestId && !conversationKey && !localSessionKey) return null;
114
- const targetKey = chatRequestId
115
- ? `${normalizeText(accountId, 'default')}|${normalizeText(viewerAgentId, 'unknown')}|request:${chatRequestId}`
116
- : `${normalizeText(accountId, 'default')}|${normalizeText(viewerAgentId, 'unknown')}|conversation:${conversationKey || localSessionKey}`;
117
- return {
118
- targetKey,
119
- accountId: normalizeText(accountId, null),
120
- viewerAgentId: normalizeText(viewerAgentId, null),
121
- role: normalizeText(role, null),
122
- source: normalizeText(source, null),
123
- status: normalizeText(payload.status, normalizeText(request?.status, normalizeText(chat?.status, null))),
124
- verdict: normalizeText(payload.verdict, null),
125
- reason: normalizeText(payload.reason, null),
126
- chatRequestId,
127
- conversationKey,
128
- localSessionKey,
129
- counterpartyAgentId: normalizeText(counterparty?.agentId, null),
130
- counterpartyName: normalizeText(counterparty?.displayName, normalizeText(counterparty?.identity, null)),
131
- worldId: normalizeText(conversation?.worldId, normalizeText(request?.conversation?.worldId, normalizeText(chat?.conversation?.worldId, null))),
132
- direction: normalizeText(request?.direction, normalizeText(chat?.direction, null)),
133
- };
134
- }
135
-
136
- async function readJsonIfPresent(filePath) {
137
- try {
138
- return JSON.parse(await fs.readFile(filePath, 'utf8'));
139
- } catch {
140
- return null;
141
- }
142
- }
143
-
144
- async function writeJson(filePath, value) {
145
- await fs.mkdir(path.dirname(filePath), { recursive: true });
146
- await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
147
- }
148
-
149
- async function readViewerIndex(root) {
150
- const index = await readJsonIfPresent(path.join(root, 'index.json'));
151
- return normalizeObject(index, { schema: CONVERSATION_VIEWER_SCHEMA, viewers: {} });
152
- }
153
-
154
- function buildPublicConversationViewer(manifest = {}) {
155
- return {
156
- id: normalizeText(manifest.viewerId, null),
157
- status: normalizeText(manifest.status, 'ready'),
158
- url: normalizeText(manifest.viewerUrl, null),
159
- localFile: normalizeText(manifest.htmlPath, null),
160
- fileUrl: normalizeText(manifest.fileUrl, null),
161
- chatRequestId: normalizeText(manifest.chatRequestId, null),
162
- conversationKey: normalizeText(manifest.conversationKey, null),
163
- localSessionKey: normalizeText(manifest.localSessionKey, null),
164
- replay: {
165
- firstOpenReplaysEndedConversation: true,
166
- secondOpenShowsFullTranscript: true,
167
- },
168
- note: 'Open this local viewer URL to watch the Claworld A2A chat transcript.',
169
- };
170
- }
171
-
172
- function buildViewerHtml(manifest = {}) {
173
- const bootstrap = safeJsonForScript({
174
- schema: CONVERSATION_VIEWER_SCHEMA,
175
- viewerId: manifest.viewerId,
176
- createdAt: manifest.createdAt,
177
- target: {
178
- chatRequestId: manifest.chatRequestId || null,
179
- conversationKey: manifest.conversationKey || null,
180
- localSessionKey: manifest.localSessionKey || null,
181
- },
182
- });
183
- return `<!doctype html>
184
- <html lang="zh-CN">
185
- <head>
186
- <meta charset="utf-8">
187
- <meta name="viewport" content="width=device-width, initial-scale=1">
188
- <title>Claworld A2A Chat</title>
189
- <style>
190
- :root {
191
- color-scheme: light;
192
- --bg: #eef2f6;
193
- --panel: #ffffff;
194
- --ink: #16202a;
195
- --muted: #607083;
196
- --line: #d9e0e8;
197
- --self: #1f7a5c;
198
- --selfText: #ffffff;
199
- --peer: #ffffff;
200
- --peerText: #17212b;
201
- --warn: #b35b00;
202
- --bad: #b42318;
203
- --good: #0f766e;
204
- --chip: #edf4ff;
205
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
206
- }
207
- * { box-sizing: border-box; }
208
- body {
209
- margin: 0;
210
- min-height: 100vh;
211
- background: linear-gradient(180deg, #f7f9fc 0%, var(--bg) 100%);
212
- color: var(--ink);
213
- }
214
- .app {
215
- min-height: 100vh;
216
- display: grid;
217
- grid-template-rows: auto 1fr auto;
218
- }
219
- .topbar {
220
- display: flex;
221
- gap: 14px;
222
- align-items: center;
223
- justify-content: space-between;
224
- padding: 14px 18px;
225
- background: rgba(255, 255, 255, 0.92);
226
- border-bottom: 1px solid var(--line);
227
- position: sticky;
228
- top: 0;
229
- z-index: 2;
230
- backdrop-filter: blur(16px);
231
- }
232
- .identity {
233
- min-width: 0;
234
- display: flex;
235
- align-items: center;
236
- gap: 12px;
237
- }
238
- .avatar {
239
- width: 40px;
240
- height: 40px;
241
- border-radius: 50%;
242
- display: grid;
243
- place-items: center;
244
- background: #17212b;
245
- color: #fff;
246
- font-weight: 700;
247
- }
248
- .title { min-width: 0; }
249
- .title h1 {
250
- margin: 0;
251
- font-size: 16px;
252
- line-height: 1.25;
253
- font-weight: 700;
254
- white-space: nowrap;
255
- overflow: hidden;
256
- text-overflow: ellipsis;
257
- }
258
- .title p {
259
- margin: 3px 0 0;
260
- color: var(--muted);
261
- font-size: 12px;
262
- }
263
- .status {
264
- display: inline-flex;
265
- align-items: center;
266
- gap: 8px;
267
- color: var(--muted);
268
- font-size: 13px;
269
- white-space: nowrap;
270
- }
271
- .dot {
272
- width: 9px;
273
- height: 9px;
274
- border-radius: 50%;
275
- background: var(--muted);
276
- }
277
- .dot.online { background: var(--good); }
278
- .dot.warn { background: var(--warn); }
279
- .dot.bad { background: var(--bad); }
280
- .messages {
281
- width: min(920px, 100%);
282
- margin: 0 auto;
283
- padding: 18px 14px 96px;
284
- display: flex;
285
- flex-direction: column;
286
- gap: 12px;
287
- }
288
- .empty {
289
- margin: 18vh auto 0;
290
- max-width: 520px;
291
- text-align: center;
292
- color: var(--muted);
293
- line-height: 1.6;
294
- }
295
- .row {
296
- display: flex;
297
- gap: 8px;
298
- align-items: flex-end;
299
- }
300
- .row.self { justify-content: flex-end; }
301
- .bubble {
302
- max-width: min(680px, 78vw);
303
- border: 1px solid var(--line);
304
- border-radius: 8px;
305
- padding: 10px 12px;
306
- background: var(--peer);
307
- color: var(--peerText);
308
- box-shadow: 0 2px 8px rgba(18, 31, 46, 0.04);
309
- overflow-wrap: anywhere;
310
- }
311
- .self .bubble {
312
- background: var(--self);
313
- color: var(--selfText);
314
- border-color: color-mix(in srgb, var(--self) 80%, #000 20%);
315
- }
316
- .meta {
317
- margin-top: 6px;
318
- font-size: 11px;
319
- color: color-mix(in srgb, currentColor 62%, transparent);
320
- }
321
- .md p { margin: 0 0 8px; }
322
- .md p:last-child { margin-bottom: 0; }
323
- .md pre {
324
- overflow: auto;
325
- margin: 8px 0;
326
- padding: 10px;
327
- border-radius: 6px;
328
- background: rgba(0, 0, 0, 0.08);
329
- }
330
- .md code {
331
- padding: 1px 4px;
332
- border-radius: 4px;
333
- background: rgba(0, 0, 0, 0.08);
334
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
335
- font-size: 0.92em;
336
- }
337
- .md ul, .md ol { margin: 8px 0 8px 20px; padding: 0; }
338
- .chips {
339
- margin-top: 8px;
340
- display: flex;
341
- flex-wrap: wrap;
342
- gap: 6px;
343
- }
344
- .chip {
345
- display: inline-flex;
346
- align-items: center;
347
- gap: 5px;
348
- border-radius: 999px;
349
- padding: 4px 8px;
350
- font-size: 12px;
351
- background: var(--chip);
352
- color: #17406d;
353
- border: 1px solid #c9ddf7;
354
- }
355
- .self .chip {
356
- background: rgba(255, 255, 255, 0.16);
357
- color: #fff;
358
- border-color: rgba(255, 255, 255, 0.28);
359
- }
360
- .composer {
361
- position: fixed;
362
- left: 0;
363
- right: 0;
364
- bottom: 0;
365
- padding: 12px 14px 14px;
366
- background: rgba(238, 242, 246, 0.92);
367
- border-top: 1px solid var(--line);
368
- backdrop-filter: blur(16px);
369
- }
370
- .composer-inner {
371
- width: min(920px, 100%);
372
- margin: 0 auto;
373
- min-height: 48px;
374
- display: flex;
375
- align-items: center;
376
- justify-content: space-between;
377
- gap: 12px;
378
- padding: 10px 12px;
379
- background: var(--panel);
380
- border: 1px solid var(--line);
381
- border-radius: 8px;
382
- }
383
- .typing {
384
- color: var(--muted);
385
- font-size: 13px;
386
- min-width: 0;
387
- }
388
- .typing .pulse::after {
389
- content: "";
390
- animation: dots 1.2s steps(4, end) infinite;
391
- }
392
- .timestamp {
393
- color: var(--muted);
394
- font-size: 12px;
395
- white-space: nowrap;
396
- }
397
- @keyframes dots {
398
- 0% { content: ""; }
399
- 25% { content: "."; }
400
- 50% { content: ".."; }
401
- 75%, 100% { content: "..."; }
402
- }
403
- @media (max-width: 640px) {
404
- .topbar { padding: 12px; align-items: flex-start; }
405
- .status { font-size: 12px; }
406
- .bubble { max-width: 86vw; }
407
- .composer-inner { align-items: flex-start; flex-direction: column; }
408
- }
409
- </style>
410
- </head>
411
- <body>
412
- <div class="app">
413
- <header class="topbar">
414
- <div class="identity">
415
- <div class="avatar" id="avatar">C</div>
416
- <div class="title">
417
- <h1 id="title">Claworld A2A Chat</h1>
418
- <p id="subtitle">Preparing transcript</p>
419
- </div>
420
- </div>
421
- <div class="status"><span class="dot" id="stateDot"></span><span id="stateText">Loading</span></div>
422
- </header>
423
- <main class="messages" id="messages">
424
- <div class="empty">Loading the local conversation viewer.</div>
425
- </main>
426
- <footer class="composer">
427
- <div class="composer-inner">
428
- <div class="typing" id="typing">Connecting to Claworld state.</div>
429
- <div class="timestamp" id="updatedAt"></div>
430
- </div>
431
- </footer>
432
- </div>
433
- <script>
434
- const BOOTSTRAP = ${bootstrap};
435
- const state = {
436
- snapshot: null,
437
- renderedIds: new Set(),
438
- replaying: false,
439
- firstSnapshotSeen: false,
440
- };
441
- const els = {
442
- avatar: document.getElementById('avatar'),
443
- title: document.getElementById('title'),
444
- subtitle: document.getElementById('subtitle'),
445
- stateDot: document.getElementById('stateDot'),
446
- stateText: document.getElementById('stateText'),
447
- messages: document.getElementById('messages'),
448
- typing: document.getElementById('typing'),
449
- updatedAt: document.getElementById('updatedAt'),
450
- };
451
- const replayKey = 'claworld.conversationViewer.' + BOOTSTRAP.viewerId + '.played';
452
-
453
- function escapeHtml(value) {
454
- return String(value ?? '')
455
- .replaceAll('&', '&amp;')
456
- .replaceAll('<', '&lt;')
457
- .replaceAll('>', '&gt;')
458
- .replaceAll('"', '&quot;')
459
- .replaceAll("'", '&#39;');
460
- }
461
-
462
- function renderInlineMarkdown(text) {
463
- let html = escapeHtml(text);
464
- html = html.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
465
- html = html.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
466
- html = html.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');
467
- html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
468
- return html;
469
- }
470
-
471
- function renderMarkdown(text) {
472
- const blocks = String(text || '').split(/\\n{2,}/);
473
- return blocks.map((block) => {
474
- if (/^\`\`\`/.test(block.trim())) {
475
- return '<pre><code>' + escapeHtml(block.replace(/^\`\`\`[a-zA-Z0-9_-]*\\n?/, '').replace(/\`\`\`$/, '')) + '</code></pre>';
476
- }
477
- const lines = block.split('\\n');
478
- if (lines.every((line) => /^\\s*[-*]\\s+/.test(line))) {
479
- return '<ul>' + lines.map((line) => '<li>' + renderInlineMarkdown(line.replace(/^\\s*[-*]\\s+/, '')) + '</li>').join('') + '</ul>';
480
- }
481
- return '<p>' + lines.map(renderInlineMarkdown).join('<br>') + '</p>';
482
- }).join('');
483
- }
484
-
485
- function parseControlTokens(text) {
486
- const tokens = [];
487
- const cleanText = String(text || '').replace(/\\[\\[(like|dislike|request_conversation_end)\\]\\]/gi, (match, token) => {
488
- tokens.push(String(token || '').toLowerCase());
489
- return '';
490
- }).replace(/\\s{2,}/g, ' ').trim();
491
- return { cleanText, tokens };
492
- }
493
-
494
- function tokenLabel(token) {
495
- if (token === 'like') return 'Liked';
496
- if (token === 'dislike') return 'Disliked';
497
- if (token === 'request_conversation_end') return 'Asked to end the conversation';
498
- return token;
499
- }
500
-
501
- function turnText(turn) {
502
- const content = Array.isArray(turn?.content) ? turn.content : [];
503
- const textParts = content.map((item) => item && typeof item === 'object' ? item.text : null).filter(Boolean);
504
- return textParts.join('\\n\\n') || turn?.previewText || '';
505
- }
506
-
507
- function isSelfTurn(turn, snapshot) {
508
- return turn?.fromAgentId && turn.fromAgentId === snapshot?.viewer?.viewerAgentId;
509
- }
510
-
511
- function formatTime(value) {
512
- const date = new Date(value || Date.now());
513
- if (Number.isNaN(date.getTime())) return '';
514
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
515
- }
516
-
517
- function terminalState(snapshot) {
518
- const status = snapshot?.state?.status || '';
519
- return ['ended', 'closed', 'expired', 'rejected', 'silent', 'kickoff_failed', 'failed'].includes(status);
520
- }
521
-
522
- function renderTurn(turn, snapshot) {
523
- const row = document.createElement('div');
524
- row.className = 'row ' + (isSelfTurn(turn, snapshot) ? 'self' : 'peer');
525
- const bubble = document.createElement('div');
526
- bubble.className = 'bubble';
527
- const parsed = parseControlTokens(turnText(turn));
528
- const md = document.createElement('div');
529
- md.className = 'md';
530
- md.innerHTML = parsed.cleanText ? renderMarkdown(parsed.cleanText) : '<p>Conversation control update</p>';
531
- bubble.appendChild(md);
532
- if (parsed.tokens.length) {
533
- const chips = document.createElement('div');
534
- chips.className = 'chips';
535
- for (const token of parsed.tokens) {
536
- const chip = document.createElement('span');
537
- chip.className = 'chip';
538
- chip.textContent = tokenLabel(token);
539
- chips.appendChild(chip);
540
- }
541
- bubble.appendChild(chips);
542
- }
543
- const meta = document.createElement('div');
544
- meta.className = 'meta';
545
- meta.textContent = formatTime(turn.createdAt || turn.updatedAt);
546
- bubble.appendChild(meta);
547
- row.appendChild(bubble);
548
- return row;
549
- }
550
-
551
- function setHeader(snapshot) {
552
- const peer = snapshot?.state?.peerName || snapshot?.chat?.counterparty?.displayName || snapshot?.request?.counterparty?.displayName || 'Peer agent';
553
- const status = snapshot?.state?.status || 'loading';
554
- const online = snapshot?.state?.peerOnline === true;
555
- els.title.textContent = peer;
556
- els.avatar.textContent = (peer || 'C').slice(0, 1).toUpperCase();
557
- els.subtitle.textContent = snapshot?.state?.scopeLabel || 'Claworld conversation';
558
- els.stateText.textContent = snapshot?.state?.label || status;
559
- els.stateDot.className = 'dot ' + (terminalState(snapshot) ? 'bad' : online ? 'online' : status === 'pending' || status === 'opening' ? 'warn' : '');
560
- els.updatedAt.textContent = snapshot?.fetchedAt ? 'Updated ' + formatTime(snapshot.fetchedAt) : '';
561
- }
562
-
563
- function setTyping(snapshot) {
564
- if (snapshot?.error) {
565
- els.typing.textContent = 'Connection interrupted. Retrying.';
566
- return;
567
- }
568
- const status = snapshot?.state?.status || 'loading';
569
- if (status === 'pending') {
570
- els.typing.textContent = 'Waiting for the peer to accept.';
571
- return;
572
- }
573
- if (terminalState(snapshot)) {
574
- els.typing.textContent = snapshot?.state?.terminalLabel || 'This conversation has ended.';
575
- return;
576
- }
577
- const turns = Array.isArray(snapshot?.turns) ? snapshot.turns : [];
578
- const last = turns[turns.length - 1] || null;
579
- const peer = snapshot?.state?.peerName || 'The peer';
580
- if (last && isSelfTurn(last, snapshot)) {
581
- els.typing.innerHTML = '<span class="pulse">' + escapeHtml(peer) + ' is typing</span>';
582
- return;
583
- }
584
- if (snapshot?.state?.peerOnline === false) {
585
- els.typing.textContent = peer + ' is currently offline. New messages will appear here when delivery resumes.';
586
- return;
587
- }
588
- els.typing.textContent = 'Conversation is live.';
589
- }
590
-
591
- function renderAll(snapshot) {
592
- const turns = Array.isArray(snapshot?.turns) ? snapshot.turns : [];
593
- els.messages.innerHTML = '';
594
- state.renderedIds.clear();
595
- if (!turns.length) {
596
- const empty = document.createElement('div');
597
- empty.className = 'empty';
598
- empty.textContent = snapshot?.state?.emptyText || 'No conversation turns yet.';
599
- els.messages.appendChild(empty);
600
- return;
601
- }
602
- for (const turn of turns) {
603
- state.renderedIds.add(turn.turnId || String(turn.seq));
604
- els.messages.appendChild(renderTurn(turn, snapshot));
605
- }
606
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
607
- }
608
-
609
- function appendNew(snapshot) {
610
- const turns = Array.isArray(snapshot?.turns) ? snapshot.turns : [];
611
- if (!turns.length) {
612
- renderAll(snapshot);
613
- return;
614
- }
615
- if (els.messages.querySelector('.empty')) els.messages.innerHTML = '';
616
- for (const turn of turns) {
617
- const id = turn.turnId || String(turn.seq);
618
- if (state.renderedIds.has(id)) continue;
619
- state.renderedIds.add(id);
620
- els.messages.appendChild(renderTurn(turn, snapshot));
621
- }
622
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
623
- }
624
-
625
- async function replay(snapshot) {
626
- state.replaying = true;
627
- const turns = Array.isArray(snapshot?.turns) ? snapshot.turns : [];
628
- els.messages.innerHTML = '';
629
- state.renderedIds.clear();
630
- for (const turn of turns) {
631
- const id = turn.turnId || String(turn.seq);
632
- state.renderedIds.add(id);
633
- els.messages.appendChild(renderTurn(turn, snapshot));
634
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
635
- await new Promise((resolve) => setTimeout(resolve, Math.min(900, Math.max(280, turnText(turn).length * 18))));
636
- }
637
- try { localStorage.setItem(replayKey, new Date().toISOString()); } catch {}
638
- state.replaying = false;
639
- }
640
-
641
- function hasReplayPlayed() {
642
- try {
643
- return Boolean(localStorage.getItem(replayKey));
644
- } catch {
645
- return false;
646
- }
647
- }
648
-
649
- async function handleSnapshot(snapshot) {
650
- state.snapshot = snapshot;
651
- setHeader(snapshot);
652
- setTyping(snapshot);
653
- const shouldReplay = !state.firstSnapshotSeen && terminalState(snapshot) && Array.isArray(snapshot?.turns) && snapshot.turns.length > 0 && !hasReplayPlayed();
654
- state.firstSnapshotSeen = true;
655
- if (shouldReplay) {
656
- await replay(snapshot);
657
- return;
658
- }
659
- if (!state.renderedIds.size) renderAll(snapshot);
660
- else if (!state.replaying) appendNew(snapshot);
661
- }
662
-
663
- async function loadSnapshot() {
664
- const basePath = location.pathname.replace(/\\/$/, '');
665
- const response = await fetch(basePath + '/snapshot', { cache: 'no-store' });
666
- const snapshot = await response.json();
667
- if (!response.ok) throw new Error(snapshot?.error || 'snapshot_failed');
668
- return snapshot;
669
- }
670
-
671
- async function tick() {
672
- try {
673
- await handleSnapshot(await loadSnapshot());
674
- } catch (error) {
675
- await handleSnapshot({
676
- error: true,
677
- fetchedAt: new Date().toISOString(),
678
- state: {
679
- status: 'failed',
680
- label: 'Viewer retrying',
681
- emptyText: 'The local viewer cannot reach the Claworld state yet.',
682
- },
683
- turns: [],
684
- });
685
- } finally {
686
- const delay = terminalState(state.snapshot) ? 5000 : 1800;
687
- setTimeout(tick, delay);
688
- }
689
- }
690
-
691
- tick();
692
- </script>
693
- </body>
694
- </html>
695
- `;
696
- }
697
-
698
- async function createOrUpdateManifest({
699
- root,
700
- target,
701
- cfg = {},
702
- env = null,
703
- workspaceRoot = null,
704
- gatewayBaseUrl = null,
705
- } = {}) {
706
- const index = await readViewerIndex(root);
707
- const existingViewerId = normalizeRouteId(index.viewers?.[target.targetKey]?.viewerId);
708
- const viewerId = existingViewerId || `cv_${crypto.randomBytes(12).toString('hex')}`;
709
- const viewerDir = path.join(root, viewerId);
710
- const manifestPath = path.join(viewerDir, 'manifest.json');
711
- const htmlPath = path.join(viewerDir, 'index.html');
712
- const previous = await readJsonIfPresent(manifestPath);
713
- const now = new Date().toISOString();
714
- const resolvedGatewayBaseUrl = resolveConversationViewerGatewayBaseUrl({
715
- cfg,
716
- env,
717
- explicitBaseUrl: gatewayBaseUrl,
718
- });
719
- const viewerUrl = `${resolvedGatewayBaseUrl}${CONVERSATION_VIEWER_ROUTE_PREFIX}${viewerId}/`;
720
- const manifest = {
721
- ...normalizeObject(previous, {}),
722
- schema: CONVERSATION_VIEWER_SCHEMA,
723
- viewerId,
724
- targetKey: target.targetKey,
725
- accountId: target.accountId,
726
- viewerAgentId: target.viewerAgentId,
727
- role: target.role || null,
728
- source: target.source || null,
729
- status: previous?.status || 'ready',
730
- targetStatus: target.status || previous?.targetStatus || null,
731
- verdict: target.verdict || null,
732
- reason: target.reason || null,
733
- chatRequestId: target.chatRequestId || previous?.chatRequestId || null,
734
- conversationKey: target.conversationKey || previous?.conversationKey || null,
735
- localSessionKey: target.localSessionKey || previous?.localSessionKey || null,
736
- counterpartyAgentId: target.counterpartyAgentId || previous?.counterpartyAgentId || null,
737
- counterpartyName: target.counterpartyName || previous?.counterpartyName || null,
738
- worldId: target.worldId || previous?.worldId || null,
739
- direction: target.direction || previous?.direction || null,
740
- workspaceRoot: normalizeText(workspaceRoot, previous?.workspaceRoot || null),
741
- root,
742
- manifestPath,
743
- htmlPath,
744
- fileUrl: pathToFileURL(htmlPath).href,
745
- routePath: `${CONVERSATION_VIEWER_ROUTE_PREFIX}${viewerId}/`,
746
- viewerUrl,
747
- createdAt: previous?.createdAt || now,
748
- updatedAt: now,
749
- };
750
- await fs.mkdir(viewerDir, { recursive: true });
751
- await fs.writeFile(htmlPath, buildViewerHtml(manifest), 'utf8');
752
- await writeJson(manifestPath, manifest);
753
- index.schema = CONVERSATION_VIEWER_SCHEMA;
754
- index.updatedAt = now;
755
- index.viewers = {
756
- ...normalizeObject(index.viewers, {}),
757
- [target.targetKey]: {
758
- viewerId,
759
- manifestPath,
760
- updatedAt: now,
761
- },
762
- };
763
- await writeJson(path.join(root, 'index.json'), index);
764
- return manifest;
765
- }
766
-
767
- function addViewerToPayload(payload = {}, viewer = null) {
768
- if (!viewer) return payload;
769
- const next = cloneJsonObject(payload, {}) || {};
770
- next.conversationViewer = viewer;
771
- if (normalizeObject(next.chatRequest, null)) {
772
- next.chatRequest = { ...next.chatRequest, conversationViewer: viewer };
773
- }
774
- if (normalizeObject(next.request, null)) {
775
- next.request = { ...next.request, conversationViewer: viewer };
776
- }
777
- if (normalizeObject(next.chat, null)) {
778
- next.chat = { ...next.chat, conversationViewer: viewer };
779
- }
780
- if (normalizeObject(next.conversation, null)) {
781
- next.conversation = { ...next.conversation, conversationViewer: viewer };
782
- }
783
- return next;
784
- }
785
-
786
- export async function attachConversationViewerToPayload(payload = {}, {
787
- accountId = null,
788
- viewerAgentId = null,
789
- role = null,
790
- source = null,
791
- cfg = {},
792
- env = null,
793
- workspaceRoot = null,
794
- gatewayBaseUrl = null,
795
- logger = console,
796
- } = {}) {
797
- const target = resolveConversationViewerTarget(payload, {
798
- accountId,
799
- viewerAgentId,
800
- role,
801
- source,
802
- });
803
- if (!target) return payload;
804
- try {
805
- const root = resolveConversationViewerRoot({ env });
806
- const manifest = await createOrUpdateManifest({
807
- root,
808
- target,
809
- cfg,
810
- env,
811
- workspaceRoot,
812
- gatewayBaseUrl,
813
- });
814
- return addViewerToPayload(payload, buildPublicConversationViewer(manifest));
815
- } catch (error) {
816
- logger?.warn?.('[claworld:conversation-viewer] failed to create local viewer', {
817
- error: error?.message || String(error),
818
- chatRequestId: target.chatRequestId || null,
819
- conversationKey: target.conversationKey || null,
820
- });
821
- return {
822
- ...payload,
823
- conversationViewer: {
824
- status: 'unavailable',
825
- reason: 'viewer_creation_failed',
826
- },
827
- };
828
- }
829
- }
830
-
831
- function sendResponse(res, statusCode, contentType, body) {
832
- if (typeof res?.status === 'function' && typeof res?.send === 'function') {
833
- const response = res.status(statusCode);
834
- if (typeof response?.type === 'function') response.type(contentType);
835
- res.send(body);
836
- return true;
837
- }
838
- if (typeof res?.setHeader === 'function') {
839
- res.statusCode = statusCode;
840
- res.setHeader('content-type', contentType);
841
- res.setHeader('cache-control', 'no-store');
842
- }
843
- if (typeof res?.end === 'function') {
844
- res.end(body);
845
- return true;
846
- }
847
- return false;
848
- }
849
-
850
- function sendJson(res, statusCode, payload) {
851
- if (typeof res?.status === 'function' && typeof res?.json === 'function') {
852
- res.status(statusCode).json(payload);
853
- return true;
854
- }
855
- return sendResponse(res, statusCode, 'application/json; charset=utf-8', JSON.stringify(payload, null, 2));
856
- }
857
-
858
- function parseViewerRoute(req) {
859
- const requestUrl = new URL(req?.url || CONVERSATION_VIEWER_ROUTE_PREFIX, 'http://127.0.0.1');
860
- if (!requestUrl.pathname.startsWith(CONVERSATION_VIEWER_ROUTE_PREFIX)) return null;
861
- const rest = requestUrl.pathname.slice(CONVERSATION_VIEWER_ROUTE_PREFIX.length);
862
- const parts = rest.split('/').filter(Boolean);
863
- const viewerId = normalizeRouteId(parts[0]);
864
- if (!viewerId) return null;
865
- return {
866
- viewerId,
867
- action: normalizeText(parts[1], 'html'),
868
- };
869
- }
870
-
871
- const DEFAULT_SNAPSHOT_FETCH_TIMEOUT_MS = 4500;
872
-
873
- function timeoutError(message) {
874
- const error = new Error(message);
875
- error.name = 'TimeoutError';
876
- return error;
877
- }
878
-
879
- async function withTimeout(promise, timeoutMs, onTimeout) {
880
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
881
- let timeoutId = null;
882
- try {
883
- return await Promise.race([
884
- promise,
885
- new Promise((_resolve, reject) => {
886
- timeoutId = setTimeout(() => {
887
- try {
888
- reject(onTimeout());
889
- } catch (error) {
890
- reject(error);
891
- }
892
- }, timeoutMs);
893
- }),
894
- ]);
895
- } finally {
896
- if (timeoutId) clearTimeout(timeoutId);
897
- }
898
- }
899
-
900
- async function fetchBackendJson(fetchImpl, runtimeConfig = {}, pathName, {
901
- timeoutMs = DEFAULT_SNAPSHOT_FETCH_TIMEOUT_MS,
902
- } = {}) {
903
- const baseUrl = normalizeRelayHttpBaseUrl(runtimeConfig.serverUrl);
904
- const controller = typeof AbortController === 'function' ? new AbortController() : null;
905
- const targetUrl = `${baseUrl}${pathName}`;
906
- const fetchPromise = fetchImpl(targetUrl, {
907
- method: 'GET',
908
- headers: {
909
- ...(runtimeConfig.apiKey ? { 'x-api-key': runtimeConfig.apiKey } : {}),
910
- ...buildRuntimeAuthHeaders(runtimeConfig),
911
- },
912
- ...(controller ? { signal: controller.signal } : {}),
913
- });
914
- let response = null;
915
- try {
916
- response = await withTimeout(fetchPromise, timeoutMs, () => {
917
- controller?.abort?.();
918
- throw timeoutError(`backend request timed out after ${timeoutMs}ms`);
919
- });
920
- } catch (error) {
921
- return {
922
- ok: false,
923
- status: 0,
924
- error: error?.message || String(error),
925
- path: pathName,
926
- };
927
- }
928
- let body = null;
929
- try {
930
- body = await response.json();
931
- } catch {
932
- body = null;
933
- }
934
- return {
935
- ok: response.ok,
936
- status: response.status,
937
- body,
938
- path: pathName,
939
- };
940
- }
941
-
942
- function queryString(params = {}) {
943
- const search = new URLSearchParams();
944
- for (const [key, value] of Object.entries(params)) {
945
- const normalized = normalizeText(value, null);
946
- if (normalized) search.set(key, normalized);
947
- }
948
- const rendered = search.toString();
949
- return rendered ? `?${rendered}` : '';
950
- }
951
-
952
- function firstMatching(items = [], predicate) {
953
- return Array.isArray(items) ? items.find((item) => item && predicate(item)) || null : null;
954
- }
955
-
956
- function resolveSnapshotRequest(inbox = {}, manifest = {}) {
957
- const chatRequestId = normalizeText(manifest.chatRequestId, null);
958
- if (!chatRequestId) return null;
959
- const matches = (item) => normalizeText(item.chatRequestId || item.requestId, null) === chatRequestId;
960
- return firstMatching(inbox.pendingRequests, matches)
961
- || firstMatching(inbox.recentRequests, matches)
962
- || null;
963
- }
964
-
965
- function resolveSnapshotChat(inbox = {}, manifest = {}) {
966
- const conversationKey = normalizeText(manifest.conversationKey, null);
967
- const chatRequestId = normalizeText(manifest.chatRequestId, null);
968
- const localSessionKey = normalizeText(manifest.localSessionKey, null);
969
- return firstMatching(inbox.chats, (item) => (
970
- (conversationKey && normalizeText(item.conversationKey, null) === conversationKey)
971
- || (chatRequestId && normalizeText(item.chatRequestId, null) === chatRequestId)
972
- || (localSessionKey && normalizeText(item.localSessionKey, normalizeText(item.sessionKey, null)) === localSessionKey)
973
- ));
974
- }
975
-
976
- function buildStateLabel(status, { peerName = null, peerOnline = null } = {}) {
977
- if (status === 'pending') return 'Waiting for peer acceptance';
978
- if (status === 'opening') return 'Opening conversation';
979
- if (status === 'active') return peerOnline === false ? 'Peer offline, delivery will retry' : 'Live conversation';
980
- if (status === 'ending') return 'Ending requested';
981
- if (status === 'ended' || status === 'closed') return 'Conversation ended';
982
- if (status === 'silent') return 'Peer kept silent';
983
- if (status === 'kickoff_failed' || status === 'failed') return 'Conversation delivery issue';
984
- if (status === 'expired') return 'Request expired';
985
- if (status === 'rejected') return 'Request rejected';
986
- return peerName ? `Conversation with ${peerName}` : 'Claworld conversation';
987
- }
988
-
989
- function resolveSnapshotStatus({
990
- request = null,
991
- chat = null,
992
- conversation = null,
993
- fallbackStatus = null,
994
- } = {}) {
995
- const conversationStatus = normalizeText(conversation?.status, null);
996
- if (conversationStatus === 'closed') return 'ended';
997
- return normalizeText(
998
- chat?.status,
999
- normalizeText(request?.status, normalizeText(conversationStatus, normalizeText(fallbackStatus, 'pending'))),
1000
- );
1001
- }
1002
-
1003
- function collectTurns(turnsPayload = {}) {
1004
- const turns = Array.isArray(turnsPayload?.items) ? turnsPayload.items : [];
1005
- return turns.slice().sort((left, right) => {
1006
- const leftSeq = Number(left?.seq);
1007
- const rightSeq = Number(right?.seq);
1008
- if (Number.isFinite(leftSeq) && Number.isFinite(rightSeq) && leftSeq !== rightSeq) return leftSeq - rightSeq;
1009
- return String(left?.createdAt || '').localeCompare(String(right?.createdAt || ''));
1010
- });
1011
- }
1012
-
1013
- function uniqueNormalized(values = []) {
1014
- const seen = new Set();
1015
- const next = [];
1016
- for (const value of values) {
1017
- const normalized = normalizeText(value, null);
1018
- if (!normalized || seen.has(normalized)) continue;
1019
- seen.add(normalized);
1020
- next.push(normalized);
1021
- }
1022
- return next;
1023
- }
1024
-
1025
- function normalizeSessionStoreKey(value) {
1026
- return normalizeText(value, '').toLowerCase();
1027
- }
1028
-
1029
- function extractLocalAgentIdsFromManifest(manifest = {}) {
1030
- const ids = [];
1031
- for (const value of [manifest.localSessionKey, manifest.sessionKey]) {
1032
- const normalized = normalizeText(value, null);
1033
- const match = normalized?.match(/^agent:([^:]+):/);
1034
- if (match?.[1]) ids.push(match[1]);
1035
- }
1036
- ids.push('main');
1037
- return uniqueNormalized(ids);
1038
- }
1039
-
1040
- function buildLocalSessionKeyCandidates(manifest = {}) {
1041
- const conversationKey = normalizeText(manifest.conversationKey, null);
1042
- const localSessionKey = normalizeText(manifest.localSessionKey, null);
1043
- const keys = [];
1044
- if (localSessionKey) {
1045
- keys.push(localSessionKey);
1046
- if (!localSessionKey.startsWith('agent:')) {
1047
- for (const localAgentId of extractLocalAgentIdsFromManifest(manifest)) {
1048
- keys.push(`agent:${localAgentId}:${localSessionKey}`);
1049
- }
1050
- }
1051
- }
1052
- if (conversationKey) {
1053
- keys.push(`conversation:${conversationKey}`);
1054
- for (const localAgentId of extractLocalAgentIdsFromManifest(manifest)) {
1055
- keys.push(`agent:${localAgentId}:conversation:${conversationKey}`);
1056
- }
1057
- }
1058
- return uniqueNormalized(keys);
1059
- }
1060
-
1061
- function resolveSessionStoreEntry(store = null, candidateKeys = [], manifest = {}) {
1062
- if (!store || typeof store !== 'object' || Array.isArray(store)) return null;
1063
- const normalizedCandidates = new Set(candidateKeys.map(normalizeSessionStoreKey));
1064
- for (const key of candidateKeys) {
1065
- if (store[key] && typeof store[key] === 'object' && !Array.isArray(store[key])) {
1066
- return { key, record: store[key] };
1067
- }
1068
- }
1069
- for (const [key, record] of Object.entries(store)) {
1070
- if (
1071
- normalizedCandidates.has(normalizeSessionStoreKey(key))
1072
- && record
1073
- && typeof record === 'object'
1074
- && !Array.isArray(record)
1075
- ) {
1076
- return { key, record };
1077
- }
1078
- }
1079
-
1080
- const conversationKey = normalizeText(manifest.conversationKey, null);
1081
- const counterpartyAgentId = normalizeText(manifest.counterpartyAgentId, null);
1082
- for (const [key, record] of Object.entries(store)) {
1083
- if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
1084
- const keyText = normalizeText(key, '');
1085
- const recordText = [
1086
- keyText,
1087
- record.sessionFile,
1088
- record.transcriptPath,
1089
- record.lastTo,
1090
- record.route?.target?.to,
1091
- record.deliveryContext?.to,
1092
- record.origin?.to,
1093
- ].map((value) => normalizeText(value, '')).join('\n');
1094
- if (conversationKey && recordText.includes(conversationKey)) {
1095
- return { key, record };
1096
- }
1097
- if (
1098
- counterpartyAgentId
1099
- && candidateKeys.some((candidate) => keyText.includes(candidate))
1100
- && recordText.includes(counterpartyAgentId)
1101
- ) {
1102
- return { key, record };
1103
- }
1104
- }
1105
- return null;
1106
- }
1107
-
1108
- function resolveTranscriptPathFromSessionRecord({
1109
- record = {},
1110
- sessionStorePath = null,
1111
- } = {}) {
1112
- const direct = normalizeText(
1113
- record.transcriptPath,
1114
- normalizeText(record.sessionFile, normalizeText(record.sessionPath, null)),
1115
- );
1116
- if (direct) {
1117
- if (path.isAbsolute(direct) || !sessionStorePath) return direct;
1118
- return path.resolve(path.dirname(sessionStorePath), direct);
1119
- }
1120
- const sessionId = normalizeText(record.sessionId, normalizeText(record.id, null));
1121
- if (!sessionId || !sessionStorePath) return null;
1122
- return path.join(path.dirname(sessionStorePath), `${sessionId}.jsonl`);
1123
- }
1124
-
1125
- function extractRequestBrief(content = '') {
1126
- const normalized = normalizeText(content, null);
1127
- if (!normalized || !normalized.startsWith('# Background')) return null;
1128
- const match = normalized.match(/## Request Brief[\s\S]*?```(?:text)?\s*\n([\s\S]*?)```/i);
1129
- return normalizeText(match?.[1], null);
1130
- }
1131
-
1132
- function messageText(message = {}) {
1133
- if (typeof message?.content === 'string') return normalizeText(message.content, '');
1134
- if (Array.isArray(message?.content)) {
1135
- return message.content
1136
- .map((item) => {
1137
- if (typeof item === 'string') return item;
1138
- if (item && typeof item === 'object') return item.text || item.content || '';
1139
- return '';
1140
- })
1141
- .filter(Boolean)
1142
- .join('\n\n')
1143
- .trim();
1144
- }
1145
- return '';
1146
- }
1147
-
1148
- function containsConversationEndToken(text = '') {
1149
- return /\[\[request_conversation_end\]\]/i.test(String(text || ''));
1150
- }
1151
-
1152
- function buildLocalTranscriptTurn({
1153
- line,
1154
- seq,
1155
- text,
1156
- fromAgentId,
1157
- role,
1158
- source,
1159
- suffix = '',
1160
- } = {}) {
1161
- const turnId = normalizeText(line.id, null) || `local_${seq}${suffix}`;
1162
- return {
1163
- turnId,
1164
- seq,
1165
- fromAgentId: normalizeText(fromAgentId, null),
1166
- role,
1167
- source,
1168
- createdAt: normalizeText(line.timestamp, null)
1169
- || (Number.isFinite(Number(line.message?.timestamp)) ? new Date(Number(line.message.timestamp)).toISOString() : null)
1170
- || new Date().toISOString(),
1171
- content: [{ type: 'text', text }],
1172
- };
1173
- }
1174
-
1175
- async function readLocalTranscriptTurns({
1176
- transcriptPath = null,
1177
- manifest = {},
1178
- viewerAgentId = null,
1179
- } = {}) {
1180
- const normalizedPath = normalizeText(transcriptPath, null);
1181
- if (!normalizedPath) return null;
1182
- let raw = '';
1183
- try {
1184
- raw = await fs.readFile(normalizedPath, 'utf8');
1185
- } catch {
1186
- return null;
1187
- }
1188
- const peerAgentId = normalizeText(manifest.counterpartyAgentId, 'peer');
1189
- const turns = [];
1190
- let seq = 1;
1191
- let sawNoReply = false;
1192
- let selfEndRequested = false;
1193
- let peerEndRequested = false;
1194
- for (const row of raw.split(/\n+/)) {
1195
- const lineText = row.trim();
1196
- if (!lineText) continue;
1197
- let line = null;
1198
- try {
1199
- line = JSON.parse(lineText);
1200
- } catch {
1201
- continue;
1202
- }
1203
- if (line?.type !== 'message') continue;
1204
- const message = line.message && typeof line.message === 'object' && !Array.isArray(line.message)
1205
- ? line.message
1206
- : {};
1207
- const text = messageText(message);
1208
- if (!text) continue;
1209
-
1210
- const brief = extractRequestBrief(text);
1211
- if (brief) {
1212
- const requesterIsSelf = normalizeText(manifest.role, null) === 'requester';
1213
- const fromAgentId = requesterIsSelf ? viewerAgentId : peerAgentId;
1214
- turns.push(buildLocalTranscriptTurn({
1215
- line,
1216
- seq,
1217
- text: brief,
1218
- fromAgentId,
1219
- role: requesterIsSelf ? 'self' : 'peer',
1220
- source: 'local_transcript_request_brief',
1221
- suffix: '_brief',
1222
- }));
1223
- seq += 1;
1224
- if (requesterIsSelf && containsConversationEndToken(brief)) selfEndRequested = true;
1225
- if (!requesterIsSelf && containsConversationEndToken(brief)) peerEndRequested = true;
1226
- continue;
1227
- }
1228
-
1229
- if (text === 'NO_REPLY') {
1230
- sawNoReply = true;
1231
- continue;
1232
- }
1233
- const isAssistant = message.role === 'assistant';
1234
- const fromAgentId = isAssistant ? viewerAgentId : peerAgentId;
1235
- if (containsConversationEndToken(text)) {
1236
- if (isAssistant) selfEndRequested = true;
1237
- else peerEndRequested = true;
1238
- }
1239
- turns.push(buildLocalTranscriptTurn({
1240
- line,
1241
- seq,
1242
- text,
1243
- fromAgentId,
1244
- role: isAssistant ? 'self' : 'peer',
1245
- source: 'local_transcript',
1246
- }));
1247
- seq += 1;
1248
- }
1249
- return {
1250
- transcriptPath: normalizedPath,
1251
- turns,
1252
- terminal: sawNoReply || (selfEndRequested && peerEndRequested),
1253
- sawNoReply,
1254
- };
1255
- }
1256
-
1257
- async function loadLocalTranscriptSnapshot({
1258
- manifest = {},
1259
- env = null,
1260
- viewerAgentId = null,
1261
- } = {}) {
1262
- const stateRoot = resolveConversationViewerOpenClawRoot({ env });
1263
- const candidateKeys = buildLocalSessionKeyCandidates(manifest);
1264
- const localAgentIds = extractLocalAgentIdsFromManifest(manifest);
1265
- for (const localAgentId of localAgentIds) {
1266
- const sessionStorePath = path.join(stateRoot, 'agents', localAgentId, 'sessions', 'sessions.json');
1267
- const store = await readJsonIfPresent(sessionStorePath);
1268
- const match = resolveSessionStoreEntry(store, candidateKeys, manifest);
1269
- if (!match) continue;
1270
- const transcriptPath = resolveTranscriptPathFromSessionRecord({
1271
- record: match.record,
1272
- sessionStorePath,
1273
- });
1274
- const transcript = await readLocalTranscriptTurns({
1275
- transcriptPath,
1276
- manifest,
1277
- viewerAgentId,
1278
- });
1279
- if (transcript && transcript.turns.length > 0) {
1280
- return {
1281
- ...transcript,
1282
- sessionStorePath,
1283
- sessionKey: match.key,
1284
- sessionId: normalizeText(match.record?.sessionId, null),
1285
- };
1286
- }
1287
- }
1288
- return null;
1289
- }
1290
-
1291
- function inferLocalTranscriptStatus(status, localTranscript = null) {
1292
- const normalized = normalizeText(status, null);
1293
- if (['ended', 'closed', 'expired', 'rejected', 'silent', 'kickoff_failed', 'failed'].includes(normalized)) {
1294
- return normalized;
1295
- }
1296
- if (localTranscript?.terminal) return 'ended';
1297
- if (localTranscript?.turns?.length) return 'active';
1298
- return normalized || 'pending';
1299
- }
1300
-
1301
- async function maybeUpdateManifestFromSnapshot(manifest = {}, patch = {}) {
1302
- const nextConversationKey = normalizeText(patch.conversationKey, normalizeText(manifest.conversationKey, null));
1303
- const nextLocalSessionKey = normalizeText(patch.localSessionKey, normalizeText(manifest.localSessionKey, null));
1304
- if (
1305
- nextConversationKey === normalizeText(manifest.conversationKey, null)
1306
- && nextLocalSessionKey === normalizeText(manifest.localSessionKey, null)
1307
- ) {
1308
- return manifest;
1309
- }
1310
- const next = {
1311
- ...manifest,
1312
- conversationKey: nextConversationKey,
1313
- localSessionKey: nextLocalSessionKey,
1314
- updatedAt: new Date().toISOString(),
1315
- };
1316
- await writeJson(manifest.manifestPath, next);
1317
- return next;
1318
- }
1319
-
1320
- export async function loadConversationViewerSnapshot({
1321
- manifest,
1322
- runtimeConfig,
1323
- fetchImpl = globalThis.fetch,
1324
- timeoutMs = DEFAULT_SNAPSHOT_FETCH_TIMEOUT_MS,
1325
- env = null,
1326
- } = {}) {
1327
- const fetchedAt = new Date().toISOString();
1328
- const diagnostics = [];
1329
- const viewerAgentId = normalizeText(manifest.viewerAgentId, runtimeConfig?.relay?.agentId || null);
1330
- let inbox = {};
1331
- let request = null;
1332
- let chat = null;
1333
- let conversationKey = normalizeText(manifest.conversationKey, null);
1334
- if (!conversationKey) {
1335
- const inboxQuery = queryString({
1336
- agentId: viewerAgentId,
1337
- chatRequestId: manifest.chatRequestId,
1338
- localSessionKey: manifest.localSessionKey,
1339
- });
1340
- let inboxResult = await fetchBackendJson(fetchImpl, runtimeConfig, `/v1/chat-requests${inboxQuery}`, { timeoutMs });
1341
- if (!inboxResult.ok && inboxQuery) {
1342
- diagnostics.push({
1343
- source: 'chat_requests_filtered',
1344
- status: inboxResult.status,
1345
- error: inboxResult.error || null,
1346
- path: inboxResult.path || null,
1347
- });
1348
- const fallbackQuery = queryString({ agentId: viewerAgentId });
1349
- inboxResult = await fetchBackendJson(fetchImpl, runtimeConfig, `/v1/chat-requests${fallbackQuery}`, { timeoutMs });
1350
- }
1351
- if (!inboxResult.ok) {
1352
- return {
1353
- ok: false,
1354
- error: 'chat_inbox_snapshot_failed',
1355
- status: inboxResult.status,
1356
- message: inboxResult.error || null,
1357
- fetchedAt,
1358
- viewer: buildPublicConversationViewer(manifest),
1359
- body: inboxResult.body,
1360
- diagnostics,
1361
- };
1362
- }
1363
- inbox = normalizeObject(inboxResult.body, {});
1364
- request = resolveSnapshotRequest(inbox, manifest);
1365
- chat = resolveSnapshotChat(inbox, manifest);
1366
- conversationKey = normalizeText(
1367
- chat?.conversationKey,
1368
- normalizeText(request?.kickoff?.conversationKey, null),
1369
- );
1370
- }
1371
- let activeManifest = manifest;
1372
- if (conversationKey) {
1373
- activeManifest = await maybeUpdateManifestFromSnapshot(manifest, {
1374
- conversationKey,
1375
- localSessionKey: normalizeText(chat?.localSessionKey, normalizeText(request?.kickoff?.localSessionKey, normalizeText(request?.kickoff?.sessionKey, null))),
1376
- });
1377
- }
1378
- let conversation = null;
1379
- let participants = [];
1380
- let turns = [];
1381
- let localTranscript = null;
1382
- if (conversationKey) {
1383
- const suffix = queryString({ agentId: viewerAgentId });
1384
- const [conversationResult, turnsResult, participantsResult] = await Promise.all([
1385
- fetchBackendJson(fetchImpl, runtimeConfig, `/v1/conversations/${encodeURIComponent(conversationKey)}${suffix}`, { timeoutMs }),
1386
- fetchBackendJson(fetchImpl, runtimeConfig, `/v1/conversations/${encodeURIComponent(conversationKey)}/turns${suffix}`, { timeoutMs }),
1387
- fetchBackendJson(fetchImpl, runtimeConfig, `/v1/conversations/${encodeURIComponent(conversationKey)}/participants${suffix}`, { timeoutMs }),
1388
- ]);
1389
- if (conversationResult.ok) conversation = conversationResult.body || null;
1390
- else diagnostics.push({
1391
- source: 'conversation',
1392
- status: conversationResult.status,
1393
- error: conversationResult.error || null,
1394
- path: conversationResult.path || null,
1395
- });
1396
- if (turnsResult.ok) turns = collectTurns(turnsResult.body);
1397
- else diagnostics.push({
1398
- source: 'turns',
1399
- status: turnsResult.status,
1400
- error: turnsResult.error || null,
1401
- path: turnsResult.path || null,
1402
- });
1403
- if (participantsResult.ok && Array.isArray(participantsResult.body?.items)) {
1404
- participants = participantsResult.body.items;
1405
- } else if (!participantsResult.ok) {
1406
- diagnostics.push({
1407
- source: 'participants',
1408
- status: participantsResult.status,
1409
- error: participantsResult.error || null,
1410
- path: participantsResult.path || null,
1411
- });
1412
- }
1413
- }
1414
- if (turns.length === 0) {
1415
- localTranscript = await loadLocalTranscriptSnapshot({
1416
- manifest: activeManifest,
1417
- env,
1418
- viewerAgentId,
1419
- });
1420
- if (localTranscript?.turns?.length) {
1421
- turns = localTranscript.turns;
1422
- diagnostics.push({
1423
- source: 'local_transcript',
1424
- status: 200,
1425
- path: localTranscript.transcriptPath,
1426
- sessionKey: localTranscript.sessionKey || null,
1427
- sessionId: localTranscript.sessionId || null,
1428
- });
1429
- }
1430
- }
1431
- if (participants.length === 0 && localTranscript?.turns?.length) {
1432
- participants = [
1433
- {
1434
- agentId: viewerAgentId,
1435
- displayName: 'You',
1436
- },
1437
- {
1438
- agentId: normalizeText(activeManifest.counterpartyAgentId, 'peer'),
1439
- displayName: normalizeText(activeManifest.counterpartyName, 'Peer agent'),
1440
- },
1441
- ];
1442
- }
1443
- const peer = normalizeObject(chat?.counterparty, null)
1444
- || normalizeObject(request?.counterparty, null)
1445
- || null;
1446
- const peerName = normalizeText(peer?.displayName, normalizeText(activeManifest.counterpartyName, 'Peer agent'));
1447
- const resolvedStatus = resolveSnapshotStatus({
1448
- request,
1449
- chat,
1450
- conversation,
1451
- fallbackStatus: activeManifest.targetStatus,
1452
- });
1453
- const status = inferLocalTranscriptStatus(resolvedStatus, localTranscript);
1454
- const worldName = normalizeText(chat?.conversation?.world?.displayName, normalizeText(request?.conversation?.world?.displayName, null));
1455
- return {
1456
- ok: true,
1457
- schema: CONVERSATION_VIEWER_SCHEMA,
1458
- fetchedAt,
1459
- viewer: {
1460
- ...buildPublicConversationViewer(activeManifest),
1461
- accountId: normalizeText(activeManifest.accountId, null),
1462
- viewerAgentId,
1463
- },
1464
- state: {
1465
- status,
1466
- label: buildStateLabel(status, { peerName, peerOnline: peer?.online }),
1467
- terminalLabel: status === 'ended' || status === 'closed' ? 'Both sides have finished this conversation.' : null,
1468
- emptyText: status === 'pending'
1469
- ? 'The request exists. Messages will appear here after the peer accepts.'
1470
- : 'No conversation turns have been recorded yet.',
1471
- peerName,
1472
- peerOnline: typeof peer?.online === 'boolean' ? peer.online : null,
1473
- scopeLabel: worldName ? `World: ${worldName}` : 'Direct Claworld chat',
1474
- },
1475
- request,
1476
- chat,
1477
- conversation,
1478
- participants,
1479
- turns,
1480
- inbox: {
1481
- counts: inbox.counts || null,
1482
- filters: inbox.filters || null,
1483
- },
1484
- localTranscript: localTranscript ? {
1485
- path: localTranscript.transcriptPath,
1486
- sessionKey: localTranscript.sessionKey || null,
1487
- sessionId: localTranscript.sessionId || null,
1488
- terminal: localTranscript.terminal === true,
1489
- } : null,
1490
- diagnostics,
1491
- };
1492
- }
1493
-
1494
- export function buildConversationViewerRoute(plugin, {
1495
- api = null,
1496
- fetchImpl = globalThis.fetch,
1497
- env = null,
1498
- logger = console,
1499
- } = {}) {
1500
- return {
1501
- method: 'GET',
1502
- path: CONVERSATION_VIEWER_ROUTE_PREFIX,
1503
- auth: 'plugin',
1504
- match: 'prefix',
1505
- async handler(req, res) {
1506
- const route = parseViewerRoute(req);
1507
- if (!route) {
1508
- return sendJson(res, 404, { error: 'viewer_not_found' });
1509
- }
1510
- const root = resolveConversationViewerRoot({ env });
1511
- const manifestPath = path.join(root, route.viewerId, 'manifest.json');
1512
- const manifest = await readJsonIfPresent(manifestPath);
1513
- if (!manifest || manifest.schema !== CONVERSATION_VIEWER_SCHEMA) {
1514
- return sendJson(res, 404, { error: 'viewer_not_found' });
1515
- }
1516
- if (route.action === 'snapshot') {
1517
- try {
1518
- const cfg = await loadCurrentConfig(api);
1519
- const accountId = normalizeText(manifest.accountId, plugin.config.defaultAccountId(cfg) || null);
1520
- const runtimeConfig = plugin.config.resolveRuntimeConfig(cfg, accountId);
1521
- const snapshot = await loadConversationViewerSnapshot({
1522
- manifest,
1523
- runtimeConfig,
1524
- fetchImpl,
1525
- env,
1526
- });
1527
- return sendJson(res, snapshot.ok === false ? 502 : 200, snapshot);
1528
- } catch (error) {
1529
- logger?.warn?.('[claworld:conversation-viewer] snapshot failed', {
1530
- viewerId: route.viewerId,
1531
- error: error?.message || String(error),
1532
- });
1533
- return sendJson(res, 502, {
1534
- ok: false,
1535
- error: 'viewer_snapshot_failed',
1536
- message: error?.message || String(error),
1537
- });
1538
- }
1539
- }
1540
- if (route.action && route.action !== 'html') {
1541
- return sendJson(res, 404, { error: 'viewer_route_not_found' });
1542
- }
1543
- const html = await fs.readFile(manifest.htmlPath, 'utf8');
1544
- return sendResponse(res, 200, 'text/html; charset=utf-8', html);
1545
- },
1546
- };
1547
- }