@gmickel/gno 0.3.4 → 0.4.0

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 (57) hide show
  1. package/README.md +194 -53
  2. package/assets/badges/license.svg +12 -0
  3. package/assets/badges/npm.svg +13 -0
  4. package/assets/badges/twitter.svg +22 -0
  5. package/assets/badges/website.svg +22 -0
  6. package/package.json +30 -1
  7. package/src/cli/commands/ask.ts +11 -186
  8. package/src/cli/commands/models/pull.ts +9 -4
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/program.ts +28 -0
  11. package/src/llm/registry.ts +3 -1
  12. package/src/pipeline/answer.ts +191 -0
  13. package/src/serve/CLAUDE.md +91 -0
  14. package/src/serve/bunfig.toml +2 -0
  15. package/src/serve/context.ts +181 -0
  16. package/src/serve/index.ts +7 -0
  17. package/src/serve/public/app.tsx +56 -0
  18. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  19. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  20. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  21. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  22. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  23. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  24. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  25. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  26. package/src/serve/public/components/preset-selector.tsx +403 -0
  27. package/src/serve/public/components/ui/badge.tsx +46 -0
  28. package/src/serve/public/components/ui/button-group.tsx +82 -0
  29. package/src/serve/public/components/ui/button.tsx +62 -0
  30. package/src/serve/public/components/ui/card.tsx +92 -0
  31. package/src/serve/public/components/ui/carousel.tsx +244 -0
  32. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  33. package/src/serve/public/components/ui/command.tsx +181 -0
  34. package/src/serve/public/components/ui/dialog.tsx +141 -0
  35. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  36. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  37. package/src/serve/public/components/ui/input-group.tsx +167 -0
  38. package/src/serve/public/components/ui/input.tsx +21 -0
  39. package/src/serve/public/components/ui/progress.tsx +28 -0
  40. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  41. package/src/serve/public/components/ui/select.tsx +188 -0
  42. package/src/serve/public/components/ui/separator.tsx +26 -0
  43. package/src/serve/public/components/ui/table.tsx +114 -0
  44. package/src/serve/public/components/ui/textarea.tsx +18 -0
  45. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  46. package/src/serve/public/globals.css +226 -0
  47. package/src/serve/public/hooks/use-api.ts +112 -0
  48. package/src/serve/public/index.html +13 -0
  49. package/src/serve/public/pages/Ask.tsx +442 -0
  50. package/src/serve/public/pages/Browse.tsx +270 -0
  51. package/src/serve/public/pages/Dashboard.tsx +202 -0
  52. package/src/serve/public/pages/DocView.tsx +302 -0
  53. package/src/serve/public/pages/Search.tsx +335 -0
  54. package/src/serve/routes/api.ts +763 -0
  55. package/src/serve/server.ts +249 -0
  56. package/src/store/sqlite/adapter.ts +47 -0
  57. package/src/store/types.ts +10 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Server context for web UI.
3
+ * Manages LLM ports and vector index for hybrid search and AI answers.
4
+ *
5
+ * @module src/serve/context
6
+ */
7
+
8
+ import type { Config } from '../config/types';
9
+ import { LlmAdapter } from '../llm/nodeLlamaCpp/adapter';
10
+ import { getActivePreset } from '../llm/registry';
11
+ import type {
12
+ DownloadProgress,
13
+ EmbeddingPort,
14
+ GenerationPort,
15
+ ModelType,
16
+ RerankPort,
17
+ } from '../llm/types';
18
+ import type { SqliteAdapter } from '../store/sqlite/adapter';
19
+ import { createVectorIndexPort, type VectorIndexPort } from '../store/vector';
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Download State (in-memory, single user)
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ export interface DownloadState {
26
+ active: boolean;
27
+ currentType: ModelType | null;
28
+ progress: DownloadProgress | null;
29
+ completed: ModelType[];
30
+ failed: Array<{ type: ModelType; error: string }>;
31
+ startedAt: number | null;
32
+ }
33
+
34
+ /** Global download state for polling */
35
+ export const downloadState: DownloadState = {
36
+ active: false,
37
+ currentType: null,
38
+ progress: null,
39
+ completed: [],
40
+ failed: [],
41
+ startedAt: null,
42
+ };
43
+
44
+ /** Reset download state */
45
+ export function resetDownloadState(): void {
46
+ downloadState.active = false;
47
+ downloadState.currentType = null;
48
+ downloadState.progress = null;
49
+ downloadState.completed = [];
50
+ downloadState.failed = [];
51
+ downloadState.startedAt = null;
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Server Context
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ export interface ServerContext {
59
+ store: SqliteAdapter;
60
+ config: Config;
61
+ vectorIndex: VectorIndexPort | null;
62
+ embedPort: EmbeddingPort | null;
63
+ genPort: GenerationPort | null;
64
+ rerankPort: RerankPort | null;
65
+ capabilities: {
66
+ bm25: boolean;
67
+ vector: boolean;
68
+ hybrid: boolean;
69
+ answer: boolean;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Initialize server context with LLM ports.
75
+ * Attempts to load models; missing models are logged but don't fail.
76
+ */
77
+ export async function createServerContext(
78
+ store: SqliteAdapter,
79
+ config: Config
80
+ ): Promise<ServerContext> {
81
+ let embedPort: EmbeddingPort | null = null;
82
+ let genPort: GenerationPort | null = null;
83
+ let rerankPort: RerankPort | null = null;
84
+ let vectorIndex: VectorIndexPort | null = null;
85
+
86
+ try {
87
+ const preset = getActivePreset(config);
88
+ const llm = new LlmAdapter(config);
89
+
90
+ // Try to create embedding port
91
+ const embedResult = await llm.createEmbeddingPort(preset.embed);
92
+ if (embedResult.ok) {
93
+ embedPort = embedResult.value;
94
+ const initResult = await embedPort.init();
95
+ if (initResult.ok) {
96
+ // Create vector index
97
+ const dimensions = embedPort.dimensions();
98
+ const db = store.getRawDb();
99
+ const vectorResult = await createVectorIndexPort(db, {
100
+ model: preset.embed,
101
+ dimensions,
102
+ });
103
+ if (vectorResult.ok) {
104
+ vectorIndex = vectorResult.value;
105
+ console.log('Vector search enabled');
106
+ }
107
+ }
108
+ }
109
+
110
+ // Try to create generation port
111
+ const genResult = await llm.createGenerationPort(preset.gen);
112
+ if (genResult.ok) {
113
+ genPort = genResult.value;
114
+ console.log('AI answer generation enabled');
115
+ }
116
+
117
+ // Try to create rerank port
118
+ const rerankResult = await llm.createRerankPort(preset.rerank);
119
+ if (rerankResult.ok) {
120
+ rerankPort = rerankResult.value;
121
+ console.log('Reranking enabled');
122
+ }
123
+ } catch (e) {
124
+ // Log but don't fail - models are optional
125
+ console.log(
126
+ 'LLM initialization skipped:',
127
+ e instanceof Error ? e.message : String(e)
128
+ );
129
+ }
130
+
131
+ const capabilities = {
132
+ bm25: true, // Always available
133
+ vector: vectorIndex?.searchAvailable ?? false,
134
+ hybrid: (vectorIndex?.searchAvailable ?? false) && embedPort !== null,
135
+ answer: genPort !== null,
136
+ };
137
+
138
+ return {
139
+ store,
140
+ config,
141
+ vectorIndex,
142
+ embedPort,
143
+ genPort,
144
+ rerankPort,
145
+ capabilities,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Dispose server context resources.
151
+ * Each port is disposed independently to prevent one failure from blocking others.
152
+ */
153
+ export async function disposeServerContext(ctx: ServerContext): Promise<void> {
154
+ const ports = [
155
+ { name: 'embed', port: ctx.embedPort },
156
+ { name: 'gen', port: ctx.genPort },
157
+ { name: 'rerank', port: ctx.rerankPort },
158
+ ];
159
+
160
+ for (const { name, port } of ports) {
161
+ if (port) {
162
+ try {
163
+ await port.dispose();
164
+ } catch (e) {
165
+ console.error(`Failed to dispose ${name} port:`, e);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Reload server context with potentially new config.
173
+ * Disposes existing ports and recreates them.
174
+ */
175
+ export async function reloadServerContext(
176
+ ctx: ServerContext,
177
+ newConfig?: Config
178
+ ): Promise<ServerContext> {
179
+ await disposeServerContext(ctx);
180
+ return createServerContext(ctx.store, newConfig ?? ctx.config);
181
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Serve module exports.
3
+ *
4
+ * @module src/serve
5
+ */
6
+
7
+ export { type ServeOptions, type ServeResult, startServer } from './server';
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import Ask from './pages/Ask';
4
+ import Browse from './pages/Browse';
5
+ import Dashboard from './pages/Dashboard';
6
+ import DocView from './pages/DocView';
7
+ import Search from './pages/Search';
8
+ import './globals.css';
9
+
10
+ type Route = '/' | '/search' | '/browse' | '/doc' | '/ask';
11
+ type Navigate = (to: string | number) => void;
12
+
13
+ const routes: Record<Route, React.ComponentType<{ navigate: Navigate }>> = {
14
+ '/': Dashboard,
15
+ '/search': Search,
16
+ '/browse': Browse,
17
+ '/doc': DocView,
18
+ '/ask': Ask,
19
+ };
20
+
21
+ function App() {
22
+ // Track full location (pathname + search) for proper query param handling
23
+ const [location, setLocation] = useState<string>(
24
+ window.location.pathname + window.location.search
25
+ );
26
+
27
+ useEffect(() => {
28
+ const handlePopState = () =>
29
+ setLocation(window.location.pathname + window.location.search);
30
+ window.addEventListener('popstate', handlePopState);
31
+ return () => window.removeEventListener('popstate', handlePopState);
32
+ }, []);
33
+
34
+ const navigate = (to: string | number) => {
35
+ if (typeof to === 'number') {
36
+ // Handle history.go(-1) style navigation
37
+ window.history.go(to);
38
+ return;
39
+ }
40
+ window.history.pushState({}, '', to);
41
+ setLocation(to);
42
+ };
43
+
44
+ // Extract base path for routing (ignore query params)
45
+ const basePath = location.split('?')[0] as Route;
46
+ const Page = routes[basePath] || Dashboard;
47
+
48
+ return <Page navigate={navigate} />;
49
+ }
50
+
51
+ const rootElement = document.getElementById('root');
52
+ if (!rootElement) {
53
+ throw new Error('Root element not found');
54
+ }
55
+ const root = createRoot(rootElement);
56
+ root.render(<App />);
@@ -0,0 +1,176 @@
1
+ import { CheckIcon, CopyIcon } from 'lucide-react';
2
+ import {
3
+ type ComponentProps,
4
+ createContext,
5
+ type HTMLAttributes,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { type BundledLanguage, codeToHtml, type ShikiTransformer } from 'shiki';
12
+ import { cn } from '../../lib/utils';
13
+ import { Button } from '../ui/button';
14
+
15
+ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
16
+ code: string;
17
+ language: BundledLanguage;
18
+ showLineNumbers?: boolean;
19
+ };
20
+
21
+ interface CodeBlockContextType {
22
+ code: string;
23
+ }
24
+
25
+ const CodeBlockContext = createContext<CodeBlockContextType>({
26
+ code: '',
27
+ });
28
+
29
+ const lineNumberTransformer: ShikiTransformer = {
30
+ name: 'line-numbers',
31
+ line(node, line) {
32
+ node.children.unshift({
33
+ type: 'element',
34
+ tagName: 'span',
35
+ properties: {
36
+ className: [
37
+ 'inline-block',
38
+ 'min-w-10',
39
+ 'mr-4',
40
+ 'text-right',
41
+ 'select-none',
42
+ 'text-muted-foreground',
43
+ ],
44
+ },
45
+ children: [{ type: 'text', value: String(line) }],
46
+ });
47
+ },
48
+ };
49
+
50
+ export async function highlightCode(
51
+ code: string,
52
+ language: BundledLanguage,
53
+ showLineNumbers = false
54
+ ) {
55
+ const transformers: ShikiTransformer[] = showLineNumbers
56
+ ? [lineNumberTransformer]
57
+ : [];
58
+
59
+ return await Promise.all([
60
+ codeToHtml(code, {
61
+ lang: language,
62
+ theme: 'one-light',
63
+ transformers,
64
+ }),
65
+ codeToHtml(code, {
66
+ lang: language,
67
+ theme: 'one-dark-pro',
68
+ transformers,
69
+ }),
70
+ ]);
71
+ }
72
+
73
+ export const CodeBlock = ({
74
+ code,
75
+ language,
76
+ showLineNumbers = false,
77
+ className,
78
+ children,
79
+ ...props
80
+ }: CodeBlockProps) => {
81
+ const [html, setHtml] = useState<string>('');
82
+ const [darkHtml, setDarkHtml] = useState<string>('');
83
+ const mounted = useRef(false);
84
+
85
+ useEffect(() => {
86
+ highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
87
+ if (!mounted.current) {
88
+ setHtml(light);
89
+ setDarkHtml(dark);
90
+ mounted.current = true;
91
+ }
92
+ });
93
+
94
+ return () => {
95
+ mounted.current = false;
96
+ };
97
+ }, [code, language, showLineNumbers]);
98
+
99
+ return (
100
+ <CodeBlockContext.Provider value={{ code }}>
101
+ <div
102
+ className={cn(
103
+ 'group relative w-full overflow-hidden rounded-md border bg-background text-foreground',
104
+ className
105
+ )}
106
+ {...props}
107
+ >
108
+ <div className="relative">
109
+ <div
110
+ className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
111
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
112
+ dangerouslySetInnerHTML={{ __html: html }}
113
+ />
114
+ <div
115
+ className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
116
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
117
+ dangerouslySetInnerHTML={{ __html: darkHtml }}
118
+ />
119
+ {children && (
120
+ <div className="absolute top-2 right-2 flex items-center gap-2">
121
+ {children}
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </CodeBlockContext.Provider>
127
+ );
128
+ };
129
+
130
+ export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
131
+ onCopy?: () => void;
132
+ onError?: (error: Error) => void;
133
+ timeout?: number;
134
+ };
135
+
136
+ export const CodeBlockCopyButton = ({
137
+ onCopy,
138
+ onError,
139
+ timeout = 2000,
140
+ children,
141
+ className,
142
+ ...props
143
+ }: CodeBlockCopyButtonProps) => {
144
+ const [isCopied, setIsCopied] = useState(false);
145
+ const { code } = useContext(CodeBlockContext);
146
+
147
+ const copyToClipboard = async () => {
148
+ if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
149
+ onError?.(new Error('Clipboard API not available'));
150
+ return;
151
+ }
152
+
153
+ try {
154
+ await navigator.clipboard.writeText(code);
155
+ setIsCopied(true);
156
+ onCopy?.();
157
+ setTimeout(() => setIsCopied(false), timeout);
158
+ } catch (error) {
159
+ onError?.(error as Error);
160
+ }
161
+ };
162
+
163
+ const Icon = isCopied ? CheckIcon : CopyIcon;
164
+
165
+ return (
166
+ <Button
167
+ className={cn('shrink-0', className)}
168
+ onClick={copyToClipboard}
169
+ size="icon"
170
+ variant="ghost"
171
+ {...props}
172
+ >
173
+ {children ?? <Icon size={14} />}
174
+ </Button>
175
+ );
176
+ };
@@ -0,0 +1,98 @@
1
+ import { ArrowDownIcon } from 'lucide-react';
2
+ import type { ComponentProps } from 'react';
3
+ import { useCallback } from 'react';
4
+ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
5
+ import { cn } from '../../lib/utils';
6
+ import { Button } from '../ui/button';
7
+
8
+ export type ConversationProps = ComponentProps<typeof StickToBottom>;
9
+
10
+ export const Conversation = ({ className, ...props }: ConversationProps) => (
11
+ <StickToBottom
12
+ className={cn('relative flex-1 overflow-y-hidden', className)}
13
+ initial="smooth"
14
+ resize="smooth"
15
+ role="log"
16
+ {...props}
17
+ />
18
+ );
19
+
20
+ export type ConversationContentProps = ComponentProps<
21
+ typeof StickToBottom.Content
22
+ >;
23
+
24
+ export const ConversationContent = ({
25
+ className,
26
+ ...props
27
+ }: ConversationContentProps) => (
28
+ <StickToBottom.Content
29
+ className={cn('flex flex-col gap-8 p-4', className)}
30
+ {...props}
31
+ />
32
+ );
33
+
34
+ export type ConversationEmptyStateProps = ComponentProps<'div'> & {
35
+ title?: string;
36
+ description?: string;
37
+ icon?: React.ReactNode;
38
+ };
39
+
40
+ export const ConversationEmptyState = ({
41
+ className,
42
+ title = 'No messages yet',
43
+ description = 'Start a conversation to see messages here',
44
+ icon,
45
+ children,
46
+ ...props
47
+ }: ConversationEmptyStateProps) => (
48
+ <div
49
+ className={cn(
50
+ 'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
51
+ className
52
+ )}
53
+ {...props}
54
+ >
55
+ {children ?? (
56
+ <>
57
+ {icon && <div className="text-muted-foreground">{icon}</div>}
58
+ <div className="space-y-1">
59
+ <h3 className="font-medium text-sm">{title}</h3>
60
+ {description && (
61
+ <p className="text-muted-foreground text-sm">{description}</p>
62
+ )}
63
+ </div>
64
+ </>
65
+ )}
66
+ </div>
67
+ );
68
+
69
+ export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
70
+
71
+ export const ConversationScrollButton = ({
72
+ className,
73
+ ...props
74
+ }: ConversationScrollButtonProps) => {
75
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
76
+
77
+ const handleScrollToBottom = useCallback(() => {
78
+ scrollToBottom();
79
+ }, [scrollToBottom]);
80
+
81
+ return (
82
+ !isAtBottom && (
83
+ <Button
84
+ className={cn(
85
+ 'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
86
+ className
87
+ )}
88
+ onClick={handleScrollToBottom}
89
+ size="icon"
90
+ type="button"
91
+ variant="outline"
92
+ {...props}
93
+ >
94
+ <ArrowDownIcon className="size-4" />
95
+ </Button>
96
+ )
97
+ );
98
+ };