@app-vitals/agent-admin 0.10.0 → 0.11.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/package.json +2 -2
- package/src/admin-ui-pages.ts +96 -1
- package/src/admin-ui-pages.unit.test.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.
|
|
46
|
+
"@app-vitals/agent-lib": "0.11.0",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -2808,6 +2808,11 @@ export function renderChatThreadPage(
|
|
|
2808
2808
|
);
|
|
2809
2809
|
const signedOutBanner = `<a id="signed-out-banner" class="chat-signed-out-banner" href="/admin/login?returnTo=${signedOutReturnTo}" role="alert" style="display:none;padding:10px 14px;margin-bottom:8px;background:#fef3c7;color:#92400e;border:1px solid #f59e0b;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;text-align:center">You're signed out. Tap to sign in.</a>`;
|
|
2810
2810
|
|
|
2811
|
+
// Held-upload prompt (ASX-1.3): hidden until the thread loads with a
|
|
2812
|
+
// voice note / file that failed on a 401 and is waiting in IndexedDB.
|
|
2813
|
+
// Never auto-sent; the user chooses Resend or Discard.
|
|
2814
|
+
const heldUploadPrompt = `<div id="held-upload-prompt" class="chat-held-upload" role="status" style="display:none;padding:10px 14px;margin-bottom:8px;background:#eff6ff;color:#1e3a8a;border:1px solid #93c5fd;border-radius:8px;font-size:14px"><span>You have an unsent voice note.</span> <button type="button" id="held-upload-resend" style="margin-left:8px;padding:4px 10px;border-radius:6px;border:1px solid #1e3a8a;background:#1e3a8a;color:#fff;cursor:pointer">Resend</button> <button type="button" id="held-upload-discard" style="margin-left:4px;padding:4px 10px;border-radius:6px;border:1px solid #93c5fd;background:#fff;color:#1e3a8a;cursor:pointer">Discard</button></div>`;
|
|
2815
|
+
|
|
2811
2816
|
const renameForm = `
|
|
2812
2817
|
<form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/rename" style="margin-top:12px">
|
|
2813
2818
|
<div class="form-row" style="align-items:center;gap:8px">
|
|
@@ -3093,6 +3098,76 @@ export function renderChatThreadPage(
|
|
|
3093
3098
|
sendBtn.disabled = true;
|
|
3094
3099
|
if (micBtn) micBtn.disabled = true;
|
|
3095
3100
|
if (signedOutBanner) signedOutBanner.style.display = 'block';
|
|
3101
|
+
hideHeldPrompt();
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// Held uploads (ASX-1.3): a voice note / file whose upload hit a 401 is
|
|
3105
|
+
// kept in IndexedDB, keyed by agent+thread (one record per thread, the most
|
|
3106
|
+
// recent wins). Every call is wrapped so a missing or failing IndexedDB
|
|
3107
|
+
// (private mode, quota) silently degrades to the plain signed-out banner.
|
|
3108
|
+
var HELD_DB = 'chat-held-uploads';
|
|
3109
|
+
var HELD_STORE = 'held-uploads';
|
|
3110
|
+
var heldKey = agentId + ':' + threadId;
|
|
3111
|
+
var heldPrompt = document.getElementById('held-upload-prompt');
|
|
3112
|
+
function openHeldDb(cb) {
|
|
3113
|
+
try {
|
|
3114
|
+
if (!window.indexedDB) { cb(null); return; }
|
|
3115
|
+
var req = indexedDB.open(HELD_DB, 1);
|
|
3116
|
+
req.onupgradeneeded = function() {
|
|
3117
|
+
try { req.result.createObjectStore(HELD_STORE); } catch (e) {}
|
|
3118
|
+
};
|
|
3119
|
+
req.onsuccess = function() { cb(req.result); };
|
|
3120
|
+
req.onerror = function() { cb(null); };
|
|
3121
|
+
req.onblocked = function() { cb(null); };
|
|
3122
|
+
} catch (e) { cb(null); }
|
|
3123
|
+
}
|
|
3124
|
+
function heldOp(mode, run, done) {
|
|
3125
|
+
openHeldDb(function(db) {
|
|
3126
|
+
if (!db) { done(null); return; }
|
|
3127
|
+
var result = null;
|
|
3128
|
+
try {
|
|
3129
|
+
var tx = db.transaction(HELD_STORE, mode);
|
|
3130
|
+
var req = run(tx.objectStore(HELD_STORE));
|
|
3131
|
+
if (req) req.onsuccess = function() { result = req.result; };
|
|
3132
|
+
tx.oncomplete = function() { try { db.close(); } catch (e) {} done(result); };
|
|
3133
|
+
tx.onerror = tx.onabort = function() { try { db.close(); } catch (e) {} done(null); };
|
|
3134
|
+
} catch (e) {
|
|
3135
|
+
try { db.close(); } catch (e2) {}
|
|
3136
|
+
done(null);
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
function holdUpload(file, then) {
|
|
3141
|
+
var called = false;
|
|
3142
|
+
function finish() { if (!called) { called = true; if (then) then(); } }
|
|
3143
|
+
try {
|
|
3144
|
+
heldOp('readwrite', function(store) {
|
|
3145
|
+
return store.put({ file: file, name: file.name, type: file.type, savedAt: Date.now() }, heldKey);
|
|
3146
|
+
}, finish);
|
|
3147
|
+
} catch (e) { finish(); }
|
|
3148
|
+
}
|
|
3149
|
+
function loadHeld(cb) {
|
|
3150
|
+
try {
|
|
3151
|
+
heldOp('readonly', function(store) { return store.get(heldKey); }, function(rec) {
|
|
3152
|
+
try { cb(rec && rec.file ? rec : null); } catch (e) {}
|
|
3153
|
+
});
|
|
3154
|
+
} catch (e) { cb(null); }
|
|
3155
|
+
}
|
|
3156
|
+
function deleteHeld(then) {
|
|
3157
|
+
var called = false;
|
|
3158
|
+
function finish() { if (!called) { called = true; if (then) then(); } }
|
|
3159
|
+
try {
|
|
3160
|
+
heldOp('readwrite', function(store) { return store.delete(heldKey); }, finish);
|
|
3161
|
+
} catch (e) { finish(); }
|
|
3162
|
+
}
|
|
3163
|
+
function hideHeldPrompt() {
|
|
3164
|
+
if (heldPrompt) heldPrompt.style.display = 'none';
|
|
3165
|
+
}
|
|
3166
|
+
function showHeldPromptIfAny() {
|
|
3167
|
+
if (signedOut || !heldPrompt) return;
|
|
3168
|
+
loadHeld(function(rec) {
|
|
3169
|
+
if (rec && !signedOut) heldPrompt.style.display = 'block';
|
|
3170
|
+
});
|
|
3096
3171
|
}
|
|
3097
3172
|
|
|
3098
3173
|
// Cheap probe so an app left open learns it is signed out as soon as the
|
|
@@ -3319,7 +3394,8 @@ export function renderChatThreadPage(
|
|
|
3319
3394
|
if (r.status === 401) {
|
|
3320
3395
|
// Keep the unsent text so it survives the sign-in round trip.
|
|
3321
3396
|
if (text) input.value = text;
|
|
3322
|
-
|
|
3397
|
+
if (file) holdUpload(file, showSignedOut);
|
|
3398
|
+
else showSignedOut();
|
|
3323
3399
|
return;
|
|
3324
3400
|
}
|
|
3325
3401
|
if (!r.ok) {
|
|
@@ -3367,6 +3443,24 @@ export function renderChatThreadPage(
|
|
|
3367
3443
|
});
|
|
3368
3444
|
|
|
3369
3445
|
input.addEventListener('input', function() { saveDraft(input.value.trim()); });
|
|
3446
|
+
|
|
3447
|
+
// Held-upload prompt actions (ASX-1.3). Resend goes through sendText; the
|
|
3448
|
+
// record is deleted first so a repeat 401 simply re-holds it.
|
|
3449
|
+
var heldResendBtn = document.getElementById('held-upload-resend');
|
|
3450
|
+
var heldDiscardBtn = document.getElementById('held-upload-discard');
|
|
3451
|
+
if (heldResendBtn) heldResendBtn.addEventListener('click', function() {
|
|
3452
|
+
if (signedOut) return;
|
|
3453
|
+
loadHeld(function(rec) {
|
|
3454
|
+
hideHeldPrompt();
|
|
3455
|
+
if (!rec) return;
|
|
3456
|
+
deleteHeld(function() { sendText('', rec.file); });
|
|
3457
|
+
});
|
|
3458
|
+
});
|
|
3459
|
+
if (heldDiscardBtn) heldDiscardBtn.addEventListener('click', function() {
|
|
3460
|
+
hideHeldPrompt();
|
|
3461
|
+
deleteHeld();
|
|
3462
|
+
});
|
|
3463
|
+
showHeldPromptIfAny();
|
|
3370
3464
|
var restoredDraft = loadDraft();
|
|
3371
3465
|
if (restoredDraft && !input.value) input.value = restoredDraft;
|
|
3372
3466
|
|
|
@@ -3614,6 +3708,7 @@ export function renderChatThreadPage(
|
|
|
3614
3708
|
${drawerScrim}
|
|
3615
3709
|
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
|
|
3616
3710
|
${signedOutBanner}
|
|
3711
|
+
${heldUploadPrompt}
|
|
3617
3712
|
<!-- Messages area (scrollable) -->
|
|
3618
3713
|
<div id="messages-container" class="chat-messages-container">
|
|
3619
3714
|
${messageBubbles}
|
|
@@ -3115,6 +3115,42 @@ describe("renderChatThreadPage", () => {
|
|
|
3115
3115
|
expect(html).toContain("clearDraft()");
|
|
3116
3116
|
});
|
|
3117
3117
|
});
|
|
3118
|
+
|
|
3119
|
+
describe("held voice notes (ASX-1.3)", () => {
|
|
3120
|
+
const page = () =>
|
|
3121
|
+
renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
|
|
3122
|
+
|
|
3123
|
+
test("includes the IndexedDB held-upload store keyed by agent+thread", () => {
|
|
3124
|
+
const html = page();
|
|
3125
|
+
expect(html).toContain("indexedDB.open(HELD_DB");
|
|
3126
|
+
expect(html).toContain("var heldKey = agentId + ':' + threadId");
|
|
3127
|
+
expect(html).toContain("function holdUpload(");
|
|
3128
|
+
expect(html).toContain("function loadHeld(");
|
|
3129
|
+
expect(html).toContain("function deleteHeld(");
|
|
3130
|
+
});
|
|
3131
|
+
|
|
3132
|
+
test("holds the file on a 401 before showing the signed-out banner", () => {
|
|
3133
|
+
const html = page();
|
|
3134
|
+
expect(html).toContain("if (file) holdUpload(file, showSignedOut);");
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
test("renders a hidden Resend/Discard prompt that never auto-sends", () => {
|
|
3138
|
+
const html = page();
|
|
3139
|
+
expect(html).toContain('id="held-upload-prompt"');
|
|
3140
|
+
expect(html).toContain("You have an unsent voice note.");
|
|
3141
|
+
expect(html).toContain('id="held-upload-resend"');
|
|
3142
|
+
expect(html).toContain('id="held-upload-discard"');
|
|
3143
|
+
expect(html).toContain("sendText('', rec.file)");
|
|
3144
|
+
// Only the resend click handler sends a held file.
|
|
3145
|
+
expect(html.match(/sendText\('', rec\.file\)/g)?.length).toBe(1);
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
test("every IndexedDB access is guarded so failures are silent", () => {
|
|
3149
|
+
const html = page();
|
|
3150
|
+
expect(html).toContain("if (!window.indexedDB)");
|
|
3151
|
+
expect(html).toContain("req.onerror = function() { cb(null); }");
|
|
3152
|
+
});
|
|
3153
|
+
});
|
|
3118
3154
|
});
|
|
3119
3155
|
|
|
3120
3156
|
// ─── renderChatThreadPage — inline styles moved to classes (CFB-1.3) ────────
|