diff --git a/package.json b/package.json index 6957a25..f6a659f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hanzo/extension", - "version": "1.9.35", + "version": "1.9.36", "private": true, "description": "Hanzo AI Extension", "license": "MIT", diff --git a/packages/browser/package.json b/packages/browser/package.json index 292dff6..4ee840d 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@hanzo/browser-extension", - "version": "1.9.35", + "version": "1.9.36", "description": "Hanzo AI Browser Extension", "main": "dist/background.js", "scripts": { diff --git a/packages/browser/src/answer/AnswerEngine.tsx b/packages/browser/src/answer/AnswerEngine.tsx index 615de39..833df37 100644 --- a/packages/browser/src/answer/AnswerEngine.tsx +++ b/packages/browser/src/answer/AnswerEngine.tsx @@ -3,6 +3,7 @@ import { createAiClient, type SearchEvent, type SearchMode, type SearchSource } import { fetchNews, type NewsItem } from './news.js'; import { AUTO_MODEL, fetchModelCatalog, type ChatModel, type ModelGroup } from './model-catalog.js'; import { renderMarkdown } from './markdown.js'; +import { capture, contributeTrainingSample } from '../shared/consent.js'; /** * Hanzo Answer Engine — the shared AI-answer surface (query → streamed answer + @@ -321,6 +322,8 @@ export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: Answer setFollowUps([]); setError(''); setNeedAuth(false); + void capture('answer_search', { mode, model: model.id }); + let fullAnswer = ''; const opts = { mode, @@ -346,6 +349,7 @@ export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: Answer setAnswerSources(ev.sources); break; case 'text': + fullAnswer += ev.delta; setAnswer((a) => a + ev.delta); break; case 'follow_ups': @@ -353,6 +357,10 @@ export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: Answer break; case 'done': setPhase('done'); + void capture('answer_shown', { mode, model: model.id }); + // Opt-in only: no-ops unless the user turned on training + // contribution (the consent module gates the send). + void contributeTrainingSample({ query: question, answer: fullAnswer, model: model.id }); break; case 'error': if (ev.error.includes('__NO_TOKEN__') || /401|invalid api key/i.test(ev.error)) { diff --git a/packages/browser/src/background.ts b/packages/browser/src/background.ts index 852b4bb..ebae27d 100644 --- a/packages/browser/src/background.ts +++ b/packages/browser/src/background.ts @@ -283,9 +283,18 @@ async function stopControlSession(): Promise { void loadDebugFlagFromStorage(); // Initialize WebGPU, Ollama, and CDP on install -chrome.runtime.onInstalled.addListener(async () => { +chrome.runtime.onInstalled.addListener(async (details) => { debugLog('[Hanzo] Extension installed, initializing...'); + // First install → open the consent onboarding once (insights + opt-in training). + if (details?.reason === 'install') { + try { + await chrome.tabs.create({ url: chrome.runtime.getURL('onboarding.html') }); + } catch { + /* non-fatal — settings still reachable from the popup */ + } + } + // WebGPU init const gpuAvailable = await webgpuAI.initialize(); if (gpuAvailable) { diff --git a/packages/browser/src/build.js b/packages/browser/src/build.js index e9956de..6a7f425 100644 --- a/packages/browser/src/build.js +++ b/packages/browser/src/build.js @@ -122,6 +122,17 @@ async function build() { sourcemap: false, }); + // Onboarding (new-install consent ask: insights + opt-in training). + await esbuild.build({ + entryPoints: ['src/onboarding.ts'], + bundle: true, + outfile: out('onboarding.js'), + platform: 'browser', + target: ['chrome90', 'firefox91', 'safari14'], + minify: true, + sourcemap: false, + }); + // Build browser control module await esbuild.build({ entryPoints: ['src/browser-control.ts'], @@ -196,7 +207,7 @@ async function build() { } // Copy popup + sidebar + answer-engine HTML/CSS - for (const f of ['popup.html', 'popup.css', 'sidebar.html', 'sidebar.css', 'newtab.html', 'newtab.css', 'callback.html', 'ai-worker.js']) { + for (const f of ['popup.html', 'popup.css', 'sidebar.html', 'sidebar.css', 'newtab.html', 'newtab.css', 'onboarding.html', 'onboarding.css', 'callback.html', 'ai-worker.js']) { const src = path.join('src', f); if (fs.existsSync(src)) { fs.copyFileSync(src, path.join(dir, f)); @@ -207,6 +218,7 @@ async function build() { fs.copyFileSync(out('popup.js'), path.join(dir, 'popup.js')); fs.copyFileSync(out('sidebar.js'), path.join(dir, 'sidebar.js')); fs.copyFileSync(out('newtab.js'), path.join(dir, 'newtab.js')); + fs.copyFileSync(out('onboarding.js'), path.join(dir, 'onboarding.js')); // Copy icons into each browser directory for (const icon of ['icon16.png', 'icon48.png', 'icon128.png']) { diff --git a/packages/browser/src/onboarding.css b/packages/browser/src/onboarding.css new file mode 100644 index 0000000..9dc2f4f --- /dev/null +++ b/packages/browser/src/onboarding.css @@ -0,0 +1,78 @@ +:root { + --bg: #0b0b0c; + --card: #131315; + --line: #26262a; + --text: #f4f4f5; + --muted: #9a9aa2; + --accent: #f4f4f5; +} +* { box-sizing: border-box; } +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} +body { display: grid; place-items: center; padding: 32px; } +.card { + width: 100%; + max-width: 520px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 16px; + padding: 32px; +} +.brand { font-weight: 700; letter-spacing: 0.02em; opacity: 0.7; margin-bottom: 20px; } +h1 { font-size: 24px; margin: 0 0 8px; } +.lede { color: var(--muted); margin: 0 0 24px; } +.row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 0; + border-top: 1px solid var(--line); + cursor: pointer; +} +.row input { position: absolute; opacity: 0; width: 0; height: 0; } +.switch { + flex: 0 0 auto; + width: 38px; + height: 22px; + border-radius: 999px; + background: #3a3a40; + position: relative; + transition: background 0.15s; + margin-top: 2px; +} +.switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; +} +.row input:checked + .switch { background: var(--accent); } +.row input:checked + .switch::after { transform: translateX(16px); background: #0b0b0c; } +.text { display: flex; flex-direction: column; gap: 2px; } +.text strong { font-weight: 600; } +.text small { color: var(--muted); } +.cta { + width: 100%; + margin-top: 24px; + padding: 12px; + border: 0; + border-radius: 10px; + background: var(--accent); + color: #0b0b0c; + font-weight: 600; + font-size: 15px; + cursor: pointer; +} +.cta:active { transform: translateY(1px); } +.fine { color: var(--muted); font-size: 12px; text-align: center; margin: 16px 0 0; } +.fine a { color: var(--text); } diff --git a/packages/browser/src/onboarding.html b/packages/browser/src/onboarding.html new file mode 100644 index 0000000..45ab5b9 --- /dev/null +++ b/packages/browser/src/onboarding.html @@ -0,0 +1,45 @@ + + + + + + Welcome to Hanzo + + + +
+
Hanzo
+

Welcome to Hanzo AI

+

+ Search, research and browse with Hanzo's own models. Before you start, + choose what you'd like to share — you can change this anytime in settings + or on your Hanzo account. +

+ + + + + + +

+ Signed in? These choices save to your Hanzo account and stay in sync + everywhere. Privacy +

+
+ + + diff --git a/packages/browser/src/onboarding.ts b/packages/browser/src/onboarding.ts new file mode 100644 index 0000000..cb06ad4 --- /dev/null +++ b/packages/browser/src/onboarding.ts @@ -0,0 +1,29 @@ +// onboarding.ts — the new-install consent ask. Opened once by the background on +// install. Records the user's choice as their consent (account-canonical when +// signed in — setConsent write-throughs to /v1/iam/consent), marks onboarding +// done so it never reopens, and closes. +import 'webextension-polyfill'; +import { setConsent, markOnboarded, capture } from './shared/consent.js'; + +const runtime: typeof chrome = (globalThis as any).browser ?? (globalThis as any).chrome; + +const insights = document.getElementById('ob-insights') as HTMLInputElement | null; +const training = document.getElementById('ob-training') as HTMLInputElement | null; +const cta = document.getElementById('ob-continue') as HTMLButtonElement | null; + +cta?.addEventListener('click', async () => { + const consent = { + insights: insights?.checked ?? true, + shareTraining: training?.checked ?? false, + }; + await setConsent(consent); + await markOnboarded(); + await capture('onboarding_complete', { shareTraining: consent.shareTraining }); + try { + const tab = await runtime.tabs.getCurrent(); + if (tab?.id) await runtime.tabs.remove(tab.id); + else window.close(); + } catch { + window.close(); + } +}); diff --git a/packages/browser/src/popup.html b/packages/browser/src/popup.html index 88924c9..95404c6 100644 --- a/packages/browser/src/popup.html +++ b/packages/browser/src/popup.html @@ -222,6 +222,34 @@
+ +

Privacy & data

+
+
+ +
+ +
+ +
+
+ +
+

Backend

diff --git a/packages/browser/src/popup.ts b/packages/browser/src/popup.ts index 10d6811..15ea9c7 100644 --- a/packages/browser/src/popup.ts +++ b/packages/browser/src/popup.ts @@ -14,6 +14,7 @@ import { shortcutFromEvent, type Shortcut, } from './shared/shortcut.js'; +import { getConsent, setConsent, capture } from './shared/consent.js'; declare const browser: typeof chrome & { sidebarAction?: { @@ -229,6 +230,21 @@ document.addEventListener('DOMContentLoaded', () => { syncSettingsUi(enabled, side, width); }); + // Privacy & data toggles — reflect stored consent, persist on change. + const insightsCheckbox = document.getElementById('insights-enabled') as HTMLInputElement | null; + const shareTrainingCheckbox = document.getElementById('share-training') as HTMLInputElement | null; + getConsent().then((c) => { + if (insightsCheckbox) insightsCheckbox.checked = c.insights; + if (shareTrainingCheckbox) shareTrainingCheckbox.checked = c.shareTraining; + }); + insightsCheckbox?.addEventListener('change', (e) => { + void setConsent({ insights: (e.target as HTMLInputElement).checked }); + }); + shareTrainingCheckbox?.addEventListener('change', (e) => { + void setConsent({ shareTraining: (e.target as HTMLInputElement).checked }); + }); + void capture('popup_opened'); + function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean { if (!tab || !tab.id) return false; const url = tab.url || ''; diff --git a/packages/browser/src/shared/consent.ts b/packages/browser/src/shared/consent.ts new file mode 100644 index 0000000..6e759fb --- /dev/null +++ b/packages/browser/src/shared/consent.ts @@ -0,0 +1,185 @@ +// consent.ts — ONE data-consent value, one source of truth. +// +// The user's data-sharing consent lives on their Hanzo ACCOUNT (asked at +// hanzo.id signup, editable in the extension + on hanzo.ai). This module keeps +// the extension in lockstep with that one value: +// • Signed in → the account is canonical. getConsent() reads it from +// GET /v1/iam/consent; setConsent() writes it with PUT /v1/iam/consent. +// The local copy is just a cache so the UI paints instantly. +// • Signed out → the local copy (from onboarding) is used, and is pushed to +// the account on next sign-in so nothing is lost or duplicated. +// +// TWO switches: +// • insights (default ON) — anonymous product events. No query/answer +// text, no PII: only which surface fired, under a random per-install id. +// • shareTraining (OPT-IN) — contribute the user's OWN searches + answers +// to train Hanzo's open models. Asked explicitly at signup/onboarding. +// +// Everything is fail-soft: a network error NEVER breaks a user action, and +// nothing is sent for a switch that is off. +import { API_BASE_URL } from './config.js'; + +const KEYS = { + insights: 'hanzo_insights_enabled', + training: 'hanzo_share_training', + onboarded: 'hanzo_onboarded', + anonId: 'hanzo_anon_id', +} as const; + +export interface Consent { + insights: boolean; + shareTraining: boolean; +} + +const runtime: typeof chrome | undefined = + (globalThis as any).browser ?? (globalThis as any).chrome; + +function area(): chrome.storage.StorageArea | undefined { + return (globalThis as any).chrome?.storage?.local ?? (globalThis as any).browser?.storage?.local; +} + +function getLocal(keys: string[]): Promise> { + return new Promise((resolve) => { + const s = area(); + if (!s) return resolve({}); + try { + s.get(keys, (v) => resolve(v || {})); + } catch { + resolve({}); + } + }); +} + +function setLocal(obj: Record): Promise { + return new Promise((resolve) => { + const s = area(); + if (!s) return resolve(); + try { + s.set(obj, () => resolve()); + } catch { + resolve(); + } + }); +} + +/** Bearer for the signed-in user, brokered by the background (null when out). */ +async function token(): Promise { + try { + const resp: any = await runtime?.runtime?.sendMessage?.({ action: 'auth.getToken' }); + return resp?.token ?? null; + } catch { + return null; + } +} + +function localConsent(v: Record): Consent { + return { insights: v[KEYS.insights] !== false, shareTraining: v[KEYS.training] === true }; +} + +/** Read consent: the account is canonical when signed in; local otherwise. */ +export async function getConsent(): Promise { + const local = localConsent(await getLocal([KEYS.insights, KEYS.training])); + const tok = await token(); + if (!tok) return local; + try { + const res = await fetch(`${API_BASE_URL}/v1/iam/consent`, { + headers: { authorization: `Bearer ${tok}` }, + }); + if (res.ok) { + const a = await res.json(); + const acct: Consent = { insights: a.insights !== false, shareTraining: a.shareTraining === true }; + // Cache so the UI paints instantly next time, keeping one value in sync. + await setLocal({ [KEYS.insights]: acct.insights, [KEYS.training]: acct.shareTraining }); + return acct; + } + } catch { + /* fall through to local */ + } + return local; +} + +/** Write consent: local cache + the account (when signed in) — one value. */ +export async function setConsent(c: Partial): Promise { + const cur = localConsent(await getLocal([KEYS.insights, KEYS.training])); + const next: Consent = { + insights: c.insights ?? cur.insights, + shareTraining: c.shareTraining ?? cur.shareTraining, + }; + await setLocal({ [KEYS.insights]: next.insights, [KEYS.training]: next.shareTraining }); + const tok = await token(); + if (!tok) return; + try { + await fetch(`${API_BASE_URL}/v1/iam/consent`, { + method: 'PUT', + headers: { 'content-type': 'application/json', authorization: `Bearer ${tok}` }, + body: JSON.stringify(next), + keepalive: true, + }).catch(() => {}); + } catch { + /* fail-soft */ + } +} + +export async function isOnboarded(): Promise { + const v = await getLocal([KEYS.onboarded]); + return v[KEYS.onboarded] === true; +} +export async function markOnboarded(): Promise { + await setLocal({ [KEYS.onboarded]: true }); +} + +async function anonId(): Promise { + const v = await getLocal([KEYS.anonId]); + if (typeof v[KEYS.anonId] === 'string') return v[KEYS.anonId]; + const id = + globalThis.crypto?.randomUUID?.() ?? + `a-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + await setLocal({ [KEYS.anonId]: id }); + return id; +} + +/** Anonymous product-usage event. No query/answer text, no PII. No-op when + * insights is off. Never throws. */ +export async function capture(event: string, props?: Record): Promise { + try { + const c = await getConsent(); + if (!c.insights) return; + const anon_id = await anonId(); + await fetch(`${API_BASE_URL}/v1/insights`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event, props: props ?? {}, anon_id, ts: Date.now(), source: 'extension' }), + keepalive: true, + }).catch(() => {}); + } catch { + /* fail-soft */ + } +} + +/** Contribute one search+answer sample to train Hanzo's open models. No-op + * unless the user opted in. Sent with the IAM bearer so it's the user's own, + * attributable data. Never throws. */ +export async function contributeTrainingSample(sample: { + query: string; + answer: string; + model?: string; + sources?: string[]; +}): Promise { + try { + const c = await getConsent(); + if (!c.shareTraining) return; + if (!sample.query || !sample.answer) return; + const tok = await token(); + await fetch(`${API_BASE_URL}/v1/training/contributions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(tok ? { authorization: `Bearer ${tok}` } : {}), + }, + body: JSON.stringify({ ...sample, source: 'extension:answer-engine', ts: Date.now() }), + keepalive: true, + }).catch(() => {}); + } catch { + /* fail-soft */ + } +}