@app-vitals/agent-admin 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/e2e/chat-thread-page.e2e.ts +0 -79
- package/openapi.json +14 -1
- package/openapi.platform.json +14 -1
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +1 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +1 -0
- package/prisma/client/models/Agent.ts +76 -1
- package/prisma/migrations/20261029000000_add_agent_specialty/migration.sql +5 -0
- package/prisma/schema/schema.prisma +3 -0
- package/src/admin-ui-pages.ts +94 -151
- package/src/admin-ui-pages.unit.test.ts +85 -221
- package/src/admin-ui.ts +27 -1
- package/src/agent-cron-jobs.ts +6 -4
- package/src/agent-specialty.ts +22 -0
- package/src/agent-specialty.unit.test.ts +43 -0
- package/src/agents-api.smoke.test.ts +1 -0
- package/src/agents-api.ts +6 -1
- package/src/agents.integration.test.ts +90 -0
- package/src/agents.ts +39 -15
- package/src/agents.unit.test.ts +19 -2
- package/src/api.ts +11 -0
- package/src/chat.smoke.test.ts +57 -0
- package/src/http-range.ts +31 -0
- package/src/http-range.unit.test.ts +65 -0
- package/src/openapi-schemas.ts +3 -0
package/src/admin-ui-pages.ts
CHANGED
|
@@ -2266,16 +2266,6 @@ const chatPageStyles = `
|
|
|
2266
2266
|
squeezed to ~160px next to the Attach/Send buttons at narrow widths. */
|
|
2267
2267
|
.chat-composer-row { flex-wrap:wrap }
|
|
2268
2268
|
.chat-message-input { flex-basis:100%; }
|
|
2269
|
-
/* Voice-first composer on phones: the mic and the auto-play toggle
|
|
2270
|
-
become 56px round thumb targets (desktop keeps the 44px text-button
|
|
2271
|
-
row), and an active recording pulses a red ring so the state is
|
|
2272
|
-
obvious at arm's length. */
|
|
2273
|
-
.chat-composer-btn--mic, .chat-composer-btn--autoplay { width:56px;height:56px;padding:0;border-radius:50%;font-size:24px;line-height:1 }
|
|
2274
|
-
.chat-composer-btn--mic.chat-mic-recording { animation:chat-mic-pulse 1.2s ease-in-out infinite }
|
|
2275
|
-
@keyframes chat-mic-pulse { 0%,100% { box-shadow:0 0 0 0 rgba(185,28,28,0.45) } 50% { box-shadow:0 0 0 10px rgba(185,28,28,0) } }
|
|
2276
|
-
@media (prefers-reduced-motion: reduce) {
|
|
2277
|
-
.chat-composer-btn--mic.chat-mic-recording { animation:none;box-shadow:0 0 0 4px rgba(185,28,28,0.3) }
|
|
2278
|
-
}
|
|
2279
2269
|
/* .chat-thread-page combines with .vos-page (specificity 0,2,0), which
|
|
2280
2270
|
otherwise beats .vos-page's own mobile padding rule (0,1,0) — pin the
|
|
2281
2271
|
effective mobile padding here. The bottom padding is intentionally
|
|
@@ -2365,21 +2355,6 @@ export const STALL_WARN_AFTER_MS = 120_000;
|
|
|
2365
2355
|
// import the lib file, so the value is hardcoded here with this reference.
|
|
2366
2356
|
export const ABSOLUTE_MAX_MS = 3_900_000;
|
|
2367
2357
|
|
|
2368
|
-
/**
|
|
2369
|
-
* localStorage key prefix for the per-thread "auto-play voice replies" switch
|
|
2370
|
-
* (`<prefix><threadId>` = "1" when on). Browser-local by design: a phone and a
|
|
2371
|
-
* laptop remember the switch separately and no server round-trip or schema
|
|
2372
|
-
* field is involved. Exported so tests can assert the inline script carries it.
|
|
2373
|
-
*/
|
|
2374
|
-
export const AUTOPLAY_STORAGE_KEY_PREFIX = "shipwright.chat.autoplay.";
|
|
2375
|
-
/**
|
|
2376
|
-
* A 10ms silent WAV. iOS Safari only lets `play()` run on a media element that
|
|
2377
|
-
* was first started inside a user gesture, so the inline script plays this on
|
|
2378
|
-
* a shared Audio element during the mic/toggle/send taps and later reuses that
|
|
2379
|
-
* unlocked element for a reply when the bubble's own control refuses.
|
|
2380
|
-
*/
|
|
2381
|
-
const AUTOPLAY_UNLOCK_WAV = "data:audio/wav;base64,UklGRnQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YVAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA==";
|
|
2382
|
-
|
|
2383
2358
|
/**
|
|
2384
2359
|
* Module-level pure bubble renderer (hoisted out of renderChatThreadPage in
|
|
2385
2360
|
* CFB-2.3). Returns the exact HTML the server emits on a full page load; the
|
|
@@ -2471,9 +2446,25 @@ export function renderChatMessageBubble(
|
|
|
2471
2446
|
// filename extension (see attachment-validation.ts's isAudioFilename); the
|
|
2472
2447
|
// src hits the admin attachment-proxy route, which streams the message's
|
|
2473
2448
|
// bytes from the chat service with a real audio/* Content-Type.
|
|
2449
|
+
const attachmentUrl = m.attachmentFilename
|
|
2450
|
+
? `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}/attachment`
|
|
2451
|
+
: "";
|
|
2452
|
+
// Assistant voice replies get a large tap-to-play control (COS-63.1) driven
|
|
2453
|
+
// by a hidden <audio>; user-uploaded clips keep the native controls.
|
|
2454
|
+
const voiceControl = `<div class="chat-voice" data-voice>
|
|
2455
|
+
<button type="button" class="chat-voice-play" aria-label="Play">▶</button>
|
|
2456
|
+
<div class="chat-voice-meta">
|
|
2457
|
+
<div class="chat-voice-progress"><div class="chat-voice-bar"></div></div>
|
|
2458
|
+
<span class="chat-voice-time">0:00 / 0:00</span>
|
|
2459
|
+
</div>
|
|
2460
|
+
<audio hidden preload="metadata" src="${escapeHtml(attachmentUrl)}"></audio>
|
|
2461
|
+
<div class="chat-voice-error" hidden>Could not play this reply <a download href="${escapeHtml(attachmentUrl)}">Download</a></div>
|
|
2462
|
+
</div>`;
|
|
2474
2463
|
const attachmentMedia = m.attachmentFilename
|
|
2475
2464
|
? isAudioFilename(m.attachmentFilename)
|
|
2476
|
-
?
|
|
2465
|
+
? isAssistant
|
|
2466
|
+
? voiceControl
|
|
2467
|
+
: `<audio controls style="display:block;margin-top:8px;max-width:100%" src="${escapeHtml(attachmentUrl)}"></audio>`
|
|
2477
2468
|
: `<div style="display:inline-block;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ${escapeHtml(m.attachmentFilename)}</div>`
|
|
2478
2469
|
: "";
|
|
2479
2470
|
|
|
@@ -2570,12 +2561,16 @@ const chatThreadStyles = `
|
|
|
2570
2561
|
.chat-composer-btn--attach { padding:0 16px }
|
|
2571
2562
|
.chat-composer-btn--mic { padding:0 16px }
|
|
2572
2563
|
.chat-composer-btn--mic.chat-mic-recording { background:#fee2e2;color:#b91c1c;border-color:#b91c1c }
|
|
2573
|
-
/*
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
.chat-
|
|
2577
|
-
.chat-
|
|
2578
|
-
.chat-
|
|
2564
|
+
/* Voice reply control (COS-63.1): 56px round tap target + progress. */
|
|
2565
|
+
.chat-voice { display:flex;align-items:center;gap:12px;margin-top:8px;flex-wrap:wrap }
|
|
2566
|
+
.chat-voice-play { width:56px;height:56px;min-width:56px;border-radius:50%;border:0;background:#2563eb;color:#fff;font-size:22px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0 }
|
|
2567
|
+
.chat-voice-play:focus-visible { outline:3px solid #93c5fd;outline-offset:2px }
|
|
2568
|
+
.chat-voice-meta { flex:1;min-width:120px }
|
|
2569
|
+
.chat-voice-progress { height:4px;border-radius:2px;background:#d1d5db;overflow:hidden }
|
|
2570
|
+
.chat-voice-bar { height:100%;width:0;background:#2563eb }
|
|
2571
|
+
.chat-voice-time { display:block;margin-top:4px;font-size:12px;color:#6b7280;font-variant-numeric:tabular-nums }
|
|
2572
|
+
.chat-voice-error { flex-basis:100%;font-size:13px;color:#b91c1c }
|
|
2573
|
+
.chat-voice-error[hidden] { display:none }
|
|
2579
2574
|
.chat-composer-btn--send { padding:0 20px }
|
|
2580
2575
|
/* Live-progress stall state (CFB-2.3): when progressSeq hasn't advanced for
|
|
2581
2576
|
STALL_WARN_AFTER_MS the status bubble gains .${STALL_INDICATOR_CLASS},
|
|
@@ -2853,7 +2848,6 @@ export function renderChatThreadPage(
|
|
|
2853
2848
|
var fileInput = document.getElementById('file-input');
|
|
2854
2849
|
var fileName = document.getElementById('file-name');
|
|
2855
2850
|
var micBtn = document.getElementById('mic-btn');
|
|
2856
|
-
var autoplayBtn = document.getElementById('autoplay-btn');
|
|
2857
2851
|
var container = document.getElementById('messages-container');
|
|
2858
2852
|
var agentId = ${JSON.stringify(agentId)};
|
|
2859
2853
|
var threadId = ${JSON.stringify(thread.id)};
|
|
@@ -2883,102 +2877,6 @@ export function renderChatThreadPage(
|
|
|
2883
2877
|
var STALL_INDICATOR_CLASS = ${JSON.stringify(STALL_INDICATOR_CLASS)};
|
|
2884
2878
|
var ASSISTANT_ROLE_CLASS = '${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}';
|
|
2885
2879
|
|
|
2886
|
-
// ─── Auto-play voice replies ─────────────────────────────────────────────
|
|
2887
|
-
// Per-thread switch remembered in localStorage (no server state). When on,
|
|
2888
|
-
// the newest assistant audio bubble inserted by a poll is played as soon as
|
|
2889
|
-
// it lands, so a voice-note round trip needs no tap to hear the answer.
|
|
2890
|
-
// Bubbles already in the DOM at load are seeded into renderedIds and can
|
|
2891
|
-
// never qualify, so reopening a thread never replays its backlog.
|
|
2892
|
-
var AUTOPLAY_STORAGE_KEY = ${JSON.stringify(AUTOPLAY_STORAGE_KEY_PREFIX)} + threadId;
|
|
2893
|
-
var AUTOPLAY_UNLOCK_SRC = ${JSON.stringify(AUTOPLAY_UNLOCK_WAV)};
|
|
2894
|
-
var autoplayEnabled = false;
|
|
2895
|
-
try {
|
|
2896
|
-
autoplayEnabled = !!window.localStorage && window.localStorage.getItem(AUTOPLAY_STORAGE_KEY) === '1';
|
|
2897
|
-
} catch (e) {
|
|
2898
|
-
autoplayEnabled = false; // private mode / storage blocked
|
|
2899
|
-
}
|
|
2900
|
-
var unlockAudio = null;
|
|
2901
|
-
|
|
2902
|
-
function setAutoplayUI() {
|
|
2903
|
-
if (!autoplayBtn) return;
|
|
2904
|
-
autoplayBtn.setAttribute('aria-pressed', autoplayEnabled ? 'true' : 'false');
|
|
2905
|
-
autoplayBtn.classList.toggle('chat-autoplay-on', autoplayEnabled);
|
|
2906
|
-
autoplayBtn.textContent = autoplayEnabled ? '🔊' : '🔇';
|
|
2907
|
-
autoplayBtn.setAttribute('title', autoplayEnabled ? 'Auto-play voice replies: on' : 'Auto-play voice replies: off');
|
|
2908
|
-
}
|
|
2909
|
-
|
|
2910
|
-
// Call from inside a user gesture (tap on mic / toggle / send). Plays the
|
|
2911
|
-
// silent clip on one shared Audio element so iOS treats that element as
|
|
2912
|
-
// user-started; playReply() falls back to it when a bubble's own control
|
|
2913
|
-
// is refused. Resets the src every time so a previous reply never replays.
|
|
2914
|
-
function unlockAutoplay() {
|
|
2915
|
-
if (!autoplayEnabled || typeof Audio === 'undefined') return;
|
|
2916
|
-
try {
|
|
2917
|
-
if (!unlockAudio) unlockAudio = new Audio();
|
|
2918
|
-
unlockAudio.src = AUTOPLAY_UNLOCK_SRC;
|
|
2919
|
-
var p = unlockAudio.play();
|
|
2920
|
-
if (p && p.then) p.then(function() { unlockAudio.pause(); }).catch(function() {});
|
|
2921
|
-
} catch (e) {
|
|
2922
|
-
// No audio support — the visible control is still there.
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
function showAutoplayHint(bubble) {
|
|
2927
|
-
if (!bubble || !bubble.querySelector || bubble.querySelector('.chat-autoplay-hint')) return;
|
|
2928
|
-
var hint = document.createElement('span');
|
|
2929
|
-
hint.className = 'chat-autoplay-hint';
|
|
2930
|
-
hint.textContent = 'Tap ▶ to listen';
|
|
2931
|
-
var audio = bubble.querySelector('audio');
|
|
2932
|
-
if (audio && audio.parentNode) audio.parentNode.insertBefore(hint, audio.nextSibling);
|
|
2933
|
-
else bubble.appendChild(hint);
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
function playReply(audio, bubble) {
|
|
2937
|
-
var attempt;
|
|
2938
|
-
try { attempt = audio.play(); } catch (e) { attempt = Promise.reject(e); }
|
|
2939
|
-
Promise.resolve(attempt).catch(function() {
|
|
2940
|
-
// The bubble's control was refused (iOS: this element never saw a
|
|
2941
|
-
// gesture). Reuse the unlocked element; pause it if the user takes
|
|
2942
|
-
// over with the visible control.
|
|
2943
|
-
if (!unlockAudio) { showAutoplayHint(bubble); return; }
|
|
2944
|
-
try {
|
|
2945
|
-
unlockAudio.src = audio.currentSrc || audio.getAttribute('src');
|
|
2946
|
-
audio.addEventListener('play', function() { unlockAudio.pause(); });
|
|
2947
|
-
Promise.resolve(unlockAudio.play()).catch(function() { showAutoplayHint(bubble); });
|
|
2948
|
-
} catch (e) {
|
|
2949
|
-
showAutoplayHint(bubble);
|
|
2950
|
-
}
|
|
2951
|
-
});
|
|
2952
|
-
}
|
|
2953
|
-
|
|
2954
|
-
// nodes: assistant bubbles first inserted by this poll, in order. Only the
|
|
2955
|
-
// newest one with an <audio> plays, so a burst never stacks overlapping
|
|
2956
|
-
// replies.
|
|
2957
|
-
function maybeAutoplay(nodes) {
|
|
2958
|
-
if (!autoplayEnabled) return;
|
|
2959
|
-
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
2960
|
-
var audio = nodes[i] && nodes[i].querySelector ? nodes[i].querySelector('audio') : null;
|
|
2961
|
-
if (!audio) continue;
|
|
2962
|
-
playReply(audio, nodes[i]);
|
|
2963
|
-
return;
|
|
2964
|
-
}
|
|
2965
|
-
}
|
|
2966
|
-
|
|
2967
|
-
if (autoplayBtn) {
|
|
2968
|
-
setAutoplayUI();
|
|
2969
|
-
autoplayBtn.addEventListener('click', function() {
|
|
2970
|
-
autoplayEnabled = !autoplayEnabled;
|
|
2971
|
-
try {
|
|
2972
|
-
if (autoplayEnabled) window.localStorage.setItem(AUTOPLAY_STORAGE_KEY, '1');
|
|
2973
|
-
else window.localStorage.removeItem(AUTOPLAY_STORAGE_KEY);
|
|
2974
|
-
} catch (e) {
|
|
2975
|
-
// Storage blocked: the switch still works for this page view.
|
|
2976
|
-
}
|
|
2977
|
-
setAutoplayUI();
|
|
2978
|
-
unlockAutoplay();
|
|
2979
|
-
});
|
|
2980
|
-
}
|
|
2981
|
-
|
|
2982
2880
|
// Poll cadence.
|
|
2983
2881
|
var POLL_PENDING_MS = 2000; // adaptive fast poll while a reply is pending
|
|
2984
2882
|
var POLL_IDLE_MS = 10000; // slow idle poll for agent-initiated messages
|
|
@@ -3044,18 +2942,16 @@ export function renderChatThreadPage(
|
|
|
3044
2942
|
else container.appendChild(node);
|
|
3045
2943
|
}
|
|
3046
2944
|
|
|
3047
|
-
// Insert a server-rendered bubbleHtml string, deduped by id.
|
|
3048
|
-
// inserted node on a first insertion, null otherwise (dupe / no html).
|
|
2945
|
+
// Insert a server-rendered bubbleHtml string, deduped by id.
|
|
3049
2946
|
function renderServerBubble(m) {
|
|
3050
|
-
if (!m || !m.id || renderedIds[m.id]) return
|
|
2947
|
+
if (!m || !m.id || renderedIds[m.id]) return false;
|
|
3051
2948
|
renderedIds[m.id] = true;
|
|
3052
|
-
if (!m.bubbleHtml) return
|
|
2949
|
+
if (!m.bubbleHtml) return false;
|
|
3053
2950
|
var tpl = document.createElement('template');
|
|
3054
2951
|
tpl.innerHTML = m.bubbleHtml.trim();
|
|
3055
2952
|
var node = tpl.content.firstChild;
|
|
3056
|
-
if (
|
|
3057
|
-
|
|
3058
|
-
return node;
|
|
2953
|
+
if (node) insertBeforeLiveStatus(node);
|
|
2954
|
+
return true;
|
|
3059
2955
|
}
|
|
3060
2956
|
|
|
3061
2957
|
// True when the user is within ~80px of the bottom, i.e. hasn't scrolled up
|
|
@@ -3177,13 +3073,10 @@ export function renderChatThreadPage(
|
|
|
3177
3073
|
// bubbleHtml, which already includes any Retry button (CFB-2.4) since
|
|
3178
3074
|
// the server computes retryBody per-message; wire up any new buttons
|
|
3179
3075
|
// after insertion (see wireRetryButtons below).
|
|
3180
|
-
var newAssistantNodes = [];
|
|
3181
3076
|
for (var i = 0; i < msgs.length; i++) {
|
|
3182
|
-
|
|
3183
|
-
if (inserted && msgs[i].role === 'assistant') newAssistantNodes.push(inserted);
|
|
3077
|
+
renderServerBubble(msgs[i]);
|
|
3184
3078
|
}
|
|
3185
3079
|
wireRetryButtons();
|
|
3186
|
-
maybeAutoplay(newAssistantNodes);
|
|
3187
3080
|
|
|
3188
3081
|
// Determine whether a reply is still pending (last message is an
|
|
3189
3082
|
// unreplied user message).
|
|
@@ -3275,7 +3168,6 @@ export function renderChatThreadPage(
|
|
|
3275
3168
|
micBtn.style.display = 'none';
|
|
3276
3169
|
} else {
|
|
3277
3170
|
micBtn.addEventListener('click', function() {
|
|
3278
|
-
unlockAutoplay(); // the tap is the gesture iOS needs for later playback
|
|
3279
3171
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
3280
3172
|
mediaRecorder.stop();
|
|
3281
3173
|
return;
|
|
@@ -3385,7 +3277,6 @@ export function renderChatThreadPage(
|
|
|
3385
3277
|
}
|
|
3386
3278
|
|
|
3387
3279
|
form.addEventListener('submit', function(e) {
|
|
3388
|
-
unlockAutoplay();
|
|
3389
3280
|
e.preventDefault();
|
|
3390
3281
|
var text = input.value.trim();
|
|
3391
3282
|
var file = fileInput && fileInput.files && fileInput.files[0];
|
|
@@ -3435,6 +3326,66 @@ export function renderChatThreadPage(
|
|
|
3435
3326
|
// Scroll to bottom on load
|
|
3436
3327
|
container.scrollTop = container.scrollHeight;
|
|
3437
3328
|
})();
|
|
3329
|
+
</script>`;
|
|
3330
|
+
|
|
3331
|
+
// Voice reply control (COS-63.1). Standalone block: media events don't
|
|
3332
|
+
// bubble, so listen in the capture phase on document — that also covers
|
|
3333
|
+
// bubbles inserted later by polling with no per-node wiring.
|
|
3334
|
+
const voiceScript = `
|
|
3335
|
+
<script>
|
|
3336
|
+
(function() {
|
|
3337
|
+
function fmt(t) {
|
|
3338
|
+
if (!isFinite(t) || t < 0) t = 0;
|
|
3339
|
+
var m = Math.floor(t / 60), s = Math.floor(t % 60);
|
|
3340
|
+
return m + ':' + (s < 10 ? '0' : '') + s;
|
|
3341
|
+
}
|
|
3342
|
+
function root(el) { return el && el.closest ? el.closest('[data-voice]') : null; }
|
|
3343
|
+
function paint(audio) {
|
|
3344
|
+
var box = root(audio);
|
|
3345
|
+
if (!box) return;
|
|
3346
|
+
var dur = audio.duration, cur = audio.currentTime || 0;
|
|
3347
|
+
box.querySelector('.chat-voice-time').textContent = fmt(cur) + ' / ' + fmt(dur);
|
|
3348
|
+
box.querySelector('.chat-voice-bar').style.width = (isFinite(dur) && dur > 0 ? Math.min(100, cur / dur * 100) : 0) + '%';
|
|
3349
|
+
}
|
|
3350
|
+
function setPlaying(audio, playing) {
|
|
3351
|
+
var box = root(audio);
|
|
3352
|
+
if (!box) return;
|
|
3353
|
+
var btn = box.querySelector('.chat-voice-play');
|
|
3354
|
+
btn.innerHTML = playing ? '❚❚' : '▶';
|
|
3355
|
+
btn.setAttribute('aria-label', playing ? 'Pause' : 'Play');
|
|
3356
|
+
}
|
|
3357
|
+
function showError(audio) {
|
|
3358
|
+
var box = root(audio);
|
|
3359
|
+
if (!box) return;
|
|
3360
|
+
box.querySelector('.chat-voice-play').hidden = true;
|
|
3361
|
+
box.querySelector('.chat-voice-meta').hidden = true;
|
|
3362
|
+
box.querySelector('.chat-voice-error').hidden = false;
|
|
3363
|
+
}
|
|
3364
|
+
['timeupdate', 'loadedmetadata', 'durationchange'].forEach(function(ev) {
|
|
3365
|
+
document.addEventListener(ev, function(e) { if (e.target.tagName === 'AUDIO') paint(e.target); }, true);
|
|
3366
|
+
});
|
|
3367
|
+
document.addEventListener('play', function(e) { if (e.target.tagName === 'AUDIO') setPlaying(e.target, true); }, true);
|
|
3368
|
+
document.addEventListener('pause', function(e) { if (e.target.tagName === 'AUDIO') setPlaying(e.target, false); }, true);
|
|
3369
|
+
document.addEventListener('ended', function(e) {
|
|
3370
|
+
if (e.target.tagName !== 'AUDIO') return;
|
|
3371
|
+
setPlaying(e.target, false);
|
|
3372
|
+
paint(e.target);
|
|
3373
|
+
}, true);
|
|
3374
|
+
document.addEventListener('error', function(e) { if (e.target.tagName === 'AUDIO' && root(e.target)) showError(e.target); }, true);
|
|
3375
|
+
document.addEventListener('click', function(e) {
|
|
3376
|
+
var btn = e.target.closest ? e.target.closest('.chat-voice-play') : null;
|
|
3377
|
+
var box = root(btn);
|
|
3378
|
+
if (!box) return;
|
|
3379
|
+
var audio = box.querySelector('audio');
|
|
3380
|
+
if (!audio) return;
|
|
3381
|
+
if (!audio.paused) { audio.pause(); return; }
|
|
3382
|
+
var others = document.querySelectorAll('[data-voice] audio');
|
|
3383
|
+
for (var i = 0; i < others.length; i++) if (others[i] !== audio) others[i].pause();
|
|
3384
|
+
var p;
|
|
3385
|
+
try { p = audio.play(); } catch (err) { showError(audio); return; }
|
|
3386
|
+
if (p && p.catch) p.catch(function() { if (audio.error) showError(audio); });
|
|
3387
|
+
});
|
|
3388
|
+
})();
|
|
3438
3389
|
</script>`;
|
|
3439
3390
|
|
|
3440
3391
|
// Standalone drawer + keyboard-inset script (CFB-3.2). Deliberately emitted
|
|
@@ -3603,14 +3554,6 @@ export function renderChatThreadPage(
|
|
|
3603
3554
|
aria-label="Record a voice message"
|
|
3604
3555
|
title="Record a voice message"
|
|
3605
3556
|
>🎤</button>
|
|
3606
|
-
<button
|
|
3607
|
-
type="button"
|
|
3608
|
-
id="autoplay-btn"
|
|
3609
|
-
class="btn btn-secondary chat-composer-btn chat-composer-btn--autoplay"
|
|
3610
|
-
aria-label="Auto-play voice replies"
|
|
3611
|
-
aria-pressed="false"
|
|
3612
|
-
title="Auto-play voice replies: off"
|
|
3613
|
-
>🔇</button>
|
|
3614
3557
|
<button
|
|
3615
3558
|
type="button"
|
|
3616
3559
|
id="attach-btn"
|
|
@@ -3627,6 +3570,6 @@ export function renderChatThreadPage(
|
|
|
3627
3570
|
</div>
|
|
3628
3571
|
</div>
|
|
3629
3572
|
</div>`,
|
|
3630
|
-
bodyEnd: `${inlineScript}\n${drawerScript}`,
|
|
3573
|
+
bodyEnd: `${inlineScript}\n${voiceScript}\n${drawerScript}`,
|
|
3631
3574
|
});
|
|
3632
3575
|
}
|
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
type MemberItem,
|
|
17
17
|
type PluginItem,
|
|
18
18
|
renderAddAnotherOrgAction,
|
|
19
|
-
AUTOPLAY_STORAGE_KEY_PREFIX,
|
|
20
19
|
renderAgentDetailPage,
|
|
21
20
|
renderAgentsPage,
|
|
22
21
|
renderChatMessageBubble,
|
|
@@ -3497,6 +3496,88 @@ describe("renderChatMessageBubble — audio attachment vs. filename badge (VM-3.
|
|
|
3497
3496
|
});
|
|
3498
3497
|
});
|
|
3499
3498
|
|
|
3499
|
+
describe("renderChatMessageBubble — voice reply play control (COS-63.1)", () => {
|
|
3500
|
+
const MSG: ChatMessage = {
|
|
3501
|
+
id: "msg-63",
|
|
3502
|
+
threadId: "thread-63",
|
|
3503
|
+
role: "assistant",
|
|
3504
|
+
body: "hello there",
|
|
3505
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
3506
|
+
claimedBy: null,
|
|
3507
|
+
repliedAt: null,
|
|
3508
|
+
tokens: null,
|
|
3509
|
+
costUsd: null,
|
|
3510
|
+
errorKind: null,
|
|
3511
|
+
attachmentFilename: "1700000000000-response.wav",
|
|
3512
|
+
attachmentSize: 10,
|
|
3513
|
+
};
|
|
3514
|
+
const URL63 =
|
|
3515
|
+
"/admin/chat/agent-1/threads/thread-63/messages/msg-63/attachment";
|
|
3516
|
+
|
|
3517
|
+
test("assistant audio renders a 56px labelled play button, time, progress and a hidden audio", () => {
|
|
3518
|
+
const html = renderChatMessageBubble(MSG, null, "agent-1");
|
|
3519
|
+
expect(html).toContain("chat-voice-play");
|
|
3520
|
+
expect(html).toContain('aria-label="Play"');
|
|
3521
|
+
expect(html).toContain("chat-voice-time");
|
|
3522
|
+
expect(html).toContain("chat-voice-progress");
|
|
3523
|
+
expect(html).toMatch(/<audio[^>]*hidden[^>]*>/);
|
|
3524
|
+
expect(html).not.toMatch(/<audio[^>]*controls/);
|
|
3525
|
+
expect(html).toContain(URL63);
|
|
3526
|
+
});
|
|
3527
|
+
|
|
3528
|
+
test("assistant transcript body is still rendered above the control", () => {
|
|
3529
|
+
const html = renderChatMessageBubble(MSG, null, "agent-1");
|
|
3530
|
+
expect(html.indexOf("hello there")).toBeLessThan(
|
|
3531
|
+
html.indexOf("chat-voice-play"),
|
|
3532
|
+
);
|
|
3533
|
+
});
|
|
3534
|
+
|
|
3535
|
+
test("includes an error fallback with a download link and message", () => {
|
|
3536
|
+
const html = renderChatMessageBubble(MSG, null, "agent-1");
|
|
3537
|
+
expect(html).toContain("Could not play this reply");
|
|
3538
|
+
expect(html).toMatch(
|
|
3539
|
+
new RegExp(
|
|
3540
|
+
`<a[^>]*download[^>]*href="${URL63}"|<a[^>]*href="${URL63}"[^>]*download`,
|
|
3541
|
+
),
|
|
3542
|
+
);
|
|
3543
|
+
});
|
|
3544
|
+
|
|
3545
|
+
test("user audio keeps native controls and no custom control", () => {
|
|
3546
|
+
const html = renderChatMessageBubble(
|
|
3547
|
+
{ ...MSG, role: "user" },
|
|
3548
|
+
null,
|
|
3549
|
+
"agent-1",
|
|
3550
|
+
);
|
|
3551
|
+
expect(html).toMatch(/<audio[^>]*controls/);
|
|
3552
|
+
expect(html).not.toContain("chat-voice-play");
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
test("thread page ships the 56px CSS and delegated click handler", () => {
|
|
3556
|
+
const html = renderChatThreadPage(
|
|
3557
|
+
"agent-1",
|
|
3558
|
+
{ id: "thread-63", title: "t" } as never,
|
|
3559
|
+
[MSG],
|
|
3560
|
+
"alice",
|
|
3561
|
+
);
|
|
3562
|
+
expect(html).toMatch(/\.chat-voice-play\s*\{[^}]*width:56px/);
|
|
3563
|
+
expect(html).toContain("chat-voice-play");
|
|
3564
|
+
});
|
|
3565
|
+
|
|
3566
|
+
test("chat stylesheet has no orphaned comment text before .chat-voice", () => {
|
|
3567
|
+
const html = renderChatThreadPage(
|
|
3568
|
+
"agent-1",
|
|
3569
|
+
{ id: "thread-63", title: "t" } as never,
|
|
3570
|
+
[MSG],
|
|
3571
|
+
"alice",
|
|
3572
|
+
);
|
|
3573
|
+
// A stray comment tail would swallow the next rule's selector.
|
|
3574
|
+
expect(html).not.toContain("will speak on their own");
|
|
3575
|
+
expect(html).toMatch(
|
|
3576
|
+
/\}\s*\/\*[^*]*\*\/\s*\.chat-voice\s*\{[^}]*display:flex/,
|
|
3577
|
+
);
|
|
3578
|
+
});
|
|
3579
|
+
});
|
|
3580
|
+
|
|
3500
3581
|
describe("renderChatThreadPage — CFB-3.2 mobile drawer", () => {
|
|
3501
3582
|
const THREAD: ChatThread = {
|
|
3502
3583
|
id: "thread-abc",
|
|
@@ -4321,18 +4402,9 @@ describe("queue-activity links (AEP-3.6)", () => {
|
|
|
4321
4402
|
|
|
4322
4403
|
describe("renderAgentDetailPage — signed commits toggle", () => {
|
|
4323
4404
|
const render = (envVars: Record<string, string>) =>
|
|
4324
|
-
renderAgentDetailPage(
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
[],
|
|
4328
|
-
[],
|
|
4329
|
-
[],
|
|
4330
|
-
[],
|
|
4331
|
-
[],
|
|
4332
|
-
USER_NAME,
|
|
4333
|
-
true,
|
|
4334
|
-
{ timezone: "UTC" },
|
|
4335
|
-
);
|
|
4405
|
+
renderAgentDetailPage(AGENT, envVars, [], [], [], [], [], USER_NAME, true, {
|
|
4406
|
+
timezone: "UTC",
|
|
4407
|
+
});
|
|
4336
4408
|
|
|
4337
4409
|
test("hidden when the agent has no GH_APP_ID", () => {
|
|
4338
4410
|
expect(render({ SHIPWRIGHT_SIGNED_COMMITS: "1" })).not.toContain(
|
|
@@ -4354,211 +4426,3 @@ describe("renderAgentDetailPage — signed commits toggle", () => {
|
|
|
4354
4426
|
expect(checkbox).toContain("checked");
|
|
4355
4427
|
});
|
|
4356
4428
|
});
|
|
4357
|
-
|
|
4358
|
-
// ─── Auto-play voice replies toggle + phone mic sizing ───────────────────────
|
|
4359
|
-
|
|
4360
|
-
describe("renderChatThreadPage — auto-play voice replies toggle", () => {
|
|
4361
|
-
const THREAD: ChatThread = {
|
|
4362
|
-
id: "thread-ap",
|
|
4363
|
-
agentId: "agent-ap",
|
|
4364
|
-
title: "Voice Thread",
|
|
4365
|
-
memberId: null,
|
|
4366
|
-
createdAt: "2024-01-01T00:00:00.000Z",
|
|
4367
|
-
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
4368
|
-
};
|
|
4369
|
-
|
|
4370
|
-
test("renders an aria-pressed auto-play toggle right after the mic button", () => {
|
|
4371
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4372
|
-
const micIdx = html.indexOf('id="mic-btn"');
|
|
4373
|
-
const toggleIdx = html.indexOf('id="autoplay-btn"');
|
|
4374
|
-
const attachIdx = html.indexOf('id="attach-btn"');
|
|
4375
|
-
expect(micIdx).toBeGreaterThan(-1);
|
|
4376
|
-
expect(toggleIdx).toBeGreaterThan(micIdx);
|
|
4377
|
-
expect(attachIdx).toBeGreaterThan(toggleIdx);
|
|
4378
|
-
expect(html).toContain('aria-pressed="false"');
|
|
4379
|
-
expect(html).toContain('aria-label="Auto-play voice replies"');
|
|
4380
|
-
});
|
|
4381
|
-
|
|
4382
|
-
test("inline script carries the per-thread localStorage key prefix", () => {
|
|
4383
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4384
|
-
expect(html).toContain(JSON.stringify(AUTOPLAY_STORAGE_KEY_PREFIX));
|
|
4385
|
-
expect(html).toContain("maybeAutoplay(newAssistantNodes)");
|
|
4386
|
-
expect(html).toContain("data:audio/wav;base64,");
|
|
4387
|
-
});
|
|
4388
|
-
|
|
4389
|
-
test("phone breakpoint grows the mic and toggle to 56px round buttons", () => {
|
|
4390
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4391
|
-
expect(html).toContain(
|
|
4392
|
-
".chat-composer-btn--mic, .chat-composer-btn--autoplay { width:56px;height:56px;padding:0;border-radius:50%",
|
|
4393
|
-
);
|
|
4394
|
-
expect(html).toContain("@keyframes chat-mic-pulse");
|
|
4395
|
-
// Desktop rule stays the plain 44px text button.
|
|
4396
|
-
expect(html).toContain(".chat-composer-btn--mic { padding:0 16px }");
|
|
4397
|
-
});
|
|
4398
|
-
|
|
4399
|
-
// Runs the inline script against stub DOM/fetch/storage (no globals touched)
|
|
4400
|
-
// and fires the first poll with one new message; returns the audio stub's
|
|
4401
|
-
// play() call count.
|
|
4402
|
-
async function bootAndPoll(opts: {
|
|
4403
|
-
stored: string | null;
|
|
4404
|
-
role: "assistant" | "user";
|
|
4405
|
-
withAudio: boolean;
|
|
4406
|
-
seededInDom?: boolean;
|
|
4407
|
-
}): Promise<{ plays: number; storedKeys: string[] }> {
|
|
4408
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4409
|
-
const script = html.match(
|
|
4410
|
-
/<script>\n\(function\(\) \{[\s\S]*?\n<\/script>/,
|
|
4411
|
-
);
|
|
4412
|
-
if (!script) throw new Error("inline chat script not found");
|
|
4413
|
-
const code = script[0].replace(/^<script>/, "").replace(/<\/script>$/, "");
|
|
4414
|
-
|
|
4415
|
-
let plays = 0;
|
|
4416
|
-
const audioStub = {
|
|
4417
|
-
play() {
|
|
4418
|
-
plays++;
|
|
4419
|
-
return Promise.resolve();
|
|
4420
|
-
},
|
|
4421
|
-
addEventListener() {},
|
|
4422
|
-
getAttribute: () => "/attachment",
|
|
4423
|
-
currentSrc: "",
|
|
4424
|
-
parentNode: null,
|
|
4425
|
-
};
|
|
4426
|
-
const makeEl = (): Record<string, unknown> => ({
|
|
4427
|
-
style: {},
|
|
4428
|
-
classList: { add() {}, remove() {}, toggle() {} },
|
|
4429
|
-
addEventListener() {},
|
|
4430
|
-
setAttribute() {},
|
|
4431
|
-
getAttribute: () => null,
|
|
4432
|
-
appendChild() {},
|
|
4433
|
-
insertBefore() {},
|
|
4434
|
-
querySelectorAll: () => [],
|
|
4435
|
-
querySelector: () => null,
|
|
4436
|
-
});
|
|
4437
|
-
const bubbleNode = {
|
|
4438
|
-
...makeEl(),
|
|
4439
|
-
querySelector: (sel: string) =>
|
|
4440
|
-
sel === "audio" && opts.withAudio ? audioStub : null,
|
|
4441
|
-
};
|
|
4442
|
-
const seeded = opts.seededInDom
|
|
4443
|
-
? [{ getAttribute: () => "msg-new" }]
|
|
4444
|
-
: [];
|
|
4445
|
-
const container = {
|
|
4446
|
-
...makeEl(),
|
|
4447
|
-
scrollTop: 0,
|
|
4448
|
-
scrollHeight: 1000,
|
|
4449
|
-
clientHeight: 400,
|
|
4450
|
-
querySelectorAll: () => seeded,
|
|
4451
|
-
};
|
|
4452
|
-
const document = {
|
|
4453
|
-
getElementById: (id: string) =>
|
|
4454
|
-
id === "messages-container"
|
|
4455
|
-
? container
|
|
4456
|
-
: id === "live-status-bubble"
|
|
4457
|
-
? null
|
|
4458
|
-
: makeEl(),
|
|
4459
|
-
createElement: (tag: string) =>
|
|
4460
|
-
tag === "template"
|
|
4461
|
-
? { innerHTML: "", content: { firstChild: bubbleNode } }
|
|
4462
|
-
: makeEl(),
|
|
4463
|
-
querySelectorAll: () => [],
|
|
4464
|
-
};
|
|
4465
|
-
const storage = new Map<string, string>();
|
|
4466
|
-
if (opts.stored !== null)
|
|
4467
|
-
storage.set(`${AUTOPLAY_STORAGE_KEY_PREFIX}thread-ap`, opts.stored);
|
|
4468
|
-
const window = {
|
|
4469
|
-
addEventListener() {},
|
|
4470
|
-
localStorage: {
|
|
4471
|
-
getItem: (k: string) => storage.get(k) ?? null,
|
|
4472
|
-
setItem: (k: string, v: string) => storage.set(k, v),
|
|
4473
|
-
removeItem: (k: string) => storage.delete(k),
|
|
4474
|
-
},
|
|
4475
|
-
};
|
|
4476
|
-
const fetchStub = () =>
|
|
4477
|
-
Promise.resolve({
|
|
4478
|
-
json: () =>
|
|
4479
|
-
Promise.resolve({
|
|
4480
|
-
messages: [
|
|
4481
|
-
{
|
|
4482
|
-
id: "msg-new",
|
|
4483
|
-
role: opts.role,
|
|
4484
|
-
body: "",
|
|
4485
|
-
repliedAt: "2024-01-01T00:00:01.000Z",
|
|
4486
|
-
bubbleHtml: "<div><audio></audio></div>",
|
|
4487
|
-
},
|
|
4488
|
-
],
|
|
4489
|
-
}),
|
|
4490
|
-
});
|
|
4491
|
-
const timers: Array<() => void> = [];
|
|
4492
|
-
new Function(
|
|
4493
|
-
"document",
|
|
4494
|
-
"fetch",
|
|
4495
|
-
"setTimeout",
|
|
4496
|
-
"clearTimeout",
|
|
4497
|
-
"setInterval",
|
|
4498
|
-
"clearInterval",
|
|
4499
|
-
"window",
|
|
4500
|
-
code,
|
|
4501
|
-
)(
|
|
4502
|
-
document,
|
|
4503
|
-
fetchStub,
|
|
4504
|
-
(fn: () => void) => {
|
|
4505
|
-
timers.push(fn);
|
|
4506
|
-
return timers.length;
|
|
4507
|
-
},
|
|
4508
|
-
() => {},
|
|
4509
|
-
() => 0,
|
|
4510
|
-
() => {},
|
|
4511
|
-
window,
|
|
4512
|
-
);
|
|
4513
|
-
timers[timers.length - 1]();
|
|
4514
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
4515
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
4516
|
-
return { plays, storedKeys: [...storage.keys()] };
|
|
4517
|
-
}
|
|
4518
|
-
|
|
4519
|
-
test("switch on + new assistant audio bubble -> plays it once", async () => {
|
|
4520
|
-
const { plays } = await bootAndPoll({
|
|
4521
|
-
stored: "1",
|
|
4522
|
-
role: "assistant",
|
|
4523
|
-
withAudio: true,
|
|
4524
|
-
});
|
|
4525
|
-
expect(plays).toBe(1);
|
|
4526
|
-
});
|
|
4527
|
-
|
|
4528
|
-
test("switch off -> a new assistant audio bubble is not played", async () => {
|
|
4529
|
-
const { plays } = await bootAndPoll({
|
|
4530
|
-
stored: null,
|
|
4531
|
-
role: "assistant",
|
|
4532
|
-
withAudio: true,
|
|
4533
|
-
});
|
|
4534
|
-
expect(plays).toBe(0);
|
|
4535
|
-
});
|
|
4536
|
-
|
|
4537
|
-
test("switch on but the new bubble is the user's own recording -> not played", async () => {
|
|
4538
|
-
const { plays } = await bootAndPoll({
|
|
4539
|
-
stored: "1",
|
|
4540
|
-
role: "user",
|
|
4541
|
-
withAudio: true,
|
|
4542
|
-
});
|
|
4543
|
-
expect(plays).toBe(0);
|
|
4544
|
-
});
|
|
4545
|
-
|
|
4546
|
-
test("switch on but the message was already in the DOM at load -> never replayed", async () => {
|
|
4547
|
-
const { plays } = await bootAndPoll({
|
|
4548
|
-
stored: "1",
|
|
4549
|
-
role: "assistant",
|
|
4550
|
-
withAudio: true,
|
|
4551
|
-
seededInDom: true,
|
|
4552
|
-
});
|
|
4553
|
-
expect(plays).toBe(0);
|
|
4554
|
-
});
|
|
4555
|
-
|
|
4556
|
-
test("switch on but the assistant bubble has no audio -> no-op", async () => {
|
|
4557
|
-
const { plays } = await bootAndPoll({
|
|
4558
|
-
stored: "1",
|
|
4559
|
-
role: "assistant",
|
|
4560
|
-
withAudio: false,
|
|
4561
|
-
});
|
|
4562
|
-
expect(plays).toBe(0);
|
|
4563
|
-
});
|
|
4564
|
-
});
|