@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
package/src/cli/commands/ask.ts
CHANGED
|
@@ -12,13 +12,12 @@ import type {
|
|
|
12
12
|
GenerationPort,
|
|
13
13
|
RerankPort,
|
|
14
14
|
} from '../../llm/types';
|
|
15
|
+
import {
|
|
16
|
+
generateGroundedAnswer,
|
|
17
|
+
processAnswerResult,
|
|
18
|
+
} from '../../pipeline/answer';
|
|
15
19
|
import { type HybridSearchDeps, searchHybrid } from '../../pipeline/hybrid';
|
|
16
|
-
import type {
|
|
17
|
-
AskOptions,
|
|
18
|
-
AskResult,
|
|
19
|
-
Citation,
|
|
20
|
-
SearchResult,
|
|
21
|
-
} from '../../pipeline/types';
|
|
20
|
+
import type { AskOptions, AskResult, Citation } from '../../pipeline/types';
|
|
22
21
|
import {
|
|
23
22
|
createVectorIndexPort,
|
|
24
23
|
type VectorIndexPort,
|
|
@@ -50,163 +49,6 @@ export type AskCommandResult =
|
|
|
50
49
|
| { success: true; data: AskResult }
|
|
51
50
|
| { success: false; error: string };
|
|
52
51
|
|
|
53
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
-
// Grounded Answer Generation
|
|
55
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
const ANSWER_PROMPT = `You are answering a question using ONLY the provided context blocks.
|
|
58
|
-
|
|
59
|
-
Rules you MUST follow:
|
|
60
|
-
1) Use ONLY facts stated in the context blocks. Do NOT use outside knowledge.
|
|
61
|
-
2) Every factual statement must include an inline citation like [1] or [2] referring to a context block.
|
|
62
|
-
3) If the context does not contain enough information to answer, reply EXACTLY:
|
|
63
|
-
"I don't have enough information in the provided sources to answer this question."
|
|
64
|
-
4) Do not cite sources you did not use. Do not invent citation numbers.
|
|
65
|
-
|
|
66
|
-
Question: {query}
|
|
67
|
-
|
|
68
|
-
Context blocks:
|
|
69
|
-
{context}
|
|
70
|
-
|
|
71
|
-
Write a concise answer (1-3 paragraphs).`;
|
|
72
|
-
|
|
73
|
-
/** Abstention message when LLM cannot ground answer */
|
|
74
|
-
const ABSTENTION_MESSAGE =
|
|
75
|
-
"I don't have enough information in the provided sources to answer this question.";
|
|
76
|
-
|
|
77
|
-
// Max characters per snippet to avoid blowing up prompt size
|
|
78
|
-
const MAX_SNIPPET_CHARS = 1500;
|
|
79
|
-
// Max number of sources to include in context
|
|
80
|
-
const MAX_CONTEXT_SOURCES = 5;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Extract VALID citation numbers from answer text.
|
|
84
|
-
* Only returns numbers in range [1, maxCitation].
|
|
85
|
-
* @param answer Answer text to parse
|
|
86
|
-
* @param maxCitation Maximum valid citation number
|
|
87
|
-
* @returns Sorted unique valid citation numbers (1-indexed)
|
|
88
|
-
*/
|
|
89
|
-
function extractValidCitationNumbers(
|
|
90
|
-
answer: string,
|
|
91
|
-
maxCitation: number
|
|
92
|
-
): number[] {
|
|
93
|
-
const nums = new Set<number>();
|
|
94
|
-
// Use fresh regex to avoid lastIndex issues
|
|
95
|
-
const re = /\[(\d+)\]/g;
|
|
96
|
-
const matches = answer.matchAll(re);
|
|
97
|
-
for (const match of matches) {
|
|
98
|
-
const n = Number(match[1]);
|
|
99
|
-
// Only accept valid citation numbers in range [1, maxCitation]
|
|
100
|
-
if (Number.isInteger(n) && n >= 1 && n <= maxCitation) {
|
|
101
|
-
nums.add(n);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return [...nums].sort((a, b) => a - b);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Filter citations to only those actually referenced in the answer.
|
|
109
|
-
* @param citations All citations provided to LLM
|
|
110
|
-
* @param validUsedNumbers Valid 1-indexed citation numbers from answer
|
|
111
|
-
*/
|
|
112
|
-
function filterCitationsByUse(
|
|
113
|
-
citations: Citation[],
|
|
114
|
-
validUsedNumbers: number[]
|
|
115
|
-
): Citation[] {
|
|
116
|
-
const usedSet = new Set(validUsedNumbers);
|
|
117
|
-
return citations.filter((_, idx) => usedSet.has(idx + 1));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Renumber citations in answer text to match filtered citations.
|
|
122
|
-
* E.g., if answer uses [2] and [5], renumber to [1] and [2].
|
|
123
|
-
* Invalid citations (not in validUsedNumbers) are removed.
|
|
124
|
-
*/
|
|
125
|
-
function renumberAnswerCitations(
|
|
126
|
-
answer: string,
|
|
127
|
-
validUsedNumbers: number[]
|
|
128
|
-
): string {
|
|
129
|
-
// Build mapping: old number -> new number (1-indexed)
|
|
130
|
-
const mapping = new Map<number, number>();
|
|
131
|
-
for (let i = 0; i < validUsedNumbers.length; i++) {
|
|
132
|
-
const oldNum = validUsedNumbers[i];
|
|
133
|
-
if (oldNum !== undefined) {
|
|
134
|
-
mapping.set(oldNum, i + 1);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Use fresh regex to avoid lastIndex issues
|
|
139
|
-
const re = /\[(\d+)\]/g;
|
|
140
|
-
// Replace valid [n] with renumbered [m], remove invalid citations
|
|
141
|
-
const replaced = answer.replace(re, (_match, numStr: string) => {
|
|
142
|
-
const oldNum = Number(numStr);
|
|
143
|
-
const newNum = mapping.get(oldNum);
|
|
144
|
-
// If not in mapping, remove the citation entirely
|
|
145
|
-
return newNum !== undefined ? `[${newNum}]` : '';
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// Clean up whitespace artifacts from removed citations
|
|
149
|
-
// e.g., "See [99] for" → "See for" → "See for"
|
|
150
|
-
return replaced.replace(/ {2,}/g, ' ').trim();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function generateGroundedAnswer(
|
|
154
|
-
genPort: GenerationPort,
|
|
155
|
-
query: string,
|
|
156
|
-
results: SearchResult[],
|
|
157
|
-
maxTokens: number
|
|
158
|
-
): Promise<{ answer: string; citations: Citation[] } | null> {
|
|
159
|
-
// Build context from top results with bounded snippet sizes
|
|
160
|
-
const contextParts: string[] = [];
|
|
161
|
-
const citations: Citation[] = [];
|
|
162
|
-
|
|
163
|
-
// Track citation index separately to ensure it matches context blocks exactly
|
|
164
|
-
let citationIndex = 0;
|
|
165
|
-
|
|
166
|
-
for (const r of results.slice(0, MAX_CONTEXT_SOURCES)) {
|
|
167
|
-
// Skip results with empty snippets
|
|
168
|
-
if (!r.snippet || r.snippet.trim().length === 0) {
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Cap snippet length to avoid prompt blowup
|
|
173
|
-
const snippet =
|
|
174
|
-
r.snippet.length > MAX_SNIPPET_CHARS
|
|
175
|
-
? `${r.snippet.slice(0, MAX_SNIPPET_CHARS)}...`
|
|
176
|
-
: r.snippet;
|
|
177
|
-
|
|
178
|
-
citationIndex += 1;
|
|
179
|
-
contextParts.push(`[${citationIndex}] ${snippet}`);
|
|
180
|
-
citations.push({
|
|
181
|
-
docid: r.docid,
|
|
182
|
-
uri: r.uri,
|
|
183
|
-
startLine: r.snippetRange?.startLine,
|
|
184
|
-
endLine: r.snippetRange?.endLine,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// If no valid context, can't generate answer
|
|
189
|
-
if (contextParts.length === 0) {
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const prompt = ANSWER_PROMPT.replace('{query}', query).replace(
|
|
194
|
-
'{context}',
|
|
195
|
-
contextParts.join('\n\n')
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
const result = await genPort.generate(prompt, {
|
|
199
|
-
temperature: 0,
|
|
200
|
-
maxTokens,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
if (!result.ok) {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return { answer: result.value, citations };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
52
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
211
53
|
// Command Implementation
|
|
212
54
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -327,7 +169,7 @@ export async function ask(
|
|
|
327
169
|
|
|
328
170
|
if (shouldGenerateAnswer && genPort) {
|
|
329
171
|
const maxTokens = options.maxAnswerTokens ?? 512;
|
|
330
|
-
const
|
|
172
|
+
const rawResult = await generateGroundedAnswer(
|
|
331
173
|
genPort,
|
|
332
174
|
query,
|
|
333
175
|
results,
|
|
@@ -335,7 +177,7 @@ export async function ask(
|
|
|
335
177
|
);
|
|
336
178
|
|
|
337
179
|
// Fail loudly if generation was requested but failed
|
|
338
|
-
if (!
|
|
180
|
+
if (!rawResult) {
|
|
339
181
|
return {
|
|
340
182
|
success: false,
|
|
341
183
|
error:
|
|
@@ -343,27 +185,10 @@ export async function ask(
|
|
|
343
185
|
};
|
|
344
186
|
}
|
|
345
187
|
|
|
346
|
-
//
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
maxCitation
|
|
351
|
-
);
|
|
352
|
-
const filteredCitations = filterCitationsByUse(
|
|
353
|
-
answerResult.citations,
|
|
354
|
-
validUsedNums
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
// Abstention guard: if no valid citations, LLM didn't ground the answer
|
|
358
|
-
if (validUsedNums.length === 0 || filteredCitations.length === 0) {
|
|
359
|
-
answer = ABSTENTION_MESSAGE;
|
|
360
|
-
citations = [];
|
|
361
|
-
} else {
|
|
362
|
-
// Renumber citations in answer to match filtered list (e.g., [2],[5] -> [1],[2])
|
|
363
|
-
// Invalid citations are removed from the answer text
|
|
364
|
-
answer = renumberAnswerCitations(answerResult.answer, validUsedNums);
|
|
365
|
-
citations = filteredCitations;
|
|
366
|
-
}
|
|
188
|
+
// Process answer: extract valid citations, filter, renumber
|
|
189
|
+
const processed = processAnswerResult(rawResult);
|
|
190
|
+
answer = processed.answer;
|
|
191
|
+
citations = processed.citations;
|
|
367
192
|
answerGenerated = true;
|
|
368
193
|
}
|
|
369
194
|
|
|
@@ -18,6 +18,8 @@ import type { DownloadProgress, ModelType } from '../../../llm/types';
|
|
|
18
18
|
export interface ModelsPullOptions {
|
|
19
19
|
/** Override config path */
|
|
20
20
|
configPath?: string;
|
|
21
|
+
/** Override config object (takes precedence over configPath) */
|
|
22
|
+
config?: import('../../../config/types').Config;
|
|
21
23
|
/** Pull all models */
|
|
22
24
|
all?: boolean;
|
|
23
25
|
/** Pull embedding model */
|
|
@@ -81,10 +83,13 @@ function getTypesToPull(options: ModelsPullOptions): ModelType[] {
|
|
|
81
83
|
export async function modelsPull(
|
|
82
84
|
options: ModelsPullOptions = {}
|
|
83
85
|
): Promise<ModelsPullResult> {
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Use provided config, or load from disk (use defaults if not initialized)
|
|
87
|
+
let config = options.config;
|
|
88
|
+
if (!config) {
|
|
89
|
+
const { createDefaultConfig } = await import('../../../config');
|
|
90
|
+
const configResult = await loadConfig(options.configPath);
|
|
91
|
+
config = configResult.ok ? configResult.value : createDefaultConfig();
|
|
92
|
+
}
|
|
88
93
|
|
|
89
94
|
const preset = getActivePreset(config);
|
|
90
95
|
const cache = new ModelCache(getModelsCachePath());
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gno serve command implementation.
|
|
3
|
+
* Start web UI server.
|
|
4
|
+
*
|
|
5
|
+
* @module src/cli/commands/serve
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { ServeOptions, ServeResult } from '../../serve';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute gno serve command.
|
|
12
|
+
* Server runs until SIGINT/SIGTERM.
|
|
13
|
+
*/
|
|
14
|
+
export async function serve(
|
|
15
|
+
options: import('../../serve').ServeOptions = {}
|
|
16
|
+
): Promise<import('../../serve').ServeResult> {
|
|
17
|
+
const { startServer } = await import('../../serve');
|
|
18
|
+
return startServer(options);
|
|
19
|
+
}
|
package/src/cli/program.ts
CHANGED
|
@@ -149,6 +149,7 @@ export function createProgram(): Command {
|
|
|
149
149
|
wireRetrievalCommands(program);
|
|
150
150
|
wireMcpCommand(program);
|
|
151
151
|
wireSkillCommands(program);
|
|
152
|
+
wireServeCommand(program);
|
|
152
153
|
|
|
153
154
|
// Add docs/support links to help footer
|
|
154
155
|
program.addHelpText(
|
|
@@ -1328,3 +1329,30 @@ function wireSkillCommands(program: Command): void {
|
|
|
1328
1329
|
});
|
|
1329
1330
|
});
|
|
1330
1331
|
}
|
|
1332
|
+
|
|
1333
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1334
|
+
// Serve Command (web UI)
|
|
1335
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1336
|
+
|
|
1337
|
+
function wireServeCommand(program: Command): void {
|
|
1338
|
+
program
|
|
1339
|
+
.command('serve')
|
|
1340
|
+
.description('Start web UI server')
|
|
1341
|
+
.option('-p, --port <num>', 'port to listen on', '3000')
|
|
1342
|
+
.action(async (cmdOpts: Record<string, unknown>) => {
|
|
1343
|
+
const globals = getGlobals();
|
|
1344
|
+
const port = parsePositiveInt('port', cmdOpts.port);
|
|
1345
|
+
|
|
1346
|
+
const { serve } = await import('./commands/serve.js');
|
|
1347
|
+
const result = await serve({
|
|
1348
|
+
port,
|
|
1349
|
+
configPath: globals.config,
|
|
1350
|
+
index: globals.index,
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
if (!result.success) {
|
|
1354
|
+
throw new CliError('RUNTIME', result.error ?? 'Server failed to start');
|
|
1355
|
+
}
|
|
1356
|
+
// Server runs until SIGINT/SIGTERM - no output needed here
|
|
1357
|
+
});
|
|
1358
|
+
}
|
package/src/llm/registry.ts
CHANGED
|
@@ -19,7 +19,9 @@ import type { ModelType } from './types';
|
|
|
19
19
|
export function getModelConfig(config: Config): ModelConfig {
|
|
20
20
|
return {
|
|
21
21
|
activePreset: config.models?.activePreset ?? 'balanced',
|
|
22
|
-
presets: config.models?.presets
|
|
22
|
+
presets: config.models?.presets?.length
|
|
23
|
+
? config.models.presets
|
|
24
|
+
: DEFAULT_MODEL_PRESETS,
|
|
23
25
|
loadTimeout: config.models?.loadTimeout ?? 60_000,
|
|
24
26
|
inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
|
|
25
27
|
warmModelTtl: config.models?.warmModelTtl ?? 300_000,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grounded answer generation.
|
|
3
|
+
* Shared between CLI ask command and web API.
|
|
4
|
+
*
|
|
5
|
+
* @module src/pipeline/answer
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GenerationPort } from '../llm/types';
|
|
9
|
+
import type { Citation, SearchResult } from './types';
|
|
10
|
+
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// Constants
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const ANSWER_PROMPT = `You are answering a question using ONLY the provided context blocks.
|
|
16
|
+
|
|
17
|
+
Rules you MUST follow:
|
|
18
|
+
1) Use ONLY facts stated in the context blocks. Do NOT use outside knowledge.
|
|
19
|
+
2) Every factual statement must include an inline citation like [1] or [2] referring to a context block.
|
|
20
|
+
3) If the context does not contain enough information to answer, reply EXACTLY:
|
|
21
|
+
"I don't have enough information in the provided sources to answer this question."
|
|
22
|
+
4) Do not cite sources you did not use. Do not invent citation numbers.
|
|
23
|
+
|
|
24
|
+
Question: {query}
|
|
25
|
+
|
|
26
|
+
Context blocks:
|
|
27
|
+
{context}
|
|
28
|
+
|
|
29
|
+
Write a concise answer (1-3 paragraphs).`;
|
|
30
|
+
|
|
31
|
+
/** Abstention message when LLM cannot ground answer */
|
|
32
|
+
export const ABSTENTION_MESSAGE =
|
|
33
|
+
"I don't have enough information in the provided sources to answer this question.";
|
|
34
|
+
|
|
35
|
+
/** Max characters per snippet to avoid blowing up prompt size */
|
|
36
|
+
const MAX_SNIPPET_CHARS = 1500;
|
|
37
|
+
|
|
38
|
+
/** Max number of sources to include in context */
|
|
39
|
+
const MAX_CONTEXT_SOURCES = 5;
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Citation Processing
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract VALID citation numbers from answer text.
|
|
47
|
+
* Only returns numbers in range [1, maxCitation].
|
|
48
|
+
*/
|
|
49
|
+
export function extractValidCitationNumbers(
|
|
50
|
+
answer: string,
|
|
51
|
+
maxCitation: number
|
|
52
|
+
): number[] {
|
|
53
|
+
const nums = new Set<number>();
|
|
54
|
+
const re = /\[(\d+)\]/g;
|
|
55
|
+
const matches = answer.matchAll(re);
|
|
56
|
+
for (const match of matches) {
|
|
57
|
+
const n = Number(match[1]);
|
|
58
|
+
if (Number.isInteger(n) && n >= 1 && n <= maxCitation) {
|
|
59
|
+
nums.add(n);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...nums].sort((a, b) => a - b);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Filter citations to only those actually referenced in the answer.
|
|
67
|
+
*/
|
|
68
|
+
export function filterCitationsByUse(
|
|
69
|
+
citations: Citation[],
|
|
70
|
+
validUsedNumbers: number[]
|
|
71
|
+
): Citation[] {
|
|
72
|
+
const usedSet = new Set(validUsedNumbers);
|
|
73
|
+
return citations.filter((_, idx) => usedSet.has(idx + 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Renumber citations in answer text to match filtered citations.
|
|
78
|
+
* E.g., if answer uses [2] and [5], renumber to [1] and [2].
|
|
79
|
+
* Invalid citations (not in validUsedNumbers) are removed.
|
|
80
|
+
*/
|
|
81
|
+
export function renumberAnswerCitations(
|
|
82
|
+
answer: string,
|
|
83
|
+
validUsedNumbers: number[]
|
|
84
|
+
): string {
|
|
85
|
+
const mapping = new Map<number, number>();
|
|
86
|
+
for (let i = 0; i < validUsedNumbers.length; i++) {
|
|
87
|
+
const oldNum = validUsedNumbers[i];
|
|
88
|
+
if (oldNum !== undefined) {
|
|
89
|
+
mapping.set(oldNum, i + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const re = /\[(\d+)\]/g;
|
|
94
|
+
const replaced = answer.replace(re, (_match, numStr: string) => {
|
|
95
|
+
const oldNum = Number(numStr);
|
|
96
|
+
const newNum = mapping.get(oldNum);
|
|
97
|
+
return newNum !== undefined ? `[${newNum}]` : '';
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return replaced.replace(/ {2,}/g, ' ').trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
// Answer Generation
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export interface AnswerGenerationResult {
|
|
108
|
+
answer: string;
|
|
109
|
+
citations: Citation[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate a grounded answer from search results.
|
|
114
|
+
* Returns null if no valid context or generation fails.
|
|
115
|
+
*/
|
|
116
|
+
export async function generateGroundedAnswer(
|
|
117
|
+
genPort: GenerationPort,
|
|
118
|
+
query: string,
|
|
119
|
+
results: SearchResult[],
|
|
120
|
+
maxTokens: number
|
|
121
|
+
): Promise<AnswerGenerationResult | null> {
|
|
122
|
+
const contextParts: string[] = [];
|
|
123
|
+
const citations: Citation[] = [];
|
|
124
|
+
let citationIndex = 0;
|
|
125
|
+
|
|
126
|
+
for (const r of results.slice(0, MAX_CONTEXT_SOURCES)) {
|
|
127
|
+
if (!r.snippet || r.snippet.trim().length === 0) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const snippet =
|
|
132
|
+
r.snippet.length > MAX_SNIPPET_CHARS
|
|
133
|
+
? `${r.snippet.slice(0, MAX_SNIPPET_CHARS)}...`
|
|
134
|
+
: r.snippet;
|
|
135
|
+
|
|
136
|
+
citationIndex += 1;
|
|
137
|
+
contextParts.push(`[${citationIndex}] ${snippet}`);
|
|
138
|
+
citations.push({
|
|
139
|
+
docid: r.docid,
|
|
140
|
+
uri: r.uri,
|
|
141
|
+
startLine: r.snippetRange?.startLine,
|
|
142
|
+
endLine: r.snippetRange?.endLine,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (contextParts.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const prompt = ANSWER_PROMPT.replace('{query}', query).replace(
|
|
151
|
+
'{context}',
|
|
152
|
+
contextParts.join('\n\n')
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const result = await genPort.generate(prompt, {
|
|
156
|
+
temperature: 0,
|
|
157
|
+
maxTokens,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!result.ok) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { answer: result.value, citations };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Process raw answer result into final answer with cleaned citations.
|
|
169
|
+
* Extracts valid citations, filters unused ones, and renumbers.
|
|
170
|
+
*/
|
|
171
|
+
export function processAnswerResult(rawResult: AnswerGenerationResult): {
|
|
172
|
+
answer: string;
|
|
173
|
+
citations: Citation[];
|
|
174
|
+
} {
|
|
175
|
+
const maxCitation = rawResult.citations.length;
|
|
176
|
+
const validUsedNums = extractValidCitationNumbers(
|
|
177
|
+
rawResult.answer,
|
|
178
|
+
maxCitation
|
|
179
|
+
);
|
|
180
|
+
const filteredCitations = filterCitationsByUse(
|
|
181
|
+
rawResult.citations,
|
|
182
|
+
validUsedNums
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (validUsedNums.length === 0 || filteredCitations.length === 0) {
|
|
186
|
+
return { answer: ABSTENTION_MESSAGE, citations: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const answer = renumberAnswerCitations(rawResult.answer, validUsedNums);
|
|
190
|
+
return { answer, citations: filteredCitations };
|
|
191
|
+
}
|
|
@@ -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
|