@gmickel/gno 0.3.5 → 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 +64 -1
- 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,91 @@
|
|
|
1
|
+
# Web UI (gno serve)
|
|
2
|
+
|
|
3
|
+
Local web server for GNO search and document browsing.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
Uses same **"Ports without DI"** pattern as CLI/MCP (see root CLAUDE.md):
|
|
8
|
+
- Adapters instantiated directly in `context.ts`
|
|
9
|
+
- Pipeline code receives port interfaces
|
|
10
|
+
- No dependency injection
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
src/serve/
|
|
14
|
+
├── server.ts # Bun.serve() entry point
|
|
15
|
+
├── context.ts # ServerContext with LLM ports
|
|
16
|
+
├── routes/
|
|
17
|
+
│ └── api.ts # REST API handlers
|
|
18
|
+
└── public/ # React frontend (Bun HTML imports)
|
|
19
|
+
├── App.tsx # Router
|
|
20
|
+
├── pages/ # Page components
|
|
21
|
+
├── components/ # UI components (ShadCN + AI Elements)
|
|
22
|
+
└── hooks/ # Custom hooks (useApi, etc.)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Key Patterns
|
|
26
|
+
|
|
27
|
+
### Ports (interfaces)
|
|
28
|
+
- `EmbeddingPort` - vector embeddings
|
|
29
|
+
- `GenerationPort` - LLM text generation
|
|
30
|
+
- `RerankPort` - cross-encoder reranking
|
|
31
|
+
- `VectorIndexPort` - vector search
|
|
32
|
+
|
|
33
|
+
### ServerContext
|
|
34
|
+
Created at startup, holds all LLM ports and capabilities:
|
|
35
|
+
```typescript
|
|
36
|
+
interface ServerContext {
|
|
37
|
+
store: SqliteAdapter;
|
|
38
|
+
config: Config;
|
|
39
|
+
vectorIndex: VectorIndexPort | null;
|
|
40
|
+
embedPort: EmbeddingPort | null;
|
|
41
|
+
genPort: GenerationPort | null;
|
|
42
|
+
rerankPort: RerankPort | null;
|
|
43
|
+
capabilities: { bm25, vector, hybrid, answer };
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Shared Pipeline Code
|
|
48
|
+
Answer generation uses shared module to stay in sync with CLI:
|
|
49
|
+
- `src/pipeline/answer.ts` - generateGroundedAnswer, processAnswerResult
|
|
50
|
+
|
|
51
|
+
## API Endpoints
|
|
52
|
+
|
|
53
|
+
| Endpoint | Method | Description |
|
|
54
|
+
|----------|--------|-------------|
|
|
55
|
+
| `/api/health` | GET | Health check |
|
|
56
|
+
| `/api/status` | GET | Index stats, collections |
|
|
57
|
+
| `/api/capabilities` | GET | Available features |
|
|
58
|
+
| `/api/collections` | GET | List collections |
|
|
59
|
+
| `/api/docs` | GET | List documents |
|
|
60
|
+
| `/api/doc` | GET | Get document content |
|
|
61
|
+
| `/api/search` | POST | BM25 search |
|
|
62
|
+
| `/api/query` | POST | Hybrid search |
|
|
63
|
+
| `/api/ask` | POST | AI answer with citations |
|
|
64
|
+
| `/api/presets` | GET | List model presets |
|
|
65
|
+
| `/api/presets` | POST | Switch preset (hot-reload) |
|
|
66
|
+
| `/api/models/status` | GET | Download progress |
|
|
67
|
+
| `/api/models/pull` | POST | Start model download |
|
|
68
|
+
|
|
69
|
+
## Frontend
|
|
70
|
+
|
|
71
|
+
- **Framework**: React (via Bun HTML imports)
|
|
72
|
+
- **Styling**: Tailwind CSS + ShadCN components
|
|
73
|
+
- **AI Elements**: Conversation, Message, Sources, CodeBlock, Loader
|
|
74
|
+
- **Routing**: Simple hash-free SPA routing in App.tsx
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Start dev server with HMR
|
|
80
|
+
bun run src/serve/index.ts
|
|
81
|
+
|
|
82
|
+
# Or via CLI
|
|
83
|
+
gno serve --port 3000
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Security
|
|
87
|
+
|
|
88
|
+
- Binds to `127.0.0.1` only (no LAN exposure)
|
|
89
|
+
- CSP headers on all responses
|
|
90
|
+
- CORS protection on POST endpoints
|
|
91
|
+
- No external font/script loading
|
|
@@ -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
|
+
};
|