@gotcos/glasses-server 6.99.12 → 6.99.16
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/CHANGELOG.md +78 -0
- package/package.json +1 -1
- package/server/index.ts +23 -2
- package/server/lib/cos-thread.ts +582 -0
- package/server/lib/drop-attachments.ts +55 -0
- package/server/lib/drop-service.ts +104 -13
- package/server/lib/drop-settings.ts +13 -2
- package/server/lib/drop-store.ts +22 -0
- package/server/lib/media-store.ts +80 -0
- package/server/lib/meeting-batch-transcribe.ts +15 -2
- package/server/lib/permission-broker.ts +6 -4
- package/server/lib/phone-recording.ts +1 -1
- package/server/routes/cos-thread.ts +79 -0
- package/server/routes/drops.ts +4 -2
- package/server/routes/permission-broker.ts +2 -2
- package/server/routes/transcribe-stream.ts +9 -1
- package/server/scripts/web-live-cls/cdp.mjs +57 -0
- package/server/scripts/web-live-cls/measure.mjs +80 -0
- package/server/scripts/web-live-cls/mock.mjs +97 -0
- package/server/scripts/web-live-cls/render.mjs +26 -0
- package/server/web-control/control.css +13 -0
- package/server/web-control/panes/cos-chat.css +17 -0
- package/server/web-control/panes/cos-chat.js +185 -40
- package/server/web-control/panes/meetings.css +61 -4
- package/server/web-control/panes/meetings.js +358 -54
- package/server/web-control/panes/messages.js +25 -18
|
@@ -11,14 +11,24 @@
|
|
|
11
11
|
Steer and Cancel are the existing query-job routes. The Model and Effort menus come from /api/models (6.99.12).
|
|
12
12
|
- Drop (outlined, Drop it on only): POST /api/drops, then the receipt with Undo and Move. A drop the server reads as a
|
|
13
13
|
question runs the Ask the same way and is linked back (POST /api/drops/:id/ask), as COS Control does.
|
|
14
|
+
6.99.13: Drop takes the composer's files too (up to capabilities.dropIt.attachments, 5), as attachmentIds. The
|
|
15
|
+
server keeps them with the drop; the receipt lists each one with Open, and Undo removes them. A drop read as a
|
|
16
|
+
question sends its files with the Ask. An older server without that field keeps the words-only note.
|
|
14
17
|
- The clip (6.99.12) attaches photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV, as the Even app does: photos are
|
|
15
18
|
resized to a 1024 px JPEG and posted to /api/media, other files go raw to /api/media/file, and the Ask carries up to
|
|
16
|
-
five attachmentIds. Paste and drag-drop add files too.
|
|
19
|
+
five attachmentIds. Paste and drag-drop add files too. Drop sends them too where the server says it takes them.
|
|
17
20
|
With a keyboard, Return asks and Option-Return drops; Shift-Return is a new line.
|
|
18
21
|
|
|
19
22
|
The same composer answers in the message reader (6.99.12): Reply sends that turn's sessionId, so the answer continues
|
|
20
23
|
that conversation when it is still live (lib/web-ask-admission.ts).
|
|
21
24
|
|
|
25
|
+
6.99.16, the COS thread (capabilities.dropIt.thread, routes/cos-thread.ts): each Ask from the chat's own composer
|
|
26
|
+
reads GET /api/cos-thread first. `continue` sends that sessionId; `fresh` sends none and carries the server's digest
|
|
27
|
+
as the Ask's `reference` (the query-jobs field for quoted context, so the Messages row shows only the question).
|
|
28
|
+
While an Ask in the thread is still answering, the next one continues its conversation without asking. When an Ask
|
|
29
|
+
finishes, POST /api/cos-thread/turn once with its answer. "New thread" in the header posts /api/cos-thread/new.
|
|
30
|
+
Without the capability, the old rule stands: one conversation per day, kept in this browser.
|
|
31
|
+
|
|
22
32
|
The rail badge on Messages is a number for `waiting` (GET /api/inbox/summary) and a dot for unread only. It is read
|
|
23
33
|
with the shell's existing status read (shell.js checkServer), never on a timer of its own, and painting it touches
|
|
24
34
|
only the badge, never the pane. */
|
|
@@ -41,7 +51,10 @@
|
|
|
41
51
|
models: null, // [{ value, label, available }]
|
|
42
52
|
host: null,
|
|
43
53
|
onChange: null, // messages.js: re-render when Drop it or Ask turns on or off
|
|
44
|
-
onAnswered: null
|
|
54
|
+
onAnswered: null, // messages.js: re-read Recent when a web Ask lands
|
|
55
|
+
thread: null, // 6.99.16: the last GET /api/cos-thread answer
|
|
56
|
+
threadCarried: '', // the thread's newest session started fresh: 'summary' (it carried a digest) or 'plain'
|
|
57
|
+
threadBusy: false
|
|
45
58
|
};
|
|
46
59
|
var saved = W.readStore(STORE);
|
|
47
60
|
C.model = typeof saved.model === 'string' ? saved.model : '';
|
|
@@ -76,20 +89,30 @@
|
|
|
76
89
|
// ── capabilities and the rail badge ───────────────────
|
|
77
90
|
// Drop it on: the pinned COS chat. Ask on (durable query jobs): the composer, with or without Drop it.
|
|
78
91
|
function dropOn() { return !!(C.caps && C.caps.dropIt); }
|
|
92
|
+
function dropFiles() { return C.caps && C.caps.dropIt && C.caps.dropFiles > 0 ? C.caps.dropFiles : 0; }
|
|
79
93
|
function askOn() { return !!(C.caps && C.caps.jobs); }
|
|
80
94
|
function on() { return dropOn() || askOn(); }
|
|
95
|
+
// 6.99.16: the COS thread, advertised inside dropIt while Drop it is on.
|
|
96
|
+
function threadOn() { return !!(C.caps && C.caps.dropIt && C.caps.thread); }
|
|
81
97
|
function setCaps(h) {
|
|
82
98
|
var caps = (h && h.capabilities) || {};
|
|
83
99
|
var drop = caps.dropIt;
|
|
84
100
|
var next = {
|
|
85
101
|
dropIt: !!(drop && drop.enabled === true),
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
// 6.99.13: how many files a drop takes. 0 (an older server): Drop is words only and says so.
|
|
103
|
+
dropFiles: drop && drop.enabled === true && Number.isInteger(drop.attachments) && drop.attachments > 0 ? Math.min(drop.attachments, MAX_ATTS) : 0,
|
|
104
|
+
// 6.99.16: /api/health carries it at features.durableQueryJobs (a boolean); /api/models at capabilities.durableQueryJobs.enabled.
|
|
105
|
+
jobs: caps.durableQueryJobs === true || h && h.durableQueryJobs === true || !!(caps.durableQueryJobs && caps.durableQueryJobs.enabled) ||
|
|
106
|
+
!!(h && h.features && h.features.durableQueryJobs === true),
|
|
107
|
+
steer: !!(C.caps && C.caps.steer),
|
|
108
|
+
thread: !!(drop && drop.enabled === true && drop.thread && drop.thread.protocol === 1)
|
|
88
109
|
};
|
|
89
|
-
var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs;
|
|
110
|
+
var changed = !C.caps || C.caps.dropIt !== next.dropIt || C.caps.jobs !== next.jobs || C.caps.thread !== next.thread;
|
|
111
|
+
var filesChanged = !!C.caps && C.caps.dropFiles !== next.dropFiles;
|
|
90
112
|
C.caps = next;
|
|
91
113
|
if (!next.dropIt) { clearRail(); C.summary = null; }
|
|
92
114
|
if (changed && C.onChange) C.onChange();
|
|
115
|
+
else if (filesChanged) paintButtons(CHAT);
|
|
93
116
|
if (next.dropIt) refreshSummary();
|
|
94
117
|
}
|
|
95
118
|
// The shell's status read (shell.js checkServer) hands its /api/health answer here.
|
|
@@ -260,14 +283,16 @@
|
|
|
260
283
|
}
|
|
261
284
|
|
|
262
285
|
// ── the chat ──────────────────────────────────────────
|
|
263
|
-
function chatMode() { return dropOn() ? 'drop' : 'ask'; }
|
|
286
|
+
function chatMode() { return dropOn() ? (threadOn() ? 'drop-thread' : 'drop') : 'ask'; }
|
|
264
287
|
function chatSkeleton() {
|
|
265
288
|
var drop = dropOn();
|
|
266
289
|
return '<section class="cc" data-cos-chat data-mode="' + chatMode() + '" data-tint="messages" aria-label="' + (drop ? 'COS chat' : 'Ask COS') + '">' +
|
|
267
290
|
'<header class="cc-head"><span class="cc-avatar big">' + MARK + '</span><div class="cc-head-text"><h2 class="cc-title">' + (drop ? 'COS' : 'Ask COS') + '</h2>' +
|
|
268
291
|
'<p class="cc-sub">' + (drop ? 'Text it anything: a thought, an ask, a reminder. It texts back.' : 'Ask anything, with photos or files. Each answer lands in Messages with its number, here and on the glasses.') + '</p></div>' +
|
|
269
292
|
'<div class="cc-head-acts"><button type="button" class="link-btn cc-all" data-cos="all">All messages</button>' +
|
|
270
|
-
(drop ? '<button type="button" class="link-btn" data-cos="read-all">Mark all read</button>' : '') +
|
|
293
|
+
(drop ? '<button type="button" class="link-btn" data-cos="read-all">Mark all read</button>' : '') +
|
|
294
|
+
(threadOn() ? '<button type="button" class="link-btn" data-cos="new-thread">New thread</button>' : '') + '</div>' +
|
|
295
|
+
(threadOn() ? '<p class="cc-thread" data-cos-thread role="status">' + esc(threadLine()) + '</p>' : '') + '</header>' +
|
|
271
296
|
'<div class="cc-feed" data-cos-feed aria-live="polite"></div>' +
|
|
272
297
|
composerHtml(CHAT, { drop: drop, placeholder: drop ? 'Ask COS, or drop a thought, a task, a reminder' : 'Ask COS anything', label: 'Message to COS' }) +
|
|
273
298
|
'</section>';
|
|
@@ -283,6 +308,7 @@
|
|
|
283
308
|
}
|
|
284
309
|
if (!visible) return;
|
|
285
310
|
loadModels();
|
|
311
|
+
if (threadOn()) loadThread();
|
|
286
312
|
// Ask only: no inbox to read; the chat holds this page's Asks. Inbox lines from a Drop it session are not shown.
|
|
287
313
|
if (!dropOn()) { if (C.feedMode !== 'ask') { C.items = []; C.feedState = 'ready'; C.feedMode = 'ask'; } paintFeed(true); return; }
|
|
288
314
|
if (C.items === null || C.feedState === 'error' || C.feedMode !== 'drop') loadFeed(); else paintFeed(true);
|
|
@@ -338,7 +364,8 @@
|
|
|
338
364
|
}).join('') + '</ol>';
|
|
339
365
|
}
|
|
340
366
|
var rest = acts.filter(function (a) { return !/^choose:/.test(a.id); });
|
|
341
|
-
|
|
367
|
+
// A P2 follow-up already carries its own Dismiss (6.99.11); add one only to an item that does not (6.99.15: it showed twice).
|
|
368
|
+
if (item.needsReply && !rest.some(function (a) { return a.id === 'dismiss'; })) rest.push({ id: 'dismiss', label: 'Dismiss' });
|
|
342
369
|
if (rest.length) {
|
|
343
370
|
html += '<div class="cc-acts">' + rest.map(function (a) {
|
|
344
371
|
var cls = PRIMARY[a.id] ? 'cx-btn primary' : 'link-btn cc-text-btn';
|
|
@@ -360,16 +387,30 @@
|
|
|
360
387
|
return '<button type="button" class="cc-choice" data-cos="move-to" data-item="' + esc(item.id) + '" data-dest="' + o.dest + '"' + (o.domain ? ' data-domain="' + esc(o.domain) + '"' : '') + '>' + esc(o.label) + '</button>';
|
|
361
388
|
}).join('') + '<button type="button" class="link-btn cc-text-btn" data-cos="move-cancel">Keep it where it is</button></div>';
|
|
362
389
|
}
|
|
390
|
+
// 6.99.13: a drop's files, each opening from the media store in a new tab. Under your words they are what you sent;
|
|
391
|
+
// on a receipt each says Open. Undo takes the links away with the files. The classes are cc-dfile*, never cc-file:
|
|
392
|
+
// .cc-file is the clip's invisible file input (opacity 0, absolute), and a link wearing it does not show.
|
|
393
|
+
function fileLinksHtml(item, mine) {
|
|
394
|
+
var files = (item.links || []).filter(function (l) { return l.kind === 'file' && MEDIA_ID.test(String(l.id)); });
|
|
395
|
+
if (!files.length) return '';
|
|
396
|
+
return '<div class="cc-dfiles' + (mine ? ' mine' : '') + '">' + files.map(function (f) {
|
|
397
|
+
var url = '/api/media/' + encodeURIComponent(f.id) + '/content';
|
|
398
|
+
var photo = /\.(jpe?g|png|gif|webp|heic|heif)$/i.test(String(f.label)) || f.label === 'Photo';
|
|
399
|
+
return '<a class="cc-dfile" href="' + esc(url + '?variant=phone') + '" target="_blank" rel="noopener" aria-label="Open ' + esc(f.label) + '">' +
|
|
400
|
+
'<span class="cc-dfile-thumb">' + (photo ? '<img src="' + esc(url + '?variant=thumb') + '" alt="" loading="lazy">' : '<svg class="icon" aria-hidden="true"><use href="#i-doc"/></svg>') + '</span>' +
|
|
401
|
+
'<span class="cc-dfile-name">' + esc(f.label) + '</span>' + (mine ? '' : '<span class="cc-dfile-open">Open</span>') + '</a>';
|
|
402
|
+
}).join('') + '</div>';
|
|
403
|
+
}
|
|
363
404
|
function itemHtml(item) {
|
|
364
405
|
if (item.kind === 'drop' || item.kind === 'answer') {
|
|
365
406
|
return '<div class="cc-line you' + (item.kind === 'answer' ? ' answer' : '') + '"' + (item.kind === 'drop' ? ' data-drop-thread="' + esc(item.threadId) + '"' : '') + '><div class="cc-bubble">' + esc(item.line) + '</div>' +
|
|
366
|
-
(item.kind === 'drop' ? '<div class="cc-src">' + esc(SOURCE[item.source] || '') + '</div>' : '') + '</div>';
|
|
407
|
+
(item.kind === 'drop' ? fileLinksHtml(item, true) + '<div class="cc-src">' + esc(SOURCE[item.source] || '') + '</div>' : '') + '</div>';
|
|
367
408
|
}
|
|
368
409
|
var quote = item.kind === 'followup' ? (function () { var d = dropOf(item); return d ? '<button type="button" class="cc-quote" data-cos="quote" data-thread="' + esc(item.threadId) + '">Re: ' + esc(clip(d.line, 70)) + '</button>' : ''; })() : '';
|
|
369
410
|
return '<div class="cc-line cos' + (item.state !== 'live' ? ' done' : '') + (item.needsReply && item.state === 'live' ? ' waits' : '') + '" data-item-id="' + esc(item.id) + '">' +
|
|
370
411
|
'<span class="cc-avatar">' + MARK + '</span><div class="cc-card">' + quote +
|
|
371
412
|
(item.title ? '<p class="cc-card-title">' + esc(item.title) + '</p>' : '') +
|
|
372
|
-
'<p class="cc-card-line">' + esc(item.line) + '</p>' + actionsHtml(item) + '</div></div>';
|
|
413
|
+
'<p class="cc-card-line">' + esc(item.line) + '</p>' + fileLinksHtml(item, false) + actionsHtml(item) + '</div></div>';
|
|
373
414
|
}
|
|
374
415
|
// The finished answer as Markdown; while it streams, plain words, so a half-written "**bold" never shows its stars.
|
|
375
416
|
function answerHtml(a) {
|
|
@@ -458,7 +499,13 @@
|
|
|
458
499
|
var jobs = !C.caps || C.caps.jobs;
|
|
459
500
|
var waiting = uploadsPending(k), failed = uploadsFailed(k);
|
|
460
501
|
if (ask) { ask.disabled = !text || !!k.busy || !jobs || waiting || failed; ask.textContent = k.busy === 'ask' ? 'Asking…' : waiting ? 'Uploading…' : (k.name === 'chat' ? 'Ask' : 'Reply'); }
|
|
461
|
-
if (drop) {
|
|
502
|
+
if (drop) {
|
|
503
|
+
// 6.99.13: with files on a server that takes them, Drop waits for the uploads as Ask does. Files with no words keep
|
|
504
|
+
// Drop tappable so the tap can say what is missing.
|
|
505
|
+
var withFiles = k.atts.length > 0 && dropFiles();
|
|
506
|
+
drop.disabled = !!k.busy || (!text && !withFiles) || (withFiles && (waiting || failed));
|
|
507
|
+
drop.textContent = k.busy === 'drop' ? 'Dropping…' : withFiles && waiting ? 'Uploading…' : 'Drop';
|
|
508
|
+
}
|
|
462
509
|
var attach = form.querySelector('.cc-attach'), picker = form.querySelector('[data-cos-file]');
|
|
463
510
|
if (attach) attach.classList.toggle('full', k.atts.length >= MAX_ATTS);
|
|
464
511
|
if (picker) picker.disabled = k.atts.length >= MAX_ATTS || !!k.busy;
|
|
@@ -476,6 +523,7 @@
|
|
|
476
523
|
|
|
477
524
|
// ── attachments (6.99.12, the Even app's composer) ────
|
|
478
525
|
var MAX_ATTS = 5; // MAX_ATTACHMENTS_PER_PROMPT
|
|
526
|
+
var MEDIA_ID = /^m_[a-f0-9]{24}$/; // shared/media-attachment.ts MEDIA_ID_RE
|
|
479
527
|
var MAX_PHOTO = 2 * 1024 * 1024; // MAX_IMAGE_BYTES, after the resize
|
|
480
528
|
var MAX_DOC = 64 * 1024 * 1024; // MAX_OTHER_MEDIA_BYTES
|
|
481
529
|
var MAX_VIDEO = 100 * 1024 * 1024; // MAX_VIDEO_MEDIA_BYTES
|
|
@@ -618,32 +666,47 @@
|
|
|
618
666
|
k.signedOut = true;
|
|
619
667
|
setNote(k, 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.');
|
|
620
668
|
} else {
|
|
621
|
-
t.error =
|
|
669
|
+
t.error = uploadErrorText(err);
|
|
622
670
|
}
|
|
623
671
|
paintChips(k); paintButtons(k);
|
|
624
672
|
}
|
|
625
|
-
|
|
626
|
-
|
|
673
|
+
/**
|
|
674
|
+
* One file to the media store, the way every web upload goes: a photo resized to a 1024 px JPEG and posted to /api/media,
|
|
675
|
+
* any other file raw to /api/media/file. Resolves with the media ref ({ id }). `t` is the upload's record ({ file, name,
|
|
676
|
+
* kind, mime }); it gets `xhr` while sending (Remove aborts it) and `thumb` for a photo. hooks.uploading() runs once the
|
|
677
|
+
* bytes start, hooks.progress(0..1) as they go. 6.99.14: the live meeting view's Add file uses this too.
|
|
678
|
+
*/
|
|
679
|
+
function uploadMedia(t, hooks) {
|
|
627
680
|
var at = new Date(t.file.lastModified || Date.now()).toISOString();
|
|
628
|
-
var step;
|
|
629
681
|
if (t.kind === 'photo') {
|
|
630
|
-
|
|
682
|
+
return resizePhoto(t.file).then(function (dataUrl) {
|
|
631
683
|
var data = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
|
632
684
|
var bytes = Math.floor(data.length * 3 / 4);
|
|
633
685
|
if (bytes > MAX_PHOTO) { var big = new Error('Over 2 MB even after resizing.'); big.code = 'photo_too_large'; throw big; }
|
|
634
|
-
t.thumb = dataUrl;
|
|
686
|
+
t.thumb = dataUrl; hooks.uploading();
|
|
635
687
|
var body = JSON.stringify({ images: [{ data: data, label: t.name.slice(0, 120), capturedAt: at }], kind: 'user_photo' });
|
|
636
|
-
return uploadRequest('/api/media', body, { 'Content-Type': 'application/json' }, function (p) {
|
|
688
|
+
return uploadRequest('/api/media', body, { 'Content-Type': 'application/json' }, function (p) { hooks.progress(p); }, t);
|
|
637
689
|
}).then(function (d) { return d && d.attachments && d.attachments[0]; });
|
|
638
|
-
} else {
|
|
639
|
-
t.state = 'uploading';
|
|
640
|
-
step = uploadRequest('/api/media/file', t.file, {
|
|
641
|
-
'Content-Type': t.mime || 'application/octet-stream',
|
|
642
|
-
'X-COS-Filename': encodeURIComponent(t.name),
|
|
643
|
-
'X-COS-Captured-At': at
|
|
644
|
-
}, function (p) { t.progress = p; paintProgress(k, t); }, t).then(function (d) { return d && d.attachment; });
|
|
645
|
-
paintChips(k);
|
|
646
690
|
}
|
|
691
|
+
hooks.uploading();
|
|
692
|
+
return uploadRequest('/api/media/file', t.file, {
|
|
693
|
+
'Content-Type': t.mime || 'application/octet-stream',
|
|
694
|
+
'X-COS-Filename': encodeURIComponent(t.name),
|
|
695
|
+
'X-COS-Captured-At': at
|
|
696
|
+
}, function (p) { hooks.progress(p); }, t).then(function (d) { return d && d.attachment; });
|
|
697
|
+
}
|
|
698
|
+
/** The chip's words for a failed upload (never the sign-in case, which each caller says its own way). */
|
|
699
|
+
function uploadErrorText(err) {
|
|
700
|
+
if (err && err.code === 'photo_too_large') return 'Over 2 MB even after resizing';
|
|
701
|
+
if (err && !err.status && !err.code) return err.message || 'Did not upload';
|
|
702
|
+
return UPLOAD_COPY[err && err.code] || (err && err.status === 413 ? 'Too large for COS.' : 'Did not upload');
|
|
703
|
+
}
|
|
704
|
+
function startUpload(k, t) {
|
|
705
|
+
t.state = 'reading'; t.progress = 0; t.error = '';
|
|
706
|
+
var step = uploadMedia(t, {
|
|
707
|
+
uploading: function () { t.state = 'uploading'; paintChips(k); paintButtons(k); },
|
|
708
|
+
progress: function (p) { t.progress = p; paintProgress(k, t); }
|
|
709
|
+
});
|
|
647
710
|
step.then(function (ref) {
|
|
648
711
|
if (!findAtt(k, t.id)) return;
|
|
649
712
|
if (!ref || !ref.id) { var e = new Error('The Mac did not keep the file.'); e.code = ''; throw e; }
|
|
@@ -651,8 +714,8 @@
|
|
|
651
714
|
paintChips(k); paintButtons(k);
|
|
652
715
|
}).catch(function (err) {
|
|
653
716
|
if (!findAtt(k, t.id)) return;
|
|
654
|
-
if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error =
|
|
655
|
-
if (err && !err.status && !err.code) { t.state = 'failed'; t.error = err
|
|
717
|
+
if (err && err.code === 'photo_too_large') { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
|
|
718
|
+
if (err && !err.status && !err.code) { t.state = 'failed'; t.error = uploadErrorText(err); paintChips(k); paintButtons(k); return; }
|
|
656
719
|
uploadError(k, t, err);
|
|
657
720
|
});
|
|
658
721
|
}
|
|
@@ -663,13 +726,20 @@
|
|
|
663
726
|
function doDrop() {
|
|
664
727
|
var k = CHAT;
|
|
665
728
|
var input = inputOf(k); var text = input ? input.value.trim() : '';
|
|
666
|
-
if (
|
|
667
|
-
//
|
|
668
|
-
if (k.atts.length) { setNote(k, 'Drop takes words only
|
|
729
|
+
if (k.busy) return;
|
|
730
|
+
// An older server files words only (no capabilities.dropIt.attachments): say so, and send nothing.
|
|
731
|
+
if (k.atts.length && !dropFiles()) { setNote(k, 'Drop takes words only on this Mac. Ask sends the files with your words, or remove them to drop.'); return; }
|
|
732
|
+
if (!text) { if (k.atts.length) setNote(k, 'Add a few words with the files so COS knows where they go.'); return; }
|
|
733
|
+
if (uploadsPending(k)) return;
|
|
734
|
+
if (uploadsFailed(k)) { setNote(k, 'A file did not upload. Retry it or remove it, then Drop.'); return; }
|
|
735
|
+
// What is shown as sent is exactly what is sent: never more than the server takes.
|
|
736
|
+
var ready = readyAtts(k).slice(0, dropFiles() || 0);
|
|
669
737
|
var dropId = uuid();
|
|
738
|
+
var body = { dropId: dropId, text: text, source: 'web' };
|
|
739
|
+
if (ready.length) body.attachmentIds = ready.map(function (t) { return t.mediaId; });
|
|
670
740
|
k.busy = 'drop'; setNote(k, ''); paintButtons(k);
|
|
671
741
|
var send = function (retry) {
|
|
672
|
-
return api('/api/drops', { method: 'POST', body:
|
|
742
|
+
return api('/api/drops', { method: 'POST', body: body, timeoutMs: 30000 }).catch(function (err) {
|
|
673
743
|
// The same dropId is safe to send again: the server answers the first result.
|
|
674
744
|
if (retry && err && err.code === 'timeout') return send(false);
|
|
675
745
|
throw err;
|
|
@@ -678,14 +748,78 @@
|
|
|
678
748
|
send(true).then(function (ans) {
|
|
679
749
|
k.busy = ''; k.draft = ''; saveDraft(k);
|
|
680
750
|
var box = inputOf(k); if (box) { box.value = ''; W.autoGrow(box); }
|
|
751
|
+
if (ready.length) clearAtts(k);
|
|
681
752
|
mergeItems(ans.items);
|
|
682
753
|
paintFeed(true); paintButtons(k);
|
|
683
|
-
if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
|
|
754
|
+
if (ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id, null, askFilesOf(ans.next));
|
|
684
755
|
refreshSummary();
|
|
685
756
|
}, function (err) {
|
|
686
757
|
k.busy = ''; paintButtons(k);
|
|
687
|
-
// The words stay in the box until the server has them.
|
|
688
|
-
|
|
758
|
+
// The words and the files stay in the box until the server has them.
|
|
759
|
+
var kept = ready.length ? ' Your words and files are still here.' : ' Your words are still here.';
|
|
760
|
+
// A refused file says why in the server's own words (attachment_expired, too_many_attachments, words_needed...).
|
|
761
|
+
setNote(k, err && err.code === 'drop_it_off' ? 'Drop it is off on this Mac.' : 'Not dropped: ' + ((err && err.message) || 'the Mac did not answer.') + kept);
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// A drop read as a question runs the Ask with the drop's files (the server names them in next.attachmentIds).
|
|
766
|
+
function askFilesOf(next) {
|
|
767
|
+
var ids = next && Array.isArray(next.attachmentIds) ? next.attachmentIds.filter(function (id) { return MEDIA_ID.test(String(id)); }).slice(0, MAX_ATTS) : [];
|
|
768
|
+
return ids.length ? { attachmentIds: ids } : undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── the COS thread (6.99.16) ──────────────────────────
|
|
772
|
+
var THREAD_REF = 'Summary carried over from earlier in this COS thread';
|
|
773
|
+
function threadLine() {
|
|
774
|
+
var t = C.thread && C.thread.thread, n = C.thread && C.thread.next;
|
|
775
|
+
var carried = C.threadCarried || (n && n.reason === 'manual' ? (n.digest ? 'summary' : 'plain') : '');
|
|
776
|
+
if (carried) return carried === 'summary' ? 'New thread. It carries a short summary of the last one.' : 'New thread.';
|
|
777
|
+
var asks = t && Number(t.asks) > 0 ? Number(t.asks) : 0;
|
|
778
|
+
return asks ? 'Continues your COS thread · ' + asks + (asks === 1 ? ' ask' : ' asks') : '';
|
|
779
|
+
}
|
|
780
|
+
function paintThread() {
|
|
781
|
+
var line = threadLine();
|
|
782
|
+
document.querySelectorAll('[data-cos-thread]').forEach(function (el) { el.textContent = line; });
|
|
783
|
+
}
|
|
784
|
+
function loadThread() {
|
|
785
|
+
return api('/api/cos-thread').then(function (t) { C.thread = t; paintThread(); return t; }, function () { return null; });
|
|
786
|
+
}
|
|
787
|
+
// What the next Ask in the thread sends: a sessionId to continue, or none and the digest to carry.
|
|
788
|
+
function threadPlan() {
|
|
789
|
+
// An Ask in the thread still answering: the next one continues its conversation (its turn is not recorded yet).
|
|
790
|
+
var live = C.asks.filter(function (x) { return x.threaded && x.sessionId && x.turnState !== 'done' && Date.now() - x.at < 20 * 60000; });
|
|
791
|
+
if (live.length) return Promise.resolve({ sessionId: live[live.length - 1].sessionId, digest: null });
|
|
792
|
+
return loadThread().then(function (t) {
|
|
793
|
+
var n = t && t.next;
|
|
794
|
+
if (n && n.mode === 'continue' && n.sessionId) return { sessionId: n.sessionId, digest: null };
|
|
795
|
+
if (n && n.mode === 'fresh') return { sessionId: '', digest: typeof n.digest === 'string' && n.digest ? n.digest : null };
|
|
796
|
+
// The thread could not be read: continue the session it last named, if any.
|
|
797
|
+
var last = C.thread && C.thread.thread && C.thread.thread.sessionId;
|
|
798
|
+
return { sessionId: last || '', digest: null };
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
// Once per Ask, when it has finished: the thread records it, with the answer when there is one.
|
|
802
|
+
function postTurn(a) {
|
|
803
|
+
if (!a.threaded || !a.sessionId || a.turnState) return;
|
|
804
|
+
a.turnState = 'posting';
|
|
805
|
+
var body = { clientId: a.cid, sessionId: a.sessionId, query: String(a.query).slice(0, 20000) };
|
|
806
|
+
if (a.status === 'completed' && typeof a.response === 'string' && a.response) body.answer = a.response.slice(0, 40000);
|
|
807
|
+
if (Number.isInteger(a.no) && a.no > 0) body.messageNo = a.no;
|
|
808
|
+
api('/api/cos-thread/turn', { method: 'POST', body: body }).then(function (t) {
|
|
809
|
+
a.turnState = 'done';
|
|
810
|
+
if (t && t.thread) { C.thread = t; paintThread(); }
|
|
811
|
+
}, function () { a.turnState = 'done'; });
|
|
812
|
+
}
|
|
813
|
+
function newThread() {
|
|
814
|
+
if (C.threadBusy) return;
|
|
815
|
+
C.threadBusy = true;
|
|
816
|
+
api('/api/cos-thread/new', { method: 'POST', body: {} }).then(function (t) {
|
|
817
|
+
C.threadBusy = false;
|
|
818
|
+
C.thread = t; C.threadCarried = '';
|
|
819
|
+
paintThread();
|
|
820
|
+
}, function (err) {
|
|
821
|
+
C.threadBusy = false;
|
|
822
|
+
W.toast((err && err.message) || 'New thread did not go through.');
|
|
689
823
|
});
|
|
690
824
|
}
|
|
691
825
|
|
|
@@ -713,7 +847,9 @@
|
|
|
713
847
|
var body = { clientJobId: a.cid, generation: 1, query: text, source: 'web', activityToolMode: 'status' };
|
|
714
848
|
if (C.model) body.model = C.model;
|
|
715
849
|
if (C.effort) body.effort = C.effort;
|
|
716
|
-
|
|
850
|
+
// 6.99.16: an Ask from the chat's own composer belongs to the COS thread (a Reply keeps its turn's conversation).
|
|
851
|
+
a.threaded = threadOn() && !opts.replyKey;
|
|
852
|
+
if (!a.threaded && C.session && C.session.id) body.sessionId = C.session.id;
|
|
717
853
|
// A reply continues the turn's own conversation (kept when it is still live; else the server starts a new one).
|
|
718
854
|
if (opts.sessionId) body.sessionId = opts.sessionId;
|
|
719
855
|
if (opts.attachmentIds && opts.attachmentIds.length) body.attachmentIds = opts.attachmentIds.slice(0, MAX_ATTS);
|
|
@@ -727,10 +863,16 @@
|
|
|
727
863
|
throw err;
|
|
728
864
|
});
|
|
729
865
|
};
|
|
730
|
-
|
|
866
|
+
var prepare = a.threaded ? threadPlan().then(function (plan) {
|
|
867
|
+
if (plan.sessionId) body.sessionId = plan.sessionId;
|
|
868
|
+
else if (plan.digest) body.reference = { query: THREAD_REF, response: plan.digest };
|
|
869
|
+
a.carried = plan.sessionId ? '' : plan.digest ? 'summary' : 'plain';
|
|
870
|
+
}) : Promise.resolve();
|
|
871
|
+
prepare.then(function () { return admit(true); }).then(function (d) {
|
|
731
872
|
var job = d && d.job; if (!job) throw new Error('The Mac did not take the question.');
|
|
732
873
|
take(a, job);
|
|
733
|
-
if (
|
|
874
|
+
if (a.threaded) { C.threadCarried = a.carried; paintThread(); }
|
|
875
|
+
else if (job.sessionId && !a.replyKey) { C.session = { id: job.sessionId, day: today() }; save(); }
|
|
734
876
|
if (dropId) api('/api/drops/' + encodeURIComponent(dropId) + '/ask', { method: 'POST', body: { clientJobId: a.cid, generation: 1 } }).catch(function () {});
|
|
735
877
|
if (done) done(true);
|
|
736
878
|
poll(a);
|
|
@@ -767,6 +909,7 @@
|
|
|
767
909
|
setTimeout(tick, 600);
|
|
768
910
|
}
|
|
769
911
|
function finished(a) {
|
|
912
|
+
postTurn(a);
|
|
770
913
|
if (a.replyKey) paintReplyFeed(a.replyKey); else paintFeed(false);
|
|
771
914
|
if (C.onAnswered) C.onAnswered();
|
|
772
915
|
// A drop that became a question gets its answer as a follow-up: read the chat again.
|
|
@@ -847,7 +990,7 @@
|
|
|
847
990
|
if (ans && ans.item) mergeItems([ans.item]);
|
|
848
991
|
// A reminder action answers the finished item itself (merged above); dismiss answers counts only.
|
|
849
992
|
if (action === 'dismiss') item.state = 'done';
|
|
850
|
-
if (ans && ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id);
|
|
993
|
+
if (ans && ans.next && ans.next.kind === 'ask' && C.caps && C.caps.jobs) submitAsk(ans.next.prompt, ans.drop.id, null, askFilesOf(ans.next));
|
|
851
994
|
paintFeed(false); refreshSummary();
|
|
852
995
|
}, function (err) { W.toast((err && err.message) || 'That did not go through.'); });
|
|
853
996
|
}
|
|
@@ -883,6 +1026,7 @@
|
|
|
883
1026
|
if (what === 'drop') return doDrop();
|
|
884
1027
|
if (what === 'all') return W.go('messages');
|
|
885
1028
|
if (what === 'read-all') return markReadIfShown(true);
|
|
1029
|
+
if (what === 'new-thread') return newThread();
|
|
886
1030
|
if (what === 'retry') { C.feedState = 'idle'; return loadFeed(); }
|
|
887
1031
|
if (what === 'act') return act(t.getAttribute('data-item'), t.getAttribute('data-act'));
|
|
888
1032
|
if (what === 'move-to') return moveTo(t.getAttribute('data-item'), t.getAttribute('data-dest'), t.getAttribute('data-domain'));
|
|
@@ -968,6 +1112,7 @@
|
|
|
968
1112
|
onClick: onClick, onKey: onKey, onInput: onInput, onChange: onChangeEvent, onSubmit: onSubmit, onPaste: onPaste, onDrag: onDrag,
|
|
969
1113
|
setOnChange: function (fn) { C.onChange = fn; }, setOnAnswered: function (fn) { C.onAnswered = fn; },
|
|
970
1114
|
// Test and render hooks: what the chat holds right now.
|
|
971
|
-
|
|
1115
|
+
classify: classify, uploadMedia: uploadMedia, uploadErrorText: uploadErrorText, uuid: uuid,
|
|
1116
|
+
_state: C, _composer: composer, _classify: classify, _modelsFrom: modelsFrom, _addFiles: addFiles, _itemHtml: itemHtml, _setCaps: setCaps, _threadLine: threadLine, _postTurn: postTurn
|
|
972
1117
|
};
|
|
973
1118
|
})();
|
|
@@ -226,10 +226,13 @@
|
|
|
226
226
|
/* ── live transcript view ─────────────────────────────── */
|
|
227
227
|
.mt-lhead { display: grid; gap: 4px; }
|
|
228
228
|
.mt-ltitle { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
229
|
+
.mt-ltitle .mt-dtitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
230
|
+
/* 6.99.14: each header line is one line whatever it says (a quiet label, a longer minute count), so the header never
|
|
231
|
+
changes height while it records and the transcript box under it never moves. */
|
|
232
|
+
.mt-lstatus, .mt-lsub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
229
233
|
.mt-lstatus { font-weight: 500; font-size: 13.5px; color: var(--ink); margin: 4px 0 0; }
|
|
230
234
|
.mt-lsub { font-size: 13px; color: var(--tertiary); margin: 0; }
|
|
231
|
-
.mt-handoff { font-size: 13px; margin: 2px 0 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
232
|
-
.mt-lhead .mt-bar { border-bottom: 0; margin-top: 6px; }
|
|
235
|
+
.mt-handoff { font-size: 13px; margin: 2px 0 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 50px; }
|
|
233
236
|
/* Control's LiveMeetingFilesRow: one collapsed "Files · N" row; open, the names and the note. */
|
|
234
237
|
.mt-files { border: 1px solid var(--hairline); border-radius: 9px; margin: 6px 0 4px; max-width: 520px; min-width: 0; }
|
|
235
238
|
.mt-files-head { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 36px; padding: 0 12px; font-size: 13px; font-weight: 600; color: var(--ink); text-align: left; }
|
|
@@ -243,15 +246,69 @@
|
|
|
243
246
|
.mt-files-note { padding: 8px 12px 10px; font-size: 12px; }
|
|
244
247
|
@media (prefers-reduced-motion: reduce) { .mt-files-head .icon:last-child { transition: none; } }
|
|
245
248
|
.mt-lgrid { display: grid; gap: 16px; }
|
|
246
|
-
.mt-lmain { min-width: 0; }
|
|
247
|
-
.
|
|
249
|
+
.mt-lmain { min-width: 0; position: relative; border-radius: 12px; }
|
|
250
|
+
/* 6.99.14: the transcript scrolls in its own box of fixed height (Control's ScrollView), so a new line moves only the
|
|
251
|
+
text inside it. overflow-anchor: none: the pane anchors the reader itself on every browser (iOS Safari has none), and
|
|
252
|
+
Chrome's own anchoring would correct a second time. */
|
|
253
|
+
.mt-tbox { position: relative; margin-top: 12px; }
|
|
254
|
+
.mt-turns { display: grid; gap: 22px; align-content: start; position: relative; padding: 16px 6px 14px 0; border-top: 1px solid var(--hairline);
|
|
255
|
+
height: clamp(240px, 52dvh, 640px); overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; -webkit-overflow-scrolling: touch; }
|
|
256
|
+
.mt-turns:focus { outline: none; }
|
|
257
|
+
.mt-turns:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
|
258
|
+
.mt-lturn { max-width: 700px; }
|
|
248
259
|
.mt-lwho { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
|
249
260
|
.mt-lname { font-weight: 600; font-size: 12.5px; color: var(--sub); }
|
|
250
261
|
.mt-lstamp { font: 400 11px/1 var(--mono); color: var(--tertiary); }
|
|
251
262
|
.mt-lturn p { margin: 0 0 0.6em; font-size: 15px; line-height: 1.6; color: var(--ink); overflow-wrap: anywhere; }
|
|
252
263
|
.mt-lturn p:last-child { margin-bottom: 0; }
|
|
253
264
|
.mt-lempty { color: var(--sub); text-align: center; font-size: 14px; padding: 26px 8px; margin: 0; }
|
|
265
|
+
.mt-jump { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); box-shadow: var(--shadow); background: var(--panel); }
|
|
266
|
+
.mt-jump[hidden] { display: none; }
|
|
267
|
+
/* The recording bar (Control's LiveRecordingBar): one row, 50 px, never wraps. Under 560 px of bar Add file is the
|
|
268
|
+
paperclip alone; under 340 px the still glyph takes the strip's place. */
|
|
269
|
+
.mt-lbarslot { container-type: inline-size; min-height: 50px; margin-top: 8px; }
|
|
270
|
+
.mt-lbar { display: flex; align-items: center; gap: 10px; height: 50px; padding: 0 8px 0 14px; border: 1px solid var(--panel-border);
|
|
271
|
+
border-radius: 11px; background: color-mix(in srgb, var(--panel) 70%, var(--page)); min-width: 0; }
|
|
272
|
+
.mt-lbar .mt-livedot { width: 9px; height: 9px; }
|
|
273
|
+
/* A fixed width (h:mm:ss in the mono face), so Starting, 9:59 and 1:00:00 never move the strip beside it. */
|
|
274
|
+
.mt-ltime { font: 500 12.5px/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; width: 7ch; flex: none; overflow: hidden; }
|
|
275
|
+
.mt-ltime.starting { font: 400 12px/1 var(--body); color: var(--sub); }
|
|
276
|
+
.mt-meterwrap { position: relative; flex: 1 1 auto; min-width: 40px; height: 22px; display: flex; align-items: center; }
|
|
277
|
+
.mt-meter { display: block; width: 100%; height: 22px; --mt-bar: color-mix(in srgb, var(--accent) 85%, transparent); --mt-dot: color-mix(in srgb, var(--sub) 70%, transparent); }
|
|
278
|
+
/* The still glyph sits over the strip's box (no levels yet, or too narrow): shown and hidden by visibility, never by
|
|
279
|
+
display, so the switch moves nothing. */
|
|
280
|
+
.mt-glyph { position: absolute; left: 0; top: 6px; display: inline-flex; visibility: hidden; align-items: center; gap: 1.5px; height: 10px; }
|
|
281
|
+
.mt-glyph i { display: block; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--rec) 85%, transparent); }
|
|
282
|
+
.mt-glyph i:nth-child(1) { height: 6px; } .mt-glyph i:nth-child(2) { height: 9px; } .mt-glyph i:nth-child(3) { height: 7px; }
|
|
283
|
+
.mt-meterwrap.mt-nolevels .mt-meter { visibility: hidden; }
|
|
284
|
+
.mt-meterwrap.mt-nolevels .mt-glyph { visibility: visible; }
|
|
285
|
+
.mt-addfile, .mt-stopbtn { flex: none; white-space: nowrap; }
|
|
286
|
+
.mt-addfile { display: inline-flex; align-items: center; gap: 6px; }
|
|
287
|
+
.mt-addfile .icon { width: 14px; height: 14px; }
|
|
288
|
+
.mt-lbar .mt-side-note { flex: none; }
|
|
289
|
+
@container (max-width: 559px) { .mt-addlabel { display: none; } .mt-addfile { padding-left: 10px; padding-right: 10px; } }
|
|
290
|
+
@container (max-width: 339px) { .mt-meter { visibility: hidden; } .mt-glyph { visibility: visible; } .mt-meterwrap { flex: 0 0 40px; } }
|
|
291
|
+
.mt-lbarslot > .mt-stopline { margin: 8px 4px 0; }
|
|
292
|
+
.mt-lbarslot > .mt-confirm { margin-top: 8px; }
|
|
293
|
+
/* A file dragged over the pane: the column is outlined. Drawn only, so it moves nothing. */
|
|
294
|
+
.mt-lmain.dropping { outline: 2px dashed var(--accent); outline-offset: 4px; }
|
|
295
|
+
.mt-pick { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; overflow: hidden; clip: rect(0 0 0 0); }
|
|
296
|
+
#mt-lfiles:empty { display: none; }
|
|
297
|
+
#mt-lfiles { margin-top: 8px; }
|
|
298
|
+
.mt-files.live { max-width: none; }
|
|
299
|
+
.mt-files-head.quiet { color: var(--sub); font-weight: 500; cursor: default; }
|
|
300
|
+
.mt-files-head > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
301
|
+
.mt-files-list li.mt-up { position: relative; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; overflow: hidden; }
|
|
302
|
+
.mt-up .mt-files-name, .mt-up-meta { grid-column: 1; }
|
|
303
|
+
.mt-up-meta { font-size: 13px; line-height: 1.45; margin: 0; }
|
|
304
|
+
.mt-up-acts { grid-column: 2; grid-row: 1 / span 2; display: flex; gap: 6px; align-items: center; }
|
|
305
|
+
.mt-up-acts .mt-link { min-height: 28px; }
|
|
306
|
+
.mt-up-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--accent); transition: width 0.2s ease; }
|
|
307
|
+
@media (prefers-reduced-motion: reduce) { .mt-up-bar { transition: none; } }
|
|
308
|
+
#mt-lfiles .mt-files { margin-top: 0; }
|
|
309
|
+
#mt-lfoot .mt-bar { border-top: 0; }
|
|
254
310
|
@media (min-width: 860px) {
|
|
311
|
+
.mt-turns { height: clamp(320px, calc(100dvh - 400px), 1100px); }
|
|
255
312
|
.mt-lgrid { grid-template-columns: minmax(0, 1fr) minmax(280px, min(380px, 38%)); align-items: start; column-gap: 24px; }
|
|
256
313
|
.mt-lgrid > .mt-side-panel { position: sticky; top: calc(62px + var(--safe-top, 0px)); max-height: calc(100vh - 80px - var(--safe-top, 0px)); overflow: auto; }
|
|
257
314
|
}
|