feat(consent): insights + opt-in training contribution, account-canonical
- 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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.35",
|
||||
"version": "1.9.36",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -283,9 +283,18 @@ async function stopControlSession(): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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); }
|
||||
@@ -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 & 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>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -222,6 +222,34 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Privacy & data -->
|
||||
<h3>Privacy & 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 & answers to train Hanzo's open models. Off by default.</small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Browser Backend -->
|
||||
<div class="setting-group backend-picker">
|
||||
<h3>Backend</h3>
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user