a2acalling 0.6.1 → 0.6.3

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,6 +1,11 @@
1
1
  const state = {
2
2
  settings: null,
3
+ dashboardStatus: null,
4
+ callbookDevices: [],
3
5
  contacts: [],
6
+ selectedContactId: null,
7
+ selectedContactCalls: [],
8
+ contactCallResult: null,
4
9
  calls: [],
5
10
  invites: [],
6
11
  logs: [],
@@ -58,6 +63,32 @@ function esc(text) {
58
63
  .replaceAll("'", ''');
59
64
  }
60
65
 
66
+ async function copyText(value) {
67
+ const text = String(value || '');
68
+ if (!text) return false;
69
+ try {
70
+ if (navigator.clipboard && navigator.clipboard.writeText) {
71
+ await navigator.clipboard.writeText(text);
72
+ return true;
73
+ }
74
+ } catch (err) {
75
+ // fall back
76
+ }
77
+ try {
78
+ const ta = document.createElement('textarea');
79
+ ta.value = text;
80
+ ta.style.position = 'fixed';
81
+ ta.style.left = '-9999px';
82
+ document.body.appendChild(ta);
83
+ ta.select();
84
+ document.execCommand('copy');
85
+ document.body.removeChild(ta);
86
+ return true;
87
+ } catch (err) {
88
+ return false;
89
+ }
90
+ }
91
+
61
92
  function bindTabs() {
62
93
  const activateTab = (tab, options = {}) => {
63
94
  const target = String(tab || '').replace(/^#/, '').trim();
@@ -91,27 +122,339 @@ function bindTabs() {
91
122
  activateTab(window.location.hash);
92
123
  }
93
124
 
125
+ function norm(value) {
126
+ return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
127
+ }
128
+
129
+ function getLocalOwnerName() {
130
+ return state.dashboardStatus?.agent?.owner_name || state.dashboardStatus?.agent?.ownerName || '';
131
+ }
132
+
133
+ function isMine(contact) {
134
+ return Boolean(contact?.is_mine);
135
+ }
136
+
137
+ function formatLocation(contact) {
138
+ const host = String(contact?.host || contact?.web_address || '').trim();
139
+ const server = String(contact?.server_name || contact?.serverName || '').trim();
140
+ if (server && host && norm(server) !== norm(host)) {
141
+ return `${server} (${host})`;
142
+ }
143
+ return server || host || '-';
144
+ }
145
+
146
+ function contactLabel(contact) {
147
+ return String(contact?.name || '').trim() || String(contact?.host || '').trim() || '-';
148
+ }
149
+
94
150
  function renderContacts() {
95
- const tbody = document.querySelector('#contacts-table tbody');
96
- tbody.innerHTML = '';
97
- state.contacts.forEach(contact => {
98
- const tr = document.createElement('tr');
99
- tr.innerHTML = `
100
- <td>${contact.name || '-'}</td>
101
- <td>${contact.owner || '-'}</td>
102
- <td>${contact.status || '-'}</td>
103
- <td>${contact.call_count || 0}</td>
104
- <td>${(contact.last_summary || contact.last_owner_summary || '-').slice(0, 120)}</td>
105
- `;
106
- tr.addEventListener('click', () => loadCallsForContact(contact.id, contact.name));
107
- tbody.appendChild(tr);
151
+ const el = document.getElementById('contacts-sections');
152
+ if (!el) return;
153
+
154
+ const contacts = Array.isArray(state.contacts) ? state.contacts.slice() : [];
155
+ const selected = state.selectedContactId ? String(state.selectedContactId) : '';
156
+
157
+ const myAgents = contacts
158
+ .filter(c => isMine(c))
159
+ .sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
160
+
161
+ const lastCalled = contacts
162
+ .filter(c => c && c.last_call_at)
163
+ .sort((a, b) => String(b.last_call_at || '').localeCompare(String(a.last_call_at || '')))
164
+ .slice(0, 12);
165
+
166
+ const groups = new Map();
167
+ for (const c of contacts) {
168
+ const owner = String(c?.owner || '').trim() || '(unknown owner)';
169
+ if (!groups.has(owner)) groups.set(owner, []);
170
+ groups.get(owner).push(c);
171
+ }
172
+
173
+ const owners = Array.from(groups.keys()).sort((a, b) => {
174
+ // Keep local owner group near the top (after the dedicated "My agents" section).
175
+ const local = norm(getLocalOwnerName());
176
+ const aIsLocal = local && norm(a) === local;
177
+ const bIsLocal = local && norm(b) === local;
178
+ if (aIsLocal && !bIsLocal) return -1;
179
+ if (!aIsLocal && bIsLocal) return 1;
180
+ if (a === '(unknown owner)' && b !== '(unknown owner)') return 1;
181
+ if (a !== '(unknown owner)' && b === '(unknown owner)') return -1;
182
+ return a.localeCompare(b);
108
183
  });
184
+
185
+ const rowHtml = (c, opts = {}) => {
186
+ const canCall = Boolean(c?.can_call);
187
+ const mine = Boolean(c?.is_mine);
188
+ const lastSummary = String(c?.last_owner_summary || c?.last_summary || '').trim();
189
+ const summaryPreview = lastSummary ? lastSummary.slice(0, 120) : '-';
190
+ const lastCallAt = c?.last_call_at ? fmtDate(c.last_call_at) : '-';
191
+ const calls = Number.isFinite(c?.call_count) ? c.call_count : (c?.call_count || 0);
192
+ const isSelected = selected && String(c?.id) === selected;
193
+
194
+ const actionBits = [];
195
+ if (c?.last_call_id) {
196
+ actionBits.push(`<button data-open-call="${esc(c.last_call_id)}" type="button">Transcript</button>`);
197
+ }
198
+ actionBits.push(`<button data-toggle-mine="${esc(c.id)}" type="button">${mine ? 'Unmark mine' : 'Mark mine'}</button>`);
199
+ actionBits.push(`<button data-remove-contact="${esc(c.id)}" type="button">Remove</button>`);
200
+
201
+ const locationCell = opts.showLocation ? `<td>${esc(formatLocation(c))}</td>` : '';
202
+ const ownerCell = opts.showOwner ? `<td>${esc(c?.owner || '-')}</td>` : '';
203
+ const summaryCell = opts.showSummary ? `<td title="${esc(lastSummary)}">${esc(summaryPreview)}</td>` : '';
204
+
205
+ return `
206
+ <tr ${isSelected ? 'data-selected="1"' : ''}>
207
+ <td>
208
+ <div class="row" style="margin:0;">
209
+ <button class="btn-link" data-contact-select="${esc(c.id)}" type="button">${esc(contactLabel(c))}</button>
210
+ <button data-contact-call="${esc(c.id)}" type="button" ${canCall ? '' : 'disabled'}>Call</button>
211
+ </div>
212
+ </td>
213
+ ${locationCell}
214
+ ${ownerCell}
215
+ <td>${esc(c?.status || '-')}</td>
216
+ <td>${esc(String(calls))}</td>
217
+ <td>${esc(lastCallAt)}</td>
218
+ ${summaryCell}
219
+ <td>${actionBits.join(' ')}</td>
220
+ </tr>
221
+ `;
222
+ };
223
+
224
+ const tableHtml = (rows, opts = {}) => {
225
+ const cols = [];
226
+ cols.push('<th>Agent</th>');
227
+ if (opts.showLocation) cols.push('<th>Location</th>');
228
+ if (opts.showOwner) cols.push('<th>Owner</th>');
229
+ cols.push('<th>Status</th>');
230
+ cols.push('<th>Calls</th>');
231
+ cols.push('<th>Last Call</th>');
232
+ if (opts.showSummary) cols.push('<th>Last Summary</th>');
233
+ cols.push('<th>Action</th>');
234
+
235
+ if (!rows.length) {
236
+ return `<table><thead><tr>${cols.join('')}</tr></thead><tbody><tr><td colspan="${cols.length}">(none)</td></tr></tbody></table>`;
237
+ }
238
+
239
+ return `<table><thead><tr>${cols.join('')}</tr></thead><tbody>${rows.map(c => rowHtml(c, opts)).join('')}</tbody></table>`;
240
+ };
241
+
242
+ const myAgentsSection = `
243
+ <div class="card">
244
+ <h3>My agents</h3>
245
+ ${tableHtml(myAgents, { showLocation: true, showOwner: false, showSummary: false })}
246
+ </div>
247
+ `;
248
+
249
+ const lastCalledSection = `
250
+ <div class="card">
251
+ <h3>Last called agents</h3>
252
+ ${tableHtml(lastCalled, { showLocation: false, showOwner: true, showSummary: false })}
253
+ </div>
254
+ `;
255
+
256
+ const groupedSections = owners.map(owner => {
257
+ const rows = (groups.get(owner) || []).slice().sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
258
+ return `
259
+ <div class="card">
260
+ <h3>${esc(owner)}</h3>
261
+ ${tableHtml(rows, { showLocation: false, showOwner: false, showSummary: true })}
262
+ </div>
263
+ `;
264
+ }).join('');
265
+
266
+ el.innerHTML = `${myAgentsSection}${lastCalledSection}${groupedSections}`;
109
267
  }
110
268
 
111
269
  async function loadContacts() {
112
270
  const payload = await request('/contacts');
113
271
  state.contacts = payload.contacts || [];
114
272
  renderContacts();
273
+ renderContactDetail();
274
+ }
275
+
276
+ function bindContactsActions() {
277
+ const form = document.getElementById('add-contact-form');
278
+ if (!form) return;
279
+
280
+ const urlEl = document.getElementById('add-contact-url');
281
+ const mineEl = document.getElementById('add-contact-mine');
282
+ const serverNameEl = document.getElementById('add-contact-server-name');
283
+ const defaultServerNameFromUrl = () => {
284
+ if (!urlEl || !serverNameEl) return;
285
+ if (mineEl && !mineEl.checked) return;
286
+ if (serverNameEl.value.trim()) return;
287
+ const match = String(urlEl.value || '').trim().match(/^(?:a2a|oclaw):\\/\\/([^/]+)\\//);
288
+ if (match && match[1]) {
289
+ serverNameEl.value = match[1];
290
+ }
291
+ };
292
+ urlEl?.addEventListener('blur', defaultServerNameFromUrl);
293
+ urlEl?.addEventListener('change', defaultServerNameFromUrl);
294
+ mineEl?.addEventListener('change', () => {
295
+ if (!serverNameEl) return;
296
+ serverNameEl.disabled = !mineEl.checked;
297
+ if (mineEl.checked) {
298
+ defaultServerNameFromUrl();
299
+ }
300
+ });
301
+ if (serverNameEl && mineEl) {
302
+ serverNameEl.disabled = !mineEl.checked;
303
+ }
304
+
305
+ form.addEventListener('submit', async (e) => {
306
+ e.preventDefault();
307
+ const url = document.getElementById('add-contact-url').value.trim();
308
+ const name = document.getElementById('add-contact-name').value.trim();
309
+ const owner = document.getElementById('add-contact-owner').value.trim();
310
+ const isMine = Boolean(document.getElementById('add-contact-mine')?.checked);
311
+ const serverName = document.getElementById('add-contact-server-name').value.trim();
312
+ const tagsRaw = document.getElementById('add-contact-tags').value.trim();
313
+ const notes = document.getElementById('add-contact-notes').value.trim();
314
+ const fieldsRaw = document.getElementById('add-contact-fields').value.trim();
315
+ const tags = tagsRaw
316
+ ? tagsRaw.split(',').map(v => v.trim()).filter(Boolean).slice(0, 30)
317
+ : [];
318
+
319
+ let fields = {};
320
+ if (fieldsRaw) {
321
+ try {
322
+ const parsed = JSON.parse(fieldsRaw);
323
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
324
+ throw new Error('Fields must be a JSON object');
325
+ }
326
+ fields = parsed;
327
+ } catch (err) {
328
+ showNotice(`Fields JSON invalid: ${err.message}`);
329
+ return;
330
+ }
331
+ }
332
+
333
+ try {
334
+ await request('/contacts', {
335
+ method: 'POST',
336
+ body: JSON.stringify({
337
+ invite_url: url,
338
+ name: name || undefined,
339
+ owner: owner || undefined,
340
+ is_mine: isMine,
341
+ server_name: serverName || undefined,
342
+ tags,
343
+ notes: notes || undefined,
344
+ fields
345
+ })
346
+ });
347
+ showNotice('Contact added');
348
+ form.reset();
349
+ await loadContacts();
350
+ } catch (err) {
351
+ showNotice(err.message);
352
+ }
353
+ });
354
+
355
+ const panel = document.getElementById('tab-contacts');
356
+ panel?.addEventListener('click', async (e) => {
357
+ const selectBtn = e.target.closest('button[data-contact-select]');
358
+ if (selectBtn) {
359
+ e.preventDefault();
360
+ const id = selectBtn.dataset.contactSelect;
361
+ if (id) {
362
+ await loadCallsForContact(id);
363
+ }
364
+ return;
365
+ }
366
+
367
+ const openBtn = e.target.closest('button[data-open-call]');
368
+ if (openBtn) {
369
+ e.preventDefault();
370
+ openCallTranscript(openBtn.dataset.openCall);
371
+ return;
372
+ }
373
+
374
+ const mineBtn = e.target.closest('button[data-toggle-mine]');
375
+ if (mineBtn) {
376
+ e.preventDefault();
377
+ const id = mineBtn.dataset.toggleMine;
378
+ if (!id) return;
379
+
380
+ const contact = (state.contacts || []).find(c => String(c.id) === String(id));
381
+ const next = contact ? !Boolean(contact.is_mine) : true;
382
+
383
+ mineBtn.disabled = true;
384
+ try {
385
+ await request(`/contacts/${encodeURIComponent(id)}`, {
386
+ method: 'PUT',
387
+ body: JSON.stringify({ is_mine: next })
388
+ });
389
+ showNotice(next ? 'Marked as mine' : 'Unmarked');
390
+ await loadContacts();
391
+ if (state.selectedContactId && String(state.selectedContactId) === String(id)) {
392
+ await loadCallsForContact(id);
393
+ }
394
+ } catch (err) {
395
+ showNotice(err.message);
396
+ mineBtn.disabled = false;
397
+ }
398
+ return;
399
+ }
400
+
401
+ const removeBtn = e.target.closest('button[data-remove-contact]');
402
+ if (removeBtn) {
403
+ e.preventDefault();
404
+ const id = removeBtn.dataset.removeContact;
405
+ if (!id) return;
406
+ removeBtn.disabled = true;
407
+ try {
408
+ await request(`/contacts/${encodeURIComponent(id)}`, { method: 'DELETE' });
409
+ showNotice('Contact removed');
410
+ if (state.selectedContactId && String(state.selectedContactId) === String(id)) {
411
+ state.selectedContactId = null;
412
+ state.selectedContactCalls = [];
413
+ state.contactCallResult = null;
414
+ }
415
+ await loadContacts();
416
+ } catch (err) {
417
+ showNotice(err.message);
418
+ removeBtn.disabled = false;
419
+ }
420
+ return;
421
+ }
422
+
423
+ const callBtn = e.target.closest('button[data-contact-call]');
424
+ if (callBtn) {
425
+ e.preventDefault();
426
+ const id = callBtn.dataset.contactCall;
427
+ if (!id) return;
428
+
429
+ const contact = (state.contacts || []).find(c => String(c.id) === String(id));
430
+ if (!contact) {
431
+ showNotice('Contact not found');
432
+ return;
433
+ }
434
+ if (!contact.can_call) {
435
+ showNotice('This contact has no callable A2A endpoint stored.');
436
+ return;
437
+ }
438
+
439
+ // Quick-call: use existing draft message if available, else prompt.
440
+ let message = '';
441
+ const draftEl = document.getElementById('contact-call-message');
442
+ if (state.selectedContactId && String(state.selectedContactId) === String(id) && draftEl && draftEl.value.trim()) {
443
+ message = draftEl.value.trim();
444
+ } else {
445
+ const prompted = window.prompt(`Message to send to ${contactLabel(contact)}:`, 'Hello from my agent.');
446
+ if (prompted === null) return;
447
+ message = String(prompted || '').trim();
448
+ }
449
+
450
+ if (!message) {
451
+ showNotice('Message required');
452
+ return;
453
+ }
454
+ await callContact(id, message);
455
+ return;
456
+ }
457
+ });
115
458
  }
116
459
 
117
460
  function renderCalls() {
@@ -154,27 +497,238 @@ async function loadCallDetail(conversationId) {
154
497
  `;
155
498
  }
156
499
 
157
- async function loadCallsForContact(contactId, contactName) {
158
- const payload = await request(`/contacts/${encodeURIComponent(contactId)}/calls?limit=100`);
159
- const calls = payload.calls || [];
160
- const el = document.getElementById('contact-calls');
161
- const rows = calls.map(call => {
500
+ function renderContactDetail() {
501
+ const el = document.getElementById('contact-detail');
502
+ if (!el) return;
503
+
504
+ const contactId = state.selectedContactId ? String(state.selectedContactId) : '';
505
+ if (!contactId) {
506
+ el.innerHTML = '<strong>Select a contact to view details and call history.</strong>';
507
+ return;
508
+ }
509
+
510
+ const contact = (state.contacts || []).find(c => String(c.id) === contactId) || null;
511
+ if (!contact) {
512
+ el.innerHTML = '<strong>Selected contact not found.</strong>';
513
+ return;
514
+ }
515
+
516
+ const calls = Array.isArray(state.selectedContactCalls) ? state.selectedContactCalls : [];
517
+ const canCall = Boolean(contact.can_call);
518
+
519
+ const tagsText = Array.isArray(contact.tags) ? contact.tags.join(', ') : '';
520
+ const fieldsText = (() => {
521
+ try {
522
+ const obj = (contact.fields && typeof contact.fields === 'object') ? contact.fields : {};
523
+ return JSON.stringify(obj, null, 2);
524
+ } catch (err) {
525
+ return '{}';
526
+ }
527
+ })();
528
+
529
+ const result = state.contactCallResult;
530
+ const resultHtml = result
531
+ ? `<div style="margin-top:0.6rem;">
532
+ <strong>Last call result:</strong> ${result.success ? 'success' : 'failed'}<br>
533
+ ${result.conversation_id ? `Conversation: <span class="mono">${esc(result.conversation_id)}</span> <button data-open-call="${esc(result.conversation_id)}" type="button">Transcript</button><br>` : ''}
534
+ ${result.error ? `<span class="mono">${esc(result.error)}</span><br>` : ''}
535
+ ${result.response ? `<pre class="summary">${esc(String(result.response))}</pre>` : ''}
536
+ </div>`
537
+ : '';
538
+
539
+ const callRows = calls.map(call => {
540
+ const summary = String(call.summary || call.owner_summary || '').trim();
541
+ const preview = summary ? summary.slice(0, 140) : '-';
162
542
  return `
163
543
  <tr>
164
- <td>${call.id}</td>
165
- <td>${call.status || '-'}</td>
166
- <td>${fmtDate(call.last_message_at)}</td>
167
- <td>${(call.summary || call.owner_summary || '-').slice(0, 140)}</td>
544
+ <td class="mono">${esc(call.id)}</td>
545
+ <td>${esc(call.status || '-')}</td>
546
+ <td>${esc(fmtDate(call.last_message_at))}</td>
547
+ <td title="${esc(summary)}">${esc(preview)}</td>
548
+ <td><button data-open-call="${esc(call.id)}" type="button">Transcript</button></td>
168
549
  </tr>
169
550
  `;
170
551
  }).join('');
552
+
171
553
  el.innerHTML = `
172
- <h3>Calls with ${contactName}</h3>
173
- <table>
174
- <thead><tr><th>ID</th><th>Status</th><th>Updated</th><th>Summary</th></tr></thead>
175
- <tbody>${rows || '<tr><td colspan="4">No calls found.</td></tr>'}</tbody>
176
- </table>
554
+ <div class="row">
555
+ <h3 style="margin:0;">Contact: ${esc(contactLabel(contact))}</h3>
556
+ <button data-contact-call="${esc(contact.id)}" type="button" ${canCall ? '' : 'disabled'}>Call</button>
557
+ <button data-remove-contact="${esc(contact.id)}" type="button">Remove</button>
558
+ </div>
559
+
560
+ <div class="row" style="margin-bottom:0.4rem;">
561
+ <div><strong>Mine:</strong> ${contact.is_mine ? 'yes' : 'no'}</div>
562
+ <div><strong>Owner:</strong> ${esc(contact.owner || '-')}</div>
563
+ <div><strong>Web address:</strong> <span class="mono">${esc(contact.web_address || contact.host || '-')}</span></div>
564
+ <div><strong>Server name:</strong> ${esc(contact.server_name || '-')}</div>
565
+ </div>
566
+ <div class="row">
567
+ <div><strong>Status:</strong> ${esc(contact.status || '-')}</div>
568
+ <div><strong>Total calls:</strong> ${esc(String(contact.call_count || 0))}</div>
569
+ <div><strong>Last call:</strong> ${esc(contact.last_call_at ? fmtDate(contact.last_call_at) : '-')}</div>
570
+ </div>
571
+
572
+ ${resultHtml}
573
+
574
+ <details style="margin-top:0.8rem;" open>
575
+ <summary><strong>Edit contact</strong></summary>
576
+ <form id="contact-edit-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
577
+ <label>Agent name <input id="contact-edit-name" type="text" value="${esc(contact.name || '')}"></label>
578
+ <label>Owner name <input id="contact-edit-owner" type="text" value="${esc(contact.owner || '')}"></label>
579
+ <label><input id="contact-edit-mine" type="checkbox" ${contact.is_mine ? 'checked' : ''}> Mark as mine (personal agent)</label>
580
+ <label>Server name (my agents only) <input id="contact-edit-server-name" type="text" value="${esc(contact.server_name || '')}" ${contact.is_mine ? '' : 'disabled'}></label>
581
+ <label>Tags <input id="contact-edit-tags" type="text" value="${esc(tagsText)}" placeholder="comma,separated"></label>
582
+ <label>Notes <textarea id="contact-edit-notes" rows="3">${esc(contact.notes || '')}</textarea></label>
583
+ <label>Fields (JSON) <textarea id="contact-edit-fields" rows="5">${esc(fieldsText)}</textarea></label>
584
+ <div class="row">
585
+ <button type="submit">Save</button>
586
+ </div>
587
+ </form>
588
+ </details>
589
+
590
+ <details style="margin-top:0.8rem;" open>
591
+ <summary><strong>Call</strong></summary>
592
+ <form id="contact-call-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
593
+ <label>Message <textarea id="contact-call-message" rows="4" placeholder="Message to send"></textarea></label>
594
+ <div class="row">
595
+ <button type="submit" ${canCall ? '' : 'disabled'}>Call</button>
596
+ </div>
597
+ </form>
598
+ </details>
599
+
600
+ <details style="margin-top:0.8rem;">
601
+ <summary><strong>Call history</strong></summary>
602
+ <div style="margin-top:0.6rem;">
603
+ <table>
604
+ <thead><tr><th>ID</th><th>Status</th><th>Updated</th><th>Summary</th><th>Action</th></tr></thead>
605
+ <tbody>${callRows || '<tr><td colspan="5">No calls found.</td></tr>'}</tbody>
606
+ </table>
607
+ </div>
608
+ </details>
177
609
  `;
610
+
611
+ const editForm = document.getElementById('contact-edit-form');
612
+ if (editForm) {
613
+ const mineEl = document.getElementById('contact-edit-mine');
614
+ const serverNameEl = document.getElementById('contact-edit-server-name');
615
+ mineEl?.addEventListener('change', () => {
616
+ if (!serverNameEl) return;
617
+ serverNameEl.disabled = !mineEl.checked;
618
+ });
619
+
620
+ editForm.addEventListener('submit', async (e) => {
621
+ e.preventDefault();
622
+ const id = editForm.dataset.contactId;
623
+ if (!id) return;
624
+
625
+ const tagsRaw = document.getElementById('contact-edit-tags').value.trim();
626
+ const tags = tagsRaw
627
+ ? tagsRaw.split(',').map(v => v.trim()).filter(Boolean).slice(0, 30)
628
+ : [];
629
+
630
+ let fields = {};
631
+ const fieldsRaw = document.getElementById('contact-edit-fields').value.trim();
632
+ if (fieldsRaw) {
633
+ try {
634
+ const parsed = JSON.parse(fieldsRaw);
635
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
636
+ throw new Error('Fields must be a JSON object');
637
+ }
638
+ fields = parsed;
639
+ } catch (err) {
640
+ showNotice(`Fields JSON invalid: ${err.message}`);
641
+ return;
642
+ }
643
+ }
644
+
645
+ try {
646
+ await request(`/contacts/${encodeURIComponent(id)}`, {
647
+ method: 'PUT',
648
+ body: JSON.stringify({
649
+ name: document.getElementById('contact-edit-name').value,
650
+ owner: document.getElementById('contact-edit-owner').value,
651
+ is_mine: Boolean(document.getElementById('contact-edit-mine')?.checked),
652
+ server_name: document.getElementById('contact-edit-server-name').value,
653
+ notes: document.getElementById('contact-edit-notes').value,
654
+ tags,
655
+ fields
656
+ })
657
+ });
658
+ showNotice('Contact saved');
659
+ await loadContacts();
660
+ await loadCallsForContact(id);
661
+ } catch (err) {
662
+ showNotice(err.message);
663
+ }
664
+ });
665
+ }
666
+
667
+ const callForm = document.getElementById('contact-call-form');
668
+ if (callForm) {
669
+ callForm.addEventListener('submit', async (e) => {
670
+ e.preventDefault();
671
+ const id = callForm.dataset.contactId;
672
+ if (!id) return;
673
+ const message = document.getElementById('contact-call-message').value.trim();
674
+ if (!message) {
675
+ showNotice('Message required');
676
+ return;
677
+ }
678
+ await callContact(id, message);
679
+ });
680
+ }
681
+ }
682
+
683
+ function openCallTranscript(conversationId) {
684
+ const id = String(conversationId || '').trim();
685
+ if (!id) return;
686
+ try { window.location.hash = 'calls'; } catch (err) {}
687
+ // Let hashchange tab switch complete before rendering details.
688
+ setTimeout(() => loadCallDetail(id).catch(err => showNotice(err.message)), 50);
689
+ }
690
+
691
+ async function callContact(contactId, message) {
692
+ const id = String(contactId || '').trim();
693
+ if (!id) return;
694
+ state.selectedContactId = id;
695
+ state.contactCallResult = { success: false, error: null, response: null, conversation_id: null };
696
+ renderContactDetail();
697
+
698
+ try {
699
+ const result = await request(`/contacts/${encodeURIComponent(id)}/call`, {
700
+ method: 'POST',
701
+ body: JSON.stringify({ message })
702
+ });
703
+ state.contactCallResult = {
704
+ success: true,
705
+ response: result.response || '',
706
+ conversation_id: result.conversation_id || null
707
+ };
708
+ showNotice('Call complete');
709
+ await Promise.all([loadContacts(), loadCalls()]);
710
+ await loadCallsForContact(id);
711
+ } catch (err) {
712
+ state.contactCallResult = { success: false, error: err.message, response: null, conversation_id: null };
713
+ renderContactDetail();
714
+ showNotice(err.message);
715
+ }
716
+ }
717
+
718
+ async function loadCallsForContact(contactId, contactName) {
719
+ const id = String(contactId || '').trim();
720
+ if (!id) return;
721
+ state.selectedContactId = id;
722
+
723
+ try {
724
+ const payload = await request(`/contacts/${encodeURIComponent(id)}/calls?limit=100`);
725
+ state.selectedContactCalls = payload.calls || [];
726
+ } catch (err) {
727
+ state.selectedContactCalls = [];
728
+ }
729
+
730
+ renderContacts();
731
+ renderContactDetail();
178
732
  }
179
733
 
180
734
  function readLogFilters() {
@@ -428,6 +982,178 @@ async function loadSettings() {
428
982
  document.getElementById('defaults-max-calls').value = payload.defaults?.maxCalls || 100;
429
983
  }
430
984
 
985
+ function renderCallbookStatus() {
986
+ const el = document.getElementById('callbook-status');
987
+ if (!el) return;
988
+
989
+ const s = state.dashboardStatus;
990
+ if (!s) {
991
+ el.textContent = 'Loading…';
992
+ return;
993
+ }
994
+
995
+ const warnings = Array.isArray(s.warnings) ? s.warnings : [];
996
+ const publicUrl = s.public_dashboard_url || '-';
997
+ const enabled = Boolean(s.callbook && s.callbook.enabled);
998
+ const deviceCount = s.callbook && Number.isFinite(s.callbook.device_count) ? s.callbook.device_count : 0;
999
+ const invite = s.invite_host || null;
1000
+ const inviteSource = invite && invite.source ? invite.source : null;
1001
+ const inviteResolved = invite && invite.host ? invite.host : null;
1002
+ const ext = s.external_ip || null;
1003
+ const extAttempts = ext && Array.isArray(ext.attempts) ? ext.attempts : [];
1004
+ const extMeta = [];
1005
+ if (ext && ext.source) extMeta.push(ext.source);
1006
+ if (ext && ext.checked_at) extMeta.push(`checked ${fmtDate(ext.checked_at)}`);
1007
+ if (ext && ext.from_cache) extMeta.push('cache');
1008
+ if (ext && ext.stale) extMeta.push('stale');
1009
+ const extMetaText = extMeta.length ? ` <span class="mono">(${esc(extMeta.join(', '))})</span>` : '';
1010
+ const extErrorText = ext && ext.error ? esc(ext.error) : '';
1011
+ const extAttemptsHtml = extAttempts.length
1012
+ ? `<details style="margin-top:0.5rem;">
1013
+ <summary>External IP probe</summary>
1014
+ <div class="mono" style="margin-top:0.35rem;">
1015
+ ${extAttempts.map(a => {
1016
+ const service = a && a.service ? String(a.service) : '-';
1017
+ const ok = Boolean(a && a.ok);
1018
+ const status = a && a.statusCode ? ` (${a.statusCode})` : '';
1019
+ const err = a && a.error ? ` (${a.error})` : '';
1020
+ return esc(`${service}: ${ok ? 'ok' + status : 'failed' + err}`);
1021
+ }).join('<br>')}
1022
+ </div>
1023
+ </details>`
1024
+ : '';
1025
+
1026
+ el.innerHTML = `
1027
+ <div><strong>Public dashboard URL:</strong> <span class="mono">${esc(publicUrl)}</span></div>
1028
+ <div><strong>Invite host:</strong> <span class="mono">${esc(inviteResolved || '-')}</span>${inviteSource ? ` <span class="mono">(${esc(inviteSource)})</span>` : ''}</div>
1029
+ <div><strong>External IP (egress):</strong> <span class="mono">${esc((ext && ext.ip) ? ext.ip : '-')}</span>${extMetaText}</div>
1030
+ ${extErrorText ? `<div style="margin-top:0.35rem;"><strong>External IP error:</strong> <span class="mono">${extErrorText}</span></div>` : ''}
1031
+ ${extAttemptsHtml}
1032
+ <div><strong>Callbook session storage:</strong> ${enabled ? 'enabled' : 'disabled'}</div>
1033
+ <div><strong>Paired devices:</strong> ${deviceCount}</div>
1034
+ ${warnings.length ? `<div style="margin-top:0.5rem;"><strong>Warnings:</strong><br>${warnings.map(w => esc(w)).join('<br>')}</div>` : ''}
1035
+ `;
1036
+ }
1037
+
1038
+ async function loadDashboardStatus(refreshIp = false) {
1039
+ const payload = await request(`/status${refreshIp ? '?refresh_ip=true' : ''}`);
1040
+ state.dashboardStatus = payload;
1041
+ renderCallbookStatus();
1042
+ renderContacts();
1043
+ renderContactDetail();
1044
+ }
1045
+
1046
+ function renderCallbookDevices() {
1047
+ const tbody = document.querySelector('#callbook-devices-table tbody');
1048
+ if (!tbody) return;
1049
+ tbody.innerHTML = '';
1050
+
1051
+ const devices = Array.isArray(state.callbookDevices) ? state.callbookDevices : [];
1052
+ if (devices.length === 0) {
1053
+ const tr = document.createElement('tr');
1054
+ tr.innerHTML = '<td colspan="6">No devices found.</td>';
1055
+ tbody.appendChild(tr);
1056
+ return;
1057
+ }
1058
+
1059
+ devices.forEach(dev => {
1060
+ const tr = document.createElement('tr');
1061
+ const revoked = Boolean(dev.revoked_at);
1062
+ const sessions = dev.active_sessions ?? '-';
1063
+ tr.innerHTML = `
1064
+ <td>${esc(dev.label || dev.id || '-')}</td>
1065
+ <td>${esc(fmtDate(dev.created_at))}</td>
1066
+ <td>${esc(fmtDate(dev.last_used_at))}</td>
1067
+ <td>${esc(String(sessions))}</td>
1068
+ <td>${revoked ? esc(fmtDate(dev.revoked_at)) : '-'}</td>
1069
+ <td>
1070
+ <button data-revoke="${esc(dev.id)}" ${revoked ? 'disabled' : ''}>Revoke</button>
1071
+ </td>
1072
+ `;
1073
+ tbody.appendChild(tr);
1074
+ });
1075
+
1076
+ tbody.querySelectorAll('button[data-revoke]').forEach(btn => {
1077
+ btn.addEventListener('click', async () => {
1078
+ const deviceId = btn.dataset.revoke;
1079
+ if (!deviceId) return;
1080
+ btn.disabled = true;
1081
+ try {
1082
+ await request(`/callbook/devices/${encodeURIComponent(deviceId)}/revoke`, { method: 'POST' });
1083
+ showNotice('Device revoked');
1084
+ await loadCallbookDevices();
1085
+ } catch (err) {
1086
+ showNotice(err.message);
1087
+ btn.disabled = false;
1088
+ }
1089
+ });
1090
+ });
1091
+ }
1092
+
1093
+ async function loadCallbookDevices() {
1094
+ const payload = await request('/callbook/devices?include_revoked=true');
1095
+ state.callbookDevices = payload.devices || [];
1096
+ renderCallbookDevices();
1097
+ }
1098
+
1099
+ function bindCallbookActions() {
1100
+ const form = document.getElementById('callbook-provision-form');
1101
+ if (!form) return;
1102
+
1103
+ const urlEl = document.getElementById('callbook-install-url');
1104
+ const labelEl = document.getElementById('callbook-label');
1105
+ const warningsEl = document.getElementById('callbook-warnings');
1106
+
1107
+ document.getElementById('callbook-refresh')?.addEventListener('click', () => {
1108
+ Promise.all([loadDashboardStatus(true), loadCallbookDevices()]).catch(err => showNotice(err.message));
1109
+ });
1110
+
1111
+ document.getElementById('callbook-refresh-devices')?.addEventListener('click', () => {
1112
+ loadCallbookDevices().catch(err => showNotice(err.message));
1113
+ });
1114
+
1115
+ document.getElementById('callbook-logout')?.addEventListener('click', async () => {
1116
+ try {
1117
+ await request('/callbook/logout', { method: 'POST' });
1118
+ showNotice('Logged out (cookie cleared)');
1119
+ } catch (err) {
1120
+ showNotice(err.message);
1121
+ }
1122
+ });
1123
+
1124
+ document.getElementById('callbook-copy-url')?.addEventListener('click', async () => {
1125
+ const ok = await copyText(urlEl?.value || '');
1126
+ showNotice(ok ? 'Copied' : 'Copy failed');
1127
+ });
1128
+
1129
+ form.addEventListener('submit', async (e) => {
1130
+ e.preventDefault();
1131
+ if (warningsEl) warningsEl.textContent = '';
1132
+ if (urlEl) urlEl.value = '';
1133
+
1134
+ const body = {
1135
+ label: labelEl ? labelEl.value : 'Callbook Remote',
1136
+ ttl_hours: 24
1137
+ };
1138
+
1139
+ try {
1140
+ const result = await request('/callbook/provision', {
1141
+ method: 'POST',
1142
+ body: JSON.stringify(body)
1143
+ });
1144
+ if (urlEl) urlEl.value = result.install_url || '';
1145
+ const warnings = Array.isArray(result.warnings) ? result.warnings : [];
1146
+ const expiresAt = result.expires_at ? `Expires: ${fmtDate(result.expires_at)}` : '';
1147
+ if (warningsEl) {
1148
+ warningsEl.textContent = [expiresAt, ...warnings].filter(Boolean).join('\n');
1149
+ }
1150
+ showNotice('Install link created');
1151
+ } catch (err) {
1152
+ showNotice(err.message);
1153
+ }
1154
+ });
1155
+ }
1156
+
431
1157
  function renderInvites() {
432
1158
  const tbody = document.querySelector('#invites-table tbody');
433
1159
  tbody.innerHTML = '';
@@ -518,12 +1244,23 @@ function bindRefreshButtons() {
518
1244
 
519
1245
  async function bootstrap() {
520
1246
  bindTabs();
1247
+ bindContactsActions();
521
1248
  bindSettingsActions();
1249
+ bindCallbookActions();
522
1250
  bindInviteActions();
523
1251
  bindRefreshButtons();
524
1252
 
525
1253
  try {
526
- await Promise.all([loadSettings(), loadContacts(), loadCalls(), loadInvites(), loadLogStats(), loadLogs()]);
1254
+ await Promise.all([
1255
+ loadSettings(),
1256
+ loadDashboardStatus(),
1257
+ loadCallbookDevices(),
1258
+ loadContacts(),
1259
+ loadCalls(),
1260
+ loadInvites(),
1261
+ loadLogStats(),
1262
+ loadLogs()
1263
+ ]);
527
1264
  showNotice('Dashboard loaded');
528
1265
  } catch (err) {
529
1266
  showNotice(err.message);