@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
|
@@ -2331,6 +2331,14 @@ function emptyChunkResponse(
|
|
|
2331
2331
|
}
|
|
2332
2332
|
}
|
|
2333
2333
|
|
|
2334
|
+
/**
|
|
2335
|
+
* 6.99.16: an iPhone recording's live lines come from the same server Whisper as a G2 meeting,
|
|
2336
|
+
* prompted with the profile vocabulary, so the dictionary shows while recording (Miles,
|
|
2337
|
+
* 2026-11-05: "as close to the g2 stack as possible"). Parakeet takes no prompt. The saved
|
|
2338
|
+
* transcript is unchanged: strict Parakeet, as the capability's asrModel says.
|
|
2339
|
+
*/
|
|
2340
|
+
export const PHONE_LIVE_ENGINE: 'server-whisper' | 'parakeet-ultra' = 'server-whisper'
|
|
2341
|
+
|
|
2334
2342
|
async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
|
|
2335
2343
|
// The worker owns reconciliation of stale health. Always attempt local ASR
|
|
2336
2344
|
// once; an availability snapshot must not divert meeting audio to cloud.
|
|
@@ -2639,7 +2647,7 @@ async function processStreamChunk(opts: {
|
|
|
2639
2647
|
model = candidate.model
|
|
2640
2648
|
mode = candidate.mode
|
|
2641
2649
|
latencyMs = candidate.latencyMs
|
|
2642
|
-
} else if (phoneCapture) {
|
|
2650
|
+
} else if (phoneCapture && PHONE_LIVE_ENGINE === 'parakeet-ultra') {
|
|
2643
2651
|
const result = await transcribePhoneParakeet(whisperAudio, quiet.speechSec)
|
|
2644
2652
|
rawText = result.text; words = result.words; backend = result.backend; asrProvider = result.asrProvider; model = result.model; latencyMs = result.latencyMs
|
|
2645
2653
|
} else if (MAC_CAPTURE_ID.test(sessionId) && stt.hq === 'parakeet-ultra') {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Minimal CDP driver over the Playwright-cached headless Chromium. No dependencies (Node 24 has WebSocket).
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { mkdtempSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const CHROME = process.env.CHROME || '/Users/ukaoma/Library/Caches/ms-playwright/chromium_headless_shell-1194/chrome-mac/headless_shell'
|
|
8
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
9
|
+
|
|
10
|
+
export async function launch(port = 9333) {
|
|
11
|
+
const dir = mkdtempSync(join(process.env.SCRATCH || tmpdir(), 'chr-'))
|
|
12
|
+
const proc = spawn(CHROME, [`--remote-debugging-port=${port}`, `--user-data-dir=${dir}`, '--no-first-run', '--hide-scrollbars', '--font-render-hinting=none', 'about:blank'], { stdio: 'ignore' })
|
|
13
|
+
let ver = null
|
|
14
|
+
for (let i = 0; i < 50 && !ver; i++) { await sleep(150); try { ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() } catch {} }
|
|
15
|
+
if (!ver) throw new Error('chromium did not start')
|
|
16
|
+
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
|
17
|
+
const page = list.find(t => t.type === 'page')
|
|
18
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
|
19
|
+
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j })
|
|
20
|
+
let id = 0
|
|
21
|
+
const waiting = new Map()
|
|
22
|
+
const listeners = []
|
|
23
|
+
ws.onmessage = ev => {
|
|
24
|
+
const m = JSON.parse(ev.data)
|
|
25
|
+
if (m.id && waiting.has(m.id)) { const w = waiting.get(m.id); waiting.delete(m.id); m.error ? w.j(new Error(m.error.message)) : w.r(m.result) }
|
|
26
|
+
else if (m.method) listeners.forEach(l => l(m))
|
|
27
|
+
}
|
|
28
|
+
const send = (method, params = {}) => new Promise((r, j) => { const i = ++id; waiting.set(i, { r, j }); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
29
|
+
const evaluate = async (expr) => {
|
|
30
|
+
const out = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })
|
|
31
|
+
if (out.exceptionDetails) throw new Error('page: ' + (out.exceptionDetails.exception?.description || out.exceptionDetails.text))
|
|
32
|
+
return out.result.value
|
|
33
|
+
}
|
|
34
|
+
await send('Page.enable'); await send('Runtime.enable')
|
|
35
|
+
const logs = []
|
|
36
|
+
listeners.push(m => { if (m.method === 'Runtime.consoleAPICalled') logs.push(m.params.type + ': ' + m.params.args.map(a => a.value ?? a.description).join(' ')); if (m.method === 'Runtime.exceptionThrown') logs.push('EXC: ' + (m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text)) })
|
|
37
|
+
return {
|
|
38
|
+
send, evaluate, logs,
|
|
39
|
+
async viewport(width, height, scheme) {
|
|
40
|
+
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 2, mobile: width < 500 })
|
|
41
|
+
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }, { name: 'prefers-reduced-motion', value: 'no-preference' }] })
|
|
42
|
+
},
|
|
43
|
+
async goto(url) {
|
|
44
|
+
// A fresh document every time: the same URL with a hash would otherwise be a same-document navigation.
|
|
45
|
+
await send('Page.navigate', { url: 'about:blank' }); await sleep(150)
|
|
46
|
+
await send('Page.navigate', { url })
|
|
47
|
+
for (let i = 0; i < 60; i++) { await sleep(100); if (await evaluate('document.readyState') === 'complete') break }
|
|
48
|
+
},
|
|
49
|
+
async shot(path) {
|
|
50
|
+
const { data } = await send('Page.captureScreenshot', { format: 'png' })
|
|
51
|
+
const { writeFileSync } = await import('node:fs')
|
|
52
|
+
writeFileSync(path, Buffer.from(data, 'base64'))
|
|
53
|
+
},
|
|
54
|
+
async close() { try { ws.close() } catch {} proc.kill('SIGTERM') },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export { sleep }
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 6.99.14: the harness behind the CHANGELOG numbers. Start node mock.mjs <web-control dir> <port> 150 (HISTORY_DELAY_MS=1500
|
|
2
|
+
// for scenario D), then node measure.mjs <port> <width> <height> 20. CHROME= overrides the headless Chromium path.
|
|
3
|
+
// node measure.mjs <mockPort> <width> <height> [N]
|
|
4
|
+
// Two scenarios on the live meeting view, N appended transcript lines each (one per poll):
|
|
5
|
+
// A following: the reader is at the newest line. Measures how far the page around the transcript moves
|
|
6
|
+
// (the title and the Ask panel, in CSS px, summed over updates) and the layout-shift total.
|
|
7
|
+
// B reading: the reader scrolled up into the middle. Measures the drift of the first visible line (CSS px, summed)
|
|
8
|
+
// and the layout-shift total.
|
|
9
|
+
import { launch, sleep } from './cdp.mjs'
|
|
10
|
+
const [port, width, height, N] = [process.argv[2] || 4517, Number(process.argv[3] || 390), Number(process.argv[4] || 844), Number(process.argv[5] || 20)]
|
|
11
|
+
const SID = 'meeting_1762349349000_g2demo'
|
|
12
|
+
const URL = `http://127.0.0.1:${port}/control/#/meetings/live/${SID}`
|
|
13
|
+
const OBS = `(() => { window.__cls = 0; window.__shifts = 0; try { new PerformanceObserver(l => { for (const e of l.getEntries()) { if (!e.hadRecentInput) { window.__cls += e.value; window.__shifts++ } } }).observe({ type: 'layout-shift', buffered: true }) } catch (e) {} })()`
|
|
14
|
+
const append = n => fetch(`http://127.0.0.1:${port}/__mock/append?n=${n}`, { method: 'POST' })
|
|
15
|
+
const browser = await launch(9300 + Math.floor(Math.random() * 500))
|
|
16
|
+
try {
|
|
17
|
+
await browser.viewport(width, height, 'light')
|
|
18
|
+
await browser.send('Page.addScriptToEvaluateOnNewDocument', { source: OBS })
|
|
19
|
+
const probe = `(() => {
|
|
20
|
+
const t = document.querySelector('.mt-dtitle'), side = document.querySelector('.mt-side-panel');
|
|
21
|
+
const box = document.getElementById('mt-turns');
|
|
22
|
+
const last = box && box.lastElementChild, lr = last && last.getBoundingClientRect();
|
|
23
|
+
return { title: t ? t.getBoundingClientRect().top : null, side: side ? side.getBoundingClientRect().top : null, y: scrollY,
|
|
24
|
+
newestShowing: !!(lr && lr.bottom <= Math.min(innerHeight, (getComputedStyle(box).overflowY !== 'visible' ? box.getBoundingClientRect().bottom : innerHeight)) + 2 && lr.bottom > 0),
|
|
25
|
+
turns: box ? box.children.length : 0, cls: window.__cls, shifts: window.__shifts };
|
|
26
|
+
})()`
|
|
27
|
+
// A: following
|
|
28
|
+
await browser.goto(URL); await sleep(3500)
|
|
29
|
+
await browser.evaluate(`window.__cls = 0; window.__shifts = 0`)
|
|
30
|
+
let prev = await browser.evaluate(probe), moved = 0, sideMoved = 0, scrolled = 0
|
|
31
|
+
const start = prev
|
|
32
|
+
for (let k = 0; k < N; k++) {
|
|
33
|
+
await append(1); await sleep(1700)
|
|
34
|
+
const now = await browser.evaluate(probe)
|
|
35
|
+
moved += Math.abs(now.title - prev.title); sideMoved += Math.abs((now.side ?? 0) - (prev.side ?? 0)); scrolled += Math.abs(now.y - prev.y)
|
|
36
|
+
prev = now
|
|
37
|
+
}
|
|
38
|
+
console.log(JSON.stringify({ scenario: 'A following', width, height, N, turnsBefore: start.turns, turnsAfter: prev.turns, titleDriftPx: Math.round(moved), askPanelDriftPx: Math.round(sideMoved), pageScrollPx: Math.round(scrolled), newestLineShowing: prev.newestShowing, layoutShift: +prev.cls.toFixed(4), shiftEntries: prev.shifts }))
|
|
39
|
+
// B: reading in the middle
|
|
40
|
+
await browser.goto(URL); await sleep(3500)
|
|
41
|
+
const pick = await browser.evaluate(`(() => {
|
|
42
|
+
const box = document.getElementById('mt-turns'), kids = [...box.children], mid = kids[Math.floor(kids.length / 2)];
|
|
43
|
+
const inner = getComputedStyle(box).overflowY !== 'visible' && box.scrollHeight > box.clientHeight + 4;
|
|
44
|
+
if (inner) box.scrollTop = mid.offsetTop - box.offsetTop - 40; else window.scrollTo(0, mid.getBoundingClientRect().top + scrollY - 160);
|
|
45
|
+
return { id: mid.getAttribute('data-turn'), inner };
|
|
46
|
+
})()`)
|
|
47
|
+
await sleep(900)
|
|
48
|
+
await browser.evaluate(`window.__cls = 0; window.__shifts = 0`)
|
|
49
|
+
const line = `(() => { const n = document.querySelector('[data-turn="${pick.id}"]'); return n ? n.getBoundingClientRect().top : null })()`
|
|
50
|
+
let p0 = await browser.evaluate(line), drift = 0, first = p0
|
|
51
|
+
for (let k = 0; k < N; k++) {
|
|
52
|
+
await append(1); await sleep(1700)
|
|
53
|
+
const p1 = await browser.evaluate(line); drift += Math.abs(p1 - p0); p0 = p1
|
|
54
|
+
}
|
|
55
|
+
const tail = await browser.evaluate(probe)
|
|
56
|
+
console.log(JSON.stringify({ scenario: 'B reading', width, height, N, innerScroller: pick.inner, firstVisibleLineDriftPx: Math.round(drift), netDriftPx: Math.round(p0 - first), layoutShift: +tail.cls.toFixed(4), shiftEntries: tail.shifts }))
|
|
57
|
+
// C: turns ABOVE the reader are rewritten (the speaker pass sends a full snapshot with longer text).
|
|
58
|
+
await browser.goto(URL); await sleep(3500)
|
|
59
|
+
const pickC = await browser.evaluate(`(() => {
|
|
60
|
+
const box = document.getElementById('mt-turns'), kids = [...box.children], mid = kids[Math.floor(kids.length * 0.6)];
|
|
61
|
+
const inner = getComputedStyle(box).overflowY !== 'visible' && box.scrollHeight > box.clientHeight + 4;
|
|
62
|
+
if (inner) box.scrollTop = mid.offsetTop - box.offsetTop - 40; else window.scrollTo(0, mid.getBoundingClientRect().top + scrollY - 160);
|
|
63
|
+
return { id: mid.getAttribute('data-turn') };
|
|
64
|
+
})()`)
|
|
65
|
+
await sleep(900)
|
|
66
|
+
await browser.evaluate(`window.__cls = 0; window.__shifts = 0`)
|
|
67
|
+
const lineC = `(() => { const n = document.querySelector('[data-turn="${pickC.id}"]'); return n ? n.getBoundingClientRect().top : null })()`
|
|
68
|
+
const c0 = await browser.evaluate(lineC)
|
|
69
|
+
await fetch(`http://127.0.0.1:${port}/__mock/rewrite?upto=40`, { method: 'POST' }); await sleep(2500)
|
|
70
|
+
const c1 = await browser.evaluate(lineC), tc = await browser.evaluate(probe)
|
|
71
|
+
console.log(JSON.stringify({ scenario: 'C rewrite above reader', width, firstVisibleLineDriftPx: Math.round(c1 - c0), layoutShift: +tc.cls.toFixed(4) }))
|
|
72
|
+
// D: a file is already on the meeting and the Ask history answers late (run the mock with HISTORY_DELAY_MS=1500).
|
|
73
|
+
await fetch(`http://127.0.0.1:${port}/__mock/addfile`, { method: 'POST' })
|
|
74
|
+
await browser.goto(URL); await sleep(500)
|
|
75
|
+
const boxTop = `(() => { const b = document.getElementById('mt-turns'); return b ? b.getBoundingClientRect().top + scrollY : null })()`
|
|
76
|
+
const d0 = await browser.evaluate(boxTop); await sleep(3000)
|
|
77
|
+
const d1 = await browser.evaluate(boxTop), td = await browser.evaluate(probe)
|
|
78
|
+
console.log(JSON.stringify({ scenario: 'D files load late', width, transcriptTopMovedPx: Math.round(d1 - d0), layoutShift: +td.cls.toFixed(4), shiftEntries: td.shifts }))
|
|
79
|
+
const errs = browser.logs.filter(l => /^(error|EXC)/.test(l)); if (errs.length) console.log('console errors:', errs.slice(0, 5))
|
|
80
|
+
} finally { await browser.close() }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// A fake COS server for rendering and measuring COS Control Web's live meeting view.
|
|
2
|
+
// node mock.mjs <web-control dir> <port> [initialChunks]
|
|
3
|
+
// Serves /control/* from the given directory and answers /api/* with a scripted live glasses meeting.
|
|
4
|
+
// POST /__mock/append?n=K adds K transcript chunks (and their levels). Never touches a real COS server.
|
|
5
|
+
import http from 'node:http'
|
|
6
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
7
|
+
import { join, extname, normalize } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const ROOT = process.argv[2]
|
|
10
|
+
const PORT = Number(process.argv[3] || 4517)
|
|
11
|
+
const INITIAL = Number(process.argv[4] || 150)
|
|
12
|
+
const SID = 'meeting_1762349349000_g2demo'
|
|
13
|
+
const START = Date.now() - 15 * 60 * 1000 - 9000
|
|
14
|
+
let seed = 7
|
|
15
|
+
function rnd() { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648 }
|
|
16
|
+
const SPEAKERS = ['Miles', 'Gina', 'Graham', 'Speaker 3']
|
|
17
|
+
const BITS = [
|
|
18
|
+
'the Liquor pacing is ahead of plan for the month', 'Jewelry demo volume dipped after the show', 'we should move the webinar to Thursday',
|
|
19
|
+
'Thrift leads came in heavier than the forecast said', 'the comparison page needs the new pricing table', 'call-in volume is up about a third',
|
|
20
|
+
'I want the brief before the board prep', 'that number ties to the MTD dashboard', 'can we get the persona pull for Pet by Friday',
|
|
21
|
+
'the endcap photo shows the spring set', 'Silas asked for the budget variance by region', 'let us keep the test running another week',
|
|
22
|
+
'opportunities are the stickler layer here', 'the form conversion moved from four to six percent', 'Ryan has the creative ready for review',
|
|
23
|
+
]
|
|
24
|
+
const chunks = []
|
|
25
|
+
let speaker = 'Miles'
|
|
26
|
+
function addChunk() {
|
|
27
|
+
const i = chunks.length
|
|
28
|
+
if (rnd() < 0.55) speaker = SPEAKERS[Math.floor(rnd() * SPEAKERS.length)]
|
|
29
|
+
const n = 1 + Math.floor(rnd() * 3)
|
|
30
|
+
const words = []
|
|
31
|
+
for (let k = 0; k < n; k++) words.push(BITS[Math.floor(rnd() * BITS.length)])
|
|
32
|
+
const text = words.join(', and ').replace(/^./, c => c.toUpperCase()) + '.'
|
|
33
|
+
const silent = rnd() < 0.15
|
|
34
|
+
chunks.push({ i, elapsedMs: i * 6000, speaker, text: silent ? '' : text, level: silent ? 0.12 : 0.35 + rnd() * 0.6, speechSec: silent ? 0.1 : 1 + rnd() * 4.5, at: Date.now() })
|
|
35
|
+
}
|
|
36
|
+
for (let k = 0; k < INITIAL; k++) addChunk()
|
|
37
|
+
let files = []
|
|
38
|
+
let rev = 0
|
|
39
|
+
const HISTORY_DELAY = Number(process.env.HISTORY_DELAY_MS || 0)
|
|
40
|
+
let mediaSeq = 0
|
|
41
|
+
function stamp() { return String(chunks.length) + '.' + files.length + '.' + rev }
|
|
42
|
+
function read(since, known, knownRev) {
|
|
43
|
+
if (known === stamp() && knownRev === String(rev)) return { sessionId: SID, ended: false, unchanged: true, stamp: stamp() }
|
|
44
|
+
const full = knownRev !== String(rev)
|
|
45
|
+
return {
|
|
46
|
+
sessionId: SID, ended: false, unchanged: false, stamp: stamp(), startTime: START, lastActivityAt: Date.now(),
|
|
47
|
+
maxIndex: chunks.length - 1, settledThrough: chunks.length - 1,
|
|
48
|
+
speakerRevision: rev, fullSnapshot: full,
|
|
49
|
+
turns: chunks.filter(c => (full || c.i > since) && c.text).map(c => ({ i: c.i, elapsedMs: c.elapsedMs, speaker: c.speaker, text: c.text })),
|
|
50
|
+
levels: chunks.slice(-120).map(c => ({ i: c.i, level: Math.round(c.level * 1000) / 1000, speechSec: Math.round(c.speechSec * 100) / 100 })),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.json': 'application/json', '.png': 'image/png' }
|
|
54
|
+
function json(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)) }
|
|
55
|
+
function body(req) { return new Promise(r => { const parts = []; req.on('data', d => parts.push(d)); req.on('end', () => r(Buffer.concat(parts))) }) }
|
|
56
|
+
const server = http.createServer(async (req, res) => {
|
|
57
|
+
const url = new URL(req.url, 'http://x')
|
|
58
|
+
const p = url.pathname
|
|
59
|
+
if (p === '/__mock/append') { const n = Number(url.searchParams.get('n') || 1); for (let k = 0; k < n; k++) addChunk(); return json(res, 200, { count: chunks.length }) }
|
|
60
|
+
if (p === '/__mock/rewrite') { const upto = Number(url.searchParams.get('upto') || 20); for (const c of chunks.slice(0, upto)) if (c.text) c.text += ' Corrected after the speaker pass, with a longer line than before.'; rev++; return json(res, 200, { rev }) }
|
|
61
|
+
if (p === '/__mock/addfile') { files = files.concat([{ id: 'phone_m_seed' + files.length, revision: 'r1', name: 'Board deck.pdf', status: 'ready', text: 'x', origin: 'phone' }]); return json(res, 200, { files }) }
|
|
62
|
+
if (p === '/__mock/state') return json(res, 200, { count: chunks.length, files })
|
|
63
|
+
if (p.startsWith('/control')) {
|
|
64
|
+
let rel = p.slice('/control'.length) || '/'
|
|
65
|
+
if (rel === '/') rel = '/index.html'
|
|
66
|
+
const full = normalize(join(ROOT, rel))
|
|
67
|
+
if (!full.startsWith(normalize(ROOT)) || !existsSync(full) || statSync(full).isDirectory()) { res.writeHead(404); return res.end() }
|
|
68
|
+
res.writeHead(200, { 'Content-Type': TYPES[extname(full)] || 'application/octet-stream', 'Cache-Control': 'no-store' })
|
|
69
|
+
return res.end(readFileSync(full))
|
|
70
|
+
}
|
|
71
|
+
if (!p.startsWith('/api/')) { res.writeHead(302, { Location: '/control/' }); return res.end() }
|
|
72
|
+
const raw = req.method === 'GET' ? Buffer.alloc(0) : await body(req)
|
|
73
|
+
let b = {}; try { b = raw.length && /json/.test(req.headers['content-type'] || '') ? JSON.parse(raw.toString('utf8')) : {} } catch { b = {} }
|
|
74
|
+
if (p === '/api/web-session/status') return json(res, 200, { authenticated: true, owner: 'Miles', version: '6.99.14' })
|
|
75
|
+
if (p === '/api/health') return json(res, 200, { status: 'ok', version: '6.99.14', capabilities: { liveMeeting: { protocol: 1, chunkLevels: true } } })
|
|
76
|
+
if (p === '/api/meetings/live-chunks') {
|
|
77
|
+
const sessions = [{ sessionId: SID, mtimeMs: Date.now(), stamp: stamp() }]
|
|
78
|
+
const sid = url.searchParams.get('sessionId')
|
|
79
|
+
if (!sid) return json(res, 200, { sessions })
|
|
80
|
+
return json(res, 200, { sessions, read: read(Number(url.searchParams.get('since') ?? -1), url.searchParams.get('stamp'), url.searchParams.get('speakerRevision')) })
|
|
81
|
+
}
|
|
82
|
+
if (p.startsWith('/api/meeting/sessions/') && p.endsWith('/status')) return json(res, 200, { state: 'active' })
|
|
83
|
+
if (p === '/api/meeting-ask/config') return json(res, 200, { protocolVersion: 2, serverInstanceId: 'inst-demo-1', selected: { engine: 'claude', model: 'opus' }, cosSearch: 'meaning', publicTopics: false, capabilities: {}, models: [{ engine: 'claude', model: 'opus', available: true }] })
|
|
84
|
+
if (p === '/api/meeting-ask/history') { if (HISTORY_DELAY) await new Promise(r => setTimeout(r, HISTORY_DELAY)); return json(res, 200, { exchanges: [], nextOffset: null, files }) }
|
|
85
|
+
if (p === '/api/meeting-ask/context') return json(res, 200, { candidates: [] })
|
|
86
|
+
if (p === '/api/live-cues/history') return json(res, 200, { cues: [] })
|
|
87
|
+
if (p === '/api/media/file') { const id = 'm_' + (++mediaSeq).toString(16).padStart(24, '0'); return json(res, 200, { attachment: { id, kind: 'document' } }) }
|
|
88
|
+
if (p === '/api/media') { const id = 'm_' + (++mediaSeq).toString(16).padStart(24, '0'); return json(res, 200, { attachments: [{ id, kind: 'image' }] }) }
|
|
89
|
+
if (p === '/api/meeting-ask/files/media') {
|
|
90
|
+
for (const m of b.media || []) files = files.filter(f => f.id !== 'phone_' + m.id).concat([{ id: 'phone_' + m.id, revision: 'r1', name: m.name || m.id, status: /\.(png|jpe?g|heic)$/i.test(m.name || '') ? 'ready' : 'ready', text: 'x', origin: 'phone', ...(/\.(png|jpe?g)$/i.test(m.name || '') ? { notice: 'Image text is available. Verify small numbers and table columns.' } : {}) }])
|
|
91
|
+
return json(res, 200, { files })
|
|
92
|
+
}
|
|
93
|
+
if (p === '/api/meetings') return json(res, 200, { meetings: [] })
|
|
94
|
+
if (p === '/api/meeting/orphans') return json(res, 200, { items: [], stranded: [], recovering: [] })
|
|
95
|
+
return json(res, 200, {})
|
|
96
|
+
})
|
|
97
|
+
server.listen(PORT, '127.0.0.1', () => console.log('mock on', PORT, 'root', ROOT, 'chunks', chunks.length))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// node render.mjs <port> <width> <height> <scheme> <out.png> [addFile]
|
|
2
|
+
import { launch, sleep } from './cdp.mjs'
|
|
3
|
+
const [port, w, h, scheme, out, add] = process.argv.slice(2)
|
|
4
|
+
const SID = 'meeting_1762349349000_g2demo'
|
|
5
|
+
const b = await launch(9800 + Math.floor(Math.random() * 150))
|
|
6
|
+
try {
|
|
7
|
+
await b.viewport(Number(w), Number(h), scheme)
|
|
8
|
+
await b.goto(`http://127.0.0.1:${port}/control/#/meetings/live/${SID}`)
|
|
9
|
+
await sleep(3500)
|
|
10
|
+
if (add === '1') {
|
|
11
|
+
const r = await b.evaluate(`(() => {
|
|
12
|
+
const dt = new DataTransfer();
|
|
13
|
+
dt.items.add(new File(['Q4 price list: Starter is 59 a month.'], 'Q4 pricing.pdf', { type: 'application/pdf' }));
|
|
14
|
+
const ta = document.querySelector('.mt-lmain');
|
|
15
|
+
ta.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
|
16
|
+
const ev = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt });
|
|
17
|
+
ta.dispatchEvent(ev);
|
|
18
|
+
return ev.defaultPrevented;
|
|
19
|
+
})()`)
|
|
20
|
+
console.log('drop prevented', r)
|
|
21
|
+
await sleep(1500)
|
|
22
|
+
}
|
|
23
|
+
if (add === 'open') { await b.evaluate(`(() => { const h = document.querySelector('[data-mt="files-toggle"]'); if (h && h.getAttribute('aria-expanded') !== 'true') h.click(); })()`); await sleep(400) }
|
|
24
|
+
await b.shot(out)
|
|
25
|
+
const errs = b.logs.filter(l => /^(error|EXC)/.test(l)); if (errs.length) console.log(errs.slice(0, 8).join('\n'))
|
|
26
|
+
} finally { await b.close() }
|
|
@@ -195,6 +195,19 @@
|
|
|
195
195
|
.cx-row-sub.three { -webkit-line-clamp: 3; }
|
|
196
196
|
.cx-id { margin-top: 3px; font: 400 10.5px/1.3 var(--mono); color: var(--tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
197
197
|
.cx-row .chev { margin-top: 12px; }
|
|
198
|
+
/* 6.99.15 Messages rows: no glyph column; the ask and its time on one line, the answer, then one quiet line of facts. */
|
|
199
|
+
.cx-msgrow { grid-template-columns: minmax(0, 1fr) 12px; padding: 12px 16px; }
|
|
200
|
+
.cx-row + .cx-msgrow::before { left: 16px; }
|
|
201
|
+
.cx-msg-top { display: flex; align-items: baseline; gap: 12px; }
|
|
202
|
+
.cx-msg-top .cx-row-title { flex: 1 1 auto; min-width: 0; margin-top: 0; font-weight: 600; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
203
|
+
.cx-msg-top .cx-time { flex: none; font-family: var(--body); font-size: 12px; }
|
|
204
|
+
.cx-msgrow .cx-row-sub { margin-top: 4px; }
|
|
205
|
+
.cx-facts { margin-top: 6px; gap: 2px 12px; }
|
|
206
|
+
.cx-facts .cx-num { font: 500 11.5px/1.3 var(--body); color: var(--tertiary); }
|
|
207
|
+
.cx-facts .cx-tag { font: 500 11.5px/1.3 var(--body); letter-spacing: 0; color: var(--gold); }
|
|
208
|
+
.cx-facts .cx-model, .cx-facts .cx-att { font: 400 11.5px/1.3 var(--body); color: var(--tertiary); }
|
|
209
|
+
.cx-msgrow .chev { margin-top: 8px; }
|
|
210
|
+
.cc-pin .cx-time { font-family: var(--body); font-size: 12px; }
|
|
198
211
|
@media (min-width: 1100px) {
|
|
199
212
|
.cx-row-title { font-size: 13.5px; }
|
|
200
213
|
.cx-row-sub { font-size: 12px; }
|
|
@@ -155,3 +155,20 @@
|
|
|
155
155
|
@media (min-width: 1100px) {
|
|
156
156
|
.rail-btn .inbox-count, .rail-btn .inbox-dot { position: static; margin: 2px 0 0 auto; flex: none; }
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
/* ── 6.99.13: a drop's files, under your words and on its receipt ── */
|
|
160
|
+
.cc-dfiles { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
|
161
|
+
.cc-dfiles.mine { align-items: flex-end; max-width: min(80%, 560px); margin-top: 6px; }
|
|
162
|
+
.cc-dfile { display: flex; align-items: center; gap: 10px; min-height: 44px; max-width: 100%; min-width: 0; padding: 4px 12px 4px 4px;
|
|
163
|
+
border: 1px solid var(--panel-border); border-radius: 12px; background: var(--panel); color: var(--ink); text-decoration: none; }
|
|
164
|
+
.cc-dfile:hover { background: var(--qopen); }
|
|
165
|
+
.cc-dfile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
166
|
+
.cc-dfile-thumb { flex: none; width: 36px; height: 36px; border-radius: 8px; overflow: hidden; display: grid; place-items: center; background: var(--qopen); color: var(--sub); }
|
|
167
|
+
.cc-dfile-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
168
|
+
.cc-dfile-thumb .icon { width: 18px; height: 18px; }
|
|
169
|
+
.cc-dfile-name { min-width: 0; flex: 1; font: 500 13.5px/1.3 var(--body); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
170
|
+
.cc-dfile-open { flex: none; font: 600 13px/1 var(--body); color: var(--accent); }
|
|
171
|
+
|
|
172
|
+
/* 6.99.16: the COS thread's one quiet line, under the title row. */
|
|
173
|
+
.cc-thread { flex-basis: 100%; margin: 0; font-size: 12.5px; line-height: 1.35; color: var(--tertiary); }
|
|
174
|
+
.cc-thread:empty { display: none; }
|