@hanzo/browser-extension 1.7.6 → 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.
Files changed (144) hide show
  1. package/LICENSE +9 -18
  2. package/dist/browser-extension/content-script.js +1642 -123
  3. package/images/icon128.png +0 -0
  4. package/images/icon16.png +0 -0
  5. package/images/icon48.png +0 -0
  6. package/manifest.json +7 -2
  7. package/package.json +28 -10
  8. package/src/answer/AnswerEngine.tsx +553 -0
  9. package/src/chat-widget.tsx +96 -0
  10. package/src/gui-primitives.tsx +116 -0
  11. package/src/icon128.png +0 -0
  12. package/src/icon16.png +0 -0
  13. package/src/icon32.png +0 -0
  14. package/src/icon48.png +0 -0
  15. package/src/manifest-firefox.json +32 -13
  16. package/src/manifest.json +35 -11
  17. package/src/newtab.css +277 -0
  18. package/src/newtab.html +13 -0
  19. package/src/package.json +1 -1
  20. package/src/popup.css +352 -110
  21. package/src/popup.html +186 -47
  22. package/src/popup.js +196 -0
  23. package/src/sidebar.css +772 -156
  24. package/src/sidebar.html +268 -85
  25. package/src/sidebar.js +885 -0
  26. package/.github/workflows/docs.yml +0 -44
  27. package/.github/workflows/release.yml +0 -55
  28. package/README.md +0 -126
  29. package/dist/browser-extension/README.md +0 -43
  30. package/dist/browser-extension/ai-worker.js +0 -30
  31. package/dist/browser-extension/background-firefox.js +0 -875
  32. package/dist/browser-extension/background.js +0 -2732
  33. package/dist/browser-extension/browser-control.js +0 -683
  34. package/dist/browser-extension/cdp-bridge-server.js +0 -444
  35. package/dist/browser-extension/chrome/ai-worker.js +0 -571
  36. package/dist/browser-extension/chrome/background.js +0 -2732
  37. package/dist/browser-extension/chrome/callback.html +0 -17
  38. package/dist/browser-extension/chrome/content-script.js +0 -1270
  39. package/dist/browser-extension/chrome/icon128.png +0 -0
  40. package/dist/browser-extension/chrome/icon16.png +0 -0
  41. package/dist/browser-extension/chrome/icon32.png +0 -0
  42. package/dist/browser-extension/chrome/icon48.png +0 -0
  43. package/dist/browser-extension/chrome/manifest.json +0 -57
  44. package/dist/browser-extension/chrome/popup.css +0 -519
  45. package/dist/browser-extension/chrome/popup.html +0 -242
  46. package/dist/browser-extension/chrome/popup.js +0 -278
  47. package/dist/browser-extension/chrome/sidebar.css +0 -1176
  48. package/dist/browser-extension/chrome/sidebar.html +0 -280
  49. package/dist/browser-extension/chrome/sidebar.js +0 -779
  50. package/dist/browser-extension/cli.js +0 -233
  51. package/dist/browser-extension/firefox/ai-worker.js +0 -571
  52. package/dist/browser-extension/firefox/background.js +0 -875
  53. package/dist/browser-extension/firefox/callback.html +0 -17
  54. package/dist/browser-extension/firefox/content-script.js +0 -1270
  55. package/dist/browser-extension/firefox/icon128.png +0 -0
  56. package/dist/browser-extension/firefox/icon16.png +0 -0
  57. package/dist/browser-extension/firefox/icon32.png +0 -0
  58. package/dist/browser-extension/firefox/icon48.png +0 -0
  59. package/dist/browser-extension/firefox/manifest.json +0 -66
  60. package/dist/browser-extension/firefox/popup.css +0 -519
  61. package/dist/browser-extension/firefox/popup.html +0 -242
  62. package/dist/browser-extension/firefox/popup.js +0 -278
  63. package/dist/browser-extension/firefox/sidebar.css +0 -1176
  64. package/dist/browser-extension/firefox/sidebar.html +0 -280
  65. package/dist/browser-extension/firefox/sidebar.js +0 -779
  66. package/dist/browser-extension/hanzo-browser-extension-firefox-1.1.0.zip +0 -0
  67. package/dist/browser-extension/icon128.png +0 -0
  68. package/dist/browser-extension/icon16.png +0 -0
  69. package/dist/browser-extension/icon32.png +0 -0
  70. package/dist/browser-extension/icon48.png +0 -0
  71. package/dist/browser-extension/manifest.json +0 -57
  72. package/dist/browser-extension/package.json +0 -41
  73. package/dist/browser-extension/popup.css +0 -468
  74. package/dist/browser-extension/popup.html +0 -191
  75. package/dist/browser-extension/popup.js +0 -278
  76. package/dist/browser-extension/safari/Info.plist +0 -21
  77. package/dist/browser-extension/safari/ai-worker.js +0 -571
  78. package/dist/browser-extension/safari/background.js +0 -2732
  79. package/dist/browser-extension/safari/callback.html +0 -17
  80. package/dist/browser-extension/safari/content-script.js +0 -1270
  81. package/dist/browser-extension/safari/icon128.png +0 -0
  82. package/dist/browser-extension/safari/icon16.png +0 -0
  83. package/dist/browser-extension/safari/icon32.png +0 -0
  84. package/dist/browser-extension/safari/icon48.png +0 -0
  85. package/dist/browser-extension/safari/popup.css +0 -519
  86. package/dist/browser-extension/safari/popup.html +0 -242
  87. package/dist/browser-extension/safari/popup.js +0 -278
  88. package/dist/browser-extension/safari/sidebar.css +0 -1176
  89. package/dist/browser-extension/safari/sidebar.html +0 -280
  90. package/dist/browser-extension/safari/sidebar.js +0 -779
  91. package/dist/browser-extension/sidebar.css +0 -718
  92. package/dist/browser-extension/sidebar.html +0 -184
  93. package/dist/browser-extension/sidebar.js +0 -779
  94. package/dist/browser-extension/webgpu-ai.js +0 -495
  95. package/dist/cdp-bridge-server.js +0 -411
  96. package/dist/hanzo-ai-chrome-1.7.2.zip +0 -0
  97. package/dist/hanzo-ai-chrome-1.7.3.zip +0 -0
  98. package/dist/hanzo-ai-firefox-1.7.2.zip +0 -0
  99. package/dist/hanzo-ai-firefox-1.7.3.zip +0 -0
  100. package/dist/safari/Hanzo AI/Hanzo AI.xcodeproj/project.pbxproj +0 -1047
  101. package/dist/safari/Hanzo AI/Hanzo AI.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  102. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AccentColor.colorset/Contents.json +0 -11
  103. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -98
  104. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png +0 -0
  105. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png +0 -0
  106. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png +0 -0
  107. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png +0 -0
  108. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png +0 -0
  109. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png +0 -0
  110. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png +0 -0
  111. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png +0 -0
  112. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png +0 -0
  113. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png +0 -0
  114. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png +0 -0
  115. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/Contents.json +0 -6
  116. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/LargeIcon.imageset/Contents.json +0 -21
  117. package/dist/safari/Hanzo AI/Shared (App)/Assets.xcassets/LargeIcon.imageset/icon128.png +0 -0
  118. package/dist/safari/Hanzo AI/Shared (App)/Resources/Base.lproj/Main.html +0 -20
  119. package/dist/safari/Hanzo AI/Shared (App)/Resources/Icon.png +0 -0
  120. package/dist/safari/Hanzo AI/Shared (App)/Resources/Script.js +0 -24
  121. package/dist/safari/Hanzo AI/Shared (App)/Resources/Style.css +0 -61
  122. package/dist/safari/Hanzo AI/Shared (App)/ViewController.swift +0 -81
  123. package/dist/safari/Hanzo AI/Shared (Extension)/SafariWebExtensionHandler.swift +0 -42
  124. package/dist/safari/Hanzo AI/iOS (App)/AppDelegate.swift +0 -24
  125. package/dist/safari/Hanzo AI/iOS (App)/Base.lproj/LaunchScreen.storyboard +0 -36
  126. package/dist/safari/Hanzo AI/iOS (App)/Base.lproj/Main.storyboard +0 -38
  127. package/dist/safari/Hanzo AI/iOS (App)/Info.plist +0 -27
  128. package/dist/safari/Hanzo AI/iOS (App)/SceneDelegate.swift +0 -18
  129. package/dist/safari/Hanzo AI/iOS (Extension)/Info.plist +0 -13
  130. package/dist/safari/Hanzo AI/macOS (App)/AppDelegate.swift +0 -21
  131. package/dist/safari/Hanzo AI/macOS (App)/Base.lproj/Main.storyboard +0 -124
  132. package/dist/safari/Hanzo AI/macOS (App)/Info.plist +0 -8
  133. package/dist/safari/Hanzo AI/macOS (Extension)/Info.plist +0 -13
  134. package/docs/_config.yml +0 -13
  135. package/docs/_layouts/default.html +0 -435
  136. package/docs/index.md +0 -54
  137. package/hanzo-ai-chrome-v1.7.6.zip +0 -0
  138. package/hanzo-ai-firefox-v1.7.6.zip +0 -0
  139. package/install-extension.sh +0 -75
  140. package/test/claude-integration.test.ts +0 -251
  141. package/test/server-frameworks-edge-cases.test.ts +0 -145
  142. package/test/server-frameworks.test.ts +0 -395
  143. package/test-cdp-connection.js +0 -140
  144. package/tsconfig.json +0 -19
Binary file
Binary file
Binary file
package/manifest.json CHANGED
@@ -5,11 +5,16 @@
5
5
  "description": "Click-to-code navigation with source-map support",
6
6
  "permissions": [
7
7
  "activeTab",
8
- "storage"
8
+ "storage",
9
+ "tabs",
10
+ "webNavigation",
11
+ "debugger",
12
+ "scripting"
9
13
  ],
10
14
  "host_permissions": [
11
15
  "http://localhost/*",
12
- "https://localhost/*"
16
+ "https://localhost/*",
17
+ "<all_urls>"
13
18
  ],
14
19
  "content_scripts": [
15
20
  {
package/package.json CHANGED
@@ -1,32 +1,50 @@
1
1
  {
2
2
  "name": "@hanzo/browser-extension",
3
- "version": "1.7.6",
3
+ "version": "1.9.33",
4
4
  "description": "Hanzo AI Browser Extension",
5
5
  "main": "dist/background.js",
6
- "scripts": {
7
- "build": "node src/build.js",
8
- "watch": "node src/build.js --watch",
9
- "test": "vitest run",
10
- "test:watch": "vitest"
11
- },
12
6
  "dependencies": {
7
+ "@hanzo/ai": "^0.2.3",
8
+ "@supabase/supabase-js": "^2.50.3",
9
+ "axios": "^1.10.0",
13
10
  "chalk": "^4.1.2",
14
11
  "commander": "^11.1.0",
12
+ "webextension-polyfill": "0.12.0",
15
13
  "ws": "^8.18.3"
16
14
  },
17
15
  "devDependencies": {
16
+ "@playwright/test": "^1.52.0",
18
17
  "@types/chrome": "^0.0.270",
19
18
  "@types/firefox-webext-browser": "^120.0.0",
20
19
  "@types/jsdom": "^21.1.7",
21
20
  "@types/ws": "^8.18.1",
22
- "esbuild": "^0.25.6",
21
+ "esbuild": "^0.25.8",
23
22
  "jsdom": "^26.1.0",
23
+ "react": "^18.3.1",
24
+ "react-dom": "^18.3.1",
24
25
  "typescript": "^5.8.3",
25
- "vitest": "^0.34.6",
26
+ "vitest": "^3.2.6",
26
27
  "ws": "^8.18.3"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=18.0.0"
30
31
  },
31
- "license": "MIT"
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "images",
40
+ "manifest.json"
41
+ ],
42
+ "scripts": {
43
+ "build": "node src/build.js",
44
+ "watch": "node src/build.js --watch",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "test:e2e": "playwright test --config=e2e/playwright.config.ts",
48
+ "check:bundle-budget": "node scripts/check-bundle-budgets.mjs"
49
+ }
32
50
  }
@@ -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
+ }