From 734f7c1e35ea14fd9668dc700bc61ad2ac2767de Mon Sep 17 00:00:00 2001 From: Barack Sokullu Date: Wed, 7 Oct 2026 03:37:21 +0300 Subject: [PATCH] Attach feedback traces to GitHub drafts with explicit consent --- docs/privacy-and-data-flow.md | 22 + docs/security-model.md | 24 +- package.json | 5 +- src/chrome/src/agent/trace-export.js | 32 +- src/chrome/src/background.js | 3 + src/chrome/src/config-transfer.js | 2 + src/chrome/src/content/feedback-attachment.js | 137 +++++ src/chrome/src/feedback-handoff.js | 163 ++++++ src/chrome/src/feedback-store.js | 65 +++ src/chrome/src/trace/feedback-export.js | 103 ++++ src/chrome/src/trace/feedback-policy.js | 33 ++ src/chrome/src/trace/recorder.js | 125 ++++- src/chrome/src/ui/feedback-consent.css | 14 + src/chrome/src/ui/feedback-consent.js | 63 +++ src/chrome/src/ui/feedback-copy.js | 107 ++++ src/chrome/src/ui/feedback-trace-preview.css | 4 + src/chrome/src/ui/feedback-trace-preview.html | 7 + src/chrome/src/ui/feedback-trace-preview.js | 34 ++ src/chrome/src/ui/i18n.js | 2 + src/chrome/src/ui/settings.html | 10 + src/chrome/src/ui/settings.js | 12 +- src/chrome/src/ui/sidepanel.html | 1 + src/chrome/src/ui/sidepanel.js | 59 ++- src/chrome/src/ui/store-review-prompt.js | 11 +- src/firefox/src/agent/trace-export.js | 32 +- src/firefox/src/background.js | 3 + src/firefox/src/config-transfer.js | 2 + .../src/content/feedback-attachment.js | 137 +++++ src/firefox/src/feedback-handoff.js | 163 ++++++ src/firefox/src/feedback-store.js | 65 +++ src/firefox/src/trace/feedback-export.js | 103 ++++ src/firefox/src/trace/feedback-policy.js | 33 ++ src/firefox/src/trace/recorder.js | 185 +++++-- src/firefox/src/ui/feedback-consent.css | 14 + src/firefox/src/ui/feedback-consent.js | 63 +++ src/firefox/src/ui/feedback-copy.js | 107 ++++ src/firefox/src/ui/feedback-trace-preview.css | 4 + .../src/ui/feedback-trace-preview.html | 7 + src/firefox/src/ui/feedback-trace-preview.js | 34 ++ src/firefox/src/ui/i18n.js | 2 + src/firefox/src/ui/settings.html | 10 + src/firefox/src/ui/settings.js | 12 +- src/firefox/src/ui/sidepanel.html | 1 + src/firefox/src/ui/sidepanel.js | 59 ++- src/firefox/src/ui/store-review-prompt.js | 11 +- test/feedback-traces.mjs | 474 ++++++++++++++++++ test/run.js | 9 +- 47 files changed, 2474 insertions(+), 94 deletions(-) create mode 100644 src/chrome/src/content/feedback-attachment.js create mode 100644 src/chrome/src/feedback-handoff.js create mode 100644 src/chrome/src/feedback-store.js create mode 100644 src/chrome/src/trace/feedback-export.js create mode 100644 src/chrome/src/trace/feedback-policy.js create mode 100644 src/chrome/src/ui/feedback-consent.css create mode 100644 src/chrome/src/ui/feedback-consent.js create mode 100644 src/chrome/src/ui/feedback-copy.js create mode 100644 src/chrome/src/ui/feedback-trace-preview.css create mode 100644 src/chrome/src/ui/feedback-trace-preview.html create mode 100644 src/chrome/src/ui/feedback-trace-preview.js create mode 100644 src/firefox/src/content/feedback-attachment.js create mode 100644 src/firefox/src/feedback-handoff.js create mode 100644 src/firefox/src/feedback-store.js create mode 100644 src/firefox/src/trace/feedback-export.js create mode 100644 src/firefox/src/trace/feedback-policy.js create mode 100644 src/firefox/src/ui/feedback-consent.css create mode 100644 src/firefox/src/ui/feedback-consent.js create mode 100644 src/firefox/src/ui/feedback-copy.js create mode 100644 src/firefox/src/ui/feedback-trace-preview.css create mode 100644 src/firefox/src/ui/feedback-trace-preview.html create mode 100644 src/firefox/src/ui/feedback-trace-preview.js create mode 100644 test/feedback-traces.mjs diff --git a/docs/privacy-and-data-flow.md b/docs/privacy-and-data-flow.md index 422fb70b6f..4467f2d289 100644 --- a/docs/privacy-and-data-flow.md +++ b/docs/privacy-and-data-flow.md @@ -268,6 +268,14 @@ the stored copies are not separately synced to WebBrain. ### Trace Recorder +**Local feedback diagnostics** is enabled by default under Settings → Display. +When ordinary trace recording is off, it retains metadata-only diagnostics for +up to ten completed runs, seven days, and 2 MiB (with a 128 KiB per-run cap). +These automatic records never inherit lossless tracing and contain no raw +conversation/tool text or screenshot bytes. Turning the setting off stops this +recording and removes its automatic history; explicitly recorded traces remain. +Retention and event-budget omissions are identified in feedback exports. + When enabled (Settings → Display → "Record traces"), every agent run is written to the local `webbrain_traces` IndexedDB database in one of two privacy tiers. An external/local provider with **Share queries for research** enabled also @@ -480,6 +488,19 @@ The only outbound HTTP requests are: 7. **Encrypted Cloud Sync calls** to `https://api.webbrain.one/v1/sync` (only after a subscriber explicitly enables sync; vault content is encrypted before upload) 8. **Slash-driven tab/screen recording** creates no outbound traffic (the .webm is saved to the Downloads folder via `chrome.downloads.download`) 9. **Voluntary research shares** to `https://api.webbrain.one/v1/improvement/generations` and `/v1/improvement/diagnostic-traces` (only for a local or bring-your-own provider with its separate sharing switch enabled) +10. **Feedback trace attachments** uploaded by GitHub's native issue editor, + only after the user chooses **Upload trace and open GitHub** in the extension. + Preparing, viewing, and downloading the trace are local operations. The + confirmation identifies the conversation, recorded content tier, run and + screenshot counts, size, and any fallback or omissions. GitHub makes the + attachment publicly accessible immediately, before the issue is submitted. + **Continue without trace** uploads no trace. Export scrubs credential-shaped + values, but conversation/page text and screenshots can still be sensitive. + Temporary exports expire after one hour and are removed after completion, + cancellation, or closing the destination tab. Failed handoffs can retry the + same authorized bytes. Full traces that exceed the attachment limit use an + explicitly disclosed diagnostic fallback; the original stays available as + a local download. The `webRequest` API shortcut observer is on by default and does not create outbound requests; it observes replay metadata for requests @@ -718,6 +739,7 @@ CDP capture → JPEG/PNG data URL | Provider prompt/tool tier | Choose Compact, Mid, or Full tool exposure for non-cloud providers | | Ask / Act / Dev mode | Choose read-only, normal action, or developer/page-inspection mode | | Tracing toggle | Controls ordinary local trace recording; a separately opted-in provider research share records a run for metadata-only diagnostic upload even when this toggle is off | +| Local feedback diagnostics | Keeps bounded metadata locally when ordinary tracing is off; disabling it clears automatic history. Uploading a feedback attachment requires separate confirmation. | | Screenshot fallback | Controls whether page images are sent to the LLM | | Auto-screenshot mode | Controls how frequently viewport captures are sent | | Strict secret handling | Keeps credentials out of assistant text and completion summaries: an instruction to the model, plus exact-match redaction in cloud runs of anything it typed, sent, or read from a labelled field | diff --git a/docs/security-model.md b/docs/security-model.md index 52d8869e54..82c2de4890 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -207,10 +207,28 @@ persistent setting, which remains active until the user turns it off. ## Trace Data Isolation +The separately configurable **Local feedback diagnostics** setting is enabled +by default. When ordinary recording is disabled, it keeps only metadata locally, +bounded to ten completed runs, seven days, 2 MiB total, and 128 KiB per run. +It cannot enable lossless recording. Disabling it clears automatic records and +leaves explicit recordings intact. + +Feedback prepares a snapshot of the conversation selected when the rating prompt +opened. A dedicated dialog must authorize the actual attachment before it leaves +the browser: GitHub uploads files immediately, even before issue submission. +Viewing a trace and continuing without it never authorize an upload. The handoff +is restricted to the chat panel, an exact GitHub repository and destination tab, +the top frame, an expiring random token, and immutable staged bytes. Transfers +are chunked; reloads do not automatically repeat an uncertain upload. The user +reviews and submits the GitHub issue separately. Legacy and lossless feedback +exports mask credential-shaped data, but this does not anonymize screenshots or +arbitrary conversation/page content. + The trace recorder (`trace/recorder.js`) writes to IndexedDB on the user's -machine when explicitly enabled (Settings → Display → "Record traces") or -when a local/bring-your-own provider's separate **Share queries for research** -switch is enabled for that run. The latter forces a local record so a bounded, +machine for bounded automatic feedback diagnostics, when explicitly enabled +(Settings → Display → "Record traces"), or when a local/bring-your-own provider's +separate **Share queries for research** switch is enabled for that run. The latter +forces a local record so a bounded, content-free diagnostic timeline can be uploaded under that explicit consent. The default tier is metadata-only: run records omit user and final assistant text; event records keep allowlisted counts, timings, usage, status/error codes, diff --git a/package.json b/package.json index 1a3fa32ff7..693360100c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:cms && npm run test:markdown && npm run test:generative-media && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs test/capsolver-cloud-broker.mjs test/captcha-providers.mjs test/captcha-weighted-fallback.mjs test/captcha-hcaptcha-providers.mjs test/captcha-native-providers.mjs test/captcha-runtime-policy.mjs test/captcha-callback-bridge.mjs test/captcha-aws-recovery.mjs test/captcha-documented-contracts.mjs test/huggingface-signup-recovery.mjs test/trace-full-export.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:billing-notice && npm run test:compass-quota && npm run test:provider-limits && npm run test:provider-catalog && npm run test:openrouter-capabilities && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:cloud-bridge-approval && npm run test:discord && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && npm run test:workflow-editor && node --test test/profile-sync-stats.mjs && node --test test/memcode-recall.mjs && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:cms && npm run test:markdown && npm run test:generative-media && npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs test/capsolver-cloud-broker.mjs test/captcha-providers.mjs test/captcha-weighted-fallback.mjs test/captcha-hcaptcha-providers.mjs test/captcha-native-providers.mjs test/captcha-runtime-policy.mjs test/captcha-callback-bridge.mjs test/captcha-aws-recovery.mjs test/captcha-documented-contracts.mjs test/huggingface-signup-recovery.mjs test/trace-full-export.mjs test/feedback-traces.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:billing-notice && npm run test:compass-quota && npm run test:provider-limits && npm run test:provider-catalog && npm run test:openrouter-capabilities && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:cloud-bridge-approval && npm run test:discord && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && npm run test:workflow-editor && node --test test/profile-sync-stats.mjs && node --test test/memcode-recall.mjs && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:captcha:ui": "node test/captcha-settings-ui.mjs && node test/captcha-application-ui.mjs", "test:captcha:bridge:ui": "node test/captcha-callback-bridge-ui.mjs", "test:billing-notice": "node test/billing-payment-notice.mjs", @@ -71,7 +71,8 @@ "test:compass-quota:ui": "node test/compass-quota-ui.mjs", "test:generative-media": "node --test test/generative-media.mjs test/generative-media-availability.mjs", "test:generative-media:ui": "node test/generative-media-ui.mjs", - "test:generative-media:extension": "node test/generative-media-extension.mjs" + "test:generative-media:extension": "node test/generative-media-extension.mjs", + "test:feedback": "node --test test/feedback-traces.mjs" }, "devDependencies": { "playwright": "^1.48.0", diff --git a/src/chrome/src/agent/trace-export.js b/src/chrome/src/agent/trace-export.js index 95b9454570..4190dd1922 100644 --- a/src/chrome/src/agent/trace-export.js +++ b/src/chrome/src/agent/trace-export.js @@ -20,6 +20,7 @@ */ import { isKnownKind } from '../trace/event-model.js'; +import { isErrorCode } from '../trace/error-codes.js'; import { isSensitiveCloudKey } from '../cloud-runs.js'; const ARGS_LIMIT = 300; @@ -55,15 +56,40 @@ function redactExportValue(value, key = '') { return Object.fromEntries(Object.entries(value).map(([childKey, item]) => [childKey, redactExportValue(item, childKey)])); } -export function sanitizeTraceExport(payload) { - if (payload?.run?.lossless === true) return redactExportValue(payload); +const DIAGNOSTIC_CODE_KINDS = new Set(['error', 'turn_start', 'turn_end', 'step_start', 'step_end']); + +function restoreDiagnosticCodes(entry, redacted) { + if (!Array.isArray(entry?.events) || !Array.isArray(redacted?.events)) return; + entry.events.forEach((event, index) => { + const data = redacted.events[index]?.data; + if (!data || typeof data !== 'object') return; + // Only catalog codes in recorder metadata are public diagnostics. A code + // in tool arguments/results or conversation content remains a credential. + if (DIAGNOSTIC_CODE_KINDS.has(event.kind) && isErrorCode(event.data?.code)) data.code = event.data.code; + if (event.kind === 'note' && data.extra && isErrorCode(event.data?.extra?.code)) data.extra.code = event.data.extra.code; + }); +} + +function redactTraceExport(payload) { + const redacted = redactExportValue(payload); + restoreDiagnosticCodes(payload, redacted); + if (Array.isArray(payload?.runs) && Array.isArray(redacted?.runs)) { + payload.runs.forEach((entry, index) => restoreDiagnosticCodes(entry, redacted.runs[index])); + } + return redacted; +} + +export function sanitizeTraceExport(payload, { allRuns = false } = {}) { + // Feedback can include legacy rows that predate the lossless marker. + if (allRuns) return redactTraceExport(payload); + if (payload?.run?.lossless === true) return redactTraceExport(payload); if (!Array.isArray(payload?.runs)) return payload; let changed = false; const runs = payload.runs.map((entry) => { if (entry?.run?.lossless !== true) return entry; changed = true; - return redactExportValue(entry); + return redactTraceExport(entry); }); return changed ? { ...payload, runs } : payload; diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 25bdd9a470..40510a21c4 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1,6 +1,8 @@ import { resolveDecisionConfig, listDecisionModels } from './agent/decision-config.js'; import { probeDecisionVision } from './agent/decision-vision-probe.js'; import { installSafeSocialBackground } from './safesocial/background.js'; +import { createFeedbackHandoff } from './feedback-handoff.js'; +const feedbackHandoff = createFeedbackHandoff(chrome); import { ProviderManager } from './providers/manager.js'; import { WEBGPU_COMPASS_TINY_V2_MODEL_ID, @@ -2837,6 +2839,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }); async function handleMessage(msg, sender) { + if (String(msg.action || '').startsWith('feedback_')) return feedbackHandoff.handle(msg, sender); if (msg.action === 'chat_steer') { // Content scripts must never turn page text into a trusted human correction. if (sender?.url?.split(/[?#]/)[0] !== chrome.runtime.getURL('src/ui/sidepanel.html')) { diff --git a/src/chrome/src/config-transfer.js b/src/chrome/src/config-transfer.js index 7437499e55..d621f27317 100644 --- a/src/chrome/src/config-transfer.js +++ b/src/chrome/src/config-transfer.js @@ -54,6 +54,7 @@ export const DEFAULT_CONFIG_SETTINGS = Object.freeze({ completionConfetti: true, completionFlashTab: true, tracingEnabled: false, + feedbackDiagnosticsEnabled: true, strictSecretMode: false, agentAllowLocalNetwork: false, scheduledTasksEnabled: true, @@ -136,6 +137,7 @@ const BOOLEAN_KEYS = new Set([ 'completionConfetti', 'completionFlashTab', 'tracingEnabled', + 'feedbackDiagnosticsEnabled', 'strictSecretMode', 'agentAllowLocalNetwork', 'scheduledTasksEnabled', diff --git a/src/chrome/src/content/feedback-attachment.js b/src/chrome/src/content/feedback-attachment.js new file mode 100644 index 0000000000..ed6c42fde4 --- /dev/null +++ b/src/chrome/src/content/feedback-attachment.js @@ -0,0 +1,137 @@ +(() => { + if (globalThis.__webbrainFeedbackAttachment) return; + globalThis.__webbrainFeedbackAttachment = true; + const api = typeof browser !== 'undefined' ? browser : chrome; + const destination = () => location.origin === 'https://github.com' + && location.pathname === '/webbrain-one/webbrain/issues/new'; + if (!destination()) return; + let draft, busy = false, banner, message, retryButton, downloadButton; + const send = async (action, extra = {}) => { + const result = await api.runtime.sendMessage({ target: 'background', action, id: draft?.id, token: draft?.token, ...extra }); + if (!result?.ok) throw new Error(result?.error || 'Feedback handoff failed.'); + return result; + }; + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + function editor() { + const scope = document.querySelector('main') || document.body; + return scope.querySelector('textarea[name="issue[body]"], textarea#issue_body') + || [...scope.querySelectorAll('textarea')].find(input => !input.disabled && !input.readOnly); + } + function fileInput(input) { + if (!input) return null; + for (const scope of [input.closest('file-attachment'), input.closest('form'), document.querySelector('main')]) { + if (!scope) continue; + const candidates = [...scope.querySelectorAll('input[type="file"]')].filter(field => !field.disabled); + if (candidates.length === 1) return candidates[0]; + } + return null; + } + function attached(input) { + // An arbitrary link or the filename by itself is not upload evidence. + return [...String(input?.value || '').matchAll(/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/g)].some(match => { + try { + const url = new URL(match[2]); + return match[1] === draft.filename && url.origin === 'https://github.com' + && (url.pathname.startsWith('/user-attachments/files/') || url.pathname.startsWith('/files/')); + } catch { return false; } + }); + } + function show(text, failed = false) { + message.textContent = text; + retryButton.hidden = !failed || !draft.includeTrace; + downloadButton.hidden = !failed || !draft.includeTrace; + } + function createBanner() { + banner = document.createElement('aside'); + banner.setAttribute('role', 'status'); + banner.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:2147483647;max-width:420px;padding:16px;border:1px solid #8b949e;border-radius:8px;background:#fff;color:#24292f;box-shadow:0 4px 24px #0003;font:14px/1.5 system-ui'; + message = document.createElement('p'); message.style.margin = '0 0 8px'; + retryButton = document.createElement('button'); retryButton.type = 'button'; + retryButton.textContent = draft.copy.retry || 'Retry attachment'; retryButton.hidden = true; + retryButton.addEventListener('click', async () => { + if (busy) return; + retryButton.disabled = true; + try { await send('feedback_retry'); await upload(); } + catch (error) { show(error.message, true); } + finally { retryButton.disabled = false; } + }); + downloadButton = document.createElement('button'); downloadButton.type = 'button'; + downloadButton.textContent = draft.copy.download || 'Download trace'; downloadButton.hidden = true; + downloadButton.addEventListener('click', () => void send('feedback_preview').catch(error => show(error.message, true))); + const close = document.createElement('button'); close.type = 'button'; close.textContent = '×'; + close.setAttribute('aria-label', draft.copy.close || 'Close'); + close.style.cssText = 'float:right;margin-left:8px'; close.addEventListener('click', () => banner.remove()); + banner.append(close, message, retryButton, downloadButton); document.body.append(banner); + } + async function waitForEditor() { + for (let attempt = 0; attempt < 120 && destination(); attempt++) { + const input = editor(); + if (input) return input; + await sleep(250); + } + throw new Error(draft.copy.failed || 'The GitHub editor is not available.'); + } + async function complete() { + await send('feedback_result', { success: true }); + show(draft.copy.ready || 'Trace attached. Review and submit your feedback on GitHub.'); + } + async function upload() { + if (busy || !destination()) return; + busy = true; + try { + const input = await waitForEditor(); + if (attached(input)) { await complete(); return; } + show(draft.copy.uploading || 'Attaching your trace. Please wait before submitting the issue.'); + const chunks = []; + for (let offset = 0; offset < draft.size; offset += draft.chunkBytes) { + if (!destination()) throw new Error('The feedback destination changed.'); + const response = await send('feedback_chunk', { offset }); + const binary = atob(response.base64); + chunks.push(Uint8Array.from(binary, character => character.charCodeAt(0))); + } + let target; + for (let attempt = 0; attempt < 80 && destination(); attempt++) { + target = fileInput(editor()); + if (target) break; + await sleep(250); + } + if (!target || !destination()) throw new Error(draft.copy.failed || 'The GitHub upload control is not available.'); + await send('feedback_upload_started'); + const transfer = new DataTransfer(); + transfer.items.add(new File(chunks, draft.filename, { type: draft.mimeType || 'application/json' })); + target.files = transfer.files; + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + for (let attempt = 0; attempt < 240 && destination(); attempt++) { + if (attached(editor())) { await complete(); return; } + await sleep(250); + } + throw new Error(draft.copy.failed || 'The trace attachment could not be confirmed.'); + } catch (error) { + await send('feedback_result', { success: false }).catch(() => {}); + show(draft.copy.failed || error.message, true); + } finally { busy = false; } + } + void (async () => { + draft = await send('feedback_claim'); + draft.copy ||= {}; + createBanner(); + const input = await waitForEditor(); + // React listens to native input events. Never replace text the user edited. + if (!input.value || input.value.trim() === draft.initialBody.trim()) { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; + setter.call(input, draft.body); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + if (!draft.includeTrace) { + await send('feedback_result', { success: true }); + show(draft.copy.draftReady || 'Review and submit your feedback on GitHub.'); + } else if (attached(input)) await complete(); + else if (draft.status === 'uploading' || draft.status === 'failed') show(draft.copy.failed || 'Check the draft, then retry the attachment if needed.', true); + else await upload(); + })().catch(error => { + if (banner) show(draft?.copy?.failed || error.message, true); + else globalThis.__webbrainFeedbackAttachment = false; + }); +})(); diff --git a/src/chrome/src/feedback-handoff.js b/src/chrome/src/feedback-handoff.js new file mode 100644 index 0000000000..ad6b55bf58 --- /dev/null +++ b/src/chrome/src/feedback-handoff.js @@ -0,0 +1,163 @@ +import { + getFeedbackDraft, listFeedbackDrafts, putFeedbackDraft, updateFeedbackDraft, deleteFeedbackDraft, + sweepFeedbackDrafts, FEEDBACK_TTL_MS, FEEDBACK_CHUNK_BYTES, +} from './feedback-store.js'; +import { buildFeedbackBody, FEEDBACK_ISSUES_URL } from './ui/store-review-prompt.js'; +import { flushPendingWrites, pruneFeedbackDiagnostics } from './trace/recorder.js'; + +export function isFeedbackDestination(url) { + try { + const parsed = new URL(url); + return parsed.origin === 'https://github.com' + && parsed.pathname === '/webbrain-one/webbrain/issues/new'; + } catch { return false; } +} + +export function mayReadFeedbackDraft(draft, sender, tabUrl, now = Date.now()) { + return Boolean(draft && draft.authorized && draft.expiresAt > now + && sender?.frameId === 0 && sender.tab?.id === draft.destinationTabId + && isFeedbackDestination(sender.url) && isFeedbackDestination(tabUrl)); +} + +export function createFeedbackHandoff(api) { + const inflight = new Set(); + const panelUrl = api.runtime.getURL('src/ui/sidepanel.html'); + const settingsUrl = api.runtime.getURL('src/ui/settings.html'); + const isPanel = sender => sender?.id === api.runtime.id + && sender.url?.split(/[?#]/)[0] === panelUrl; + async function cleanup() { + const settings = await api.storage.local.get('feedbackDiagnosticsEnabled'); + await Promise.all([ + sweepFeedbackDrafts(), + pruneFeedbackDiagnostics({ clear: settings.feedbackDiagnosticsEnabled === false }), + ]); + } + const storeReady = cleanup().catch(() => {}); + + async function inject(tabId) { + await storeReady; + if (inflight.has(tabId)) return; + const tab = await api.tabs.get(tabId); + if (!isFeedbackDestination(tab.url)) return; + const draft = (await listFeedbackDrafts()).find(row => row.destinationTabId === tabId && row.authorized && row.expiresAt > Date.now()); + if (!draft) return; + inflight.add(tabId); + try { + if (api.scripting?.executeScript) await api.scripting.executeScript({ + target: { tabId, frameIds: [0] }, files: ['src/content/feedback-attachment.js'], + }); + else await api.tabs.executeScript(tabId, { file: 'src/content/feedback-attachment.js', frameId: 0 }); + } finally { inflight.delete(tabId); } + } + + api.tabs.onUpdated.addListener((tabId, change) => { + if (change.status === 'complete') void inject(tabId).catch(() => {}); + }); + api.tabs.onRemoved.addListener(tabId => { void sweepFeedbackDrafts({ tabId }).catch(() => {}); }); + api.alarms.onAlarm.addListener(alarm => { + if (alarm.name === 'feedback-draft-cleanup') void cleanup().catch(() => {}); + }); + api.storage.onChanged?.addListener((changes, area) => { + if (area === 'local' && changes.feedbackDiagnosticsEnabled?.newValue === false) { + void pruneFeedbackDiagnostics({ clear: true }).catch(() => {}); + } + }); + api.alarms.create('feedback-draft-cleanup', { periodInMinutes: 5 }); + // A worker may have restarted after the document loaded, without an update event. + void storeReady.then(async () => { + for (const draft of await listFeedbackDrafts()) { + if (draft.destinationTabId !== null) void inject(draft.destinationTabId).catch(() => {}); + } + }).catch(() => {}); + + return { + async handle(msg, sender) { + await storeReady; + if (msg.action === 'feedback_flush') { + if (!isPanel(sender)) throw new Error('Chat panel only.'); + await flushPendingWrites(); + return { ok: true }; + } + if (msg.action === 'feedback_clear_diagnostics') { + if (sender?.id !== api.runtime.id || sender.url?.split(/[?#]/)[0] !== settingsUrl) throw new Error('Settings only.'); + await pruneFeedbackDiagnostics({ clear: true }); + return { ok: true }; + } + if (msg.action === 'feedback_open') { + if (!isPanel(sender)) throw new Error('Feedback can only be sent from the chat panel.'); + const rating = Math.max(1, Math.min(5, Math.round(Number(msg.rating) || 3))); + const body = buildFeedbackBody({ rating, comment: msg.comment, traceNote: msg.traceNote }); + let draft = msg.includeTrace ? await getFeedbackDraft(String(msg.id || '')) : null; + if (msg.includeTrace && (!draft?.blob || draft.expiresAt <= Date.now() + || draft.status !== 'prepared')) throw new Error('Prepare and review the trace again before uploading.'); + // The dedicated action from the consent button authorizes these staged, + // immutable bytes; page content cannot call this extension-page route. + draft = draft || { + id: crypto.randomUUID(), token: crypto.randomUUID(), + createdAt: Date.now(), expiresAt: Date.now() + FEEDBACK_TTL_MS, + }; + draft = { ...draft, body, initialBody: `**Rating:** ${rating}/5`, + authorized: true, consentAt: msg.includeTrace ? Date.now() : null, + status: 'pending', copy: msg.copy || {}, includeTrace: msg.includeTrace === true }; + if (encodeURIComponent(body).length < 6000) draft.initialBody = body; + let tab; + try { + tab = await api.tabs.create({ url: 'about:blank' }); + draft.destinationTabId = tab.id; + await putFeedbackDraft(draft); + const url = new URL(FEEDBACK_ISSUES_URL); + url.searchParams.set('title', `WebBrain feedback (${rating}/5)`); + url.searchParams.set('body', draft.initialBody); + await api.tabs.update(tab.id, { url: url.href }); + return { ok: true, tabId: tab.id }; + } catch (error) { + await deleteFeedbackDraft(draft.id); + if (tab) await api.tabs.remove(tab.id).catch(() => {}); + throw error; + } + } + const draft = msg.action === 'feedback_claim' + ? (await listFeedbackDrafts()).find(row => row.destinationTabId === sender?.tab?.id) + : await getFeedbackDraft(String(msg.id || '')); + const tab = sender?.tab ? await api.tabs.get(sender.tab.id) : null; + if (sender?.id !== api.runtime.id || !mayReadFeedbackDraft(draft, sender, tab?.url) + || (msg.action !== 'feedback_claim' && msg.token !== draft.token)) throw new Error('Unauthorized feedback handoff.'); + if (msg.action === 'feedback_claim') return { + ok: true, id: draft.id, token: draft.token, body: draft.body, initialBody: draft.initialBody, + filename: draft.filename, size: draft.blob?.size || 0, mimeType: draft.blob?.type, + status: draft.status, includeTrace: draft.includeTrace, chunkBytes: FEEDBACK_CHUNK_BYTES, + copy: draft.copy || {}, + }; + if (msg.action === 'feedback_preview') { + if (!draft.includeTrace) throw new Error('No trace was selected.'); + await api.tabs.create({ url: api.runtime.getURL(`src/ui/feedback-trace-preview.html?id=${encodeURIComponent(draft.id)}`) }); + return { ok: true }; + } + if (msg.action === 'feedback_chunk') { + if (!draft.includeTrace || !Number.isSafeInteger(msg.offset) || msg.offset < 0 || msg.offset >= draft.blob.size) throw new Error('Invalid feedback chunk.'); + const bytes = new Uint8Array(await draft.blob.slice(msg.offset, msg.offset + FEEDBACK_CHUNK_BYTES).arrayBuffer()); + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + return { ok: true, base64: btoa(binary) }; + } + if (msg.action === 'feedback_upload_started') { + await updateFeedbackDraft(draft.id, current => { + if (!current?.includeTrace || !['pending', 'failed'].includes(current.status)) throw new Error('Attachment already dispatched.'); + return { ...current, status: 'uploading' }; + }); + return { ok: true }; + } + if (msg.action === 'feedback_retry') { + if (!draft.includeTrace || !['failed', 'uploading'].includes(draft.status)) throw new Error('No failed attachment to retry.'); + await putFeedbackDraft({ ...draft, status: 'failed' }); + return { ok: true }; + } + if (msg.action === 'feedback_result') { + if (msg.success === true) await deleteFeedbackDraft(draft.id); + else await putFeedbackDraft({ ...draft, status: 'failed' }); + return { ok: true }; + } + throw new Error('Unknown feedback action.'); + }, + }; +} diff --git a/src/chrome/src/feedback-store.js b/src/chrome/src/feedback-store.js new file mode 100644 index 0000000000..e3762e5c9d --- /dev/null +++ b/src/chrome/src/feedback-store.js @@ -0,0 +1,65 @@ +export const FEEDBACK_TTL_MS = 60 * 60 * 1000; +export const FEEDBACK_CHUNK_BYTES = 128 * 1024; +let database; + +function openDB() { + if (!database) database = new Promise((resolve, reject) => { + const request = indexedDB.open('webbrain_feedback', 1); + request.onupgradeneeded = () => request.result.createObjectStore('drafts', { keyPath: 'id' }); + request.onsuccess = () => resolve(request.result); + request.onerror = () => { database = null; reject(request.error); }; + }); + return database; +} + +async function operation(mode, action) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('drafts', mode); + const request = action(transaction.objectStore('drafts')); + transaction.oncomplete = () => resolve(request?.result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error || new Error('Feedback storage aborted.')); + }); +} + +export const getFeedbackDraft = id => operation('readonly', store => store.get(id)); +export const listFeedbackDrafts = () => operation('readonly', store => store.getAll()); +export const putFeedbackDraft = draft => operation('readwrite', store => store.put(draft)); +export const deleteFeedbackDraft = id => operation('readwrite', store => store.delete(id)); + +// A single transaction prevents two restarted/injected helpers from both +// dispatching the same attachment after reading a pending state. +export async function updateFeedbackDraft(id, update) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('drafts', 'readwrite'); + const store = transaction.objectStore('drafts'); + const request = store.get(id); + let updated; + request.onsuccess = () => { + try { updated = update(request.result); if (updated) store.put(updated); } + catch (error) { reject(error); transaction.abort(); } + }; + transaction.oncomplete = () => resolve(updated); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error || new Error('Feedback update aborted.')); + }); +} + +export async function stageFeedbackTrace(prepared) { + const draft = { + ...prepared, preview: undefined, + id: crypto.randomUUID(), token: crypto.randomUUID(), + createdAt: Date.now(), expiresAt: Date.now() + FEEDBACK_TTL_MS, + authorized: false, status: 'prepared', destinationTabId: null, + }; + await putFeedbackDraft(draft); + return draft; +} + +export async function sweepFeedbackDrafts({ now = Date.now(), tabId = null } = {}) { + for (const draft of await listFeedbackDrafts()) { + if (draft.expiresAt <= now || (tabId !== null && draft.destinationTabId === tabId)) await deleteFeedbackDraft(draft.id); + } +} diff --git a/src/chrome/src/trace/feedback-export.js b/src/chrome/src/trace/feedback-export.js new file mode 100644 index 0000000000..b5e2ada1af --- /dev/null +++ b/src/chrome/src/trace/feedback-export.js @@ -0,0 +1,103 @@ +import { exportRecordedSession } from './session-export.js'; +import { sanitizeTraceExport } from '../agent/trace-export.js'; +import { projectTraceEventData } from './privacy.js'; +import { normalizeRuntimeTraceConfig } from './runtime-config.js'; + +export const FEEDBACK_ATTACHMENT_LIMIT = 20 * 1024 * 1024; + +const DIAGNOSTIC_RUN_FIELDS = [ + 'runId', 'conversationId', 'parentRunId', 'parentSessionId', 'delegationDepth', + 'startedAt', 'endedAt', 'durationMs', 'status', 'model', 'providerId', 'providerClass', + 'webbrainVersion', 'traceFormatVersion', 'mode', 'stepCount', 'totalInputTokens', + 'totalOutputTokens', 'totalCost', 'llmRequestCount', 'llmResponseCount', 'toolCallCount', + 'visionSubCallCount', 'errorCount', 'retryCount', 'totalLlmLatencyMs', 'totalToolLatencyMs', + 'feedbackOnly', 'feedbackHistoryOmitted', 'feedbackEventsOmitted', +]; + +export function diagnosticFeedbackPayload(payload) { + return { + ...payload, + runs: payload.runs.map(({ run, events }) => ({ + // Legacy rows can have unknown content fields. A diagnostic fallback + // must not copy those fields, including unknown event-envelope fields. + run: { ...Object.fromEntries(DIAGNOSTIC_RUN_FIELDS.filter(key => run[key] !== undefined).map(key => [key, run[key]])), + runtimeConfig: normalizeRuntimeTraceConfig(run.runtimeConfig), lossless: false }, + events: events.map(event => ({ runId: event.runId, seq: event.seq, ts: event.ts, kind: event.kind, data: { + ...projectTraceEventData(event.kind, event.data), + ...(event.kind === 'tool' && !event.data?.result ? { + resultStatus: event.data?.resultStatus || 'unknown', + ...(event.data?.resultErrorCode || event.data?.errorCode ? { resultErrorCode: event.data.resultErrorCode || event.data.errorCode } : {}), + } : {}), + } })), + })), + feedbackOmissions: [...(payload.feedbackOmissions || []), 'Full trace exceeds the attachment size limit; diagnostic metadata only.'], + }; +} + +export async function prepareFeedbackTrace(store, sessionId, version = '', { + limit = FEEDBACK_ATTACHMENT_LIMIT, + compress = async blob => typeof CompressionStream === 'function' + ? new Response(blob.stream().pipeThrough(new CompressionStream('gzip'))).blob() : null, +} = {}) { + if (!sessionId) return null; + const exported = await exportRecordedSession(store, sessionId, version); + if (!exported.turnCount) return null; + let payload = sanitizeTraceExport(JSON.parse(exported.json), { allRuns: true }); + const omissions = []; + if (exported.recordingTruncated) omissions.push('Some content was omitted at recording time.'); + if (payload.runs.some(entry => entry.run.feedbackHistoryOmitted || entry.run.feedbackEventsOmitted)) { + omissions.push('Automatic diagnostic history is bounded; older runs or events may be absent.'); + } + payload.feedbackOmissions = omissions; + const originalBlob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + let blob = originalBlob; + let filename = `webbrain-feedback-${Date.now()}.json`; + if (blob.size >= limit) { + let compressed; + try { compressed = await compress(blob); } catch { /* diagnostic fallback below */ } + if (compressed && compressed.size < limit) { + blob = new Blob([compressed], { type: 'application/gzip' }); + filename += '.gz'; + } else { + payload = diagnosticFeedbackPayload(payload); + // Bound the fallback as well; retain a visible omission marker. + const budget = Math.min(limit / 2, 256 * 1024); + let used = 0; + payload.runs = payload.runs.slice().reverse().map(entry => { + const events = []; + for (const event of entry.events.slice().reverse()) { + const size = new TextEncoder().encode(JSON.stringify(event)).length; + if (used + size > budget) { entry.run.feedbackEventsOmitted = true; continue; } + used += size; + events.unshift(event); + } + return { run: entry.run, events }; + }).reverse(); + blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + // Whitespace and run headers also count toward GitHub's file limit. + while (blob.size >= limit) { + const entry = payload.runs.find(item => item.events.length); + if (entry) { entry.events.shift(); entry.run.feedbackEventsOmitted = true; } + else if (payload.runs.length > 1) { + payload.runs.shift(); + if (!payload.feedbackOmissions.includes('Older runs omitted to fit the attachment.')) payload.feedbackOmissions.push('Older runs omitted to fit the attachment.'); + } else break; + blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + } + filename = filename.replace(/\.json$/, '-diagnostics.json'); + if (blob.size >= limit) throw new Error('Diagnostic attachment exceeds the size limit.'); + } + } + const screenshotCount = payload.runs.reduce((sum, entry) => sum + entry.events.filter(event => + event.kind === 'screenshot' && (event.data?.screenshot_base64 || event.data?.screenshot_dataUrl)).length, 0); + const traceType = screenshotCount > 0 || payload.runs.some(entry => entry.run.lossless === true + || ['userMessage', 'finalContent', 'tabUrl', 'tabTitle'].some(key => entry.run[key]) + || entry.run.attachments?.length + || entry.events.some(event => ['messages', 'result', 'args', 'content', 'text', 'message', 'reasoning', 'tools'].some(key => event.data?.[key]))) + ? 'full' : 'diagnostic'; + return { + blob, originalBlob, filename, sessionId, traceType, + runCount: payload.runs.length, screenshotCount, + omissions: payload.feedbackOmissions, + }; +} diff --git a/src/chrome/src/trace/feedback-policy.js b/src/chrome/src/trace/feedback-policy.js new file mode 100644 index 0000000000..23a14f4485 --- /dev/null +++ b/src/chrome/src/trace/feedback-policy.js @@ -0,0 +1,33 @@ +export const FEEDBACK_DIAGNOSTICS_KEY = 'feedbackDiagnosticsEnabled'; +export const FEEDBACK_HISTORY_RUNS = 10; +export const FEEDBACK_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000; +export const FEEDBACK_HISTORY_BYTES = 2 * 1024 * 1024; +export const FEEDBACK_RUN_BYTES = 128 * 1024; + +// Automatic diagnostics must never inherit a stale lossless preference. +export function feedbackRecordingPolicy(settings, forced = false) { + const feedbackOnly = !forced && settings.tracingEnabled !== true + && settings[FEEDBACK_DIAGNOSTICS_KEY] !== false; + return { + enabled: forced || settings.tracingEnabled === true || feedbackOnly, + feedbackOnly, + lossless: !feedbackOnly && settings.losslessTrace === true, + }; +} + +export function feedbackRunsToEvict(runs, now = Date.now()) { + const automatic = runs.filter(run => run.feedbackOnly === true); + const completed = automatic.filter(run => run.status !== 'running') + .sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0)); + let bytes = automatic.filter(run => run.status === 'running') + .reduce((sum, run) => sum + (Number(run.feedbackBytes) || 0), 0); + let kept = 0; + return completed.filter(run => { + const size = Number(run.feedbackBytes) || 0; + if (now - (run.startedAt || 0) > FEEDBACK_HISTORY_AGE_MS + || kept >= FEEDBACK_HISTORY_RUNS || bytes + size > FEEDBACK_HISTORY_BYTES) return true; + kept += 1; + bytes += size; + return false; + }).map(run => run.runId); +} diff --git a/src/chrome/src/trace/recorder.js b/src/chrome/src/trace/recorder.js index 9f745a9704..0c54ac9c65 100644 --- a/src/chrome/src/trace/recorder.js +++ b/src/chrome/src/trace/recorder.js @@ -14,6 +14,7 @@ import { import { createTraceStats, addTraceEvent, aggregateTraceRuns } from './stats.js'; import { projectTraceEventData, projectTraceRun } from './privacy.js'; import { toolOutcome } from './tool-outcome.js'; +import { feedbackRecordingPolicy, feedbackRunsToEvict, FEEDBACK_RUN_BYTES, FEEDBACK_HISTORY_BYTES } from './feedback-policy.js'; /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, @@ -25,9 +26,10 @@ import { toolOutcome } from './tool-outcome.js'; * - shots keyPath=[runId, seq] // screenshot Blobs * * All writes are fire-and-forget. Recording is gated on the `tracingEnabled` - * setting. When disabled, every call is a cheap no-op. + * setting, explicit forced runs, or bounded automatic feedback diagnostics. */ +const traceStorage = () => (typeof browser !== 'undefined' ? browser : chrome).storage.local; const DB_NAME = 'webbrain_traces'; const DB_VERSION = 2; @@ -96,29 +98,27 @@ function promisifyReq(req) { async function tracingEnabled() { try { if (typeof indexedDB === 'undefined') return false; - const { tracingEnabled } = await chrome.storage.local.get(['tracingEnabled']); + const { tracingEnabled } = await traceStorage().get(['tracingEnabled']); return tracingEnabled === true; } catch { return false; } } -// Opt-in lossless tier: same event pipeline, full request payloads instead of -// content-free provenance. Read once per run at startRun; never per event. -async function losslessTraceEnabled() { - try { - if (typeof indexedDB === 'undefined') return false; - const { losslessTrace } = await chrome.storage.local.get(['losslessTrace']); - return losslessTrace === true; - } catch { return false; } -} - async function isForcedTraceRun(runId) { if (!runId) return false; + if (_runState.get(runId)?.feedbackOnly === true) { + const settings = await traceStorage().get('feedbackDiagnosticsEnabled'); + return settings.feedbackDiagnosticsEnabled !== false; + } if (_runState.get(runId)?.forced === true) return true; try { const db = await openDB(); const record = await promisifyReq( tx(db, ['runs'], 'readonly').objectStore('runs').get(runId), ); + if (record?.feedbackOnly === true) { + const settings = await traceStorage().get('feedbackDiagnosticsEnabled'); + return settings.feedbackDiagnosticsEnabled !== false; + } return record?.forced === true; } catch { return false; } } @@ -134,7 +134,7 @@ async function peekRunFlags(db, runId) { const record = await promisifyReq( tx(db, ['runs'], 'readonly').objectStore('runs').get(runId), ); - return { forced: record?.forced === true, lossless: record?.lossless === true, losslessBytes: record?.losslessBytes || 0, losslessBytesEncoding: record?.losslessBytesEncoding || '' }; + return { forced: record?.forced === true, feedbackOnly: record?.feedbackOnly === true, feedbackBytes: record?.feedbackBytes || 0, lossless: record?.lossless === true, losslessBytes: record?.losslessBytes || 0, losslessBytesEncoding: record?.losslessBytesEncoding || '' }; } catch { return { forced: false, lossless: false, losslessBytes: 0 }; } } @@ -199,7 +199,7 @@ async function _ensureRunState(runId, db = null) { const resolvedDb = db || await openDB(); const seq = await _peekSeq(resolvedDb, runId); const flags = await peekRunFlags(resolvedDb, runId); - const state = { seq, forced: flags.forced, lossless: flags.lossless, losslessBytes: Number(flags.losslessBytes) || 0, losslessBytesEncoding: flags.losslessBytesEncoding }; + const state = { seq, forced: flags.forced, feedbackOnly: flags.feedbackOnly, feedbackBytes: flags.feedbackBytes, lossless: flags.lossless, losslessBytes: Number(flags.losslessBytes) || 0, losslessBytesEncoding: flags.losslessBytesEncoding }; _runState.set(runId, state); return state; } catch { return null; } @@ -383,15 +383,18 @@ function normalizeTraceAttachments(attachments) { // ----- Public API ------------------------------------------------------------ export async function startRun(meta = {}) { - const forced = meta.force === true; - if (!forced && !(await tracingEnabled())) return null; try { + if (typeof indexedDB === 'undefined') return null; + const settings = await traceStorage().get(['tracingEnabled', 'losslessTrace', 'feedbackDiagnosticsEnabled']); + const policy = feedbackRecordingPolicy(settings, meta.force === true); + if (!policy.enabled) return null; + const forced = meta.force === true || policy.feedbackOnly; const db = await openDB(); const runId = meta.runId || `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const lineage = normalizeRunHeader(meta) || {}; // Tier is decided once per run: explicit caller override wins, otherwise // the opt-in setting. Never forced for local runs by default. - const lossless = meta.lossless === true || await losslessTraceEnabled(); + const lossless = !policy.feedbackOnly && (meta.lossless === true || policy.lossless); const record = projectTraceRun({ runId, // Stable per-conversation id so the Traces UI can group sibling runs @@ -421,6 +424,7 @@ export async function startRun(meta = {}) { attachments: normalizeTraceAttachments(meta.attachments), ...(lossless ? { lossless: true, losslessBytes: 0, losslessBytesEncoding: 'utf8' } : {}), forced, + ...(policy.feedbackOnly ? { feedbackOnly: true, feedbackHistoryOmitted: true, feedbackBytes: 0 } : {}), stepCount: 0, totalInputTokens: 0, totalOutputTokens: 0, @@ -434,8 +438,31 @@ export async function startRun(meta = {}) { totalToolLatencyMs: 0, finalContent: null, }, { includeContent: lossless }); - await promisifyReq(tx(db, ['runs']).objectStore('runs').put(record)); - _runState.set(runId, { seq: 0, model: record.model, providerId: record.providerId, forced, lossless, losslessBytes: 0, losslessBytesEncoding: lossless ? 'utf8' : '' }); + if (policy.feedbackOnly) { + await pruneFeedbackDiagnostics(); + // Reserve room for final statistics and omission flags as well as events. + record.feedbackBytes = utf8ByteLength(JSON.stringify(record)) + 1024; + } + if (policy.feedbackOnly) { + const saved = await new Promise((resolve, reject) => { + const transaction = tx(db, ['runs']); + const store = transaction.objectStore('runs'); + const request = store.getAll(); + let inserted = false; + request.onsuccess = () => { + const total = request.result.filter(row => row.feedbackOnly) + .reduce((sum, row) => sum + (Number(row.feedbackBytes) || 0), 0); + if (record.feedbackBytes > FEEDBACK_RUN_BYTES + || total + record.feedbackBytes > FEEDBACK_HISTORY_BYTES - 4096) return; + store.put(record); inserted = true; + }; + transaction.oncomplete = () => resolve(inserted); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + if (!saved) return null; + } else await promisifyReq(tx(db, ['runs']).objectStore('runs').put(record)); + _runState.set(runId, { seq: 0, model: record.model, providerId: record.providerId, forced, feedbackOnly: policy.feedbackOnly, feedbackBytes: record.feedbackBytes || 0, lossless, losslessBytes: 0, losslessBytesEncoding: lossless ? 'utf8' : '' }); _workflowTraceCaptures.set(runId, { run: { runId, @@ -535,6 +562,9 @@ async function _appendEventNow(runId, kind, data) { console.warn('[trace] dropped invalid event:', kind); return null; } + if (state?.feedbackOnly === true) { + return await writeFeedbackEvent(db, runId, ev, state); + } if (state?.lossless === true && (kind === 'llm_request' || kind === 'tool')) { if (losslessBudgetOmitted) { await promisifyReq(tx(db, ['events']).objectStore('events').put(ev)); @@ -559,6 +589,58 @@ async function _appendEventNow(runId, kind, data) { } } +let _feedbackWriteQueue = Promise.resolve(); + +// Serialize the shared byte budget across concurrently running tabs. +function writeFeedbackEvent(db, runId, event, state) { + const pending = _feedbackWriteQueue.catch(() => {}).then(async () => { + const bytes = utf8ByteLength(JSON.stringify(event)); + return new Promise((resolve, reject) => { + const transaction = tx(db, ['runs', 'events']); + const store = transaction.objectStore('runs'); + // Read the budget and write the event in one transaction. Other + // extension contexts can prune/start runs without invalidating a cache. + const request = store.getAll(); + let saved = false; + request.onsuccess = () => { + const run = request.result.find(row => row.runId === runId); + const total = request.result.filter(row => row.feedbackOnly) + .reduce((sum, row) => sum + (Number(row.feedbackBytes) || 0), 0); + if (!run?.feedbackOnly) return; + // Reserve a little space for final statistics and omission flags. + if ((run.feedbackBytes || 0) + bytes > FEEDBACK_RUN_BYTES + || total + bytes > FEEDBACK_HISTORY_BYTES - 4096) { + run.feedbackEventsOmitted = true; + store.put(run); + return; + } + run.feedbackBytes = (run.feedbackBytes || 0) + bytes; + store.put(run); + transaction.objectStore('events').put(event); + saved = true; + }; + transaction.oncomplete = () => { + if (saved) state.feedbackBytes += bytes; + resolve(saved ? event.seq : null); + }; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error || new Error('Feedback diagnostic write aborted.')); + }); + }); + _feedbackWriteQueue = pending; + return pending; +} + +export async function pruneFeedbackDiagnostics({ clear = false, now = Date.now() } = {}) { + if (typeof indexedDB === 'undefined') return; + const db = await openDB(); + const rows = await promisifyReq(tx(db, ['runs'], 'readonly').objectStore('runs').getAll()); + const ids = clear ? rows.filter(run => run.feedbackOnly === true).map(run => run.runId) + : [...new Set([...feedbackRunsToEvict(rows, now), ...rows.filter(run => run.feedbackOnly === true + && now - (run.startedAt || 0) > 7 * 24 * 60 * 60 * 1000).map(run => run.runId)])]; + for (const id of ids) { await deleteRun(id); if (clear) _runState.delete(id); } +} + // Retain the active run and evict completed lossless runs oldest first when // their aggregate recorded payload crosses the global budget. A cached running // total keeps under-budget writes free of any store-wide scan; the scan itself @@ -826,6 +908,7 @@ export function recordScreenshot(runId, step, dataUrl, caption = '') { projectTraceEventData('screenshot', { step, caption }, { includeContent: state?.lossless === true }), ); if (marker) { + if (state?.feedbackOnly === true) return await writeFeedbackEvent(db, runId, marker, state); await promisifyReq(tx(db, ['events']).objectStore('events').put(marker)); } return seq; @@ -1014,8 +1097,11 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { } finally { _runState.delete(runId); } + // Retention may delete this run and its transient capture. Keep the + // completed workflow independent of which diagnostics remain on disk. const workflowCapture = _workflowTraceCaptures.get(runId) || null; _workflowTraceCaptures.delete(runId); + await pruneFeedbackDiagnostics().catch(() => {}); if (!workflowCapture) return null; workflowCapture.run.status = resolvedStatus; return { run: workflowCapture.run, events: workflowCapture.events }; @@ -1088,6 +1174,7 @@ export async function flushPendingWrites() { } export async function listRuns({ limit = 500, conversationId = null } = {}) { + await pruneFeedbackDiagnostics(); const db = await openDB(); const store = tx(db, ['runs'], 'readonly').objectStore('runs'); const sessionQuery = Boolean(conversationId && store.indexNames.contains('sessionId')); diff --git a/src/chrome/src/ui/feedback-consent.css b/src/chrome/src/ui/feedback-consent.css new file mode 100644 index 0000000000..2b1972a1c7 --- /dev/null +++ b/src/chrome/src/ui/feedback-consent.css @@ -0,0 +1,14 @@ +.feedback-trace-dialog { + margin: auto; + width: min(440px, calc(100vw - 48px)); max-height: calc(100vh - 48px); + overflow: auto; padding: 20px; border: 1px solid var(--border, #8886); + border-radius: 12px; background: var(--bg-secondary, Canvas); color: var(--text-primary, CanvasText); + font: inherit; box-shadow: 0 12px 48px #0005; +} +.feedback-trace-dialog::backdrop { background: #0008; } +.feedback-trace-dialog h2 { font-size: 18px; margin: 0 0 12px; } +.feedback-trace-dialog p { line-height: 1.5; margin: 0 0 12px; } +.feedback-trace-details { overflow-wrap: anywhere; font-size: 12px; } +.feedback-trace-actions { display: grid; gap: 8px; } +.feedback-trace-actions button { white-space: normal; } +.feedback-trace-secondary { padding: 9px; border: 1px solid #8886; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; } diff --git a/src/chrome/src/ui/feedback-consent.js b/src/chrome/src/ui/feedback-consent.js new file mode 100644 index 0000000000..0613ac470e --- /dev/null +++ b/src/chrome/src/ui/feedback-consent.js @@ -0,0 +1,63 @@ +import { formatFeedbackDetails } from './feedback-copy.js'; + +export function requestFeedbackConsent(trace, copy, viewTrace) { + return new Promise(resolve => { + const dialog = document.createElement('dialog'); + dialog.className = 'feedback-trace-dialog'; + dialog.setAttribute('aria-labelledby', 'feedback-trace-title'); + dialog.setAttribute('aria-describedby', 'feedback-trace-public'); + const previousFocus = document.activeElement; + const text = (tag, value, id = '') => { + const element = document.createElement(tag); element.textContent = value; + if (id) element.id = id; + dialog.append(element); return element; + }; + text('h2', copy.title, 'feedback-trace-title'); + text('p', copy.publicWarning, 'feedback-trace-public'); + text('p', copy[trace.traceType]); + text('p', formatFeedbackDetails(trace, copy)).className = 'feedback-trace-details'; + if (trace.omissions.length) text('p', trace.blob !== trace.originalBlob && trace.filename.endsWith('-diagnostics.json') ? copy.oversized : copy.omitted); + const actions = text('div', ''); actions.className = 'feedback-trace-actions'; + const finish = choice => { dialog.close(); dialog.remove(); previousFocus?.focus(); resolve(choice); }; + for (const [choice, label] of [['view', copy.view], ['upload', copy.upload], ['without', copy.without], ['cancel', copy.cancel]]) { + const button = document.createElement('button'); button.type = 'button'; button.textContent = label; + button.dataset.feedbackChoice = choice; + button.className = choice === 'upload' ? 'store-review-primary' : 'feedback-trace-secondary'; + button.addEventListener('click', () => choice === 'view' ? void viewTrace().catch(error => { + text('p', error.message).setAttribute('role', 'alert'); + }) : finish(choice)); + actions.append(button); + } + dialog.addEventListener('cancel', event => { event.preventDefault(); finish('cancel'); }); + dialog.addEventListener('keydown', event => event.stopPropagation()); + document.body.append(dialog); dialog.showModal(); + actions.querySelector('[data-feedback-choice="cancel"]').focus(); + }); +} + +// The only call capable of opening the upload destination is after consent. +export async function submitFeedbackWithTrace({ prepare, stage, consent, discard, open, rating, comment, copy }) { + let prepared, staged, traceNote = '', handedOff = false; + try { prepared = await prepare(); } catch { traceNote = copy.exportFailed; } + if (!prepared) { + await open({ rating, comment, includeTrace: false, traceNote: traceNote || copy.unavailable, copy }); + return true; + } + staged = await stage(prepared); + try { + const choice = await consent(prepared, staged); + if (choice === 'cancel') return false; + const includeTrace = choice === 'upload'; + if (!includeTrace) await discard(staged.id); + await open({ id: staged.id, rating, comment, includeTrace, copy, + traceNote: includeTrace ? `${copy[`${prepared.traceType}Label`]} (${prepared.runCount} runs, ${prepared.screenshotCount} screenshots).` + + (prepared.omissions.length ? ` ${copy.omitted}` : '') : '' }); + handedOff = includeTrace; + return true; + } catch (error) { + await discard(staged.id); throw error; + } finally { + // The background owns an accepted handoff; keep its immutable bytes for retries. + if (!handedOff) await discard(staged.id); + } +} diff --git a/src/chrome/src/ui/feedback-copy.js b/src/chrome/src/ui/feedback-copy.js new file mode 100644 index 0000000000..e35f22615f --- /dev/null +++ b/src/chrome/src/ui/feedback-copy.js @@ -0,0 +1,107 @@ +const EN = { + title: 'Share this trace on GitHub?', + publicWarning: 'Uploading makes this file publicly accessible immediately, even if you never submit the GitHub issue.', + full: 'Includes recorded conversation text, tool details, and any saved screenshots. Screenshots may contain private information. Obvious credentials are masked; review the trace before sharing.', + diagnostic: 'Includes diagnostic metadata: tool names, outcomes, error codes, and timings. Conversation text and screenshots that were not recorded are unavailable.', + upload: 'Upload trace and open GitHub', without: 'Continue without trace', cancel: 'Cancel', view: 'View trace', + fullLabel: 'Full recorded trace', diagnosticLabel: 'Diagnostic trace', + details: 'Conversation {session} · {type} · {runs} runs · {shots} screenshots · {size}', + preparing: 'Preparing trace locally…', + omitted: 'Recording or retention limits omitted some content. See the trace for details.', + oversized: 'The full export is too large to attach. Only the diagnostic fallback will be uploaded. View trace to download the full export locally.', + failed: 'The trace attachment could not be confirmed. Check the draft before retrying, or download the trace.', + uploading: 'Attaching your trace. Please wait before submitting the issue.', + ready: 'Trace attached. Review and submit your feedback on GitHub.', + draftReady: 'Review and submit your feedback on GitHub.', + retry: 'Retry attachment', download: 'Download trace', close: 'Close', + local: 'Local preview. Nothing is uploaded by viewing or downloading this trace.', + original: 'Download full export', previewLimited: 'Preview shortened. Download the export to inspect the complete file.', + unavailable: 'No recorded trace is available for this conversation.', + exportFailed: 'The trace could not be read; no trace is attached.', + settingLabel: 'Local feedback diagnostics', + settingDescription: 'Keep recent diagnostic metadata locally, even when Record traces is off. Limited to 10 completed runs, 7 days, and 2 MiB. Upload requires confirmation when sending feedback; conversation text and screenshots are not recorded by this option.', +}; + +// Keep the consequential disclosure and actions translated for every UI locale. +// Secondary status text uses the same English fallback as the main UI. +const CORE = { + ru: ['Поделиться этой трассировкой на GitHub?', 'Файл станет общедоступным сразу после загрузки, даже если вы не отправите обращение на GitHub.', 'Загрузить трассировку и открыть GitHub', 'Продолжить без трассировки', 'Отмена', 'Посмотреть трассировку'], + ar: ['مشاركة هذا التتبع على GitHub؟', 'يصبح هذا الملف متاحًا للجميع فور رفعه، حتى لو لم ترسل المشكلة على GitHub.', 'رفع التتبع وفتح GitHub', 'المتابعة دون تتبع', 'إلغاء', 'عرض التتبع'], + bn: ['এই ট্রেস GitHub-এ শেয়ার করবেন?', 'আপলোড করলেই ফাইলটি সবার জন্য উন্মুক্ত হবে, আপনি GitHub ইস্যু জমা না দিলেও।', 'ট্রেস আপলোড করে GitHub খুলুন', 'ট্রেস ছাড়া চালিয়ে যান', 'বাতিল', 'ট্রেস দেখুন'], + de: ['Diesen Trace auf GitHub teilen?', 'Die Datei ist sofort nach dem Hochladen öffentlich zugänglich, auch wenn du das GitHub-Issue nie absendest.', 'Trace hochladen und GitHub öffnen', 'Ohne Trace fortfahren', 'Abbrechen', 'Trace ansehen'], + es: ['¿Compartir esta traza en GitHub?', 'El archivo será público inmediatamente al subirlo, aunque nunca envíes la incidencia de GitHub.', 'Subir traza y abrir GitHub', 'Continuar sin traza', 'Cancelar', 'Ver traza'], + fa: ['این ردگیری در GitHub به اشتراک گذاشته شود؟', 'فایل بلافاصله پس از بارگذاری برای همه قابل دسترسی می‌شود، حتی اگر گزارش GitHub را ارسال نکنید.', 'بارگذاری ردگیری و باز کردن GitHub', 'ادامه بدون ردگیری', 'لغو', 'مشاهده ردگیری'], + fr: ['Partager cette trace sur GitHub ?', 'Le fichier devient public dès son téléversement, même si vous ne soumettez jamais le ticket GitHub.', 'Téléverser la trace et ouvrir GitHub', 'Continuer sans trace', 'Annuler', 'Voir la trace'], + he: ['לשתף את התיעוד הזה ב-GitHub?', 'הקובץ יהיה נגיש לציבור מיד לאחר ההעלאה, גם אם לא תשלחו את הדיווח ב-GitHub.', 'העלאת התיעוד ופתיחת GitHub', 'המשך ללא תיעוד', 'ביטול', 'הצגת התיעוד'], + hi: ['यह ट्रेस GitHub पर साझा करें?', 'अपलोड होते ही फ़ाइल सार्वजनिक हो जाएगी, भले ही आप GitHub समस्या कभी जमा न करें।', 'ट्रेस अपलोड करें और GitHub खोलें', 'ट्रेस के बिना जारी रखें', 'रद्द करें', 'ट्रेस देखें'], + id: ['Bagikan trace ini di GitHub?', 'Berkas langsung dapat diakses publik setelah diunggah, meskipun Anda tidak pernah mengirim issue GitHub.', 'Unggah trace dan buka GitHub', 'Lanjutkan tanpa trace', 'Batal', 'Lihat trace'], + ja: ['このトレースをGitHubで共有しますか?', 'アップロードすると、GitHubのIssueを送信しなくても、ファイルは直ちに公開されます。', 'トレースをアップロードしてGitHubを開く', 'トレースなしで続行', 'キャンセル', 'トレースを表示'], + ko: ['이 트레이스를 GitHub에 공유할까요?', '업로드하면 GitHub 이슈를 제출하지 않아도 파일이 즉시 공개됩니다.', '트레이스 업로드 후 GitHub 열기', '트레이스 없이 계속', '취소', '트레이스 보기'], + ms: ['Kongsi jejak ini di GitHub?', 'Fail boleh diakses umum serta-merta selepas dimuat naik, walaupun anda tidak menghantar isu GitHub.', 'Muat naik jejak dan buka GitHub', 'Teruskan tanpa jejak', 'Batal', 'Lihat jejak'], + nl: ['Deze trace op GitHub delen?', 'Het bestand is direct na het uploaden openbaar, ook als je het GitHub-issue nooit indient.', 'Trace uploaden en GitHub openen', 'Doorgaan zonder trace', 'Annuleren', 'Trace bekijken'], + pl: ['Udostępnić ten ślad na GitHub?', 'Plik stanie się publicznie dostępny natychmiast po przesłaniu, nawet jeśli nie wyślesz zgłoszenia GitHub.', 'Prześlij ślad i otwórz GitHub', 'Kontynuuj bez śladu', 'Anuluj', 'Zobacz ślad'], + pt: ['Compartilhar este rastreamento no GitHub?', 'O arquivo ficará público imediatamente após o envio, mesmo que você nunca publique a issue no GitHub.', 'Enviar rastreamento e abrir GitHub', 'Continuar sem rastreamento', 'Cancelar', 'Ver rastreamento'], + th: ['แชร์บันทึกนี้บน GitHub หรือไม่?', 'ไฟล์จะเปิดให้สาธารณะเข้าถึงได้ทันทีที่อัปโหลด แม้คุณจะไม่ส่งปัญหาบน GitHub ก็ตาม', 'อัปโหลดบันทึกและเปิด GitHub', 'ดำเนินการต่อโดยไม่แนบบันทึก', 'ยกเลิก', 'ดูบันทึก'], + tl: ['Ibahagi ang trace na ito sa GitHub?', 'Magiging pampubliko agad ang file kapag na-upload, kahit hindi mo isumite ang GitHub issue.', 'I-upload ang trace at buksan ang GitHub', 'Magpatuloy nang walang trace', 'Kanselahin', 'Tingnan ang trace'], + tr: ['Bu izi GitHub’da paylaşmak ister misiniz?', 'Dosya yüklendiği anda herkese açık olur; GitHub sorununu göndermeseniz bile dosyaya erişilebilir.', 'İzi yükle ve GitHub’ı aç', 'İz olmadan devam et', 'İptal', 'İzi görüntüle'], + uk: ['Поділитися цим трасуванням на GitHub?', 'Файл стане загальнодоступним одразу після завантаження, навіть якщо ви не надішлете звернення на GitHub.', 'Завантажити трасування й відкрити GitHub', 'Продовжити без трасування', 'Скасувати', 'Переглянути трасування'], + vi: ['Chia sẻ dấu vết này trên GitHub?', 'Tệp sẽ công khai ngay khi tải lên, kể cả khi bạn không gửi vấn đề trên GitHub.', 'Tải dấu vết lên và mở GitHub', 'Tiếp tục không kèm dấu vết', 'Hủy', 'Xem dấu vết'], + zh: ['在 GitHub 上分享此跟踪记录?', '上传后文件会立即公开,即使您最终没有提交 GitHub 问题。', '上传跟踪记录并打开 GitHub', '不附带跟踪记录继续', '取消', '查看跟踪记录'], +}; +const CONTENT = { + ar: ['يتضمن نص المحادثة وتفاصيل الأدوات ولقطات الشاشة المحفوظة، إن وُجدت. تُخفى بيانات الاعتماد الواضحة؛ راجع الملف قبل مشاركته.', 'يتضمن بيانات تشخيصية فقط: أسماء الأدوات والنتائج ورموز الأخطاء والتوقيتات. لا تتوفر النصوص أو الصور غير المسجلة.'], + bn: ['সংরক্ষিত কথোপকথন, টুলের বিবরণ ও উপলব্ধ স্ক্রিনশট অন্তর্ভুক্ত থাকে। স্পষ্ট পরিচয়পত্র গোপন করা হয়; শেয়ার করার আগে ফাইলটি দেখুন।', 'শুধু ডায়াগনস্টিক তথ্য থাকে: টুলের নাম, ফলাফল, ত্রুটি কোড ও সময়। রেকর্ড না করা লেখা বা স্ক্রিনশট নেই।'], + de: ['Enthält aufgezeichnete Gespräche, Werkzeugdetails und vorhandene Screenshots. Offensichtliche Zugangsdaten werden maskiert; prüfe die Datei vor dem Teilen.', 'Enthält nur Diagnosedaten: Werkzeugnamen, Ergebnisse, Fehlercodes und Zeitangaben. Nicht aufgezeichnete Texte und Screenshots sind nicht verfügbar.'], + es: ['Incluye conversaciones grabadas, detalles de herramientas y capturas disponibles. Se ocultan las credenciales evidentes; revisa el archivo antes de compartirlo.', 'Solo incluye datos de diagnóstico: nombres de herramientas, resultados, códigos de error y tiempos. Los textos y capturas no grabados no están disponibles.'], + fa: ['شامل گفت‌وگوی ثبت‌شده، جزئیات ابزارها و تصاویر ذخیره‌شده است. اطلاعات ورود آشکار پوشانده می‌شوند؛ پیش از اشتراک‌گذاری فایل را بررسی کنید.', 'فقط شامل اطلاعات تشخیصی است: نام ابزارها، نتایج، کدهای خطا و زمان‌ها. متن و تصاویر ثبت‌نشده موجود نیستند.'], + he: ['כולל שיחות שהוקלטו, פרטי כלים וצילומי מסך זמינים. פרטי גישה ברורים מוסתרים; בדקו את הקובץ לפני השיתוף.', 'כולל רק נתוני אבחון: שמות כלים, תוצאות, קודי שגיאה וזמנים. טקסט ותמונות שלא תועדו אינם זמינים.'], + hi: ['रिकॉर्ड की गई बातचीत, टूल विवरण और उपलब्ध स्क्रीनशॉट शामिल हैं। स्पष्ट लॉगिन जानकारी छिपाई जाती है; साझा करने से पहले फ़ाइल देखें।', 'केवल निदान जानकारी: टूल के नाम, परिणाम, त्रुटि कोड और समय। रिकॉर्ड न किया गया पाठ या स्क्रीनशॉट उपलब्ध नहीं है।'], + id: ['Berisi percakapan yang direkam, detail alat, dan tangkapan layar yang tersedia. Kredensial yang jelas disamarkan; periksa berkas sebelum berbagi.', 'Hanya berisi diagnostik: nama alat, hasil, kode kesalahan, dan waktu. Teks dan tangkapan layar yang tidak direkam tidak tersedia.'], + ja: ['記録された会話、ツールの詳細、保存済みのスクリーンショットを含みます。明らかな認証情報はマスクされます。共有前にファイルを確認してください。', 'ツール名、結果、エラーコード、時間などの診断情報のみを含みます。記録されていない会話やスクリーンショットは含まれません。'], + ko: ['기록된 대화, 도구 세부 정보 및 저장된 스크린샷을 포함합니다. 명백한 인증 정보는 가려집니다. 공유하기 전에 파일을 확인하세요.', '도구 이름, 결과, 오류 코드 및 시간 등 진단 정보만 포함합니다. 기록하지 않은 대화나 스크린샷은 제공되지 않습니다.'], + ms: ['Mengandungi perbualan yang direkodkan, butiran alat dan tangkapan skrin yang tersedia. Bukti kelayakan yang jelas disamarkan; semak fail sebelum berkongsi.', 'Hanya mengandungi diagnostik: nama alat, hasil, kod ralat dan masa. Teks dan tangkapan skrin yang tidak direkodkan tidak tersedia.'], + nl: ['Bevat opgenomen gesprekken, tooldetails en beschikbare schermafbeeldingen. Duidelijke inloggegevens worden afgeschermd; controleer het bestand voordat je het deelt.', 'Bevat alleen diagnostiek: toolnamen, resultaten, foutcodes en tijden. Niet opgenomen tekst en schermafbeeldingen zijn niet beschikbaar.'], + pl: ['Zawiera zapisane rozmowy, szczegóły narzędzi i dostępne zrzuty ekranu. Oczywiste dane logowania są maskowane; sprawdź plik przed udostępnieniem.', 'Zawiera tylko diagnostykę: nazwy narzędzi, wyniki, kody błędów i czasy. Niezapisane teksty i zrzuty ekranu nie są dostępne.'], + pt: ['Inclui conversas gravadas, detalhes de ferramentas e capturas disponíveis. Credenciais evidentes são ocultadas; revise o arquivo antes de compartilhar.', 'Inclui apenas diagnóstico: nomes de ferramentas, resultados, códigos de erro e tempos. Textos e capturas não gravados não estão disponíveis.'], + ru: ['Содержит записанные разговоры, детали инструментов и сохранённые снимки экрана. Очевидные учётные данные маскируются; проверьте файл перед отправкой.', 'Содержит только диагностику: имена инструментов, результаты, коды ошибок и время. Незаписанный текст и снимки экрана недоступны.'], + th: ['รวมบทสนทนาที่บันทึกไว้ รายละเอียดเครื่องมือ และภาพหน้าจอที่มีอยู่ ข้อมูลรับรองที่ชัดเจนจะถูกปิดบัง โปรดตรวจสอบไฟล์ก่อนแชร์', 'มีเฉพาะข้อมูลวินิจฉัย: ชื่อเครื่องมือ ผลลัพธ์ รหัสข้อผิดพลาด และเวลา ไม่มีข้อความหรือภาพหน้าจอที่ไม่ได้บันทึกไว้'], + tl: ['Kasama ang naitalang usapan, detalye ng mga tool, at available na screenshot. Itinatago ang malinaw na kredensyal; suriin ang file bago ibahagi.', 'Diagnostic data lamang: pangalan ng mga tool, resulta, error code, at oras. Walang tekstong o screenshot na hindi naitala.'], + uk: ['Містить записані розмови, деталі інструментів і збережені знімки екрана. Очевидні облікові дані маскуються; перевірте файл перед надсиланням.', 'Містить лише діагностику: назви інструментів, результати, коди помилок і час. Незаписаний текст і знімки екрана недоступні.'], + vi: ['Gồm cuộc trò chuyện đã ghi, chi tiết công cụ và ảnh chụp màn hình có sẵn. Thông tin đăng nhập rõ ràng được che; hãy xem tệp trước khi chia sẻ.', 'Chỉ gồm chẩn đoán: tên công cụ, kết quả, mã lỗi và thời gian. Văn bản và ảnh chụp chưa ghi không có sẵn.'], + zh: ['包含已记录的对话、工具详情和已保存的截图。明显的凭据会被遮盖;分享前请检查文件。', '仅包含诊断信息:工具名称、结果、错误代码和时间。未记录的对话文本和截图不可用。'], +}; +const EXTRA = { + tr: { + full: 'Kaydedilmiş konuşma metni, araç ayrıntıları ve varsa ekran görüntülerini içerir. Ekran görüntülerinde özel bilgiler olabilir. Belirgin kimlik bilgileri maskelenir; paylaşmadan önce izi inceleyin.', + diagnostic: 'Araç adları, sonuçlar, hata kodları ve süreler gibi tanılama bilgilerini içerir. Kaydedilmemiş konuşma metni ve ekran görüntüleri mevcut değildir.', + fullLabel: 'Tam kaydedilmiş iz', diagnosticLabel: 'Tanılama izi', + details: 'Konuşma {session} · {type} · {runs} çalışma · {shots} ekran görüntüsü · {size}', + preparing: 'İz yerel olarak hazırlanıyor…', settingLabel: 'Yerel geri bildirim tanılaması', + settingDescription: 'İz kaydı kapalı olsa da son tanılama bilgilerini cihazda saklar. En fazla 10 tamamlanan çalışma, 7 gün ve 2 MiB. Geri bildirim gönderirken yükleme için onay gerekir; bu seçenek konuşma metni veya ekran görüntüsü kaydetmez.', + }, + fr: { + full: 'Contient le texte de conversation enregistré, les détails des outils et les captures conservées. Les captures peuvent contenir des informations privées. Les identifiants évidents sont masqués ; vérifiez la trace avant de la partager.', + diagnostic: 'Contient les noms des outils, les résultats, les codes d’erreur et les durées. Le texte et les captures non enregistrés ne sont pas disponibles.', + fullLabel: 'Trace enregistrée complète', diagnosticLabel: 'Trace de diagnostic', + details: 'Conversation {session} · {type} · {runs} exécutions · {shots} captures · {size}', + }, +}; +export function getFeedbackCopy(locale = 'en') { + const copy = { ...EN, ...EXTRA[locale] }; + if (CORE[locale]) ['title', 'publicWarning', 'upload', 'without', 'cancel', 'view'].forEach((key, index) => { copy[key] = CORE[locale][index]; }); + if (CONTENT[locale]) [copy.full, copy.diagnostic] = CONTENT[locale]; + return copy; +} +export function formatFeedbackDetails(trace, copy) { + const values = { session: trace.sessionId, type: copy[`${trace.traceType}Label`], + runs: trace.runCount, shots: trace.screenshotCount, size: `${(trace.blob.size / 1024).toFixed(1)} KiB` }; + return copy.details.replace(/\{(\w+)\}/g, (_, key) => values[key] ?? ''); +} + +export const feedbackTranslations = Object.fromEntries(['en', ...Object.keys(CORE)].map(locale => { + const copy = getFeedbackCopy(locale); + return [locale, { + 'st.display.feedback_diagnostics.label': copy.settingLabel, + 'st.display.feedback_diagnostics.desc': copy.settingDescription, + }]; +})); diff --git a/src/chrome/src/ui/feedback-trace-preview.css b/src/chrome/src/ui/feedback-trace-preview.css new file mode 100644 index 0000000000..276b23d6a4 --- /dev/null +++ b/src/chrome/src/ui/feedback-trace-preview.css @@ -0,0 +1,4 @@ +:root { color-scheme: light dark; font: 15px/1.5 system-ui; } +body { margin: 24px; } main { max-width: 1100px; margin: auto; } +pre { white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid #8886; padding: 16px; border-radius: 8px; } +button { padding: 8px 16px; margin-right: 8px; cursor: pointer; } diff --git a/src/chrome/src/ui/feedback-trace-preview.html b/src/chrome/src/ui/feedback-trace-preview.html new file mode 100644 index 0000000000..e9244c6258 --- /dev/null +++ b/src/chrome/src/ui/feedback-trace-preview.html @@ -0,0 +1,7 @@ + + +WebBrain trace preview + +

WebBrain trace preview

+ +

diff --git a/src/chrome/src/ui/feedback-trace-preview.js b/src/chrome/src/ui/feedback-trace-preview.js new file mode 100644 index 0000000000..a8afd9849e --- /dev/null +++ b/src/chrome/src/ui/feedback-trace-preview.js @@ -0,0 +1,34 @@ +import { getFeedbackDraft } from '../feedback-store.js'; +import { getFeedbackCopy, formatFeedbackDetails } from './feedback-copy.js'; +import { getLocale } from './i18n.js'; + +let copy = getFeedbackCopy(getLocale()); +document.getElementById('title').textContent = copy.view; +document.getElementById('notice').textContent = copy.local; +function download(blob, filename) { + const url = URL.createObjectURL(blob), anchor = document.createElement('a'); + anchor.href = url; anchor.download = filename; anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +try { + const draft = await getFeedbackDraft(new URL(location.href).searchParams.get('id')); + if (!draft?.blob || draft.expiresAt <= Date.now()) throw new Error(copy.unavailable); + copy = draft.copy || copy; + document.getElementById('title').textContent = copy.view; + document.getElementById('notice').textContent = copy.local; + document.getElementById('details').textContent = formatFeedbackDetails(draft, copy); + // The original uncompressed JSON is retained only locally, including when + // the consent dialog proposes a smaller diagnostic fallback. + const previewBlob = draft.filename.endsWith('-diagnostics.json') ? draft.blob : draft.originalBlob; + document.getElementById('preview').textContent = await previewBlob.slice(0, 500_000).text(); + if (previewBlob.size > 500_000) document.getElementById('limit').textContent = copy.previewLimited; + const button = document.getElementById('download'); button.textContent = copy.download; + button.addEventListener('click', () => download(draft.blob, draft.filename)); + if (draft.blob.size !== draft.originalBlob.size) { + const original = document.getElementById('download-original'); original.hidden = false; original.textContent = copy.original; + original.addEventListener('click', () => download(draft.originalBlob, 'webbrain-feedback-full.json')); + } +} catch (error) { + document.getElementById('preview').textContent = error.message; + document.getElementById('download').hidden = true; +} diff --git a/src/chrome/src/ui/i18n.js b/src/chrome/src/ui/i18n.js index 7c7acb9327..343332dbd9 100644 --- a/src/chrome/src/ui/i18n.js +++ b/src/chrome/src/ui/i18n.js @@ -4,6 +4,7 @@ import { decisionEnglish, decisionTranslations } from './locales/decision-copy.m // Works identically in Chrome MV3 and Firefox MV2. import en from './locales/en.js'; +import { feedbackTranslations } from './feedback-copy.js'; import { quotaTranslations } from './locales/compass-quota-copy.mjs'; import { memcodeEnglish } from './locales/memcode-copy.mjs'; import { captchaEnglish, captchaTranslations } from './locales/captcha-copy.mjs'; @@ -38,6 +39,7 @@ import { pdfViewerEnglish } from './locales/pdf-viewer-copy.mjs'; const DICTS = Object.fromEntries(Object.entries({ en, es, fr, tr, zh, ru, uk, ar, ja, ko, id, th, ms, tl, pl, he, hi, pt, vi, bn, fa, nl, de }) .map(([code, dict]) => [code, { ...dict, + ...feedbackTranslations[code], ...(composerDeliveryTranslations[code] || {}), ...(cloudBridgeTranslations[code] || {}), ...quotaTranslations[code], diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index 631c315e8a..4c2ddc551b 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -1666,6 +1666,16 @@

+
+
+
+
+
+ +
diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index 8ca2a85fce..3e715adaaa 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -136,6 +136,7 @@ const notifySoundToggle = document.getElementById('toggle-notify-sound'); const completionConfettiToggle = document.getElementById('toggle-completion-confetti'); const completionFlashTabToggle = document.getElementById('toggle-completion-flash-tab'); const tracingToggle = document.getElementById('toggle-tracing'); +const feedbackDiagnosticsToggle = document.getElementById('toggle-feedback-diagnostics'); const losslessTracingToggle = document.getElementById('toggle-lossless-tracing'); const strictSecretToggle = document.getElementById('toggle-strict-secret'); const allowLocalNetworkToggle = document.getElementById('toggle-allow-local-network'); @@ -624,7 +625,7 @@ async function init() { chrome.storage.local.remove(['authToken', 'authEmail', 'authDefaultModel']).catch(() => {}); // Load display settings - const stored = await chrome.storage.local.get([...DECISION_SETTINGS_KEYS, 'verboseMode', 'composerDeliveryMode', 'selectionShortcutEnabled', 'pdfViewerEnabled', AUTO_GROUP_TABS_KEY, 'helpImproveWebBrain', 'screenshotFallback', 'maxAgentSteps', 'autoScreenshot', 'useSiteAdapters', 'researchEscalationEnabled', 'researchEscalationEngine', 'voiceInputEnabled', 'alwaysAllowApiMutations', 'apiMutationObserverEnabled', 'webMcpEnabled', 'openaiAskStreamingEnabled', 'planBeforeActMode', 'planBeforeAct', 'planReviewMode', 'planReviewConfidenceThreshold', DOWNLOAD_DIRECTORY_STORAGE_KEY, 'notifySound', 'completionConfetti', 'completionFlashTab', 'tracingEnabled', 'losslessTrace', 'strictSecretMode', 'agentAllowLocalNetwork', 'scheduledTasksEnabled', 'scheduledRequireConsequentialConfirmation', 'systemOneEnabled', 'systemOneWatchEnabled', 'systemOneCompletionEnabled', 'systemOneFastClassifications', 'systemOneFastBrowser', 'systemOneWatchThreshold', 'systemOneCompletionThreshold', 'typesafeApiKey', 'providerFilter', 'requestTimeoutMs', 'clarifyTimeoutSec', 'clarifyTimeoutSemanticsV2', 'costAllowanceSessionUsd', 'costAllowanceTotalUsd', 'meteredProviderCostSpentUsd', 'screenshotRedaction', 'imageDetail', 'maxScreenshotsPerTurn', 'maxImageDimension']); + const stored = await chrome.storage.local.get([...DECISION_SETTINGS_KEYS, 'verboseMode', 'composerDeliveryMode', 'selectionShortcutEnabled', 'pdfViewerEnabled', AUTO_GROUP_TABS_KEY, 'helpImproveWebBrain', 'screenshotFallback', 'maxAgentSteps', 'autoScreenshot', 'useSiteAdapters', 'researchEscalationEnabled', 'researchEscalationEngine', 'voiceInputEnabled', 'alwaysAllowApiMutations', 'apiMutationObserverEnabled', 'webMcpEnabled', 'openaiAskStreamingEnabled', 'planBeforeActMode', 'planBeforeAct', 'planReviewMode', 'planReviewConfidenceThreshold', DOWNLOAD_DIRECTORY_STORAGE_KEY, 'notifySound', 'completionConfetti', 'completionFlashTab', 'tracingEnabled', 'feedbackDiagnosticsEnabled', 'losslessTrace', 'strictSecretMode', 'agentAllowLocalNetwork', 'scheduledTasksEnabled', 'scheduledRequireConsequentialConfirmation', 'systemOneEnabled', 'systemOneWatchEnabled', 'systemOneCompletionEnabled', 'systemOneFastClassifications', 'systemOneFastBrowser', 'systemOneWatchThreshold', 'systemOneCompletionThreshold', 'typesafeApiKey', 'providerFilter', 'requestTimeoutMs', 'clarifyTimeoutSec', 'clarifyTimeoutSemanticsV2', 'costAllowanceSessionUsd', 'costAllowanceTotalUsd', 'meteredProviderCostSpentUsd', 'screenshotRedaction', 'imageDetail', 'maxScreenshotsPerTurn', 'maxImageDimension']); if (typeof stored.providerFilter === 'string' && ['all','active','local','cloud','router'].includes(stored.providerFilter)) { providerFilter = stored.providerFilter; } @@ -701,6 +702,7 @@ async function init() { completionConfettiToggle.checked = stored.completionConfetti ?? true; // on by default completionFlashTabToggle.checked = stored.completionFlashTab ?? true; // on by default tracingToggle.checked = stored.tracingEnabled === true; + if (feedbackDiagnosticsToggle) feedbackDiagnosticsToggle.checked = stored.feedbackDiagnosticsEnabled !== false; losslessTracingToggle.checked = stored.losslessTrace === true; // Lossless recording only means something when tracing is on; mirror the // disabled state so the disclosure reads honestly. @@ -1494,6 +1496,14 @@ losslessTracingToggle.addEventListener('change', async () => { await chrome.storage.local.set({ losslessTrace: losslessTracingToggle.checked }).catch(() => {}); }); +feedbackDiagnosticsToggle?.addEventListener('change', async () => { + await chrome.storage.local.set({ feedbackDiagnosticsEnabled: feedbackDiagnosticsToggle.checked }); + if (!feedbackDiagnosticsToggle.checked) { + const response = await chrome.runtime.sendMessage({ target: 'background', action: 'feedback_clear_diagnostics' }); + if (!response?.ok) console.error('[feedback] Could not clear local diagnostics:', response?.error); + } +}); + costSessionLimitInput?.addEventListener('change', async () => { const value = normalizeCostAmount(costSessionLimitInput.value); costSessionLimitInput.value = value.toFixed(2); diff --git a/src/chrome/src/ui/sidepanel.html b/src/chrome/src/ui/sidepanel.html index c6b8927f3b..ea2cb02638 100644 --- a/src/chrome/src/ui/sidepanel.html +++ b/src/chrome/src/ui/sidepanel.html @@ -11,6 +11,7 @@ +