feat(consent): insights + opt-in training contribution, account-canonical
publish / publish (push) Failing after 32s
ci / build (push) Successful in 27s
ci / test (push) Successful in 28s

- shared/consent.ts: ONE consent value. Signed in → the Hanzo account is the
  source of truth (GET/PUT /v1/iam/consent); signed out → local, pushed on next
  sign-in. Two switches: anonymous usage insights (default on, no query text) +
  opt-in training contribution (share your own searches/answers; off until asked).
- Onboarding on new install (background onInstalled → onboarding.html) explicitly
  ASKS consent; settings toggles in the popup mirror the same value.
- Answer engine instrumented: anonymous answer_search/answer_shown events +
  contributeTrainingSample (no-op unless opted in). All fail-soft.
- release: browser-extension 1.9.36
This commit is contained in:
Hanzo AI
2026-07-26 11:12:06 -07:00
parent a4fd6b1557
commit 86bc401545
11 changed files with 414 additions and 4 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hanzo/extension", "name": "@hanzo/extension",
"version": "1.9.35", "version": "1.9.36",
"private": true, "private": true,
"description": "Hanzo AI Extension", "description": "Hanzo AI Extension",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hanzo/browser-extension", "name": "@hanzo/browser-extension",
"version": "1.9.35", "version": "1.9.36",
"description": "Hanzo AI Browser Extension", "description": "Hanzo AI Browser Extension",
"main": "dist/background.js", "main": "dist/background.js",
"scripts": { "scripts": {
@@ -3,6 +3,7 @@ import { createAiClient, type SearchEvent, type SearchMode, type SearchSource }
import { fetchNews, type NewsItem } from './news.js'; import { fetchNews, type NewsItem } from './news.js';
import { AUTO_MODEL, fetchModelCatalog, type ChatModel, type ModelGroup } from './model-catalog.js'; import { AUTO_MODEL, fetchModelCatalog, type ChatModel, type ModelGroup } from './model-catalog.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
import { capture, contributeTrainingSample } from '../shared/consent.js';
/** /**
* Hanzo Answer Engine — the shared AI-answer surface (query → streamed answer + * Hanzo Answer Engine — the shared AI-answer surface (query → streamed answer +
@@ -321,6 +322,8 @@ export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: Answer
setFollowUps([]); setFollowUps([]);
setError(''); setError('');
setNeedAuth(false); setNeedAuth(false);
void capture('answer_search', { mode, model: model.id });
let fullAnswer = '';
const opts = { const opts = {
mode, mode,
@@ -346,6 +349,7 @@ export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: Answer
setAnswerSources(ev.sources); setAnswerSources(ev.sources);
break; break;
case 'text': case 'text':
fullAnswer += ev.delta;
setAnswer((a) => a + ev.delta); setAnswer((a) => a + ev.delta);
break; break;
case 'follow_ups': case 'follow_ups':
@@ -353,6 +357,10 @@ export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: Answer
break; break;
case 'done': case 'done':
setPhase('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; break;
case 'error': case 'error':
if (ev.error.includes('__NO_TOKEN__') || /401|invalid api key/i.test(ev.error)) { if (ev.error.includes('__NO_TOKEN__') || /401|invalid api key/i.test(ev.error)) {
+10 -1
View File
@@ -283,9 +283,18 @@ async function stopControlSession(): Promise<void> {
void loadDebugFlagFromStorage(); void loadDebugFlagFromStorage();
// Initialize WebGPU, Ollama, and CDP on install // Initialize WebGPU, Ollama, and CDP on install
chrome.runtime.onInstalled.addListener(async () => { chrome.runtime.onInstalled.addListener(async (details) => {
debugLog('[Hanzo] Extension installed, initializing...'); 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 // WebGPU init
const gpuAvailable = await webgpuAI.initialize(); const gpuAvailable = await webgpuAI.initialize();
if (gpuAvailable) { if (gpuAvailable) {
+13 -1
View File
@@ -122,6 +122,17 @@ async function build() {
sourcemap: false, 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 // Build browser control module
await esbuild.build({ await esbuild.build({
entryPoints: ['src/browser-control.ts'], entryPoints: ['src/browser-control.ts'],
@@ -196,7 +207,7 @@ async function build() {
} }
// Copy popup + sidebar + answer-engine HTML/CSS // 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); const src = path.join('src', f);
if (fs.existsSync(src)) { if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(dir, f)); 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('popup.js'), path.join(dir, 'popup.js'));
fs.copyFileSync(out('sidebar.js'), path.join(dir, 'sidebar.js')); fs.copyFileSync(out('sidebar.js'), path.join(dir, 'sidebar.js'));
fs.copyFileSync(out('newtab.js'), path.join(dir, 'newtab.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 // Copy icons into each browser directory
for (const icon of ['icon16.png', 'icon48.png', 'icon128.png']) { for (const icon of ['icon16.png', 'icon48.png', 'icon128.png']) {
+78
View File
@@ -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); }
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Welcome to Hanzo</title>
<link rel="stylesheet" href="onboarding.css" />
</head>
<body>
<main class="card">
<div class="brand">Hanzo</div>
<h1>Welcome to Hanzo AI</h1>
<p class="lede">
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.
</p>
<label class="row">
<input type="checkbox" id="ob-insights" checked />
<span class="switch"></span>
<span class="text">
<strong>Anonymous usage insights</strong>
<small>Helps us improve Hanzo. No search text, no personal data — just a random per-install id.</small>
</span>
</label>
<label class="row">
<input type="checkbox" id="ob-training" />
<span class="switch"></span>
<span class="text">
<strong>Contribute to training Hanzo's open models</strong>
<small>Share your own searches &amp; answers so Hanzo's open models get better. Opt in — off unless you turn it on.</small>
</span>
</label>
<button id="ob-continue" class="cta">Get started</button>
<p class="fine">
Signed in? These choices save to your Hanzo account and stay in sync
everywhere. <a href="https://hanzo.ai/privacy" target="_blank" rel="noopener">Privacy</a>
</p>
</main>
<script src="onboarding.js"></script>
</body>
</html>
+29
View File
@@ -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();
}
});
+28
View File
@@ -222,6 +222,34 @@
<div class="divider"></div> <div class="divider"></div>
<!-- Privacy & data -->
<h3>Privacy &amp; data</h3>
<div class="features">
<div class="feature-toggle">
<label>
<input type="checkbox" id="insights-enabled" checked>
<span class="toggle"></span>
<div class="feature-info">
<strong>Usage insights</strong>
<small>Anonymous, helps improve Hanzo. Never your search text.</small>
</div>
</label>
</div>
<div class="feature-toggle">
<label>
<input type="checkbox" id="share-training">
<span class="toggle"></span>
<div class="feature-info">
<strong>Contribute to training</strong>
<small>Opt in to share your searches &amp; answers to train Hanzo's open models. Off by default.</small>
</div>
</label>
</div>
</div>
<div class="divider"></div>
<!-- Browser Backend --> <!-- Browser Backend -->
<div class="setting-group backend-picker"> <div class="setting-group backend-picker">
<h3>Backend</h3> <h3>Backend</h3>
+16
View File
@@ -14,6 +14,7 @@ import {
shortcutFromEvent, shortcutFromEvent,
type Shortcut, type Shortcut,
} from './shared/shortcut.js'; } from './shared/shortcut.js';
import { getConsent, setConsent, capture } from './shared/consent.js';
declare const browser: typeof chrome & { declare const browser: typeof chrome & {
sidebarAction?: { sidebarAction?: {
@@ -229,6 +230,21 @@ document.addEventListener('DOMContentLoaded', () => {
syncSettingsUi(enabled, side, width); 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 { function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
if (!tab || !tab.id) return false; if (!tab || !tab.id) return false;
const url = tab.url || ''; const url = tab.url || '';
+185
View File
@@ -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<Record<string, any>> {
return new Promise((resolve) => {
const s = area();
if (!s) return resolve({});
try {
s.get(keys, (v) => resolve(v || {}));
} catch {
resolve({});
}
});
}
function setLocal(obj: Record<string, any>): Promise<void> {
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<string | null> {
try {
const resp: any = await runtime?.runtime?.sendMessage?.({ action: 'auth.getToken' });
return resp?.token ?? null;
} catch {
return null;
}
}
function localConsent(v: Record<string, any>): 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<Consent> {
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<Consent>): Promise<void> {
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<boolean> {
const v = await getLocal([KEYS.onboarded]);
return v[KEYS.onboarded] === true;
}
export async function markOnboarded(): Promise<void> {
await setLocal({ [KEYS.onboarded]: true });
}
async function anonId(): Promise<string> {
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<string, unknown>): Promise<void> {
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<void> {
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 */
}
}