@hanzo/browser-extension 1.9.31 → 1.9.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/answer/AnswerEngine.tsx +553 -0
- package/src/manifest-firefox.json +10 -2
- package/src/manifest.json +17 -2
- package/src/newtab.css +277 -0
- package/src/newtab.html +13 -0
- package/src/popup.html +28 -0
- package/dist/browser-extension/README.md +0 -43
- package/dist/browser-extension/background-firefox.js +0 -4556
- package/dist/browser-extension/background.js +0 -7245
- package/dist/browser-extension/browser-control.js +0 -1317
- package/dist/browser-extension/chrome/ai-worker.js +0 -571
- package/dist/browser-extension/chrome/background.js +0 -7245
- package/dist/browser-extension/chrome/callback.html +0 -17
- package/dist/browser-extension/chrome/content-script.js +0 -2789
- package/dist/browser-extension/chrome/icon128.png +0 -0
- package/dist/browser-extension/chrome/icon16.png +0 -0
- package/dist/browser-extension/chrome/icon48.png +0 -0
- package/dist/browser-extension/chrome/manifest.json +0 -66
- package/dist/browser-extension/chrome/popup.css +0 -761
- package/dist/browser-extension/chrome/popup.html +0 -353
- package/dist/browser-extension/chrome/popup.js +0 -1650
- package/dist/browser-extension/chrome/sidebar.css +0 -1792
- package/dist/browser-extension/chrome/sidebar.html +0 -463
- package/dist/browser-extension/chrome/sidebar.js +0 -26541
- package/dist/browser-extension/cli.js +0 -233
- package/dist/browser-extension/firefox/ai-worker.js +0 -571
- package/dist/browser-extension/firefox/background.js +0 -4556
- package/dist/browser-extension/firefox/callback.html +0 -17
- package/dist/browser-extension/firefox/content-script.js +0 -2789
- package/dist/browser-extension/firefox/icon128.png +0 -0
- package/dist/browser-extension/firefox/icon16.png +0 -0
- package/dist/browser-extension/firefox/icon48.png +0 -0
- package/dist/browser-extension/firefox/manifest.json +0 -77
- package/dist/browser-extension/firefox/popup.css +0 -761
- package/dist/browser-extension/firefox/popup.html +0 -353
- package/dist/browser-extension/firefox/popup.js +0 -1650
- package/dist/browser-extension/firefox/sidebar.css +0 -1792
- package/dist/browser-extension/firefox/sidebar.html +0 -463
- package/dist/browser-extension/firefox/sidebar.js +0 -26541
- package/dist/browser-extension/icon128.png +0 -0
- package/dist/browser-extension/icon16.png +0 -0
- package/dist/browser-extension/icon48.png +0 -0
- package/dist/browser-extension/manifest.json +0 -66
- package/dist/browser-extension/package.json +0 -41
- package/dist/browser-extension/popup.js +0 -1650
- package/dist/browser-extension/safari/Info.plist +0 -21
- package/dist/browser-extension/safari/ai-worker.js +0 -571
- package/dist/browser-extension/safari/background.js +0 -7245
- package/dist/browser-extension/safari/callback.html +0 -17
- package/dist/browser-extension/safari/content-script.js +0 -2789
- package/dist/browser-extension/safari/icon128.png +0 -0
- package/dist/browser-extension/safari/icon16.png +0 -0
- package/dist/browser-extension/safari/icon48.png +0 -0
- package/dist/browser-extension/safari/popup.css +0 -761
- package/dist/browser-extension/safari/popup.html +0 -353
- package/dist/browser-extension/safari/popup.js +0 -1650
- package/dist/browser-extension/safari/sidebar.css +0 -1792
- package/dist/browser-extension/safari/sidebar.html +0 -463
- package/dist/browser-extension/safari/sidebar.js +0 -26541
- package/dist/browser-extension/sidebar.js +0 -26541
- package/dist/browser-extension/webgpu-ai.js +0 -568
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/browser-extension",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.33",
|
|
4
4
|
"description": "Hanzo AI Browser Extension",
|
|
5
5
|
"main": "dist/background.js",
|
|
6
6
|
"dependencies": {
|
|
7
|
+
"@hanzo/ai": "^0.2.3",
|
|
7
8
|
"@supabase/supabase-js": "^2.50.3",
|
|
8
9
|
"axios": "^1.10.0",
|
|
9
10
|
"chalk": "^4.1.2",
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createAiClient, type SearchEvent, type SearchMode, type SearchSource } from '@hanzo/ai';
|
|
3
|
+
import { fetchNews, type NewsItem } from './news.js';
|
|
4
|
+
import { AUTO_MODEL, fetchModelCatalog, type ChatModel, type ModelGroup } from './model-catalog.js';
|
|
5
|
+
import { renderMarkdown } from './markdown.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hanzo Answer Engine — the shared AI-answer surface (query → streamed answer +
|
|
9
|
+
* inline citations + sources + live news). Backend = api.hanzo.ai ONLY, reached
|
|
10
|
+
* through the @hanzo/ai `search`/`deepResearch` primitive (the one place the
|
|
11
|
+
* capability is defined). Rendered on the @hanzo/gui primitive surface; strict
|
|
12
|
+
* monochrome per the Hanzo brand.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface AnswerEngineProps {
|
|
16
|
+
apiBase: string;
|
|
17
|
+
/** Resolves the current IAM bearer, or null when signed out. */
|
|
18
|
+
getToken: () => Promise<string | null>;
|
|
19
|
+
/** Kicks off IAM sign-in (opens the OAuth tab). */
|
|
20
|
+
signIn: () => void;
|
|
21
|
+
/** Optional initial query (from the omnibox / address bar `?q=`). */
|
|
22
|
+
initialQuery?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MODES: { id: SearchMode; label: string; hint: string }[] = [
|
|
26
|
+
{ id: 'search', label: 'Search', hint: 'Fast grounded answer' },
|
|
27
|
+
{ id: 'news', label: 'News', hint: 'Recency-biased' },
|
|
28
|
+
{ id: 'research', label: 'Research', hint: 'Multi-source synthesis' },
|
|
29
|
+
{ id: 'deep', label: 'Deep', hint: 'Plan + wide research' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const SOURCE_CHIPS = ['web', 'news', 'academic', 'github', 'reddit', 'x'];
|
|
33
|
+
|
|
34
|
+
// Hanzo tiers mirror @hanzo/plans (the SoT for pricing); shown in the picker's
|
|
35
|
+
// upgrade banner. Full plan detail lives at console.hanzo.ai/pricing.
|
|
36
|
+
const PLAN_TIERS = [
|
|
37
|
+
{ name: 'Pro', price: '$20' },
|
|
38
|
+
{ name: 'Max', price: '$100' },
|
|
39
|
+
{ name: 'Ultra', price: '$200' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function HanzoMark({ size = 28 }: { size?: number }) {
|
|
43
|
+
return (
|
|
44
|
+
<svg viewBox="0 0 67 67" width={size} height={size} xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
45
|
+
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor" />
|
|
46
|
+
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor" />
|
|
47
|
+
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor" />
|
|
48
|
+
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor" />
|
|
49
|
+
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ModelPicker({
|
|
55
|
+
groups,
|
|
56
|
+
current,
|
|
57
|
+
onPick,
|
|
58
|
+
}: {
|
|
59
|
+
groups: ModelGroup[];
|
|
60
|
+
current: ChatModel;
|
|
61
|
+
onPick: (m: ChatModel) => void;
|
|
62
|
+
}) {
|
|
63
|
+
const [open, setOpen] = useState(false);
|
|
64
|
+
const [filter, setFilter] = useState('');
|
|
65
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!open) return;
|
|
69
|
+
const onDoc = (e: MouseEvent) => {
|
|
70
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
71
|
+
};
|
|
72
|
+
document.addEventListener('mousedown', onDoc);
|
|
73
|
+
return () => document.removeEventListener('mousedown', onDoc);
|
|
74
|
+
}, [open]);
|
|
75
|
+
|
|
76
|
+
const q = filter.trim().toLowerCase();
|
|
77
|
+
const shown = useMemo(
|
|
78
|
+
() =>
|
|
79
|
+
groups
|
|
80
|
+
.map((g) => ({
|
|
81
|
+
...g,
|
|
82
|
+
models: g.models.filter(
|
|
83
|
+
(m) => !q || m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
|
84
|
+
),
|
|
85
|
+
}))
|
|
86
|
+
.filter((g) => g.models.length),
|
|
87
|
+
[groups, q],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="ae-picker" ref={ref}>
|
|
92
|
+
<button className="ae-picker-btn" onClick={() => setOpen((v) => !v)} title="Choose model">
|
|
93
|
+
<span className="ae-dot" />
|
|
94
|
+
{current.name}
|
|
95
|
+
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
|
96
|
+
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
97
|
+
</svg>
|
|
98
|
+
</button>
|
|
99
|
+
{open && (
|
|
100
|
+
<div className="ae-picker-menu">
|
|
101
|
+
<input
|
|
102
|
+
className="ae-picker-filter"
|
|
103
|
+
placeholder="Search models…"
|
|
104
|
+
value={filter}
|
|
105
|
+
autoFocus
|
|
106
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
<div className="ae-picker-list">
|
|
109
|
+
<button
|
|
110
|
+
className={'ae-model-row' + (current.id === AUTO_MODEL.id ? ' active' : '')}
|
|
111
|
+
onClick={() => {
|
|
112
|
+
onPick(AUTO_MODEL);
|
|
113
|
+
setOpen(false);
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<span className="ae-model-name">Auto</span>
|
|
117
|
+
<span className="ae-model-desc">Routes to the best model</span>
|
|
118
|
+
</button>
|
|
119
|
+
{shown.map((g) => (
|
|
120
|
+
<div key={g.family} className="ae-model-group">
|
|
121
|
+
<div className="ae-model-group-label">{g.label}</div>
|
|
122
|
+
{g.models.map((m) => (
|
|
123
|
+
<button
|
|
124
|
+
key={m.id}
|
|
125
|
+
className={'ae-model-row' + (current.id === m.id ? ' active' : '')}
|
|
126
|
+
onClick={() => {
|
|
127
|
+
onPick(m);
|
|
128
|
+
setOpen(false);
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<span className="ae-model-name">
|
|
132
|
+
{m.name}
|
|
133
|
+
{m.tier && <span className="ae-badge">{m.tier}</span>}
|
|
134
|
+
</span>
|
|
135
|
+
{m.description && <span className="ae-model-desc">{m.description}</span>}
|
|
136
|
+
</button>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
<a className="ae-plan-banner" href="https://console.hanzo.ai/pricing" target="_blank" rel="noreferrer">
|
|
142
|
+
<span>Unlock every model</span>
|
|
143
|
+
<span className="ae-plan-tiers">
|
|
144
|
+
{PLAN_TIERS.map((t) => (
|
|
145
|
+
<span key={t.name} className="ae-plan-tier">
|
|
146
|
+
{t.name} <b>{t.price}</b>
|
|
147
|
+
</span>
|
|
148
|
+
))}
|
|
149
|
+
</span>
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function SourceCard({ s, index }: { s: SearchSource; index: number }) {
|
|
158
|
+
let host = '';
|
|
159
|
+
try {
|
|
160
|
+
host = new URL(s.url).hostname.replace(/^www\./, '');
|
|
161
|
+
} catch {
|
|
162
|
+
host = s.url;
|
|
163
|
+
}
|
|
164
|
+
return (
|
|
165
|
+
<a className="ae-source" href={s.url} target="_blank" rel="noreferrer" title={s.title}>
|
|
166
|
+
<span className="ae-source-head">
|
|
167
|
+
{s.favicon ? <img src={s.favicon} alt="" width="14" height="14" /> : <span className="ae-dot" />}
|
|
168
|
+
<span className="ae-source-host">{host}</span>
|
|
169
|
+
<span className="ae-source-idx">{index + 1}</span>
|
|
170
|
+
</span>
|
|
171
|
+
<span className="ae-source-title">{s.title}</span>
|
|
172
|
+
</a>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function InputCard({
|
|
177
|
+
value,
|
|
178
|
+
onChange,
|
|
179
|
+
onSubmit,
|
|
180
|
+
mode,
|
|
181
|
+
setMode,
|
|
182
|
+
sources,
|
|
183
|
+
toggleSource,
|
|
184
|
+
models,
|
|
185
|
+
model,
|
|
186
|
+
setModel,
|
|
187
|
+
busy,
|
|
188
|
+
}: {
|
|
189
|
+
value: string;
|
|
190
|
+
onChange: (v: string) => void;
|
|
191
|
+
onSubmit: () => void;
|
|
192
|
+
mode: SearchMode;
|
|
193
|
+
setMode: (m: SearchMode) => void;
|
|
194
|
+
sources: Set<string>;
|
|
195
|
+
toggleSource: (s: string) => void;
|
|
196
|
+
models: ModelGroup[];
|
|
197
|
+
model: ChatModel;
|
|
198
|
+
setModel: (m: ChatModel) => void;
|
|
199
|
+
busy: boolean;
|
|
200
|
+
}) {
|
|
201
|
+
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const ta = taRef.current;
|
|
204
|
+
if (!ta) return;
|
|
205
|
+
ta.style.height = 'auto';
|
|
206
|
+
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
|
|
207
|
+
}, [value]);
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="ae-card">
|
|
211
|
+
<textarea
|
|
212
|
+
ref={taRef}
|
|
213
|
+
className="ae-input"
|
|
214
|
+
placeholder="Type @ for sources or / for modes"
|
|
215
|
+
value={value}
|
|
216
|
+
rows={1}
|
|
217
|
+
onChange={(e) => onChange(e.target.value)}
|
|
218
|
+
onKeyDown={(e) => {
|
|
219
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
onSubmit();
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
<div className="ae-card-row">
|
|
226
|
+
<div className="ae-chips">
|
|
227
|
+
{SOURCE_CHIPS.map((s) => (
|
|
228
|
+
<button
|
|
229
|
+
key={s}
|
|
230
|
+
className={'ae-chip' + (sources.has(s) ? ' on' : '')}
|
|
231
|
+
onClick={() => toggleSource(s)}
|
|
232
|
+
title={`@${s}`}
|
|
233
|
+
>
|
|
234
|
+
@{s}
|
|
235
|
+
</button>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
<div className="ae-card-right">
|
|
239
|
+
<div className="ae-modes">
|
|
240
|
+
{MODES.map((m) => (
|
|
241
|
+
<button
|
|
242
|
+
key={m.id}
|
|
243
|
+
className={'ae-mode' + (mode === m.id ? ' on' : '')}
|
|
244
|
+
onClick={() => setMode(m.id)}
|
|
245
|
+
title={m.hint}
|
|
246
|
+
>
|
|
247
|
+
{m.label}
|
|
248
|
+
</button>
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
<ModelPicker groups={models} current={model} onPick={setModel} />
|
|
252
|
+
<button className="ae-send" onClick={onSubmit} disabled={busy || !value.trim()} title="Search (Enter)">
|
|
253
|
+
{busy ? (
|
|
254
|
+
<span className="ae-spinner" />
|
|
255
|
+
) : (
|
|
256
|
+
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
|
257
|
+
<path d="M3 13V3l10 5-10 5z" fill="currentColor" />
|
|
258
|
+
</svg>
|
|
259
|
+
)}
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function AnswerEngine({ apiBase, getToken, signIn, initialQuery }: AnswerEngineProps) {
|
|
268
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
269
|
+
const [query, setQuery] = useState('');
|
|
270
|
+
const [submitted, setSubmitted] = useState('');
|
|
271
|
+
const [mode, setMode] = useState<SearchMode>('search');
|
|
272
|
+
const [sources, setSources] = useState<Set<string>>(new Set());
|
|
273
|
+
const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
|
|
274
|
+
const [model, setModel] = useState<ChatModel>(AUTO_MODEL);
|
|
275
|
+
const [news, setNews] = useState<NewsItem[]>([]);
|
|
276
|
+
|
|
277
|
+
const [phase, setPhase] = useState<'idle' | 'running' | 'done'>('idle');
|
|
278
|
+
const [status, setStatus] = useState('');
|
|
279
|
+
const [answer, setAnswer] = useState('');
|
|
280
|
+
const [answerSources, setAnswerSources] = useState<SearchSource[]>([]);
|
|
281
|
+
const [followUps, setFollowUps] = useState<string[]>([]);
|
|
282
|
+
const [error, setError] = useState('');
|
|
283
|
+
const [needAuth, setNeedAuth] = useState(false);
|
|
284
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
285
|
+
|
|
286
|
+
// Load real model catalog + live news (both public — work signed out).
|
|
287
|
+
// Default stays on Auto ("routes to the best model"); the picker changes it.
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
fetchModelCatalog(apiBase)
|
|
290
|
+
.then(({ groups }) => setModelGroups(groups))
|
|
291
|
+
.catch(() => {});
|
|
292
|
+
fetchNews().then(setNews).catch(() => {});
|
|
293
|
+
}, [apiBase]);
|
|
294
|
+
|
|
295
|
+
const client = useMemo(
|
|
296
|
+
() =>
|
|
297
|
+
createAiClient({
|
|
298
|
+
baseUrl: apiBase,
|
|
299
|
+
getToken: async () => {
|
|
300
|
+
const t = await getToken();
|
|
301
|
+
if (!t) throw new Error('__NO_TOKEN__');
|
|
302
|
+
return t;
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
[apiBase, getToken],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const run = useCallback(
|
|
309
|
+
async (q: string) => {
|
|
310
|
+
const question = q.trim();
|
|
311
|
+
if (!question) return;
|
|
312
|
+
abortRef.current?.abort();
|
|
313
|
+
const ctrl = new AbortController();
|
|
314
|
+
abortRef.current = ctrl;
|
|
315
|
+
|
|
316
|
+
setSubmitted(question);
|
|
317
|
+
setPhase('running');
|
|
318
|
+
setStatus('Searching the web');
|
|
319
|
+
setAnswer('');
|
|
320
|
+
setAnswerSources([]);
|
|
321
|
+
setFollowUps([]);
|
|
322
|
+
setError('');
|
|
323
|
+
setNeedAuth(false);
|
|
324
|
+
|
|
325
|
+
const opts = {
|
|
326
|
+
mode,
|
|
327
|
+
model: model.id,
|
|
328
|
+
sources: [...sources],
|
|
329
|
+
signal: ctrl.signal,
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
for await (const ev of client.search(question, opts) as AsyncGenerator<SearchEvent>) {
|
|
333
|
+
switch (ev.type) {
|
|
334
|
+
case 'status':
|
|
335
|
+
setStatus(
|
|
336
|
+
ev.stage === 'planning'
|
|
337
|
+
? 'Planning research'
|
|
338
|
+
: ev.stage === 'searching'
|
|
339
|
+
? 'Searching the web' + (ev.detail ? `: ${ev.detail}` : '')
|
|
340
|
+
: ev.stage === 'reading'
|
|
341
|
+
? 'Reading sources'
|
|
342
|
+
: 'Writing the answer',
|
|
343
|
+
);
|
|
344
|
+
break;
|
|
345
|
+
case 'sources':
|
|
346
|
+
setAnswerSources(ev.sources);
|
|
347
|
+
break;
|
|
348
|
+
case 'text':
|
|
349
|
+
setAnswer((a) => a + ev.delta);
|
|
350
|
+
break;
|
|
351
|
+
case 'follow_ups':
|
|
352
|
+
setFollowUps(ev.questions);
|
|
353
|
+
break;
|
|
354
|
+
case 'done':
|
|
355
|
+
setPhase('done');
|
|
356
|
+
break;
|
|
357
|
+
case 'error':
|
|
358
|
+
if (ev.error.includes('__NO_TOKEN__') || /401|invalid api key/i.test(ev.error)) {
|
|
359
|
+
setNeedAuth(true);
|
|
360
|
+
} else {
|
|
361
|
+
setError(ev.error);
|
|
362
|
+
}
|
|
363
|
+
setPhase('done');
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch (e) {
|
|
368
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
369
|
+
if (msg.includes('__NO_TOKEN__') || /401|invalid api key/i.test(msg)) setNeedAuth(true);
|
|
370
|
+
else setError(msg);
|
|
371
|
+
setPhase('done');
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
[client, mode, model, sources],
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Fire the initial (address-bar) query once models are ready enough.
|
|
378
|
+
const didInit = useRef(false);
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (didInit.current || !initialQuery) return;
|
|
381
|
+
didInit.current = true;
|
|
382
|
+
setQuery(initialQuery);
|
|
383
|
+
void run(initialQuery);
|
|
384
|
+
}, [initialQuery, run]);
|
|
385
|
+
|
|
386
|
+
const submit = useCallback(() => void run(query), [query, run]);
|
|
387
|
+
const toggleSource = useCallback((s: string) => {
|
|
388
|
+
setSources((prev) => {
|
|
389
|
+
const next = new Set(prev);
|
|
390
|
+
next.has(s) ? next.delete(s) : next.add(s);
|
|
391
|
+
return next;
|
|
392
|
+
});
|
|
393
|
+
}, []);
|
|
394
|
+
|
|
395
|
+
const newThread = useCallback(() => {
|
|
396
|
+
abortRef.current?.abort();
|
|
397
|
+
setPhase('idle');
|
|
398
|
+
setQuery('');
|
|
399
|
+
setSubmitted('');
|
|
400
|
+
setAnswer('');
|
|
401
|
+
setAnswerSources([]);
|
|
402
|
+
setFollowUps([]);
|
|
403
|
+
setError('');
|
|
404
|
+
setNeedAuth(false);
|
|
405
|
+
}, []);
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<div className={'ae-root' + (collapsed ? ' collapsed' : '')}>
|
|
409
|
+
<aside className="ae-sidebar">
|
|
410
|
+
<div className="ae-side-top">
|
|
411
|
+
<button className="ae-brand" onClick={newThread} title="Hanzo">
|
|
412
|
+
<HanzoMark size={22} />
|
|
413
|
+
{!collapsed && <span>Hanzo</span>}
|
|
414
|
+
</button>
|
|
415
|
+
<button className="ae-collapse" onClick={() => setCollapsed((v) => !v)} title="Toggle sidebar">
|
|
416
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
|
417
|
+
<path d="M6 3v10M3 3h10v10H3z" fill="none" stroke="currentColor" strokeWidth="1.3" />
|
|
418
|
+
</svg>
|
|
419
|
+
</button>
|
|
420
|
+
</div>
|
|
421
|
+
<button className="ae-new" onClick={newThread}>
|
|
422
|
+
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
|
423
|
+
<path d="M8 3v10M3 8h10" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
424
|
+
</svg>
|
|
425
|
+
{!collapsed && <span>New thread</span>}
|
|
426
|
+
</button>
|
|
427
|
+
<nav className="ae-nav">
|
|
428
|
+
<a href="https://hanzo.chat" target="_blank" rel="noreferrer">Threads</a>
|
|
429
|
+
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">Docs</a>
|
|
430
|
+
<a href="https://hanzo.ai" target="_blank" rel="noreferrer">About</a>
|
|
431
|
+
</nav>
|
|
432
|
+
<div className="ae-side-bottom">
|
|
433
|
+
<button className="ae-signin" onClick={signIn}>
|
|
434
|
+
{collapsed ? '↪' : 'Sign in — free to start'}
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
</aside>
|
|
438
|
+
|
|
439
|
+
<main className="ae-main">
|
|
440
|
+
{phase === 'idle' ? (
|
|
441
|
+
<div className="ae-hero">
|
|
442
|
+
<div className="ae-wordmark">
|
|
443
|
+
<HanzoMark size={44} />
|
|
444
|
+
<span>Hanzo</span>
|
|
445
|
+
</div>
|
|
446
|
+
<div className="ae-hero-card">
|
|
447
|
+
<InputCard
|
|
448
|
+
value={query}
|
|
449
|
+
onChange={setQuery}
|
|
450
|
+
onSubmit={submit}
|
|
451
|
+
mode={mode}
|
|
452
|
+
setMode={setMode}
|
|
453
|
+
sources={sources}
|
|
454
|
+
toggleSource={toggleSource}
|
|
455
|
+
models={modelGroups}
|
|
456
|
+
model={model}
|
|
457
|
+
setModel={setModel}
|
|
458
|
+
busy={false}
|
|
459
|
+
/>
|
|
460
|
+
</div>
|
|
461
|
+
{news.length > 0 && (
|
|
462
|
+
<div className="ae-news">
|
|
463
|
+
<div className="ae-news-head">Top stories</div>
|
|
464
|
+
<div className="ae-news-grid">
|
|
465
|
+
{news.slice(0, 6).map((n) => (
|
|
466
|
+
<a key={n.url} className="ae-news-item" href={n.url} target="_blank" rel="noreferrer">
|
|
467
|
+
<span className="ae-news-title">{n.title}</span>
|
|
468
|
+
<span className="ae-news-meta">
|
|
469
|
+
{n.source}
|
|
470
|
+
{typeof n.points === 'number' ? ` · ${n.points} pts` : ''}
|
|
471
|
+
</span>
|
|
472
|
+
</a>
|
|
473
|
+
))}
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
) : (
|
|
479
|
+
<div className="ae-answer-wrap">
|
|
480
|
+
<div className="ae-answer-scroll">
|
|
481
|
+
<h1 className="ae-question">{submitted}</h1>
|
|
482
|
+
|
|
483
|
+
{answerSources.length > 0 && (
|
|
484
|
+
<div className="ae-sources">
|
|
485
|
+
{answerSources.map((s, i) => (
|
|
486
|
+
<SourceCard key={s.url} s={s} index={i} />
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
|
|
491
|
+
{phase === 'running' && !answer && (
|
|
492
|
+
<div className="ae-status">
|
|
493
|
+
<span className="ae-spinner" /> {status}…
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
|
|
497
|
+
{needAuth ? (
|
|
498
|
+
<div className="ae-auth">
|
|
499
|
+
<p>Sign in with Hanzo to get AI answers.</p>
|
|
500
|
+
<button className="ae-signin big" onClick={signIn}>
|
|
501
|
+
Sign in — free to start
|
|
502
|
+
</button>
|
|
503
|
+
</div>
|
|
504
|
+
) : error ? (
|
|
505
|
+
<div className="ae-error">{error}</div>
|
|
506
|
+
) : (
|
|
507
|
+
<div
|
|
508
|
+
className="ae-answer markdown"
|
|
509
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(answer) }}
|
|
510
|
+
/>
|
|
511
|
+
)}
|
|
512
|
+
|
|
513
|
+
{followUps.length > 0 && (
|
|
514
|
+
<div className="ae-followups">
|
|
515
|
+
<div className="ae-followups-head">Follow-up</div>
|
|
516
|
+
{followUps.map((f) => (
|
|
517
|
+
<button
|
|
518
|
+
key={f}
|
|
519
|
+
className="ae-followup"
|
|
520
|
+
onClick={() => {
|
|
521
|
+
setQuery(f);
|
|
522
|
+
void run(f);
|
|
523
|
+
}}
|
|
524
|
+
>
|
|
525
|
+
{f}
|
|
526
|
+
<span className="ae-followup-arrow">→</span>
|
|
527
|
+
</button>
|
|
528
|
+
))}
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
<div className="ae-answer-composer">
|
|
534
|
+
<InputCard
|
|
535
|
+
value={query}
|
|
536
|
+
onChange={setQuery}
|
|
537
|
+
onSubmit={submit}
|
|
538
|
+
mode={mode}
|
|
539
|
+
setMode={setMode}
|
|
540
|
+
sources={sources}
|
|
541
|
+
toggleSource={toggleSource}
|
|
542
|
+
models={modelGroups}
|
|
543
|
+
model={model}
|
|
544
|
+
setModel={setModel}
|
|
545
|
+
busy={phase === 'running'}
|
|
546
|
+
/>
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
)}
|
|
550
|
+
</main>
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Hanzo AI",
|
|
4
|
-
"version": "1.9.
|
|
5
|
-
"description": "AI
|
|
4
|
+
"version": "1.9.33",
|
|
5
|
+
"description": "AI answer engine, search, and dev assistant — Hanzo AI in a new tab, address-bar search, source-cited answers, realtime news, WebGPU AI, and MCP.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
|
8
8
|
"identity",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"host_permissions": [
|
|
18
18
|
"http://localhost/*",
|
|
19
19
|
"https://localhost/*",
|
|
20
|
+
"https://claude.ai/*",
|
|
21
|
+
"https://chatgpt.com/*",
|
|
20
22
|
"<all_urls>"
|
|
21
23
|
],
|
|
22
24
|
"content_security_policy": {
|
|
@@ -47,6 +49,12 @@
|
|
|
47
49
|
"128": "icon128.png"
|
|
48
50
|
}
|
|
49
51
|
},
|
|
52
|
+
"chrome_url_overrides": {
|
|
53
|
+
"newtab": "newtab.html"
|
|
54
|
+
},
|
|
55
|
+
"omnibox": {
|
|
56
|
+
"keyword": "hanzo"
|
|
57
|
+
},
|
|
50
58
|
"sidebar_action": {
|
|
51
59
|
"default_title": "Hanzo AI",
|
|
52
60
|
"default_panel": "sidebar.html",
|
package/src/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Hanzo AI",
|
|
4
|
-
"version": "1.9.
|
|
5
|
-
"description": "AI
|
|
4
|
+
"version": "1.9.33",
|
|
5
|
+
"description": "AI answer engine, search, and dev assistant — Hanzo AI in a new tab, address-bar search, source-cited answers, realtime news, WebGPU AI, and MCP.",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfXGh0lPUT5m04GKfjUwrLsV6pLaK3VcZuFRPogqAir2tzyLYnQPRtHynue9yvDyguIVnlkwvcwfDOYZrvH76zbw4s6onPBG8HqTKO6LQ9K3kdO1qBBkMMjdOgULQ1MrWThEbpU7NSTiwLYpEta/jAvrKRCAeKIlQE8p6htZmPy9aRUZuae66JgLcAlzD2vviX9sVB1asFABJVswL1RgZ55/8IzZaUrFjzOo9OHK4hmEOtudzkML+5silsAYdC+1BZugph2x94ai17YmZTCL1XyUa5Ke4q80cj+i9rOTgzhZs+mruyhL/AvNVOXilsgqCdNqSz77naWzC3pVGbxOewIDAQAB",
|
|
7
7
|
"permissions": [
|
|
8
8
|
"activeTab",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"host_permissions": [
|
|
20
20
|
"http://localhost/*",
|
|
21
21
|
"https://localhost/*",
|
|
22
|
+
"https://claude.ai/*",
|
|
23
|
+
"https://chatgpt.com/*",
|
|
22
24
|
"<all_urls>"
|
|
23
25
|
],
|
|
24
26
|
"content_security_policy": {
|
|
@@ -52,6 +54,19 @@
|
|
|
52
54
|
"48": "icon48.png",
|
|
53
55
|
"128": "icon128.png"
|
|
54
56
|
},
|
|
57
|
+
"chrome_url_overrides": {
|
|
58
|
+
"newtab": "newtab.html"
|
|
59
|
+
},
|
|
60
|
+
"chrome_settings_overrides": {
|
|
61
|
+
"search_provider": {
|
|
62
|
+
"name": "Hanzo",
|
|
63
|
+
"keyword": "hanzo",
|
|
64
|
+
"search_url": "https://hanzo.chat/c/new?q={searchTerms}&submit=true",
|
|
65
|
+
"favicon_url": "https://hanzo.ai/favicon.ico",
|
|
66
|
+
"encoding": "UTF-8",
|
|
67
|
+
"is_default": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
55
70
|
"web_accessible_resources": [
|
|
56
71
|
{
|
|
57
72
|
"resources": [
|