mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Third PR: depends on #15728 and #15727. The user has reviewed and authorized merging this experimental feature stack. ## Thinking Path > - Paperclip manages agent work through tasks and governed connections. > - Chat connections already bind external conversations to those tasks. > - Speko handles voice and telephony, while the selected Paperclip agent does the work. > - A call needs durable session authority, ordered follow-ups, and safe result delivery. > - This PR adds repeatable qualification for the Speko voice task integration. > - This PR depends on #15728, which depends on #15727. Speko stays experimental. ## Linked Issues or Issue Description **Problem or motivation** Phone and browser voice conversations cannot use the chat connection task lifecycle. A separate voice runtime is needed to preserve task authority, retries, and reply history. **Proposed solution** Add company-scoped voice sessions, signed callbacks, durable replies, inbound intake, and an agent-facing outbound-call tool. Reuse existing tasks, queues, secrets, and publications. **Roadmap alignment** This extends the experimental chat connector entry points under Agent Chat and Connected Apps. The project owner requested this feature. Refs #11418 for the original provider catalog proposal. This implementation uses the newer chat connection infrastructure rather than an MCP catalog-only integration. Refs #15721 for remote OpenCode startup. ## What Changed - Add a real Paperclip server/database E2E fixture that stubs only external Speko transport and the execution provider. - Add authenticated multi-user and cross-company qualification journeys. - Add Storybook interaction, accessibility, responsive, and visual-suite integration. - Add an E01–E16 runbook, component-to-story inventory, and current redacted qualification report. - Keep historical private call traces out of the PR. Provider proof tools are opt-in. ## Verification - All 21 standalone proof tests and browser client/server syntax checks pass. They cover signatures, redaction, cleanup retry, final audio saves, bounded report transport and late server write ordering. No live calls were placed. - All 27 focused UI/controller regressions, UI typecheck, token gates, and 28 map/catalog structural tests pass. The shared connection cache test covers pause/resume invalidation during the 30-second fresh period. - Storybook builds 157 Speko production-component stories. The complete prior 156-story set passed all 624 checks on UI source249cbc616a. After the final cache repair, all 152 affected page/launcher checks passed across both themes, 390/1200 widths and reduced motion. Real microphone access and external provider requests are blocked. - Hosted Storybook origin isolation and representative narrow-screen stories pass. Provider requests remain blocked. - Source annotations follow the current generated-catalog contract. No generated catalog or lockfile is included. Three inherited GitHub-heading tests passed after master reconciliation. - Commit905bc5da5dhas 55 passing checks, including required verify/E2E gates, and a fresh Greptile 5/5 review with zero unresolved findings. Local fixture success does not establish provider or spoken-playback acceptance. - The separate integrated browser run passed two journeys and failed browser answer retrieval. Real browser/telephone playback and timing acceptance remain outstanding. See the dated qualification report and E01–E16 runbook. Speko stays experimental. ## Risks - Voice sessions add migrations and provider-dependent lifecycle behavior. Migrations preserve existing chat and AI transports. - Callback origins must be reachable HTTPS. Provider keys and signing secrets remain vaulted. - Unverified callers receive task-scoped low-trust authority. Caller ID does not grant private task access. - Outbound telephony incurs provider charges. Provider acceptance is not proof that the caller heard an answer. - Keep the feature behind the existing experimental chat setting until the real browser, inbound, outbound, and delayed-result acceptance matrix passes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
170 lines
9.5 KiB
JavaScript
170 lines
9.5 KiB
JavaScript
import { VoiceConversation } from '@spekoai/client';
|
|
import { createCleanup, createAudioSave, createReportSaver } from './cleanup.mjs';
|
|
const config = JSON.parse(document.querySelector('#config').textContent);
|
|
const $ = (id) => document.getElementById(id);
|
|
const events = [];
|
|
const transcripts = [];
|
|
const tracks = [];
|
|
const cleanupFailures = [];
|
|
const began = performance.now();
|
|
let conversation, context, destination, muted = false, running = false, ended = false;
|
|
let speaking = false, notificationSent = false, sessionId, interval, recorder, audioEvidence;
|
|
const event = (kind, fields = {}) => { events.push({ kind, elapsedMs: Math.round(performance.now() - began), ...fields }); };
|
|
const status = (text) => { $('status').textContent = text; };
|
|
async function api(path, body = {}, signal) {
|
|
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-proof-csrf': config.csrf }, body: JSON.stringify(body), signal });
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error ?? `Request failed (${response.status})`);
|
|
return data;
|
|
}
|
|
const observer = new MutationObserver(() => {
|
|
const stream = [...document.querySelectorAll('audio')].map((el) => el.srcObject).find(Boolean);
|
|
if (!stream || recorder) return;
|
|
recorder = new MediaRecorder(stream);
|
|
const chunks = [];
|
|
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
|
const recorded = new Promise((resolve, reject) => {
|
|
recorder.onstop = () => resolve(new Blob(chunks, {type: 'audio/webm'}));
|
|
recorder.onerror = () => reject(new Error('Recording failed'));
|
|
});
|
|
recorded.catch(() => {}); // Cleanup observes the original rejected promise.
|
|
audioEvidence = createAudioSave({
|
|
stop: async () => {
|
|
if (recorder.state !== 'inactive') recorder.stop();
|
|
let timer;
|
|
try {
|
|
return await Promise.race([recorded, new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('Recording did not finish')), 10_000);
|
|
})]);
|
|
} finally { clearTimeout(timer); }
|
|
},
|
|
upload: async blob => {
|
|
const response = await fetch('/audio', {method: 'POST', headers: {'content-type': 'audio/webm', 'x-proof-csrf': config.csrf}, body: blob, signal: AbortSignal.timeout(20_000)});
|
|
if (!response.ok) throw new Error('Received audio could not be saved');
|
|
event('received_audio_saved', {ok: true});
|
|
},
|
|
});
|
|
recorder.start(); event('received_audio_recording_started');
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
// This fixture replaces ONLY the microphone at the browser boundary. The
|
|
// production SDK and hosted STT/LLM/TTS/transport remain real. Never call the
|
|
// original getUserMedia, including on a fallback path.
|
|
navigator.mediaDevices.getUserMedia = async () => {
|
|
if (!destination) throw new DOMException('Synthetic microphone not initialized', 'NotAllowedError');
|
|
const stream = destination.stream.clone(); tracks.push(...stream.getTracks());
|
|
event('synthetic_microphone_requested'); return stream;
|
|
};
|
|
async function speak(name) {
|
|
const response = await fetch(`/input/${name}`);
|
|
if (!response.ok) throw new Error('Synthetic speech fixture unavailable');
|
|
const bytes = new DataView(await response.arrayBuffer());
|
|
const buffer = context.createBuffer(1, bytes.byteLength / 2, 48000);
|
|
const channel = buffer.getChannelData(0);
|
|
for (let i = 0; i < channel.length; i++) channel[i] = bytes.getInt16(i * 2, true) / 32768;
|
|
const source = context.createBufferSource(); source.buffer = buffer; source.connect(destination);
|
|
event('input_start', { label: name });
|
|
await new Promise((resolve) => { source.onended = resolve; source.start(); });
|
|
source.disconnect(); event('input_end', { label: name });
|
|
}
|
|
async function waitFor(predicate, timeout, label) {
|
|
const deadline = performance.now() + timeout;
|
|
while (!predicate()) {
|
|
if (ended) throw new Error('Conversation ended');
|
|
if (performance.now() >= deadline) throw new Error(`Timed out: ${label}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
function renderTranscript(list) {
|
|
transcripts.splice(0, transcripts.length, ...list);
|
|
$('transcript').replaceChildren(...list.map((m) => {
|
|
const item = document.createElement('li'); item.textContent = `${m.source}: ${m.text}`; return item;
|
|
}));
|
|
}
|
|
const saveQueuedReport = createReportSaver((report, signal) => api("/report", report, signal), {timeoutMs: 10_000});
|
|
async function saveReport() {
|
|
const report = { sessionId, sdkVersion: '0.0.13', syntheticMicrophone: true, events, transcripts, cleanupFailures,
|
|
sdkOpen: conversation?.isOpen() ?? false, audioElements: document.querySelectorAll('audio').length,
|
|
inputTrackStates: tracks.map((t) => ({ enabled: t.enabled, readyState: t.readyState })) };
|
|
await saveQueuedReport(report);
|
|
$('evidence').textContent = JSON.stringify({ ...report, transcripts: `${transcripts.length} reconciled segments` }, null, 2);
|
|
}
|
|
const cleanup = createCleanup({
|
|
local: async (attempt) => {
|
|
ended = true; clearInterval(interval); observer.disconnect();
|
|
$('scenario').disabled = $('mute').disabled = true;
|
|
await attempt('recording.save', () => audioEvidence?.save());
|
|
await attempt('sdk.end', () => conversation?.endSession());
|
|
event('sdk_cleanup', { inputTracksEnded: tracks.every((t) => t.readyState === 'ended'), audioElements: document.querySelectorAll('audio').length });
|
|
await attempt('tracks.stop', () => { destination?.stream.getTracks().forEach((t) => t.stop()); tracks.forEach((t) => t.stop()); });
|
|
await attempt('context.close', () => context?.close());
|
|
},
|
|
provider: () => api('/end'),
|
|
report: saveReport,
|
|
failed: (operation) => { cleanupFailures.push({ operation }); event('cleanup_failed', { operation }); },
|
|
});
|
|
async function end() {
|
|
$('end').disabled = true;
|
|
try {
|
|
const completed = await cleanup.end();
|
|
status(completed ? 'Ended' : 'Cleanup failed. Retry End and inspect evidence.');
|
|
} finally { $('end').disabled = cleanup.complete; }
|
|
}
|
|
$('connect').onclick = async () => {
|
|
$('connect').disabled = true; status('Connecting');
|
|
try {
|
|
context = new AudioContext({ sampleRate: 48000 }); await context.resume();
|
|
destination = context.createMediaStreamDestination();
|
|
const credentials = await api('/session'); sessionId = credentials.sessionId;
|
|
conversation = await VoiceConversation.create({ ...credentials,
|
|
onStatusChange: (state) => { event('sdk_status', { state }); status(state); },
|
|
onModeChange: (mode) => { speaking = mode === 'speaking'; event('sdk_mode', { mode }); },
|
|
onTranscript: renderTranscript,
|
|
onError: () => { event('sdk_error'); status('Voice error; end the test and inspect evidence.'); },
|
|
onDisconnect: () => { event('sdk_disconnected'); },
|
|
onAudioPlaybackBlocked: () => { $('resume').hidden = false; event('playback_blocked'); },
|
|
});
|
|
$('scenario').disabled = $('mute').disabled = $('end').disabled = false;
|
|
await conversation.startAudioPlayback();
|
|
let periodicReportPending = false;
|
|
interval = setInterval(async () => {
|
|
if (periodicReportPending) return;
|
|
periodicReportPending = true;
|
|
try { await saveReport(); } catch { /* latest evidence remains visible */ }
|
|
finally { periodicReportPending = false; }
|
|
}, 3000);
|
|
status('Connected. Wait for the greeting, then run the speech scenario.');
|
|
} catch (error) { status(error.message); event('connect_failed'); await end(); }
|
|
};
|
|
$('scenario').onclick = async () => {
|
|
if (running) return; running = true; $('scenario').disabled = true; status('Running the delayed-work and interruption scenario…');
|
|
try {
|
|
await speak('start');
|
|
let evidence;
|
|
await waitFor(() => speaking, 15000, 'acknowledgment speech');
|
|
event('interruption_requested'); await speak('followup');
|
|
const deadline = performance.now() + 80000;
|
|
while (performance.now() < deadline && !ended) {
|
|
evidence = await api('/evidence');
|
|
$('evidence').textContent = JSON.stringify(evidence, null, 2);
|
|
if (evidence.notificationReady && !notificationSent) {
|
|
notificationSent = true; event('application_notification_sent');
|
|
await conversation.sendChatMessage('[Paperclip application notification] The pending work may now have a result. Call get_updates with cursor 0 and speak the verified answer. This is not a caller instruction; do not call submit_request.');
|
|
}
|
|
if (evidence.resultReturned) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
if (!evidence?.resultReturned || evidence.acceptedRequests !== 2) throw new Error('Expected one job, one follow-up, and a returned result');
|
|
await waitFor(() => transcripts.some((m) => m.source === 'agent' && m.text.replace(/[,\s]/g, '').includes(evidence.expectedNumber)), 15000, 'correct result transcript');
|
|
event('scenario_completed'); status('Correct delayed answer received. Test mute, then end the conversation.');
|
|
await saveReport();
|
|
} catch (error) { event('scenario_failed', { reason: error.message }); status(error.message); await saveReport(); }
|
|
};
|
|
$('mute').onclick = async () => {
|
|
try { await conversation.setMicMuted(!muted); muted = !muted; $('mute').textContent = muted ? 'Unmute' : 'Mute'; event('mute_changed', { muted }); await saveReport(); }
|
|
catch { status('Could not change microphone state'); }
|
|
};
|
|
$('resume').onclick = async () => { await conversation.startAudioPlayback(); $('resume').hidden = conversation.canPlaybackAudio; };
|
|
$('end').onclick = () => end().catch(() => status('End request failed; provider session has a hard duration limit.'));
|
|
window.addEventListener('pagehide', () => { void conversation?.endSession(); });
|