@gmickel/gno 0.9.0 → 0.9.2
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/README.md +71 -59
- package/assets/screenshots/claudecodeskill.jpg +0 -0
- package/assets/screenshots/cli.jpg +0 -0
- package/assets/screenshots/raycast-mcp.jpg +0 -0
- package/assets/screenshots/webui-ask-answer.jpg +0 -0
- package/assets/screenshots/webui-collections.jpg +0 -0
- package/assets/screenshots/webui-editor.jpg +0 -0
- package/assets/screenshots/webui-home.jpg +0 -0
- package/assets/screenshots/webui-search.jpg +0 -0
- package/package.json +2 -1
- package/src/cli/commands/skill/paths.ts +47 -28
- package/src/serve/public/components/editor/CodeMirrorEditor.tsx +49 -3
- package/src/serve/public/components/editor/MarkdownPreview.tsx +61 -22
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Ask.tsx +67 -3
- package/src/serve/public/pages/DocView.tsx +48 -4
- package/src/serve/public/pages/DocumentEditor.tsx +111 -2
- package/src/serve/routes/api.ts +4 -0
- package/assets/screenshots/webui-ask-answer.png +0 -0
- package/assets/screenshots/webui-collections.png +0 -0
- package/assets/screenshots/webui-editor.png +0 -0
- package/assets/screenshots/webui-home.png +0 -0
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
FileText,
|
|
6
6
|
Sparkles,
|
|
7
7
|
} from "lucide-react";
|
|
8
|
-
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
9
|
|
|
10
10
|
import { Loader } from "../components/ai-elements/loader";
|
|
11
11
|
import {
|
|
@@ -15,11 +15,16 @@ import {
|
|
|
15
15
|
SourcesTrigger,
|
|
16
16
|
} from "../components/ai-elements/sources";
|
|
17
17
|
import { AIModelSelector } from "../components/AIModelSelector";
|
|
18
|
+
import {
|
|
19
|
+
ThoroughnessSelector,
|
|
20
|
+
type Thoroughness,
|
|
21
|
+
} from "../components/ThoroughnessSelector";
|
|
18
22
|
import { Badge } from "../components/ui/badge";
|
|
19
23
|
import { Button } from "../components/ui/button";
|
|
20
24
|
import { Card, CardContent } from "../components/ui/card";
|
|
21
25
|
import { Textarea } from "../components/ui/textarea";
|
|
22
26
|
import { apiFetch } from "../hooks/use-api";
|
|
27
|
+
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
|
23
28
|
|
|
24
29
|
interface PageProps {
|
|
25
30
|
navigate: (to: string | number) => void;
|
|
@@ -75,6 +80,8 @@ interface ConversationEntry {
|
|
|
75
80
|
error?: string;
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
|
|
84
|
+
|
|
78
85
|
/**
|
|
79
86
|
* Render answer text with clickable citation badges.
|
|
80
87
|
* Citations like [1] become clickable to navigate to source.
|
|
@@ -133,6 +140,7 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
133
140
|
const [query, setQuery] = useState("");
|
|
134
141
|
const [conversation, setConversation] = useState<ConversationEntry[]>([]);
|
|
135
142
|
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
143
|
+
const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
|
|
136
144
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
137
145
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
138
146
|
|
|
@@ -142,11 +150,33 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
142
150
|
const { data } = await apiFetch<Capabilities>("/api/capabilities");
|
|
143
151
|
if (data) {
|
|
144
152
|
setCapabilities(data);
|
|
153
|
+
// Auto-select balanced if hybrid available, otherwise fast
|
|
154
|
+
if (data.hybrid) {
|
|
155
|
+
setThoroughness("balanced");
|
|
156
|
+
} else {
|
|
157
|
+
setThoroughness("fast");
|
|
158
|
+
}
|
|
145
159
|
}
|
|
146
160
|
}
|
|
147
161
|
void fetchCapabilities();
|
|
148
162
|
}, []);
|
|
149
163
|
|
|
164
|
+
// Cycle thoroughness with 't' key
|
|
165
|
+
const cycleThoroughness = useCallback(() => {
|
|
166
|
+
setThoroughness((current) => {
|
|
167
|
+
const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
|
|
168
|
+
const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
|
|
169
|
+
return THOROUGHNESS_ORDER[nextIdx];
|
|
170
|
+
});
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const shortcuts = useMemo(
|
|
174
|
+
() => [{ key: "t", action: cycleThoroughness }],
|
|
175
|
+
[cycleThoroughness]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
useKeyboardShortcuts(shortcuts);
|
|
179
|
+
|
|
150
180
|
// Scroll to bottom when conversation updates
|
|
151
181
|
useEffect(() => {
|
|
152
182
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
@@ -168,10 +198,31 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
168
198
|
]);
|
|
169
199
|
setQuery("");
|
|
170
200
|
|
|
201
|
+
// Build request body with thoroughness-mapped params
|
|
202
|
+
// fast: BM25-only via noExpand + noRerank
|
|
203
|
+
// balanced: with reranking, no expansion
|
|
204
|
+
// thorough: full pipeline
|
|
205
|
+
const requestBody: Record<string, unknown> = {
|
|
206
|
+
query: currentQuery,
|
|
207
|
+
limit: 5,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (thoroughness === "fast") {
|
|
211
|
+
requestBody.noExpand = true;
|
|
212
|
+
requestBody.noRerank = true;
|
|
213
|
+
} else if (thoroughness === "balanced") {
|
|
214
|
+
requestBody.noExpand = true;
|
|
215
|
+
requestBody.noRerank = false;
|
|
216
|
+
} else {
|
|
217
|
+
// thorough - full pipeline
|
|
218
|
+
requestBody.noExpand = false;
|
|
219
|
+
requestBody.noRerank = false;
|
|
220
|
+
}
|
|
221
|
+
|
|
171
222
|
// Make API call
|
|
172
223
|
const { data, error } = await apiFetch<AskResponse>("/api/ask", {
|
|
173
224
|
method: "POST",
|
|
174
|
-
body: JSON.stringify(
|
|
225
|
+
body: JSON.stringify(requestBody),
|
|
175
226
|
});
|
|
176
227
|
|
|
177
228
|
// Update conversation with response
|
|
@@ -213,8 +264,21 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
213
264
|
Back
|
|
214
265
|
</Button>
|
|
215
266
|
<h1 className="font-semibold text-xl">Ask</h1>
|
|
216
|
-
<div className="ml-auto flex items-center gap-
|
|
267
|
+
<div className="ml-auto flex items-center gap-4">
|
|
268
|
+
{/* Search depth selector */}
|
|
269
|
+
<ThoroughnessSelector
|
|
270
|
+
disabled={!capabilities?.hybrid}
|
|
271
|
+
onChange={setThoroughness}
|
|
272
|
+
value={thoroughness}
|
|
273
|
+
/>
|
|
274
|
+
|
|
275
|
+
{/* Divider */}
|
|
276
|
+
<div className="h-6 w-px bg-border/40" />
|
|
277
|
+
|
|
278
|
+
{/* AI model selector */}
|
|
217
279
|
<AIModelSelector />
|
|
280
|
+
|
|
281
|
+
{/* Capability badges */}
|
|
218
282
|
{capabilities && (
|
|
219
283
|
<div className="flex items-center gap-2">
|
|
220
284
|
{capabilities.vector && (
|
|
@@ -3,11 +3,13 @@ import {
|
|
|
3
3
|
ArrowLeft,
|
|
4
4
|
Calendar,
|
|
5
5
|
ChevronRightIcon,
|
|
6
|
+
CodeIcon,
|
|
6
7
|
FileText,
|
|
7
8
|
FolderOpen,
|
|
8
9
|
HardDrive,
|
|
9
10
|
Loader2Icon,
|
|
10
11
|
PencilIcon,
|
|
12
|
+
TextIcon,
|
|
11
13
|
TrashIcon,
|
|
12
14
|
} from "lucide-react";
|
|
13
15
|
import { useEffect, useState } from "react";
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
CodeBlockCopyButton,
|
|
18
20
|
} from "../components/ai-elements/code-block";
|
|
19
21
|
import { Loader } from "../components/ai-elements/loader";
|
|
22
|
+
import { MarkdownPreview } from "../components/editor";
|
|
20
23
|
import { Badge } from "../components/ui/badge";
|
|
21
24
|
import { Button } from "../components/ui/button";
|
|
22
25
|
import {
|
|
@@ -165,6 +168,7 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
165
168
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
166
169
|
const [deleting, setDeleting] = useState(false);
|
|
167
170
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
171
|
+
const [showRawView, setShowRawView] = useState(false);
|
|
168
172
|
|
|
169
173
|
useEffect(() => {
|
|
170
174
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -188,6 +192,10 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
188
192
|
);
|
|
189
193
|
}, []);
|
|
190
194
|
|
|
195
|
+
const isMarkdown =
|
|
196
|
+
doc?.source.ext &&
|
|
197
|
+
[".md", ".markdown"].includes(doc.source.ext.toLowerCase());
|
|
198
|
+
|
|
191
199
|
const isCodeFile =
|
|
192
200
|
doc?.source.ext &&
|
|
193
201
|
[
|
|
@@ -393,9 +401,31 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
393
401
|
{/* Content */}
|
|
394
402
|
<Card>
|
|
395
403
|
<CardHeader className="pb-0">
|
|
396
|
-
<CardTitle className="flex items-center
|
|
397
|
-
<
|
|
398
|
-
|
|
404
|
+
<CardTitle className="flex items-center justify-between text-lg">
|
|
405
|
+
<span className="flex items-center gap-2">
|
|
406
|
+
<FileText className="size-4" />
|
|
407
|
+
Content
|
|
408
|
+
</span>
|
|
409
|
+
{isMarkdown && doc.contentAvailable && (
|
|
410
|
+
<Button
|
|
411
|
+
className="gap-1.5"
|
|
412
|
+
onClick={() => setShowRawView(!showRawView)}
|
|
413
|
+
size="sm"
|
|
414
|
+
variant="ghost"
|
|
415
|
+
>
|
|
416
|
+
{showRawView ? (
|
|
417
|
+
<>
|
|
418
|
+
<TextIcon className="size-4" />
|
|
419
|
+
<span className="hidden sm:inline">Rendered</span>
|
|
420
|
+
</>
|
|
421
|
+
) : (
|
|
422
|
+
<>
|
|
423
|
+
<CodeIcon className="size-4" />
|
|
424
|
+
<span className="hidden sm:inline">Source</span>
|
|
425
|
+
</>
|
|
426
|
+
)}
|
|
427
|
+
</Button>
|
|
428
|
+
)}
|
|
399
429
|
</CardTitle>
|
|
400
430
|
</CardHeader>
|
|
401
431
|
<CardContent className="pt-4">
|
|
@@ -406,7 +436,21 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
406
436
|
</p>
|
|
407
437
|
</div>
|
|
408
438
|
)}
|
|
409
|
-
{doc.contentAvailable &&
|
|
439
|
+
{doc.contentAvailable && isMarkdown && !showRawView && (
|
|
440
|
+
<div className="rounded-lg border border-border/40 bg-gradient-to-br from-background to-muted/10 p-6 shadow-inner">
|
|
441
|
+
<MarkdownPreview content={doc.content ?? ""} />
|
|
442
|
+
</div>
|
|
443
|
+
)}
|
|
444
|
+
{doc.contentAvailable && isMarkdown && showRawView && (
|
|
445
|
+
<CodeBlock
|
|
446
|
+
code={doc.content ?? ""}
|
|
447
|
+
language={"markdown" as BundledLanguage}
|
|
448
|
+
showLineNumbers
|
|
449
|
+
>
|
|
450
|
+
<CodeBlockCopyButton />
|
|
451
|
+
</CodeBlock>
|
|
452
|
+
)}
|
|
453
|
+
{doc.contentAvailable && isCodeFile && !isMarkdown && (
|
|
410
454
|
<CodeBlock
|
|
411
455
|
code={doc.content ?? ""}
|
|
412
456
|
language={
|
|
@@ -16,8 +16,10 @@ import {
|
|
|
16
16
|
CloudIcon,
|
|
17
17
|
EyeIcon,
|
|
18
18
|
EyeOffIcon,
|
|
19
|
+
LinkIcon,
|
|
19
20
|
Loader2Icon,
|
|
20
21
|
PenIcon,
|
|
22
|
+
UnlinkIcon,
|
|
21
23
|
} from "lucide-react";
|
|
22
24
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
25
|
|
|
@@ -131,11 +133,81 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
131
133
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
132
134
|
|
|
133
135
|
const [showPreview, setShowPreview] = useState(true);
|
|
136
|
+
const [syncScroll, setSyncScroll] = useState(true);
|
|
134
137
|
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
|
135
138
|
const editorRef = useRef<CodeMirrorEditorRef>(null);
|
|
139
|
+
const previewRef = useRef<HTMLDivElement>(null);
|
|
140
|
+
// Event-based suppression: ignore the echo event caused by programmatic scroll
|
|
141
|
+
const ignoreNextEditorScroll = useRef(false);
|
|
142
|
+
const ignoreNextPreviewScroll = useRef(false);
|
|
136
143
|
|
|
137
144
|
const hasUnsavedChanges = content !== originalContent;
|
|
138
145
|
|
|
146
|
+
// Reset ignore flags when sync is toggled to prevent stale state
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
ignoreNextEditorScroll.current = false;
|
|
149
|
+
ignoreNextPreviewScroll.current = false;
|
|
150
|
+
}, [syncScroll, showPreview]);
|
|
151
|
+
|
|
152
|
+
// Scroll sync handlers with event-based loop prevention
|
|
153
|
+
// Note: Uses percentage-based mapping which provides approximate correspondence.
|
|
154
|
+
// For very different layouts (headings, code blocks, images), perfect alignment
|
|
155
|
+
// would require anchor-based mapping between editor lines and rendered elements.
|
|
156
|
+
const handleEditorScroll = useCallback(
|
|
157
|
+
(scrollPercent: number) => {
|
|
158
|
+
// Clear ignore flag first, even if we early-return (prevents lingering)
|
|
159
|
+
if (ignoreNextEditorScroll.current) {
|
|
160
|
+
ignoreNextEditorScroll.current = false;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!syncScroll || !showPreview) return;
|
|
164
|
+
if (!Number.isFinite(scrollPercent)) return;
|
|
165
|
+
|
|
166
|
+
const clamped = Math.max(0, Math.min(1, scrollPercent));
|
|
167
|
+
const preview = previewRef.current;
|
|
168
|
+
if (!preview) return;
|
|
169
|
+
|
|
170
|
+
const maxScroll = preview.scrollHeight - preview.clientHeight;
|
|
171
|
+
if (maxScroll <= 0) return;
|
|
172
|
+
|
|
173
|
+
const targetScroll = clamped * maxScroll;
|
|
174
|
+
// Only set ignore flag if scroll position actually changes (avoids lingering flag)
|
|
175
|
+
if (Math.abs(preview.scrollTop - targetScroll) > 0.5) {
|
|
176
|
+
ignoreNextPreviewScroll.current = true;
|
|
177
|
+
preview.scrollTop = targetScroll;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
[syncScroll, showPreview]
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const handlePreviewScroll = useCallback(() => {
|
|
184
|
+
// Clear ignore flag first, even if we early-return (prevents lingering)
|
|
185
|
+
if (ignoreNextPreviewScroll.current) {
|
|
186
|
+
ignoreNextPreviewScroll.current = false;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!syncScroll) return;
|
|
190
|
+
|
|
191
|
+
const preview = previewRef.current;
|
|
192
|
+
if (!preview) return;
|
|
193
|
+
|
|
194
|
+
const maxScroll = preview.scrollHeight - preview.clientHeight;
|
|
195
|
+
if (maxScroll <= 0) return;
|
|
196
|
+
|
|
197
|
+
const scrollPercentRaw = preview.scrollTop / maxScroll;
|
|
198
|
+
if (!Number.isFinite(scrollPercentRaw)) return;
|
|
199
|
+
const scrollPercent = Math.max(0, Math.min(1, scrollPercentRaw));
|
|
200
|
+
|
|
201
|
+
// Set ignore flag BEFORE programmatic scroll to prevent race condition
|
|
202
|
+
ignoreNextEditorScroll.current = true;
|
|
203
|
+
const didScroll =
|
|
204
|
+
editorRef.current?.scrollToPercent(scrollPercent) ?? false;
|
|
205
|
+
// Clear flag if no scroll actually occurred (avoids lingering)
|
|
206
|
+
if (!didScroll) {
|
|
207
|
+
ignoreNextEditorScroll.current = false;
|
|
208
|
+
}
|
|
209
|
+
}, [syncScroll]);
|
|
210
|
+
|
|
139
211
|
// Save function
|
|
140
212
|
const saveDocument = useCallback(
|
|
141
213
|
async (contentToSave: string) => {
|
|
@@ -442,6 +514,36 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
442
514
|
</Tooltip>
|
|
443
515
|
</TooltipProvider>
|
|
444
516
|
|
|
517
|
+
{/* Sync scroll toggle (only show when preview is visible) */}
|
|
518
|
+
{showPreview && (
|
|
519
|
+
<TooltipProvider>
|
|
520
|
+
<Tooltip>
|
|
521
|
+
<TooltipTrigger asChild>
|
|
522
|
+
<Button
|
|
523
|
+
aria-label={
|
|
524
|
+
syncScroll ? "Disable scroll sync" : "Enable scroll sync"
|
|
525
|
+
}
|
|
526
|
+
className="transition-all duration-200"
|
|
527
|
+
onClick={() => setSyncScroll(!syncScroll)}
|
|
528
|
+
size="sm"
|
|
529
|
+
variant={syncScroll ? "secondary" : "ghost"}
|
|
530
|
+
>
|
|
531
|
+
{syncScroll ? (
|
|
532
|
+
<LinkIcon className="size-4" />
|
|
533
|
+
) : (
|
|
534
|
+
<UnlinkIcon className="size-4" />
|
|
535
|
+
)}
|
|
536
|
+
</Button>
|
|
537
|
+
</TooltipTrigger>
|
|
538
|
+
<TooltipContent>
|
|
539
|
+
<p>
|
|
540
|
+
{syncScroll ? "Disable scroll sync" : "Enable scroll sync"}
|
|
541
|
+
</p>
|
|
542
|
+
</TooltipContent>
|
|
543
|
+
</Tooltip>
|
|
544
|
+
</TooltipProvider>
|
|
545
|
+
)}
|
|
546
|
+
|
|
445
547
|
{/* Save button */}
|
|
446
548
|
<Button
|
|
447
549
|
disabled={!hasUnsavedChanges || saveStatus === "saving"}
|
|
@@ -462,19 +564,26 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
462
564
|
<div className="flex min-h-0 flex-1">
|
|
463
565
|
{/* Editor pane */}
|
|
464
566
|
<div
|
|
465
|
-
className={`min-h-0 overflow-hidden ${showPreview ? "w-1/2 border-border/
|
|
567
|
+
className={`min-h-0 overflow-hidden ${showPreview ? "w-1/2 border-r border-border/40 shadow-[1px_0_3px_-1px_rgba(0,0,0,0.3)]" : "w-full"}`}
|
|
466
568
|
>
|
|
467
569
|
<CodeMirrorEditor
|
|
468
570
|
className="h-full"
|
|
469
571
|
initialContent={content}
|
|
470
572
|
onChange={handleContentChange}
|
|
573
|
+
onScroll={
|
|
574
|
+
syncScroll && showPreview ? handleEditorScroll : undefined
|
|
575
|
+
}
|
|
471
576
|
ref={editorRef}
|
|
472
577
|
/>
|
|
473
578
|
</div>
|
|
474
579
|
|
|
475
580
|
{/* Preview pane */}
|
|
476
581
|
{showPreview && (
|
|
477
|
-
<div
|
|
582
|
+
<div
|
|
583
|
+
className="w-1/2 min-h-0 overflow-auto bg-background px-8 py-6"
|
|
584
|
+
onScroll={handlePreviewScroll}
|
|
585
|
+
ref={previewRef}
|
|
586
|
+
>
|
|
478
587
|
<div className="mx-auto max-w-3xl">
|
|
479
588
|
<MarkdownPreview content={content} />
|
|
480
589
|
</div>
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface AskRequestBody {
|
|
|
71
71
|
collection?: string;
|
|
72
72
|
lang?: string;
|
|
73
73
|
maxAnswerTokens?: number;
|
|
74
|
+
noExpand?: boolean;
|
|
75
|
+
noRerank?: boolean;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export interface CreateCollectionRequestBody {
|
|
@@ -928,6 +930,8 @@ export async function handleAsk(
|
|
|
928
930
|
limit,
|
|
929
931
|
collection: body.collection,
|
|
930
932
|
lang: body.lang,
|
|
933
|
+
noExpand: body.noExpand,
|
|
934
|
+
noRerank: body.noRerank,
|
|
931
935
|
}
|
|
932
936
|
);
|
|
933
937
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|