@gmickel/gno 0.8.4 → 0.8.6
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 +3 -1
- package/src/serve/CLAUDE.md +8 -0
- package/src/serve/public/app.tsx +5 -5
- package/src/serve/public/components/AIModelSelector.tsx +475 -0
- package/src/serve/public/components/CaptureButton.tsx +3 -4
- package/src/serve/public/components/HelpButton.tsx +105 -0
- package/src/serve/public/components/ShortcutHelpModal.tsx +151 -50
- package/src/serve/public/components/ThoroughnessSelector.tsx +148 -0
- package/src/serve/public/globals.built.css +2 -0
- package/src/serve/public/hooks/useCaptureModal.tsx +2 -3
- package/src/serve/public/hooks/useKeyboardShortcuts.ts +43 -26
- package/src/serve/public/index.html +1 -1
- package/src/serve/public/pages/Ask.tsx +18 -14
- package/src/serve/public/pages/Dashboard.tsx +16 -25
- package/src/serve/public/pages/Search.tsx +78 -57
- package/src/serve/public/components/preset-selector.tsx +0 -404
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmickel/gno",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"embeddings",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"website:dev": "cd website && make serve",
|
|
55
55
|
"website:build": "cd website && make build",
|
|
56
56
|
"website:demos": "cd website/demos && ./build-demos.sh",
|
|
57
|
+
"build:css": "bunx @tailwindcss/cli -i src/serve/public/globals.css -o src/serve/public/globals.built.css --minify",
|
|
57
58
|
"serve": "bun src/index.ts serve",
|
|
58
59
|
"serve:dev": "NODE_ENV=development bun --hot src/index.ts serve",
|
|
59
60
|
"version:patch": "npm version patch --no-git-tag-version",
|
|
@@ -108,6 +109,7 @@
|
|
|
108
109
|
},
|
|
109
110
|
"devDependencies": {
|
|
110
111
|
"@biomejs/biome": "2.3.10",
|
|
112
|
+
"@tailwindcss/cli": "^4.1.18",
|
|
111
113
|
"@types/bun": "latest",
|
|
112
114
|
"@types/react": "^19.2.7",
|
|
113
115
|
"@types/react-dom": "^19.2.3",
|
package/src/serve/CLAUDE.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Local web server for GNO search and document browsing.
|
|
4
4
|
|
|
5
|
+
## UI Development
|
|
6
|
+
|
|
7
|
+
**ALWAYS use the `frontend-design` plugin** for any UI component work. This ensures distinctive, high-quality designs that match the "Scholarly Dusk" aesthetic rather than generic AI-generated patterns.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/frontend-design:frontend-design <description of component>
|
|
11
|
+
```
|
|
12
|
+
|
|
5
13
|
## Architecture
|
|
6
14
|
|
|
7
15
|
Uses same **"Ports without DI"** pattern as CLI/MCP (see root CLAUDE.md):
|
package/src/serve/public/app.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
|
|
4
|
+
import { HelpButton } from "./components/HelpButton";
|
|
4
5
|
import { ShortcutHelpModal } from "./components/ShortcutHelpModal";
|
|
5
6
|
import { CaptureModalProvider } from "./hooks/useCaptureModal";
|
|
6
7
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
|
@@ -57,12 +58,11 @@ function App() {
|
|
|
57
58
|
setLocation(to);
|
|
58
59
|
}, []);
|
|
59
60
|
|
|
60
|
-
// Global keyboard shortcuts
|
|
61
|
+
// Global keyboard shortcuts (single-key, GitHub/Gmail pattern)
|
|
61
62
|
const shortcuts = useMemo(
|
|
62
63
|
() => [
|
|
63
64
|
{
|
|
64
|
-
key: "
|
|
65
|
-
meta: true,
|
|
65
|
+
key: "/",
|
|
66
66
|
action: () => {
|
|
67
67
|
// Focus search input on current page or navigate to search
|
|
68
68
|
const searchInput = document.querySelector<HTMLInputElement>(
|
|
@@ -77,8 +77,7 @@ function App() {
|
|
|
77
77
|
},
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
|
-
key: "
|
|
81
|
-
meta: true,
|
|
80
|
+
key: "?",
|
|
82
81
|
action: () => setShortcutHelpOpen(true),
|
|
83
82
|
},
|
|
84
83
|
],
|
|
@@ -94,6 +93,7 @@ function App() {
|
|
|
94
93
|
return (
|
|
95
94
|
<CaptureModalProvider>
|
|
96
95
|
<Page navigate={navigate} />
|
|
96
|
+
<HelpButton onClick={() => setShortcutHelpOpen(true)} />
|
|
97
97
|
<ShortcutHelpModal
|
|
98
98
|
onOpenChange={setShortcutHelpOpen}
|
|
99
99
|
open={shortcutHelpOpen}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIModelSelector - Vacuum tube display for LLM preset selection.
|
|
3
|
+
*
|
|
4
|
+
* Design: "Tube Display" - Evokes vintage radio tuners and oscilloscope
|
|
5
|
+
* selectors. The current model glows warmly in an amber display window,
|
|
6
|
+
* suggesting analog warmth in a digital interface.
|
|
7
|
+
*
|
|
8
|
+
* Uses Old Gold (secondary) to clearly distinguish from search/primary
|
|
9
|
+
* actions - this controls AI answer generation only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
AlertCircle,
|
|
14
|
+
Check,
|
|
15
|
+
ChevronDown,
|
|
16
|
+
Download,
|
|
17
|
+
Loader2,
|
|
18
|
+
Sparkles,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
21
|
+
|
|
22
|
+
import { apiFetch } from "../hooks/use-api";
|
|
23
|
+
import { cn } from "../lib/utils";
|
|
24
|
+
|
|
25
|
+
interface Preset {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
embed: string;
|
|
29
|
+
rerank: string;
|
|
30
|
+
gen: string;
|
|
31
|
+
active: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PresetsResponse {
|
|
35
|
+
presets: Preset[];
|
|
36
|
+
activePreset: string;
|
|
37
|
+
capabilities: Capabilities;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Capabilities {
|
|
41
|
+
bm25: boolean;
|
|
42
|
+
vector: boolean;
|
|
43
|
+
hybrid: boolean;
|
|
44
|
+
answer: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SetPresetResponse {
|
|
48
|
+
success: boolean;
|
|
49
|
+
activePreset: string;
|
|
50
|
+
capabilities: Capabilities;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface DownloadProgress {
|
|
54
|
+
downloadedBytes: number;
|
|
55
|
+
totalBytes: number;
|
|
56
|
+
percent: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface DownloadStatus {
|
|
60
|
+
active: boolean;
|
|
61
|
+
currentType: string | null;
|
|
62
|
+
progress: DownloadProgress | null;
|
|
63
|
+
completed: string[];
|
|
64
|
+
failed: Array<{ type: string; error: string }>;
|
|
65
|
+
startedAt: number | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract readable model name from preset name
|
|
69
|
+
const SIZE_REGEX = /~[\d.]+GB/;
|
|
70
|
+
|
|
71
|
+
function extractBaseName(name: string): string {
|
|
72
|
+
return name.split("(")[0].trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractSize(name: string): string | null {
|
|
76
|
+
const match = name.match(SIZE_REGEX);
|
|
77
|
+
return match ? match[0] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AIModelSelector() {
|
|
81
|
+
const [presets, setPresets] = useState<Preset[]>([]);
|
|
82
|
+
const [activeId, setActiveId] = useState<string>("");
|
|
83
|
+
const [loading, setLoading] = useState(true);
|
|
84
|
+
const [switching, setSwitching] = useState(false);
|
|
85
|
+
const [error, setError] = useState<string | null>(null);
|
|
86
|
+
const [modelsNeeded, setModelsNeeded] = useState(false);
|
|
87
|
+
const [open, setOpen] = useState(false);
|
|
88
|
+
|
|
89
|
+
// Download state
|
|
90
|
+
const [downloading, setDownloading] = useState(false);
|
|
91
|
+
const [downloadStatus, setDownloadStatus] = useState<DownloadStatus | null>(
|
|
92
|
+
null
|
|
93
|
+
);
|
|
94
|
+
const pollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
95
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
|
|
97
|
+
// Click outside to close
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
function handleClickOutside(e: MouseEvent) {
|
|
100
|
+
if (
|
|
101
|
+
containerRef.current &&
|
|
102
|
+
!containerRef.current.contains(e.target as Node)
|
|
103
|
+
) {
|
|
104
|
+
setOpen(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
108
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// Check capabilities
|
|
112
|
+
const checkCapabilities = useCallback((caps: Capabilities) => {
|
|
113
|
+
if (!caps.answer) {
|
|
114
|
+
setError("AI model not loaded");
|
|
115
|
+
setModelsNeeded(true);
|
|
116
|
+
} else {
|
|
117
|
+
setError(null);
|
|
118
|
+
setModelsNeeded(false);
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
// Poll download status
|
|
123
|
+
const pollStatus = useCallback(async () => {
|
|
124
|
+
const { data } = await apiFetch<DownloadStatus>("/api/models/status");
|
|
125
|
+
if (data) {
|
|
126
|
+
setDownloadStatus(data);
|
|
127
|
+
|
|
128
|
+
if (!data.active && downloading) {
|
|
129
|
+
setDownloading(false);
|
|
130
|
+
if (pollInterval.current) {
|
|
131
|
+
clearInterval(pollInterval.current);
|
|
132
|
+
pollInterval.current = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Refresh presets
|
|
136
|
+
const { data: presetsData } =
|
|
137
|
+
await apiFetch<PresetsResponse>("/api/presets");
|
|
138
|
+
if (presetsData) {
|
|
139
|
+
checkCapabilities(presetsData.capabilities);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (data.failed.length > 0) {
|
|
143
|
+
setError(`Failed: ${data.failed.map((f) => f.type).join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}, [downloading, checkCapabilities]);
|
|
148
|
+
|
|
149
|
+
// Initial load
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
void apiFetch<PresetsResponse>("/api/presets").then(({ data }) => {
|
|
152
|
+
if (data) {
|
|
153
|
+
setPresets(data.presets);
|
|
154
|
+
setActiveId(data.activePreset);
|
|
155
|
+
checkCapabilities(data.capabilities);
|
|
156
|
+
}
|
|
157
|
+
setLoading(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
void apiFetch<DownloadStatus>("/api/models/status").then(({ data }) => {
|
|
161
|
+
if (data?.active) {
|
|
162
|
+
setDownloading(true);
|
|
163
|
+
setDownloadStatus(data);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}, [checkCapabilities]);
|
|
167
|
+
|
|
168
|
+
// Polling
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (downloading && !pollInterval.current) {
|
|
171
|
+
pollInterval.current = setInterval(pollStatus, 1000);
|
|
172
|
+
}
|
|
173
|
+
return () => {
|
|
174
|
+
if (pollInterval.current) {
|
|
175
|
+
clearInterval(pollInterval.current);
|
|
176
|
+
pollInterval.current = null;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}, [downloading, pollStatus]);
|
|
180
|
+
|
|
181
|
+
const activePreset = presets.find((p) => p.id === activeId);
|
|
182
|
+
|
|
183
|
+
const handleSelect = async (id: string) => {
|
|
184
|
+
if (id === activeId || switching || downloading) return;
|
|
185
|
+
|
|
186
|
+
setSwitching(true);
|
|
187
|
+
setError(null);
|
|
188
|
+
|
|
189
|
+
const { data, error: fetchError } = await apiFetch<SetPresetResponse>(
|
|
190
|
+
"/api/presets",
|
|
191
|
+
{
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "Content-Type": "application/json" },
|
|
194
|
+
body: JSON.stringify({ presetId: id }),
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
setSwitching(false);
|
|
199
|
+
|
|
200
|
+
if (fetchError) {
|
|
201
|
+
setError(fetchError);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (data?.success) {
|
|
206
|
+
setActiveId(data.activePreset);
|
|
207
|
+
checkCapabilities(data.capabilities);
|
|
208
|
+
setOpen(false);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const handleDownload = async () => {
|
|
213
|
+
if (downloading) return;
|
|
214
|
+
|
|
215
|
+
setDownloading(true);
|
|
216
|
+
setError(null);
|
|
217
|
+
|
|
218
|
+
const { error: fetchError } = await apiFetch("/api/models/pull", {
|
|
219
|
+
method: "POST",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (fetchError) {
|
|
223
|
+
setError(fetchError);
|
|
224
|
+
setDownloading(false);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
void pollStatus();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Loading skeleton
|
|
232
|
+
if (loading) {
|
|
233
|
+
return (
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
|
|
236
|
+
AI Model
|
|
237
|
+
</span>
|
|
238
|
+
<div
|
|
239
|
+
className={cn(
|
|
240
|
+
"h-7 w-24 rounded",
|
|
241
|
+
"bg-[hsl(var(--secondary)/0.1)]",
|
|
242
|
+
"animate-pulse"
|
|
243
|
+
)}
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (presets.length === 0) return null;
|
|
250
|
+
|
|
251
|
+
const displayName = activePreset
|
|
252
|
+
? extractBaseName(activePreset.name)
|
|
253
|
+
: "Select";
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div className="relative" ref={containerRef}>
|
|
257
|
+
{/* Label */}
|
|
258
|
+
<div className="flex items-center gap-2">
|
|
259
|
+
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
|
|
260
|
+
AI Model
|
|
261
|
+
</span>
|
|
262
|
+
|
|
263
|
+
{/* Tube Display Button */}
|
|
264
|
+
<button
|
|
265
|
+
className={cn(
|
|
266
|
+
"group relative flex items-center gap-2 px-3 py-1.5",
|
|
267
|
+
"rounded border",
|
|
268
|
+
// Tube display aesthetic - warm amber glow
|
|
269
|
+
"border-[hsl(var(--secondary)/0.3)]",
|
|
270
|
+
"bg-gradient-to-b from-[hsl(var(--secondary)/0.08)] to-[hsl(var(--secondary)/0.04)]",
|
|
271
|
+
// Inner glow effect
|
|
272
|
+
"shadow-[inset_0_1px_1px_hsl(var(--secondary)/0.1),inset_0_-1px_1px_hsl(var(--background)/0.5)]",
|
|
273
|
+
// Hover: warm up the tube
|
|
274
|
+
"hover:border-[hsl(var(--secondary)/0.5)]",
|
|
275
|
+
"hover:bg-gradient-to-b hover:from-[hsl(var(--secondary)/0.12)] hover:to-[hsl(var(--secondary)/0.06)]",
|
|
276
|
+
"hover:shadow-[inset_0_1px_2px_hsl(var(--secondary)/0.15),0_0_12px_-4px_hsl(var(--secondary)/0.3)]",
|
|
277
|
+
// Transition
|
|
278
|
+
"transition-all duration-300",
|
|
279
|
+
// Focus
|
|
280
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[hsl(var(--secondary)/0.5)]",
|
|
281
|
+
// States
|
|
282
|
+
switching && "opacity-70 pointer-events-none",
|
|
283
|
+
open &&
|
|
284
|
+
"border-[hsl(var(--secondary)/0.6)] shadow-[0_0_16px_-4px_hsl(var(--secondary)/0.4)]"
|
|
285
|
+
)}
|
|
286
|
+
disabled={switching}
|
|
287
|
+
onClick={() => setOpen(!open)}
|
|
288
|
+
type="button"
|
|
289
|
+
>
|
|
290
|
+
{/* Status indicator */}
|
|
291
|
+
{switching ? (
|
|
292
|
+
<Loader2 className="size-3 animate-spin text-[hsl(var(--secondary))]" />
|
|
293
|
+
) : downloading ? (
|
|
294
|
+
<Loader2 className="size-3 animate-spin text-[hsl(var(--secondary)/0.7)]" />
|
|
295
|
+
) : error || modelsNeeded ? (
|
|
296
|
+
<AlertCircle className="size-3 text-amber-500" />
|
|
297
|
+
) : (
|
|
298
|
+
<Sparkles className="size-3 text-[hsl(var(--secondary)/0.7)] transition-colors group-hover:text-[hsl(var(--secondary))]" />
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{/* Model name - nixie tube style */}
|
|
302
|
+
<span
|
|
303
|
+
className={cn(
|
|
304
|
+
"font-mono text-xs tracking-wide",
|
|
305
|
+
"text-[hsl(var(--secondary)/0.9)]",
|
|
306
|
+
"transition-colors duration-300",
|
|
307
|
+
"group-hover:text-[hsl(var(--secondary))]",
|
|
308
|
+
// Subtle text glow on hover
|
|
309
|
+
"group-hover:drop-shadow-[0_0_4px_hsl(var(--secondary)/0.5)]"
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
{downloading
|
|
313
|
+
? "Downloading..."
|
|
314
|
+
: switching
|
|
315
|
+
? "Loading..."
|
|
316
|
+
: displayName}
|
|
317
|
+
</span>
|
|
318
|
+
|
|
319
|
+
<ChevronDown
|
|
320
|
+
className={cn(
|
|
321
|
+
"size-3 text-[hsl(var(--secondary)/0.5)]",
|
|
322
|
+
"transition-transform duration-200",
|
|
323
|
+
open && "rotate-180"
|
|
324
|
+
)}
|
|
325
|
+
/>
|
|
326
|
+
</button>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
{/* Dropdown Panel */}
|
|
330
|
+
{open && (
|
|
331
|
+
<div
|
|
332
|
+
className={cn(
|
|
333
|
+
"absolute top-full right-0 z-50 mt-2",
|
|
334
|
+
"min-w-[240px] rounded-md border p-1",
|
|
335
|
+
// Panel styling - instrument panel aesthetic
|
|
336
|
+
"border-[hsl(var(--secondary)/0.2)]",
|
|
337
|
+
"bg-card/95 backdrop-blur-sm",
|
|
338
|
+
"shadow-[0_8px_32px_-8px_hsl(var(--secondary)/0.2),0_0_1px_hsl(var(--secondary)/0.1)]",
|
|
339
|
+
// Entrance animation
|
|
340
|
+
"animate-in fade-in-0 zoom-in-95 slide-in-from-top-2",
|
|
341
|
+
"duration-200"
|
|
342
|
+
)}
|
|
343
|
+
>
|
|
344
|
+
{/* Download progress */}
|
|
345
|
+
{downloading && downloadStatus && (
|
|
346
|
+
<div className="mb-2 space-y-2 rounded bg-[hsl(var(--secondary)/0.05)] p-2">
|
|
347
|
+
<div className="flex items-center justify-between">
|
|
348
|
+
<span className="font-mono text-[10px] text-muted-foreground">
|
|
349
|
+
{downloadStatus.currentType || "Preparing..."}
|
|
350
|
+
</span>
|
|
351
|
+
<span className="font-mono text-[10px] text-[hsl(var(--secondary)/0.7)]">
|
|
352
|
+
{downloadStatus.progress?.percent.toFixed(0) ?? 0}%
|
|
353
|
+
</span>
|
|
354
|
+
</div>
|
|
355
|
+
{/* Vintage meter bar */}
|
|
356
|
+
<div className="relative h-1.5 overflow-hidden rounded-full bg-muted/50">
|
|
357
|
+
<div
|
|
358
|
+
className={cn(
|
|
359
|
+
"absolute inset-y-0 left-0 rounded-full",
|
|
360
|
+
"bg-gradient-to-r from-[hsl(var(--secondary)/0.6)] to-[hsl(var(--secondary))]",
|
|
361
|
+
"shadow-[0_0_8px_hsl(var(--secondary)/0.5)]",
|
|
362
|
+
"transition-all duration-300"
|
|
363
|
+
)}
|
|
364
|
+
style={{ width: `${downloadStatus.progress?.percent ?? 0}%` }}
|
|
365
|
+
/>
|
|
366
|
+
</div>
|
|
367
|
+
{downloadStatus.completed.length > 0 && (
|
|
368
|
+
<p className="font-mono text-[9px] text-muted-foreground/60">
|
|
369
|
+
Done: {downloadStatus.completed.join(", ")}
|
|
370
|
+
</p>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Preset options */}
|
|
376
|
+
<div className="space-y-0.5">
|
|
377
|
+
{presets.map((preset) => {
|
|
378
|
+
const isActive = preset.id === activeId;
|
|
379
|
+
const baseName = extractBaseName(preset.name);
|
|
380
|
+
const size = extractSize(preset.name);
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<button
|
|
384
|
+
className={cn(
|
|
385
|
+
"group/item flex w-full items-center justify-between gap-3",
|
|
386
|
+
"rounded px-3 py-2.5",
|
|
387
|
+
"transition-all duration-150",
|
|
388
|
+
// Base
|
|
389
|
+
"text-muted-foreground",
|
|
390
|
+
// Hover
|
|
391
|
+
!isActive &&
|
|
392
|
+
"hover:bg-[hsl(var(--secondary)/0.08)] hover:text-foreground",
|
|
393
|
+
// Active state
|
|
394
|
+
isActive && [
|
|
395
|
+
"bg-[hsl(var(--secondary)/0.1)]",
|
|
396
|
+
"text-[hsl(var(--secondary))]",
|
|
397
|
+
],
|
|
398
|
+
// Disabled
|
|
399
|
+
(switching || downloading) &&
|
|
400
|
+
"pointer-events-none opacity-50"
|
|
401
|
+
)}
|
|
402
|
+
disabled={switching || downloading}
|
|
403
|
+
key={preset.id}
|
|
404
|
+
onClick={() => handleSelect(preset.id)}
|
|
405
|
+
type="button"
|
|
406
|
+
>
|
|
407
|
+
<div className="flex flex-col items-start gap-0.5">
|
|
408
|
+
<span
|
|
409
|
+
className={cn(
|
|
410
|
+
"font-medium text-sm",
|
|
411
|
+
isActive && "text-[hsl(var(--secondary))]"
|
|
412
|
+
)}
|
|
413
|
+
>
|
|
414
|
+
{baseName}
|
|
415
|
+
</span>
|
|
416
|
+
{size && (
|
|
417
|
+
<span className="font-mono text-[10px] text-muted-foreground/60">
|
|
418
|
+
{size}
|
|
419
|
+
</span>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
{isActive && (
|
|
424
|
+
<Check className="size-4 text-[hsl(var(--secondary))]" />
|
|
425
|
+
)}
|
|
426
|
+
</button>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
{/* Error / Download prompt */}
|
|
432
|
+
{(error || modelsNeeded) && !downloading && (
|
|
433
|
+
<>
|
|
434
|
+
<div className="my-1 border-t border-border/50" />
|
|
435
|
+
<div className="space-y-2 p-2">
|
|
436
|
+
{error && (
|
|
437
|
+
<p className="font-mono text-[10px] text-amber-500">
|
|
438
|
+
{error}
|
|
439
|
+
</p>
|
|
440
|
+
)}
|
|
441
|
+
{modelsNeeded && (
|
|
442
|
+
<button
|
|
443
|
+
className={cn(
|
|
444
|
+
"flex w-full items-center justify-center gap-2",
|
|
445
|
+
"rounded border px-3 py-2",
|
|
446
|
+
"border-[hsl(var(--secondary)/0.3)]",
|
|
447
|
+
"bg-[hsl(var(--secondary)/0.05)]",
|
|
448
|
+
"font-medium text-[hsl(var(--secondary))] text-xs",
|
|
449
|
+
"transition-all duration-200",
|
|
450
|
+
"hover:border-[hsl(var(--secondary)/0.5)]",
|
|
451
|
+
"hover:bg-[hsl(var(--secondary)/0.1)]",
|
|
452
|
+
"hover:shadow-[0_0_12px_-4px_hsl(var(--secondary)/0.3)]"
|
|
453
|
+
)}
|
|
454
|
+
onClick={handleDownload}
|
|
455
|
+
type="button"
|
|
456
|
+
>
|
|
457
|
+
<Download className="size-3.5" />
|
|
458
|
+
Download Models
|
|
459
|
+
</button>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
</>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
{/* Footer note */}
|
|
466
|
+
<div className="mt-1 border-t border-border/30 px-3 py-2">
|
|
467
|
+
<p className="font-mono text-[9px] text-muted-foreground/50">
|
|
468
|
+
Controls AI answer generation only
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
* - Triggers parent's onClick handler
|
|
7
7
|
* - Subtle hover animation
|
|
8
8
|
*
|
|
9
|
-
* Note: Modal and
|
|
9
|
+
* Note: Modal and 'n' shortcut are managed at App level for single instance.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { PenIcon } from "lucide-react";
|
|
13
13
|
|
|
14
|
-
import { modKey } from "../hooks/useKeyboardShortcuts";
|
|
15
14
|
import { cn } from "../lib/utils";
|
|
16
15
|
import { Button } from "./ui/button";
|
|
17
16
|
import {
|
|
@@ -48,11 +47,11 @@ export function CaptureButton({ className, onClick }: CaptureButtonProps) {
|
|
|
48
47
|
size="icon"
|
|
49
48
|
>
|
|
50
49
|
<PenIcon className="size-6 transition-transform duration-200 group-hover:rotate-[-8deg]" />
|
|
51
|
-
<span className="sr-only">New note (
|
|
50
|
+
<span className="sr-only">New note (N)</span>
|
|
52
51
|
</Button>
|
|
53
52
|
</TooltipTrigger>
|
|
54
53
|
<TooltipContent side="left">
|
|
55
|
-
<p>New note (
|
|
54
|
+
<p>New note (N)</p>
|
|
56
55
|
</TooltipContent>
|
|
57
56
|
</Tooltip>
|
|
58
57
|
</TooltipProvider>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HelpButton - A scholar's reference mark for keyboard shortcuts.
|
|
3
|
+
*
|
|
4
|
+
* Design: "Marginalia" - Like a faded notation in an old manuscript
|
|
5
|
+
* that reveals itself when the reader's attention draws near.
|
|
6
|
+
*
|
|
7
|
+
* Uses Old Gold (secondary) to distinguish from primary actions,
|
|
8
|
+
* evoking warm candlelight on aged paper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cn } from "../lib/utils";
|
|
12
|
+
import {
|
|
13
|
+
Tooltip,
|
|
14
|
+
TooltipContent,
|
|
15
|
+
TooltipProvider,
|
|
16
|
+
TooltipTrigger,
|
|
17
|
+
} from "./ui/tooltip";
|
|
18
|
+
|
|
19
|
+
export interface HelpButtonProps {
|
|
20
|
+
/** Additional CSS classes */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Callback when button clicked */
|
|
23
|
+
onClick: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function HelpButton({ className, onClick }: HelpButtonProps) {
|
|
27
|
+
return (
|
|
28
|
+
<TooltipProvider delayDuration={400}>
|
|
29
|
+
<Tooltip>
|
|
30
|
+
<TooltipTrigger asChild>
|
|
31
|
+
<button
|
|
32
|
+
aria-label="Keyboard shortcuts"
|
|
33
|
+
className={cn(
|
|
34
|
+
// Position: margin notation, slightly inset
|
|
35
|
+
"group fixed left-4 bottom-4 z-50",
|
|
36
|
+
// Size: small, refined - like a superscript
|
|
37
|
+
"flex size-7 items-center justify-center",
|
|
38
|
+
// Base state: faded marginalia
|
|
39
|
+
"rounded-sm border border-transparent",
|
|
40
|
+
"bg-transparent text-muted-foreground/30",
|
|
41
|
+
// The reference mark itself - serif typography
|
|
42
|
+
"font-serif text-sm italic",
|
|
43
|
+
// Hover: ink freshens, warm gold emerges
|
|
44
|
+
"hover:text-[hsl(var(--secondary))]",
|
|
45
|
+
"hover:border-[hsl(var(--secondary)/0.2)]",
|
|
46
|
+
"hover:bg-[hsl(var(--secondary)/0.05)]",
|
|
47
|
+
// Subtle ink-bleed glow on hover
|
|
48
|
+
"hover:shadow-[0_0_12px_-4px_hsl(var(--secondary)/0.4)]",
|
|
49
|
+
// Refined transition - slow reveal like turning a page
|
|
50
|
+
"transition-all duration-500 ease-out",
|
|
51
|
+
// Focus: accessible but subtle
|
|
52
|
+
"focus-visible:text-[hsl(var(--secondary))]",
|
|
53
|
+
"focus-visible:outline-none focus-visible:ring-1",
|
|
54
|
+
"focus-visible:ring-[hsl(var(--secondary)/0.5)]",
|
|
55
|
+
"focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
56
|
+
// Cursor
|
|
57
|
+
"cursor-pointer",
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
onClick={onClick}
|
|
61
|
+
type="button"
|
|
62
|
+
>
|
|
63
|
+
{/* The mark: a serif question mark, styled like manuscript notation */}
|
|
64
|
+
<span
|
|
65
|
+
className={cn(
|
|
66
|
+
"select-none",
|
|
67
|
+
// Subtle lift on hover
|
|
68
|
+
"transition-transform duration-300",
|
|
69
|
+
"group-hover:-translate-y-px group-hover:scale-105"
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
?
|
|
73
|
+
</span>
|
|
74
|
+
</button>
|
|
75
|
+
</TooltipTrigger>
|
|
76
|
+
<TooltipContent
|
|
77
|
+
className={cn(
|
|
78
|
+
// Scholarly tooltip styling
|
|
79
|
+
"border-[hsl(var(--secondary)/0.2)] bg-card/95",
|
|
80
|
+
"shadow-[0_4px_20px_-4px_hsl(var(--secondary)/0.15)]",
|
|
81
|
+
"backdrop-blur-sm"
|
|
82
|
+
)}
|
|
83
|
+
side="right"
|
|
84
|
+
sideOffset={8}
|
|
85
|
+
>
|
|
86
|
+
<p className="flex items-center gap-2.5 font-sans text-sm">
|
|
87
|
+
<span className="text-muted-foreground">Shortcuts</span>
|
|
88
|
+
<kbd
|
|
89
|
+
className={cn(
|
|
90
|
+
"inline-flex min-w-[1.25rem] items-center justify-center",
|
|
91
|
+
"rounded border px-1.5 py-0.5",
|
|
92
|
+
"border-[hsl(var(--secondary)/0.3)] bg-[hsl(var(--secondary)/0.1)]",
|
|
93
|
+
"font-serif text-[11px] italic text-[hsl(var(--secondary))]",
|
|
94
|
+
// Subtle pressed effect
|
|
95
|
+
"shadow-[inset_0_1px_2px_hsl(var(--background)/0.3)]"
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
?
|
|
99
|
+
</kbd>
|
|
100
|
+
</p>
|
|
101
|
+
</TooltipContent>
|
|
102
|
+
</Tooltip>
|
|
103
|
+
</TooltipProvider>
|
|
104
|
+
);
|
|
105
|
+
}
|