@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.
- package/README.md +194 -53
- package/assets/badges/license.svg +12 -0
- package/assets/badges/npm.svg +13 -0
- package/assets/badges/twitter.svg +22 -0
- package/assets/badges/website.svg +22 -0
- package/package.json +30 -1
- package/src/cli/commands/ask.ts +11 -186
- package/src/cli/commands/models/pull.ts +9 -4
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/program.ts +28 -0
- package/src/llm/registry.ts +3 -1
- package/src/pipeline/answer.ts +191 -0
- package/src/serve/CLAUDE.md +91 -0
- package/src/serve/bunfig.toml +2 -0
- package/src/serve/context.ts +181 -0
- package/src/serve/index.ts +7 -0
- package/src/serve/public/app.tsx +56 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
- package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
- package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
- package/src/serve/public/components/ai-elements/loader.tsx +96 -0
- package/src/serve/public/components/ai-elements/message.tsx +443 -0
- package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
- package/src/serve/public/components/ai-elements/sources.tsx +75 -0
- package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
- package/src/serve/public/components/preset-selector.tsx +403 -0
- package/src/serve/public/components/ui/badge.tsx +46 -0
- package/src/serve/public/components/ui/button-group.tsx +82 -0
- package/src/serve/public/components/ui/button.tsx +62 -0
- package/src/serve/public/components/ui/card.tsx +92 -0
- package/src/serve/public/components/ui/carousel.tsx +244 -0
- package/src/serve/public/components/ui/collapsible.tsx +31 -0
- package/src/serve/public/components/ui/command.tsx +181 -0
- package/src/serve/public/components/ui/dialog.tsx +141 -0
- package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
- package/src/serve/public/components/ui/hover-card.tsx +42 -0
- package/src/serve/public/components/ui/input-group.tsx +167 -0
- package/src/serve/public/components/ui/input.tsx +21 -0
- package/src/serve/public/components/ui/progress.tsx +28 -0
- package/src/serve/public/components/ui/scroll-area.tsx +56 -0
- package/src/serve/public/components/ui/select.tsx +188 -0
- package/src/serve/public/components/ui/separator.tsx +26 -0
- package/src/serve/public/components/ui/table.tsx +114 -0
- package/src/serve/public/components/ui/textarea.tsx +18 -0
- package/src/serve/public/components/ui/tooltip.tsx +59 -0
- package/src/serve/public/globals.css +226 -0
- package/src/serve/public/hooks/use-api.ts +112 -0
- package/src/serve/public/index.html +13 -0
- package/src/serve/public/pages/Ask.tsx +442 -0
- package/src/serve/public/pages/Browse.tsx +270 -0
- package/src/serve/public/pages/Dashboard.tsx +202 -0
- package/src/serve/public/pages/DocView.tsx +302 -0
- package/src/serve/public/pages/Search.tsx +335 -0
- package/src/serve/routes/api.ts +763 -0
- package/src/serve/server.ts +249 -0
- package/src/store/sqlite/adapter.ts +47 -0
- 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,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
|
+
};
|