browzy 1.0.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 +324 -0
- package/dist/cli/app.d.ts +16 -0
- package/dist/cli/app.js +615 -0
- package/dist/cli/banner.d.ts +1 -0
- package/dist/cli/banner.js +60 -0
- package/dist/cli/commands/compile.d.ts +2 -0
- package/dist/cli/commands/compile.js +42 -0
- package/dist/cli/commands/ingest.d.ts +2 -0
- package/dist/cli/commands/ingest.js +32 -0
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +48 -0
- package/dist/cli/commands/lint.d.ts +2 -0
- package/dist/cli/commands/lint.js +40 -0
- package/dist/cli/commands/query.d.ts +2 -0
- package/dist/cli/commands/query.js +36 -0
- package/dist/cli/commands/search.d.ts +2 -0
- package/dist/cli/commands/search.js +34 -0
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +27 -0
- package/dist/cli/components/Banner.d.ts +13 -0
- package/dist/cli/components/Banner.js +20 -0
- package/dist/cli/components/Markdown.d.ts +14 -0
- package/dist/cli/components/Markdown.js +324 -0
- package/dist/cli/components/Message.d.ts +14 -0
- package/dist/cli/components/Message.js +17 -0
- package/dist/cli/components/Spinner.d.ts +7 -0
- package/dist/cli/components/Spinner.js +19 -0
- package/dist/cli/components/StatusBar.d.ts +14 -0
- package/dist/cli/components/StatusBar.js +19 -0
- package/dist/cli/components/Suggestions.d.ts +13 -0
- package/dist/cli/components/Suggestions.js +14 -0
- package/dist/cli/entry.d.ts +2 -0
- package/dist/cli/entry.js +61 -0
- package/dist/cli/helpers.d.ts +14 -0
- package/dist/cli/helpers.js +32 -0
- package/dist/cli/hooks/useAutocomplete.d.ts +11 -0
- package/dist/cli/hooks/useAutocomplete.js +71 -0
- package/dist/cli/hooks/useHistory.d.ts +13 -0
- package/dist/cli/hooks/useHistory.js +106 -0
- package/dist/cli/hooks/useSession.d.ts +16 -0
- package/dist/cli/hooks/useSession.js +133 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +41 -0
- package/dist/cli/keystore.d.ts +28 -0
- package/dist/cli/keystore.js +59 -0
- package/dist/cli/onboarding.d.ts +18 -0
- package/dist/cli/onboarding.js +306 -0
- package/dist/cli/personality.d.ts +34 -0
- package/dist/cli/personality.js +196 -0
- package/dist/cli/repl.d.ts +20 -0
- package/dist/cli/repl.js +338 -0
- package/dist/cli/theme.d.ts +25 -0
- package/dist/cli/theme.js +64 -0
- package/dist/core/compile/compiler.d.ts +25 -0
- package/dist/core/compile/compiler.js +229 -0
- package/dist/core/compile/index.d.ts +2 -0
- package/dist/core/compile/index.js +1 -0
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.js +92 -0
- package/dist/core/index.d.ts +12 -0
- package/dist/core/index.js +11 -0
- package/dist/core/ingest/image.d.ts +3 -0
- package/dist/core/ingest/image.js +61 -0
- package/dist/core/ingest/index.d.ts +18 -0
- package/dist/core/ingest/index.js +79 -0
- package/dist/core/ingest/pdf.d.ts +2 -0
- package/dist/core/ingest/pdf.js +36 -0
- package/dist/core/ingest/text.d.ts +2 -0
- package/dist/core/ingest/text.js +38 -0
- package/dist/core/ingest/web.d.ts +2 -0
- package/dist/core/ingest/web.js +202 -0
- package/dist/core/lint/index.d.ts +1 -0
- package/dist/core/lint/index.js +1 -0
- package/dist/core/lint/linter.d.ts +27 -0
- package/dist/core/lint/linter.js +147 -0
- package/dist/core/llm/index.d.ts +2 -0
- package/dist/core/llm/index.js +1 -0
- package/dist/core/llm/provider.d.ts +15 -0
- package/dist/core/llm/provider.js +241 -0
- package/dist/core/prompts.d.ts +28 -0
- package/dist/core/prompts.js +374 -0
- package/dist/core/query/engine.d.ts +29 -0
- package/dist/core/query/engine.js +131 -0
- package/dist/core/query/index.d.ts +2 -0
- package/dist/core/query/index.js +1 -0
- package/dist/core/sanitization.d.ts +11 -0
- package/dist/core/sanitization.js +50 -0
- package/dist/core/storage/filesystem.d.ts +23 -0
- package/dist/core/storage/filesystem.js +106 -0
- package/dist/core/storage/index.d.ts +2 -0
- package/dist/core/storage/index.js +2 -0
- package/dist/core/storage/sqlite.d.ts +30 -0
- package/dist/core/storage/sqlite.js +104 -0
- package/dist/core/types.d.ts +95 -0
- package/dist/core/types.js +4 -0
- package/dist/core/utils.d.ts +8 -0
- package/dist/core/utils.js +94 -0
- package/dist/core/wiki/index.d.ts +1 -0
- package/dist/core/wiki/index.js +1 -0
- package/dist/core/wiki/wiki.d.ts +19 -0
- package/dist/core/wiki/wiki.js +37 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/package.json +54 -0
package/dist/cli/app.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { Box, Text, Static, useInput, useApp, useStdout } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { writeFileSync as wfs, readFileSync as rfs, unlinkSync } from 'fs';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import { getTheme } from './theme.js';
|
|
10
|
+
import { Banner } from './components/Banner.js';
|
|
11
|
+
import { touchProfile, getWelcomeMessage } from './onboarding.js';
|
|
12
|
+
import { Message } from './components/Message.js';
|
|
13
|
+
import { BrowzySpinner } from './components/Spinner.js';
|
|
14
|
+
import { SuggestionList } from './components/Suggestions.js';
|
|
15
|
+
import { StatusBar } from './components/StatusBar.js';
|
|
16
|
+
import { renderMarkdown } from './components/Markdown.js';
|
|
17
|
+
import { useHistory } from './hooks/useHistory.js';
|
|
18
|
+
import { useAutocomplete } from './hooks/useAutocomplete.js';
|
|
19
|
+
import { useSession } from './hooks/useSession.js';
|
|
20
|
+
import { updateStreak, recordSourceAdded, recordQuery, checkMilestones, loadStreak, getThinkingMessage, getIngestingMessage, getCompilingMessage, getHealthMessage, getAddReward, getQueryReward, getExitMessage, getHealthReward, } from './personality.js';
|
|
21
|
+
import { getKey, saveKey, looksLikeApiKey } from './keystore.js';
|
|
22
|
+
import { loadConfig, ensureDataDirs, createProvider } from '../core/index.js';
|
|
23
|
+
import { ingest } from '../core/ingest/index.js';
|
|
24
|
+
import { WikiCompiler } from '../core/compile/index.js';
|
|
25
|
+
import { QueryEngine } from '../core/query/index.js';
|
|
26
|
+
import { WikiLinter } from '../core/lint/index.js';
|
|
27
|
+
import { Wiki } from '../core/wiki/index.js';
|
|
28
|
+
import { QUERY_SYSTEM_PROMPT, CONVERSATION_CONTEXT_PROMPT } from '../core/prompts.js';
|
|
29
|
+
export class BrowzyErrorBoundary extends React.Component {
|
|
30
|
+
constructor(props) {
|
|
31
|
+
super(props);
|
|
32
|
+
this.state = { hasError: false };
|
|
33
|
+
}
|
|
34
|
+
static getDerivedStateFromError(error) {
|
|
35
|
+
return { hasError: true, error };
|
|
36
|
+
}
|
|
37
|
+
render() {
|
|
38
|
+
if (this.state.hasError) {
|
|
39
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { color: "red", bold: true, children: "browzy encountered an error:" }), _jsx(Text, { color: "red", children: this.state.error?.message })] }));
|
|
40
|
+
}
|
|
41
|
+
return this.props.children;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Main App ───────────────────────────────────────────────────
|
|
45
|
+
//
|
|
46
|
+
// Layout pattern from Claude Code:
|
|
47
|
+
// - <Static> for completed messages — renders once, NEVER re-renders
|
|
48
|
+
// - Dynamic section below for: streaming text, spinner, input, status
|
|
49
|
+
// This prevents re-render collapse during streaming.
|
|
50
|
+
export const BrowzyApp = () => {
|
|
51
|
+
const theme = getTheme();
|
|
52
|
+
const { exit } = useApp();
|
|
53
|
+
const { stdout } = useStdout();
|
|
54
|
+
const cols = stdout.columns || 80;
|
|
55
|
+
// State
|
|
56
|
+
const [input, setInput] = useState('');
|
|
57
|
+
const [loading, setLoading] = useState(false);
|
|
58
|
+
const [loadingLabel, setLoadingLabel] = useState('thinking...');
|
|
59
|
+
const [elapsed, setElapsed] = useState(0);
|
|
60
|
+
const [streamingText, setStreamingText] = useState('');
|
|
61
|
+
const [tempStatus, setTempStatus] = useState('');
|
|
62
|
+
const [stashedInput, setStashedInput] = useState(null);
|
|
63
|
+
const [currentModel, setCurrentModel] = useState('');
|
|
64
|
+
const [lastModelList, setLastModelList] = useState([]);
|
|
65
|
+
// Refs
|
|
66
|
+
const inputRef = useRef(input);
|
|
67
|
+
inputRef.current = input;
|
|
68
|
+
const loadingRef = useRef(loading);
|
|
69
|
+
loadingRef.current = loading;
|
|
70
|
+
// Hooks
|
|
71
|
+
const history = useHistory();
|
|
72
|
+
const autocomplete = useAutocomplete();
|
|
73
|
+
const session = useSession();
|
|
74
|
+
// Config + LLM
|
|
75
|
+
const [config, setConfig] = useState(() => {
|
|
76
|
+
const c = loadConfig();
|
|
77
|
+
ensureDataDirs(c);
|
|
78
|
+
return c;
|
|
79
|
+
});
|
|
80
|
+
const [llm, setLlm] = useState(() => createProvider(config.llm));
|
|
81
|
+
// Set initial model name
|
|
82
|
+
useEffect(() => { setCurrentModel(config.llm.model || 'default'); }, []);
|
|
83
|
+
// Stats — loaded synchronously so the banner has correct values on first render
|
|
84
|
+
const [stats, setStats] = useState(() => {
|
|
85
|
+
try {
|
|
86
|
+
const wiki = new Wiki(config.dataDir);
|
|
87
|
+
const s = wiki.stats();
|
|
88
|
+
wiki.close();
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { sources: 0, articles: 0, concepts: 0 };
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
// Welcome & streak — computed once with stats available
|
|
96
|
+
const [welcomeMsg] = useState(() => {
|
|
97
|
+
updateStreak();
|
|
98
|
+
const profile = touchProfile();
|
|
99
|
+
return profile ? getWelcomeMessage(profile, stats) : 'Your knowledge, compiled.';
|
|
100
|
+
});
|
|
101
|
+
const refreshStats = useCallback(() => {
|
|
102
|
+
try {
|
|
103
|
+
const wiki = new Wiki(config.dataDir);
|
|
104
|
+
setStats(wiki.stats());
|
|
105
|
+
wiki.close();
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore */ }
|
|
108
|
+
}, [config.dataDir]);
|
|
109
|
+
useEffect(() => { refreshStats(); }, [refreshStats]);
|
|
110
|
+
// Elapsed timer
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!loading) {
|
|
113
|
+
setElapsed(0);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const start = Date.now();
|
|
117
|
+
const timer = setInterval(() => setElapsed((Date.now() - start) / 1000), 100);
|
|
118
|
+
return () => clearInterval(timer);
|
|
119
|
+
}, [loading]);
|
|
120
|
+
// Save session on unmount
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
return () => { session.saveSession(); };
|
|
123
|
+
}, []);
|
|
124
|
+
// ── Streaming with throttle ─────────────────────────────────
|
|
125
|
+
const streamThrottleRef = useRef(null);
|
|
126
|
+
const latestSnapshotRef = useRef('');
|
|
127
|
+
const handleQuery = useCallback(async (question) => {
|
|
128
|
+
session.addMessage('user', question);
|
|
129
|
+
recordQuery();
|
|
130
|
+
setLoading(true);
|
|
131
|
+
setLoadingLabel(getThinkingMessage());
|
|
132
|
+
setStreamingText('');
|
|
133
|
+
latestSnapshotRef.current = '';
|
|
134
|
+
try {
|
|
135
|
+
// Use real streaming from the LLM provider
|
|
136
|
+
// Gather wiki context
|
|
137
|
+
const wikiObj = new Wiki(config.dataDir);
|
|
138
|
+
const searchResults = wikiObj.search(question, 5);
|
|
139
|
+
wikiObj.close();
|
|
140
|
+
// Build context
|
|
141
|
+
const engine = new QueryEngine(config.dataDir, llm);
|
|
142
|
+
const fullResult = await engine.query(question, { format: 'markdown', save: false });
|
|
143
|
+
// Now stream via provider
|
|
144
|
+
let finalText = '';
|
|
145
|
+
if (llm.stream) {
|
|
146
|
+
try {
|
|
147
|
+
// Build conversation history for context continuity
|
|
148
|
+
const recentHistory = session.messages.slice(-6).map(m => ({
|
|
149
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
150
|
+
content: m.content.slice(0, 500), // Truncate for context window
|
|
151
|
+
}));
|
|
152
|
+
const systemPrompt = QUERY_SYSTEM_PROMPT + '\n\n' + CONVERSATION_CONTEXT_PROMPT;
|
|
153
|
+
for await (const chunk of llm.stream([...recentHistory, { role: 'user', content: `Context from wiki:\n${fullResult.answer.slice(0, 2000)}\n\nQuestion: ${question}` }], { system: systemPrompt, maxTokens: 8192 })) {
|
|
154
|
+
latestSnapshotRef.current = chunk.snapshot;
|
|
155
|
+
finalText = chunk.snapshot;
|
|
156
|
+
// Throttle to ~4fps
|
|
157
|
+
if (!streamThrottleRef.current) {
|
|
158
|
+
streamThrottleRef.current = setTimeout(() => {
|
|
159
|
+
setStreamingText(latestSnapshotRef.current);
|
|
160
|
+
streamThrottleRef.current = null;
|
|
161
|
+
}, 250);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Fallback to non-streaming result
|
|
167
|
+
finalText = fullResult.answer;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
finalText = fullResult.answer;
|
|
172
|
+
}
|
|
173
|
+
// Clear throttle
|
|
174
|
+
if (streamThrottleRef.current) {
|
|
175
|
+
clearTimeout(streamThrottleRef.current);
|
|
176
|
+
streamThrottleRef.current = null;
|
|
177
|
+
}
|
|
178
|
+
setStreamingText('');
|
|
179
|
+
session.addMessage('assistant', finalText || fullResult.answer, fullResult.sourcesUsed);
|
|
180
|
+
setTempStatus(getQueryReward(fullResult.sourcesUsed.length));
|
|
181
|
+
// Check for milestones
|
|
182
|
+
const milestone = checkMilestones(stats);
|
|
183
|
+
if (milestone)
|
|
184
|
+
session.addMessage('system', `\n${milestone}`);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
setStreamingText('');
|
|
188
|
+
session.addMessage('system', `Error: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
setLoading(false);
|
|
191
|
+
refreshStats();
|
|
192
|
+
}, [llm, config, session, refreshStats, stats]);
|
|
193
|
+
// ── Commands ────────────────────────────────────────────────
|
|
194
|
+
const handleCommand = useCallback(async (cmdInput) => {
|
|
195
|
+
const parts = cmdInput.split(/\s+/);
|
|
196
|
+
const cmd = parts[0].toLowerCase();
|
|
197
|
+
const args = parts.slice(1).join(' ');
|
|
198
|
+
switch (cmd) {
|
|
199
|
+
case '/add': {
|
|
200
|
+
if (!args) {
|
|
201
|
+
session.addMessage('system', 'Drop a URL or file path after /add. Drag files into the terminal to paste their paths.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const sources = parseMultipleSources(args);
|
|
205
|
+
setLoading(true);
|
|
206
|
+
let lastTitle = '';
|
|
207
|
+
for (let i = 0; i < sources.length; i++) {
|
|
208
|
+
setLoadingLabel(getIngestingMessage());
|
|
209
|
+
try {
|
|
210
|
+
const result = await ingest(sources[i], config.dataDir, { llm });
|
|
211
|
+
lastTitle = result.title;
|
|
212
|
+
recordSourceAdded();
|
|
213
|
+
session.addMessage('system', `✓ ${result.title}`);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
session.addMessage('system', `✗ ${sources[i]}: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
setLoadingLabel(getCompilingMessage());
|
|
220
|
+
let created = 0, updated = 0;
|
|
221
|
+
try {
|
|
222
|
+
const compiler = new WikiCompiler(config.dataDir, llm);
|
|
223
|
+
const result = await compiler.compile({ batchSize: config.compile.batchSize, extractConcepts: config.compile.extractConcepts });
|
|
224
|
+
created = result.articlesCreated.length;
|
|
225
|
+
updated = result.articlesUpdated.length;
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
session.addMessage('system', `Compile hiccup: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
setLoading(false);
|
|
231
|
+
refreshStats();
|
|
232
|
+
// Playful reward
|
|
233
|
+
const newStats = stats;
|
|
234
|
+
const reward = getAddReward(lastTitle, created, updated, newStats.articles + created);
|
|
235
|
+
if (reward)
|
|
236
|
+
session.addMessage('system', reward);
|
|
237
|
+
// Check milestones
|
|
238
|
+
const milestone = checkMilestones({ ...newStats, articles: newStats.articles + created });
|
|
239
|
+
if (milestone)
|
|
240
|
+
session.addMessage('system', milestone);
|
|
241
|
+
setTempStatus(`+${sources.length} source${sources.length > 1 ? 's' : ''}`);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case '/health': {
|
|
245
|
+
setLoading(true);
|
|
246
|
+
setLoadingLabel(getHealthMessage());
|
|
247
|
+
refreshStats();
|
|
248
|
+
try {
|
|
249
|
+
const linter = new WikiLinter(config.dataDir, llm);
|
|
250
|
+
const issues = await linter.lint();
|
|
251
|
+
const e = issues.filter((i) => i.severity === 'error').length;
|
|
252
|
+
const w = issues.filter((i) => i.severity === 'warning').length;
|
|
253
|
+
const s = issues.filter((i) => i.severity === 'suggestion').length;
|
|
254
|
+
if (issues.length > 0) {
|
|
255
|
+
const txt = issues.map((i) => ` ${i.severity === 'error' ? '✗' : i.severity === 'warning' ? '!' : '·'} [${i.article}] ${i.message}`).join('\n');
|
|
256
|
+
session.addMessage('system', txt);
|
|
257
|
+
}
|
|
258
|
+
session.addMessage('system', getHealthReward(e, w, s));
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
session.addMessage('system', `Health check failed: ${err.message}`);
|
|
262
|
+
}
|
|
263
|
+
setLoading(false);
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case '/rebuild': {
|
|
267
|
+
setLoading(true);
|
|
268
|
+
setLoadingLabel(getCompilingMessage());
|
|
269
|
+
try {
|
|
270
|
+
const compiler = new WikiCompiler(config.dataDir, llm);
|
|
271
|
+
const r = await compiler.compile({ batchSize: config.compile.batchSize, extractConcepts: config.compile.extractConcepts });
|
|
272
|
+
const total = r.articlesCreated.length + r.articlesUpdated.length;
|
|
273
|
+
session.addMessage('system', total === 0
|
|
274
|
+
? 'Your browzy is up to date. Nothing to rebuild.'
|
|
275
|
+
: `Rebuilt: ${r.articlesCreated.length} new, ${r.articlesUpdated.length} updated. Your browzy just got sharper.`);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
session.addMessage('system', `Rebuild hit a snag: ${err.message}`);
|
|
279
|
+
}
|
|
280
|
+
setLoading(false);
|
|
281
|
+
refreshStats();
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case '/model': {
|
|
285
|
+
const switchTo = (provider, modelId, apiKey, displayName) => {
|
|
286
|
+
const newLlmConfig = { provider, model: modelId, apiKey };
|
|
287
|
+
const newConfig = { ...config, llm: newLlmConfig };
|
|
288
|
+
setConfig(newConfig);
|
|
289
|
+
setLlm(createProvider(newLlmConfig));
|
|
290
|
+
setCurrentModel(displayName || modelId);
|
|
291
|
+
session.addMessage('system', `Switched to ${displayName || modelId}. Let's see what this one can do.`);
|
|
292
|
+
};
|
|
293
|
+
if (args) {
|
|
294
|
+
// /model <number> — pick from last fetched list
|
|
295
|
+
const num = parseInt(args, 10);
|
|
296
|
+
if (!isNaN(num) && num >= 1 && num <= lastModelList.length) {
|
|
297
|
+
const picked = lastModelList[num - 1];
|
|
298
|
+
// Determine provider from model ID
|
|
299
|
+
const provider = picked.id.startsWith('claude') ? 'claude'
|
|
300
|
+
: picked.id.includes('/') ? 'openrouter'
|
|
301
|
+
: 'openai';
|
|
302
|
+
const apiKey = provider === 'claude' ? (getKey('anthropic') || config.llm.apiKey)
|
|
303
|
+
: provider === 'openrouter' ? (getKey('openrouter') || config.llm.apiKey)
|
|
304
|
+
: (getKey('openai') || config.llm.apiKey);
|
|
305
|
+
switchTo(provider, picked.id, apiKey, picked.display_name);
|
|
306
|
+
}
|
|
307
|
+
// /model claude — show Claude models
|
|
308
|
+
else if (args === 'claude') {
|
|
309
|
+
await fetchAndShowModels('claude');
|
|
310
|
+
}
|
|
311
|
+
// /model openrouter — show OpenRouter models
|
|
312
|
+
else if (args === 'openrouter') {
|
|
313
|
+
await fetchAndShowModels('openrouter');
|
|
314
|
+
}
|
|
315
|
+
// /model openai — show OpenAI models
|
|
316
|
+
else if (args === 'openai') {
|
|
317
|
+
await fetchAndShowModels('openai');
|
|
318
|
+
}
|
|
319
|
+
// /model <exact-model-id> — direct switch
|
|
320
|
+
else {
|
|
321
|
+
const provider = args.startsWith('claude') ? 'claude'
|
|
322
|
+
: args.includes('/') ? 'openrouter'
|
|
323
|
+
: 'openai';
|
|
324
|
+
const apiKey = provider === 'claude' ? (getKey('anthropic') || config.llm.apiKey)
|
|
325
|
+
: provider === 'openrouter' ? (getKey('openrouter') || config.llm.apiKey)
|
|
326
|
+
: (getKey('openai') || config.llm.apiKey);
|
|
327
|
+
if (!apiKey) {
|
|
328
|
+
const envVar = provider === 'claude' ? 'ANTHROPIC_API_KEY' : provider === 'openrouter' ? 'OPENROUTER_API_KEY' : 'OPENAI_API_KEY';
|
|
329
|
+
session.addMessage('system', `No API key for ${provider}. Set ${envVar} in your environment:\n\n export ${envVar}=your-key-here\n\nThen restart browzy.`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
switchTo(provider, args, apiKey);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
// /model — show menu of providers
|
|
338
|
+
const hasAnthropic = !!(getKey('anthropic') || config.llm.apiKey);
|
|
339
|
+
const hasOpenRouter = !!getKey('openrouter');
|
|
340
|
+
const hasOpenAI = !!getKey('openai');
|
|
341
|
+
const lines = [
|
|
342
|
+
'Choose a provider:',
|
|
343
|
+
'',
|
|
344
|
+
hasAnthropic ? ' /model claude Browse Claude models' : ' /model claude Paste your API key to enable',
|
|
345
|
+
hasOpenRouter ? ' /model openrouter Browse 200+ models (GPT, Gemini, Llama, Mistral...)' : ' /model openrouter Paste your API key to enable (openrouter.ai)',
|
|
346
|
+
hasOpenAI ? ' /model openai Browse OpenAI models' : ' /model openai Paste your API key to enable',
|
|
347
|
+
'',
|
|
348
|
+
` Current: ${currentModel}`,
|
|
349
|
+
];
|
|
350
|
+
session.addMessage('system', lines.join('\n'));
|
|
351
|
+
}
|
|
352
|
+
async function fetchAndShowModels(provider) {
|
|
353
|
+
setLoading(true);
|
|
354
|
+
setLoadingLabel('Fetching models...');
|
|
355
|
+
try {
|
|
356
|
+
let models = [];
|
|
357
|
+
if (provider === 'claude') {
|
|
358
|
+
const key = getKey('anthropic') || config.llm.apiKey;
|
|
359
|
+
if (!key) {
|
|
360
|
+
session.addMessage('system', 'No Claude API key found. Paste your key below — it starts with sk-ant-...\nGet one at console.anthropic.com/settings/keys');
|
|
361
|
+
setLoading(false);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
365
|
+
const client = new Anthropic({ apiKey: key });
|
|
366
|
+
const page = await client.models.list({ limit: 50 });
|
|
367
|
+
models = page.data
|
|
368
|
+
.filter((m) => m.id.startsWith('claude'))
|
|
369
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
370
|
+
.map((m) => ({ id: m.id, display_name: m.display_name }));
|
|
371
|
+
}
|
|
372
|
+
else if (provider === 'openrouter') {
|
|
373
|
+
const key = getKey('openrouter');
|
|
374
|
+
if (!key) {
|
|
375
|
+
session.addMessage('system', 'No OpenRouter API key found. Paste your key below — it starts with sk-or-...\nGet one at openrouter.ai/keys');
|
|
376
|
+
setLoading(false);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const resp = await fetch('https://openrouter.ai/api/v1/models', {
|
|
380
|
+
headers: { 'Authorization': `Bearer ${key}` },
|
|
381
|
+
});
|
|
382
|
+
const data = await resp.json();
|
|
383
|
+
// Show top models, grouped nicely
|
|
384
|
+
const popular = ['anthropic/claude', 'openai/gpt-4', 'openai/o', 'google/gemini', 'meta-llama', 'mistralai', 'deepseek'];
|
|
385
|
+
models = data.data
|
|
386
|
+
.filter((m) => popular.some(p => m.id.startsWith(p)))
|
|
387
|
+
.slice(0, 30)
|
|
388
|
+
.map((m) => ({ id: m.id, display_name: m.name }));
|
|
389
|
+
}
|
|
390
|
+
else if (provider === 'openai') {
|
|
391
|
+
const key = getKey('openai');
|
|
392
|
+
if (!key) {
|
|
393
|
+
session.addMessage('system', 'No OpenAI API key found. Paste your key below — it starts with sk-...\nGet one at platform.openai.com/api-keys');
|
|
394
|
+
setLoading(false);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const { default: OpenAI } = await import('openai');
|
|
398
|
+
const client = new OpenAI({ apiKey: key });
|
|
399
|
+
const list = await client.models.list();
|
|
400
|
+
models = Array.from(list.data)
|
|
401
|
+
.filter((m) => m.id.startsWith('gpt-'))
|
|
402
|
+
.sort((a, b) => b.id.localeCompare(a.id))
|
|
403
|
+
.slice(0, 15)
|
|
404
|
+
.map((m) => ({ id: m.id, display_name: m.id }));
|
|
405
|
+
}
|
|
406
|
+
setLastModelList(models);
|
|
407
|
+
if (models.length === 0) {
|
|
408
|
+
session.addMessage('system', 'No models found. Check your API key.');
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const lines = models.map((m, i) => {
|
|
412
|
+
const marker = m.id === currentModel ? ' (current)' : '';
|
|
413
|
+
return ` [${i + 1}] ${m.display_name}${m.display_name !== m.id ? ` — ${m.id}` : ''}${marker}`;
|
|
414
|
+
});
|
|
415
|
+
session.addMessage('system', `${provider} models:\n${lines.join('\n')}\n\nType /model <number> to switch.`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
session.addMessage('system', `Couldn't fetch models: ${err.message}`);
|
|
420
|
+
}
|
|
421
|
+
setLoading(false);
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
case '/export': {
|
|
426
|
+
const safe = (args || `session-${session.sessionId}.md`).replace(/\.\./g, '').replace(/^\//, '').replace(/[^\w\-./]/g, '_');
|
|
427
|
+
const path = session.exportSession(join(config.dataDir, 'output', safe));
|
|
428
|
+
session.addMessage('system', `Saved to ${path}. Your research, preserved.`);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
case '/help':
|
|
432
|
+
session.addMessage('system', [
|
|
433
|
+
'Just type a question — your browzy will find the answer.',
|
|
434
|
+
'',
|
|
435
|
+
'/add <sources...> Feed your browzy new knowledge',
|
|
436
|
+
'/model [model-id] Switch models',
|
|
437
|
+
'/health How is your browzy doing?',
|
|
438
|
+
'/rebuild Recompile from scratch',
|
|
439
|
+
'/export [file] Save this session as markdown',
|
|
440
|
+
'/quit Exit (your browzy remembers everything)',
|
|
441
|
+
'',
|
|
442
|
+
'Keys: Tab complete · ↑↓ history · Ctrl+E editor · Ctrl+S stash',
|
|
443
|
+
].join('\n'));
|
|
444
|
+
break;
|
|
445
|
+
case '/quit':
|
|
446
|
+
case '/exit':
|
|
447
|
+
case '/q':
|
|
448
|
+
session.saveSession();
|
|
449
|
+
session.addMessage('system', getExitMessage(loadStreak()));
|
|
450
|
+
setTimeout(() => exit(), 300); // Brief pause so they see the exit message
|
|
451
|
+
break;
|
|
452
|
+
default: session.addMessage('system', `Hmm, I don't know "${cmd}". Type /help to see what I can do.`);
|
|
453
|
+
}
|
|
454
|
+
}, [llm, config, session, refreshStats, handleQuery, exit]);
|
|
455
|
+
const handleSubmit = useCallback(async (value) => {
|
|
456
|
+
const trimmed = value.trim();
|
|
457
|
+
if (!trimmed)
|
|
458
|
+
return;
|
|
459
|
+
setInput('');
|
|
460
|
+
autocomplete.setVisible(false);
|
|
461
|
+
// Detect pasted API keys — save them, don't send to LLM
|
|
462
|
+
const keyDetect = looksLikeApiKey(trimmed);
|
|
463
|
+
if (keyDetect) {
|
|
464
|
+
saveKey(keyDetect.provider, keyDetect.key);
|
|
465
|
+
const names = { anthropic: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter' };
|
|
466
|
+
session.addMessage('system', [
|
|
467
|
+
`${names[keyDetect.provider]} API key saved.`,
|
|
468
|
+
'',
|
|
469
|
+
`Stored locally at ~/.browzy/keys.json on your machine only.`,
|
|
470
|
+
`browzy is fully local — your keys never leave your device,`,
|
|
471
|
+
`never touch our servers, and are never sent anywhere except`,
|
|
472
|
+
`directly to ${names[keyDetect.provider]}'s API when you ask a question.`,
|
|
473
|
+
'',
|
|
474
|
+
`Try /model ${keyDetect.provider === 'anthropic' ? 'claude' : keyDetect.provider} to browse models.`,
|
|
475
|
+
].join('\n'));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
history.addToHistory(trimmed);
|
|
479
|
+
let normalized = trimmed.replace(/^browzy\s+/i, '');
|
|
480
|
+
const cmds = ['add', 'health', 'rebuild', 'model', 'export', 'help', 'quit', 'exit', 'q'];
|
|
481
|
+
const first = normalized.split(/\s+/)[0].toLowerCase().replace(/^\//, '');
|
|
482
|
+
if (cmds.includes(first))
|
|
483
|
+
normalized = '/' + (normalized.startsWith('/') ? normalized.slice(1) : normalized);
|
|
484
|
+
if (normalized.startsWith('/'))
|
|
485
|
+
await handleCommand(normalized);
|
|
486
|
+
else
|
|
487
|
+
await handleQuery(normalized);
|
|
488
|
+
}, [autocomplete, history, handleCommand, handleQuery]);
|
|
489
|
+
// ── Keyboard ────────────────────────────────────────────────
|
|
490
|
+
useInput((ch, key) => {
|
|
491
|
+
if (loadingRef.current)
|
|
492
|
+
return;
|
|
493
|
+
if (key.ctrl && ch === 'c') {
|
|
494
|
+
if (inputRef.current)
|
|
495
|
+
setInput('');
|
|
496
|
+
else {
|
|
497
|
+
session.saveSession();
|
|
498
|
+
setTimeout(() => exit(), 50);
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (key.ctrl && ch === 'd') {
|
|
503
|
+
session.saveSession();
|
|
504
|
+
setTimeout(() => exit(), 50);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (key.ctrl && ch === 'e') {
|
|
508
|
+
handleOpenEditor();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (key.ctrl && ch === 's') {
|
|
512
|
+
if (inputRef.current.trim()) {
|
|
513
|
+
setStashedInput(inputRef.current);
|
|
514
|
+
setInput('');
|
|
515
|
+
setTempStatus('Stashed');
|
|
516
|
+
}
|
|
517
|
+
else if (stashedInput) {
|
|
518
|
+
setInput(stashedInput);
|
|
519
|
+
setStashedInput(null);
|
|
520
|
+
setTempStatus('Restored');
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (key.rightArrow && autocomplete.getGhostText(inputRef.current)) {
|
|
525
|
+
const a = autocomplete.acceptSuggestion(inputRef.current);
|
|
526
|
+
if (a)
|
|
527
|
+
setInput(a);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (key.upArrow && autocomplete.visible) {
|
|
531
|
+
autocomplete.moveSelection('up', inputRef.current);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (key.downArrow && autocomplete.visible) {
|
|
535
|
+
autocomplete.moveSelection('down', inputRef.current);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (key.upArrow) {
|
|
539
|
+
const p = history.navigateHistory('up', inputRef.current);
|
|
540
|
+
if (p !== null)
|
|
541
|
+
setInput(p);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (key.downArrow) {
|
|
545
|
+
const n = history.navigateHistory('down', inputRef.current);
|
|
546
|
+
if (n !== null)
|
|
547
|
+
setInput(n);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (key.tab) {
|
|
551
|
+
const a = autocomplete.acceptSuggestion(inputRef.current);
|
|
552
|
+
if (a)
|
|
553
|
+
setInput(a);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (key.escape) {
|
|
557
|
+
if (autocomplete.visible)
|
|
558
|
+
autocomplete.setVisible(false);
|
|
559
|
+
else if (stashedInput) {
|
|
560
|
+
setInput(stashedInput);
|
|
561
|
+
setStashedInput(null);
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
const handleOpenEditor = () => {
|
|
567
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
|
|
568
|
+
const tmp = join(tmpdir(), `browzy-edit-${Date.now()}.txt`);
|
|
569
|
+
wfs(tmp, inputRef.current, 'utf-8');
|
|
570
|
+
try {
|
|
571
|
+
execSync(`${editor} ${tmp}`, { stdio: 'inherit' });
|
|
572
|
+
const r = rfs(tmp, 'utf-8').trim();
|
|
573
|
+
if (r)
|
|
574
|
+
setInput(r);
|
|
575
|
+
}
|
|
576
|
+
catch { /* cancelled */ }
|
|
577
|
+
try {
|
|
578
|
+
unlinkSync(tmp);
|
|
579
|
+
}
|
|
580
|
+
catch { /* ignore */ }
|
|
581
|
+
};
|
|
582
|
+
useEffect(() => { autocomplete.updateForInput(input); }, [input]);
|
|
583
|
+
// ── Render ──────────────────────────────────────────────────
|
|
584
|
+
//
|
|
585
|
+
// KEY PATTERN (from Claude Code):
|
|
586
|
+
// <Static> renders completed items ONCE — they stay in terminal
|
|
587
|
+
// scrollback and are NEVER re-rendered. Only the dynamic section
|
|
588
|
+
// below (streaming + input) re-renders on state changes.
|
|
589
|
+
const ghostText = autocomplete.getGhostText(input);
|
|
590
|
+
const matches = autocomplete.getMatches(input);
|
|
591
|
+
// Build static items: banner + completed messages
|
|
592
|
+
const staticItems = [];
|
|
593
|
+
// Banner as first static item
|
|
594
|
+
if (session.messages.length === 0) {
|
|
595
|
+
staticItems.push({ id: 'banner', type: 'banner' });
|
|
596
|
+
}
|
|
597
|
+
// All completed messages
|
|
598
|
+
for (const msg of session.messages) {
|
|
599
|
+
staticItems.push({ id: msg.id, type: 'message', data: msg });
|
|
600
|
+
}
|
|
601
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: staticItems, children: (item) => {
|
|
602
|
+
if (item.type === 'banner') {
|
|
603
|
+
return (_jsx(Box, { children: _jsx(Banner, { welcome: welcomeMsg, stats: stats, model: config.llm.model || 'default', dataDir: config.dataDir }) }, "banner"));
|
|
604
|
+
}
|
|
605
|
+
return _jsx(Message, { message: item.data }, item.id);
|
|
606
|
+
} }), streamingText && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { children: renderMarkdown(streamingText) }) })), loading && !streamingText && (_jsx(Box, { children: _jsx(BrowzySpinner, { label: loadingLabel, elapsed: elapsed }) })), _jsx(SuggestionList, { items: matches, selectedIndex: autocomplete.selectedIndex, visible: autocomplete.visible }), _jsx(Box, { children: _jsx(Text, { color: theme.separator, children: '─'.repeat(cols) }) }), _jsxs(Box, { children: [_jsx(Text, { color: theme.brand, children: '› ' }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: loading ? '' : 'Ask a question or type / for commands...' }), ghostText && _jsx(Text, { color: theme.textMuted, children: ghostText })] }), stashedInput && _jsx(Box, { children: _jsx(Text, { color: theme.textMuted, children: " 1 stashed draft (Ctrl+S to restore)" }) }), _jsx(StatusBar, { model: currentModel, sources: stats.sources, articles: stats.articles, hint: loading ? undefined : 'Tab complete · ↑↓ history · Ctrl+E editor · Ctrl+S stash', temporaryStatus: tempStatus })] }));
|
|
607
|
+
};
|
|
608
|
+
function parseMultipleSources(args) {
|
|
609
|
+
const sources = [];
|
|
610
|
+
const regex = /"([^"]+)"|'([^']+)'|(\S+)/g;
|
|
611
|
+
let match;
|
|
612
|
+
while ((match = regex.exec(args)) !== null)
|
|
613
|
+
sources.push(match[1] || match[2] || match[3]);
|
|
614
|
+
return sources;
|
|
615
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function showBanner(): void;
|