@dominikcz/greg 0.9.27
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 +397 -0
- package/bin/greg.js +241 -0
- package/bin/init.js +351 -0
- package/bin/templates/docs/getting-started.md +47 -0
- package/bin/templates/docs/index.md +11 -0
- package/bin/templates/greg.config.js +39 -0
- package/bin/templates/greg.config.ts +38 -0
- package/bin/templates/index.html +16 -0
- package/bin/templates/src/App.svelte +5 -0
- package/bin/templates/src/app.css +20 -0
- package/bin/templates/src/main.js +9 -0
- package/bin/templates/svelte.config.js +1 -0
- package/bin/templates/tsconfig.json +21 -0
- package/bin/templates/vite.config.js +23 -0
- package/docs/__partials/markdown/examples/basic.md +4 -0
- package/docs/__partials/markdown/examples/diff.md +10 -0
- package/docs/__partials/markdown/examples/focus.md +5 -0
- package/docs/__partials/markdown/examples/language-title.md +3 -0
- package/docs/__partials/markdown/examples/line-highlighting.md +5 -0
- package/docs/__partials/markdown/examples/line-numbers.md +5 -0
- package/docs/__partials/note.md +4 -0
- package/docs/guide/__shared-warning.md +4 -0
- package/docs/guide/asset-handling.md +88 -0
- package/docs/guide/deploying.md +162 -0
- package/docs/guide/getting-started.md +334 -0
- package/docs/guide/index.md +23 -0
- package/docs/guide/localization.md +290 -0
- package/docs/guide/markdown/code.md +95 -0
- package/docs/guide/markdown/components-and-mermaid.md +43 -0
- package/docs/guide/markdown/containers.md +110 -0
- package/docs/guide/markdown/header-anchors.md +34 -0
- package/docs/guide/markdown/includes.md +84 -0
- package/docs/guide/markdown/index.md +20 -0
- package/docs/guide/markdown/inline-attributes.md +21 -0
- package/docs/guide/markdown/links-and-toc.md +64 -0
- package/docs/guide/markdown/math.md +54 -0
- package/docs/guide/markdown/syntax-highlighting.md +75 -0
- package/docs/guide/routing.md +150 -0
- package/docs/guide/using-svelte.md +88 -0
- package/docs/guide/versioning.md +281 -0
- package/docs/incompatibilities.md +48 -0
- package/docs/index.md +43 -0
- package/docs/reference/badge.md +100 -0
- package/docs/reference/carbon-ads.md +46 -0
- package/docs/reference/code-group.md +126 -0
- package/docs/reference/home-page.md +232 -0
- package/docs/reference/index.md +18 -0
- package/docs/reference/markdowndocs.md +275 -0
- package/docs/reference/outline.md +79 -0
- package/docs/reference/search.md +263 -0
- package/docs/reference/steps.md +200 -0
- package/docs/reference/team-page.md +189 -0
- package/docs/reference/theme.md +150 -0
- package/fakeDocsGenerator/generate_docs.js +310 -0
- package/package.json +92 -0
- package/scripts/build-versions.js +609 -0
- package/scripts/generate-static.js +79 -0
- package/scripts/render-markdown.js +420 -0
- package/src/lib/MarkdownDocs/AiChat.svelte +936 -0
- package/src/lib/MarkdownDocs/BackToTop.svelte +68 -0
- package/src/lib/MarkdownDocs/Breadcrumb.svelte +68 -0
- package/src/lib/MarkdownDocs/DocsNavigation.svelte +149 -0
- package/src/lib/MarkdownDocs/DocsSiteHeader.svelte +758 -0
- package/src/lib/MarkdownDocs/DocsVersionSwitcher.svelte +103 -0
- package/src/lib/MarkdownDocs/MarkdownDocs.svelte +2115 -0
- package/src/lib/MarkdownDocs/MarkdownRenderer.svelte +487 -0
- package/src/lib/MarkdownDocs/Outline.svelte +238 -0
- package/src/lib/MarkdownDocs/PrevNext.svelte +115 -0
- package/src/lib/MarkdownDocs/SearchModal.svelte +1241 -0
- package/src/lib/MarkdownDocs/TreeView.svelte +32 -0
- package/src/lib/MarkdownDocs/TreeViewItem.svelte +219 -0
- package/src/lib/MarkdownDocs/VersionOutdatedNotice.svelte +72 -0
- package/src/lib/MarkdownDocs/__tests__/codeDirectives.test.js +54 -0
- package/src/lib/MarkdownDocs/__tests__/common.test.js +41 -0
- package/src/lib/MarkdownDocs/__tests__/docsExamplesLint.test.js +77 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/__partial-basic.md +3 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/snippet.js +9 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/includes/part.md +11 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/includes/wrapper.md +5 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.js +8 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.md +5 -0
- package/src/lib/MarkdownDocs/__tests__/helpers.js +67 -0
- package/src/lib/MarkdownDocs/__tests__/localeUtils.test.js +204 -0
- package/src/lib/MarkdownDocs/__tests__/markdown.test.js +704 -0
- package/src/lib/MarkdownDocs/__tests__/markdownRendererRuntime.test.js +65 -0
- package/src/lib/MarkdownDocs/__tests__/searchIndexBuilder.test.js +117 -0
- package/src/lib/MarkdownDocs/__tests__/sqliteStore.test.js +202 -0
- package/src/lib/MarkdownDocs/__tests__/useRouter.test.js +16 -0
- package/src/lib/MarkdownDocs/ai/adapters/customAdapter.js +14 -0
- package/src/lib/MarkdownDocs/ai/adapters/customAdapter.ts +43 -0
- package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.js +81 -0
- package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.ts +116 -0
- package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.js +92 -0
- package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.ts +137 -0
- package/src/lib/MarkdownDocs/ai/aiProvider.ts +31 -0
- package/src/lib/MarkdownDocs/ai/characters.js +52 -0
- package/src/lib/MarkdownDocs/ai/characters.ts +69 -0
- package/src/lib/MarkdownDocs/ai/chunkStore.ts +25 -0
- package/src/lib/MarkdownDocs/ai/chunker.js +85 -0
- package/src/lib/MarkdownDocs/ai/chunker.ts +135 -0
- package/src/lib/MarkdownDocs/ai/docLinker.js +26 -0
- package/src/lib/MarkdownDocs/ai/docLinker.ts +36 -0
- package/src/lib/MarkdownDocs/ai/promptBuilder.js +33 -0
- package/src/lib/MarkdownDocs/ai/promptBuilder.ts +53 -0
- package/src/lib/MarkdownDocs/ai/ragPipeline.js +54 -0
- package/src/lib/MarkdownDocs/ai/ragPipeline.ts +106 -0
- package/src/lib/MarkdownDocs/ai/stores/memoryStore.js +88 -0
- package/src/lib/MarkdownDocs/ai/stores/memoryStore.ts +112 -0
- package/src/lib/MarkdownDocs/ai/stores/sqliteStore.ts +372 -0
- package/src/lib/MarkdownDocs/ai/types.ts +71 -0
- package/src/lib/MarkdownDocs/aiServer.js +288 -0
- package/src/lib/MarkdownDocs/codeDirectives.js +191 -0
- package/src/lib/MarkdownDocs/codeFenceInfo.js +45 -0
- package/src/lib/MarkdownDocs/codeGroup.ts +46 -0
- package/src/lib/MarkdownDocs/common.ts +47 -0
- package/src/lib/MarkdownDocs/docsUtils.js +281 -0
- package/src/lib/MarkdownDocs/index.plugins.js +22 -0
- package/src/lib/MarkdownDocs/layouts/LayoutDoc.svelte +8 -0
- package/src/lib/MarkdownDocs/layouts/LayoutHome.svelte +58 -0
- package/src/lib/MarkdownDocs/layouts/LayoutPage.svelte +9 -0
- package/src/lib/MarkdownDocs/loadGregConfig.js +82 -0
- package/src/lib/MarkdownDocs/localeUtils.ts +682 -0
- package/src/lib/MarkdownDocs/markdownRendererRuntime.ts +314 -0
- package/src/lib/MarkdownDocs/mermaidThemes.js +319 -0
- package/src/lib/MarkdownDocs/navigationUtils.js +22 -0
- package/src/lib/MarkdownDocs/rehypeCodeGroup.js +326 -0
- package/src/lib/MarkdownDocs/rehypeCodeTitle.js +96 -0
- package/src/lib/MarkdownDocs/rehypeToc.js +170 -0
- package/src/lib/MarkdownDocs/remarkCodeMeta.js +22 -0
- package/src/lib/MarkdownDocs/remarkContainers.js +329 -0
- package/src/lib/MarkdownDocs/remarkCustomAnchors.js +42 -0
- package/src/lib/MarkdownDocs/remarkEscapeSvelte.js +33 -0
- package/src/lib/MarkdownDocs/remarkGlobalComponents.js +65 -0
- package/src/lib/MarkdownDocs/remarkImports.js +461 -0
- package/src/lib/MarkdownDocs/remarkImportsBrowser.js +349 -0
- package/src/lib/MarkdownDocs/remarkInlineAttrs.js +95 -0
- package/src/lib/MarkdownDocs/remarkMathToHtml.js +138 -0
- package/src/lib/MarkdownDocs/searchIndexBuilder.js +497 -0
- package/src/lib/MarkdownDocs/searchServer.js +263 -0
- package/src/lib/MarkdownDocs/treeViewTypes.ts +11 -0
- package/src/lib/MarkdownDocs/useRouter.svelte.ts +114 -0
- package/src/lib/MarkdownDocs/useSplitter.svelte.ts +33 -0
- package/src/lib/MarkdownDocs/versioningDefaults.js +20 -0
- package/src/lib/MarkdownDocs/vitePluginAiServer.js +204 -0
- package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +153 -0
- package/src/lib/MarkdownDocs/vitePluginFrontmatter.js +109 -0
- package/src/lib/MarkdownDocs/vitePluginGregConfig.js +108 -0
- package/src/lib/MarkdownDocs/vitePluginSearchIndex.js +57 -0
- package/src/lib/MarkdownDocs/vitePluginSearchServer.js +190 -0
- package/src/lib/components/Badge.svelte +59 -0
- package/src/lib/components/Button.svelte +138 -0
- package/src/lib/components/CarbonAds.svelte +99 -0
- package/src/lib/components/CodeGroup.svelte +102 -0
- package/src/lib/components/Feature.svelte +209 -0
- package/src/lib/components/Features.svelte +123 -0
- package/src/lib/components/Hero.svelte +399 -0
- package/src/lib/components/Image.svelte +128 -0
- package/src/lib/components/Link.svelte +105 -0
- package/src/lib/components/SocialLink.svelte +84 -0
- package/src/lib/components/SocialLinks.svelte +33 -0
- package/src/lib/components/Steps.svelte +143 -0
- package/src/lib/components/TeamMember.svelte +273 -0
- package/src/lib/components/TeamMembers.svelte +81 -0
- package/src/lib/components/TeamPage.svelte +65 -0
- package/src/lib/components/TeamPageSection.svelte +108 -0
- package/src/lib/components/TeamPageTitle.svelte +89 -0
- package/src/lib/components/index.js +24 -0
- package/src/lib/portal/context.js +12 -0
- package/src/lib/portal/index.js +3 -0
- package/src/lib/portal/portal.svelte +14 -0
- package/src/lib/portal/slot.svelte +8 -0
- package/src/lib/scss/__code.scss +128 -0
- package/src/lib/scss/__containers.scss +99 -0
- package/src/lib/scss/__markdown.scss +447 -0
- package/src/lib/scss/__scrollbar.scss +60 -0
- package/src/lib/scss/__steps.scss +100 -0
- package/src/lib/scss/__theme.scss +238 -0
- package/src/lib/scss/__toc.scss +55 -0
- package/src/lib/scss/__utilities.scss +7 -0
- package/src/lib/scss/greg.scss +9 -0
- package/src/lib/spinner/spinner.svelte +42 -0
- package/svelte.config.js +146 -0
- package/types/index.d.ts +456 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export class OpenAiAdapter {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.name = 'openai';
|
|
4
|
+
const key = options.apiKey ?? process.env.GREG_OPENAI_API_KEY ?? '';
|
|
5
|
+
if (!key) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
'OpenAiAdapter: no API key provided. ' +
|
|
8
|
+
'Set GREG_OPENAI_API_KEY env variable or pass apiKey in options.',
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
this.apiKey = key;
|
|
12
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.openai.com/v1').replace(/\/$/, '');
|
|
13
|
+
this.model = options.model ?? 'gpt-4o-mini';
|
|
14
|
+
this.embeddingModel = options.embeddingModel ?? 'text-embedding-3-small';
|
|
15
|
+
this.timeout = options.timeout ?? 60000;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get headers() {
|
|
19
|
+
return {
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async chat(messages, options) {
|
|
26
|
+
const body = JSON.stringify({
|
|
27
|
+
model: options?.model ?? this.model,
|
|
28
|
+
messages,
|
|
29
|
+
temperature: options?.temperature ?? 0.3,
|
|
30
|
+
...(options?.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: this.headers,
|
|
40
|
+
body,
|
|
41
|
+
signal: controller.signal,
|
|
42
|
+
});
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const text = await response.text().catch(() => '');
|
|
49
|
+
throw new Error(`OpenAI chat error ${response.status}: ${text}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
if (data.error) throw new Error(`OpenAI error: ${data.error.message}`);
|
|
54
|
+
return data.choices?.[0]?.message?.content ?? '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async embed(texts) {
|
|
58
|
+
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: this.headers,
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model: this.embeddingModel,
|
|
63
|
+
input: texts,
|
|
64
|
+
encoding_format: 'float',
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const err = await response.text().catch(() => '');
|
|
70
|
+
throw new Error(`OpenAI embed error ${response.status}: ${err}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = await response.json();
|
|
74
|
+
if (data.error) throw new Error(`OpenAI embed error: ${data.error.message}`);
|
|
75
|
+
const sorted = (data.data ?? []).sort((a, b) => a.index - b.index);
|
|
76
|
+
return sorted.map(d => d.embedding);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async isAvailable() {
|
|
80
|
+
try {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
setTimeout(() => controller.abort(), 5000);
|
|
83
|
+
const response = await fetch(`${this.baseUrl}/models`, {
|
|
84
|
+
headers: this.headers,
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
return response.ok;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { AiProvider } from '../aiProvider.js';
|
|
2
|
+
import type { AiProviderOptions, ChatMessage } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export type OpenAiAdapterOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* API key.
|
|
7
|
+
* If omitted, read from the GREG_OPENAI_API_KEY environment variable.
|
|
8
|
+
* Never store the key in greg.config.js — use the env variable.
|
|
9
|
+
*/
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
/** Chat model to use. Default: gpt-4o-mini */
|
|
12
|
+
model?: string;
|
|
13
|
+
/** Embedding model. Default: text-embedding-3-small */
|
|
14
|
+
embeddingModel?: string;
|
|
15
|
+
/**
|
|
16
|
+
* API base URL — supports OpenAI-compatible endpoints
|
|
17
|
+
* (Groq, Together AI, local llama.cpp server, Mistral API, …).
|
|
18
|
+
* Default: https://api.openai.com/v1
|
|
19
|
+
*/
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
/** Request timeout in milliseconds. Default: 60_000 */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Adapter for OpenAI and OpenAI-compatible REST APIs.
|
|
27
|
+
*
|
|
28
|
+
* Set the API key via the GREG_OPENAI_API_KEY environment variable
|
|
29
|
+
* or pass it explicitly via options.apiKey.
|
|
30
|
+
*/
|
|
31
|
+
export class OpenAiAdapter implements AiProvider {
|
|
32
|
+
readonly name = 'openai';
|
|
33
|
+
private readonly baseUrl: string;
|
|
34
|
+
private readonly model: string;
|
|
35
|
+
private readonly embeddingModel: string;
|
|
36
|
+
private readonly timeout: number;
|
|
37
|
+
private readonly apiKey: string;
|
|
38
|
+
|
|
39
|
+
constructor(options: OpenAiAdapterOptions = {}) {
|
|
40
|
+
const key = options.apiKey ?? process.env.GREG_OPENAI_API_KEY ?? '';
|
|
41
|
+
if (!key) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'OpenAiAdapter: no API key provided. ' +
|
|
44
|
+
'Set GREG_OPENAI_API_KEY env variable or pass apiKey in options.',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
this.apiKey = key;
|
|
48
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.openai.com/v1').replace(/\/$/, '');
|
|
49
|
+
this.model = options.model ?? 'gpt-4o-mini';
|
|
50
|
+
this.embeddingModel = options.embeddingModel ?? 'text-embedding-3-small';
|
|
51
|
+
this.timeout = options.timeout ?? 60_000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private get headers(): Record<string, string> {
|
|
55
|
+
return {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async chat(messages: ChatMessage[], options?: AiProviderOptions): Promise<string> {
|
|
62
|
+
const body = JSON.stringify({
|
|
63
|
+
model: options?.model ?? this.model,
|
|
64
|
+
messages,
|
|
65
|
+
temperature: options?.temperature ?? 0.3,
|
|
66
|
+
...(options?.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
71
|
+
let response: Response;
|
|
72
|
+
try {
|
|
73
|
+
response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: this.headers,
|
|
76
|
+
body,
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const text = await response.text().catch(() => '');
|
|
85
|
+
throw new Error(`OpenAI chat error ${response.status}: ${text}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data = await response.json() as {
|
|
89
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
90
|
+
error?: { message?: string };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (data.error) throw new Error(`OpenAI error: ${data.error.message}`);
|
|
94
|
+
return data.choices?.[0]?.message?.content ?? '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
98
|
+
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: this.headers,
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
model: this.embeddingModel,
|
|
103
|
+
input: texts,
|
|
104
|
+
encoding_format: 'float',
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const err = await response.text().catch(() => '');
|
|
110
|
+
throw new Error(`OpenAI embed error ${response.status}: ${err}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const data = await response.json() as {
|
|
114
|
+
data?: Array<{ embedding: number[]; index: number }>;
|
|
115
|
+
error?: { message?: string };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (data.error) throw new Error(`OpenAI embed error: ${data.error.message}`);
|
|
119
|
+
// Sort by index to guarantee input order is preserved
|
|
120
|
+
const sorted = (data.data ?? []).sort((a, b) => a.index - b.index);
|
|
121
|
+
return sorted.map(d => d.embedding);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async isAvailable(): Promise<boolean> {
|
|
125
|
+
try {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
setTimeout(() => controller.abort(), 5_000);
|
|
128
|
+
const response = await fetch(`${this.baseUrl}/models`, {
|
|
129
|
+
headers: this.headers,
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
return response.ok;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ChatMessage, AiProviderOptions } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adapter interface for LLM providers.
|
|
5
|
+
*
|
|
6
|
+
* Implement this interface to add support for a new LLM backend (local or cloud).
|
|
7
|
+
* See: OllamaAdapter, OpenAiAdapter, CustomAdapter for reference implementations.
|
|
8
|
+
*/
|
|
9
|
+
export interface AiProvider {
|
|
10
|
+
/** Human-readable provider name (used in log output). */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Send a conversation and return the assistant's reply as a plain string.
|
|
15
|
+
* The implementor is responsible for all network / inference calls.
|
|
16
|
+
*/
|
|
17
|
+
chat(messages: ChatMessage[], options?: AiProviderOptions): Promise<string>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate dense embeddings for an array of text strings.
|
|
21
|
+
* Returns null-equivalent (empty arrays) or throws if unsupported.
|
|
22
|
+
* Optional — used only by vector-capable stores.
|
|
23
|
+
*/
|
|
24
|
+
embed?(texts: string[]): Promise<number[][]>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Quick availability probe — should resolve without throwing.
|
|
28
|
+
* Returns false if the provider/service is unreachable.
|
|
29
|
+
*/
|
|
30
|
+
isAvailable(): Promise<boolean>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const aiCharacters = [
|
|
2
|
+
{
|
|
3
|
+
id: 'professional',
|
|
4
|
+
name: 'Professional',
|
|
5
|
+
icon: '👔',
|
|
6
|
+
description: 'Precise, formal, technical answers',
|
|
7
|
+
systemPrompt: `You are a professional documentation assistant. Answer concisely, precisely and formally. Use appropriate technical terminology. Structure longer answers with headers or bullet points for readability. Always cite sources from the documentation context. Always respond in the same language as the user's question.`,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: 'friendly',
|
|
11
|
+
name: 'Friendly',
|
|
12
|
+
icon: '😊',
|
|
13
|
+
description: 'Warm, approachable explanations',
|
|
14
|
+
systemPrompt: `You are a friendly and enthusiastic documentation helper! Explain things in simple, everyday language. Use relatable analogies, concrete examples, and a warm conversational tone. Encourage the reader to explore. Always link back to the relevant documentation sections so they can dig deeper. Always respond in the same language as the user's question.`,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'pirate',
|
|
18
|
+
name: 'Pirate',
|
|
19
|
+
icon: '🏴☠️',
|
|
20
|
+
description: 'Arr! Knowledge on the high seas!',
|
|
21
|
+
systemPrompt: `Ye be a seasoned pirate-developer who sails the seas of code and documentation! Answer in pirate style — use pirate-flavoured exclamations and expressions naturally adapted to the language of the user's question (if they write in Polish, use Polish pirate flair; if English, use "arr", "ye", "ahoy"). Keep every piece of technical content 100% accurate. Call the documentation yer "treasure map" and call sections "buried treasure". Always mark yer sources like proper treasure coordinates so the crew can find 'em. Always respond in the same language as the user's question.`,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'sensei',
|
|
25
|
+
name: 'Sensei',
|
|
26
|
+
icon: '🥋',
|
|
27
|
+
description: 'Patient teacher, step-by-step guidance',
|
|
28
|
+
systemPrompt: `You are a wise and patient sensei. Guide the student step by step through concepts. Explain not just "how" but "why". Occasionally ask a gentle question to provoke independent thinking. Praise curiosity and effort. The documentation is your textbook — always reference it so the student can study further. Always respond in the same language as the user's question.`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'concise',
|
|
32
|
+
name: 'Concise',
|
|
33
|
+
icon: '✂️',
|
|
34
|
+
description: 'Maximum density, minimum words',
|
|
35
|
+
systemPrompt: `Answer in maximum 4 sentences or 5 bullet points. No filler words, no introductions, no conclusions. Facts and links only. Every answer must include at least one source citation. Always respond in the same language as the user's question.`,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function resolveCharacters(enabledIds, customCharacters) {
|
|
40
|
+
const all = [...aiCharacters];
|
|
41
|
+
for (const custom of customCharacters ?? []) {
|
|
42
|
+
const idx = all.findIndex(c => c.id === custom.id);
|
|
43
|
+
if (idx >= 0) {
|
|
44
|
+
all[idx] = custom;
|
|
45
|
+
} else {
|
|
46
|
+
all.push(custom);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!enabledIds || enabledIds.length === 0) return all;
|
|
51
|
+
return all.filter(c => enabledIds.includes(c.id));
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AiCharacter } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in AI characters (personas).
|
|
5
|
+
* Each character defines a distinct response style via its system prompt.
|
|
6
|
+
* Users can add custom characters or override defaults by matching the `id`.
|
|
7
|
+
*/
|
|
8
|
+
export const aiCharacters: AiCharacter[] = [
|
|
9
|
+
{
|
|
10
|
+
id: 'professional',
|
|
11
|
+
name: 'Professional',
|
|
12
|
+
icon: '👔',
|
|
13
|
+
description: 'Precise, formal, technical answers',
|
|
14
|
+
systemPrompt: `You are a professional documentation assistant. Answer concisely, precisely and formally. Use appropriate technical terminology. Structure longer answers with headers or bullet points for readability. Always cite sources from the documentation context. Always respond in the same language as the user's question.`,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'friendly',
|
|
18
|
+
name: 'Friendly',
|
|
19
|
+
icon: '😊',
|
|
20
|
+
description: 'Warm, approachable explanations',
|
|
21
|
+
systemPrompt: `You are a friendly and enthusiastic documentation helper! Explain things in simple, everyday language. Use relatable analogies, concrete examples, and a warm conversational tone. Encourage the reader to explore. Always link back to the relevant documentation sections so they can dig deeper. Always respond in the same language as the user's question.`,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'pirate',
|
|
25
|
+
name: 'Pirate',
|
|
26
|
+
icon: '🏴☠️',
|
|
27
|
+
description: "Arr! Knowledge on the high seas!",
|
|
28
|
+
systemPrompt: `Ye be a seasoned pirate-developer who sails the seas of code and documentation! Answer in pirate style — use pirate-flavoured exclamations and expressions naturally adapted to the language of the user's question (if they write in Polish, use Polish pirate flair; if English, use "arr", "ye", "ahoy"). Keep every piece of technical content 100% accurate. Call the documentation yer "treasure map" and call sections "buried treasure". Always mark yer sources like proper treasure coordinates so the crew can find 'em. Always respond in the same language as the user's question.`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'sensei',
|
|
32
|
+
name: 'Sensei',
|
|
33
|
+
icon: '🥋',
|
|
34
|
+
description: 'Patient teacher, step-by-step guidance',
|
|
35
|
+
systemPrompt: `You are a wise and patient sensei. Guide the student step by step through concepts. Explain not just "how" but "why". Occasionally ask a gentle question to provoke independent thinking. Praise curiosity and effort. The documentation is your textbook — always reference it so the student can study further. Always respond in the same language as the user's question.`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'concise',
|
|
39
|
+
name: 'Concise',
|
|
40
|
+
icon: '✂️',
|
|
41
|
+
description: 'Maximum density, minimum words',
|
|
42
|
+
systemPrompt: `Answer in maximum 4 sentences or 5 bullet points. No filler words, no introductions, no conclusions. Facts and links only. Every answer must include at least one source citation. Always respond in the same language as the user's question.`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Merge default characters with user-supplied custom characters,
|
|
48
|
+
* then filter to only the requested IDs.
|
|
49
|
+
*
|
|
50
|
+
* Custom characters with the same `id` as a default override the default.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveCharacters(
|
|
53
|
+
enabledIds?: string[],
|
|
54
|
+
customCharacters?: AiCharacter[],
|
|
55
|
+
): AiCharacter[] {
|
|
56
|
+
// Merge: defaults first, then custom (custom overrides by matching id)
|
|
57
|
+
const all = [...aiCharacters];
|
|
58
|
+
for (const custom of customCharacters ?? []) {
|
|
59
|
+
const idx = all.findIndex(c => c.id === custom.id);
|
|
60
|
+
if (idx >= 0) {
|
|
61
|
+
all[idx] = custom; // override
|
|
62
|
+
} else {
|
|
63
|
+
all.push(custom); // new character
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!enabledIds || enabledIds.length === 0) return all;
|
|
68
|
+
return all.filter(c => enabledIds.includes(c.id));
|
|
69
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DocChunk, RetrievedChunk } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backend interface for storing and searching documentation chunks.
|
|
5
|
+
*
|
|
6
|
+
* Implement this interface to plug in a different storage strategy:
|
|
7
|
+
* - MemoryStore — in-memory BM25 (default, zero deps)
|
|
8
|
+
* - Custom — Qdrant, Chroma, SQLite+vec, Pinecone, …
|
|
9
|
+
*/
|
|
10
|
+
export interface ChunkStore {
|
|
11
|
+
/**
|
|
12
|
+
* (Re-)build the store from a list of DocChunks.
|
|
13
|
+
* Replaces all previously indexed content.
|
|
14
|
+
*/
|
|
15
|
+
index(chunks: DocChunk[]): Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Search for the most relevant chunks matching `query`.
|
|
19
|
+
* Returns at most `limit` results ordered by descending relevance.
|
|
20
|
+
*/
|
|
21
|
+
search(query: string, limit?: number): Promise<RetrievedChunk[]>;
|
|
22
|
+
|
|
23
|
+
/** Total number of indexed chunks. */
|
|
24
|
+
size(): number;
|
|
25
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a flat search index (SearchEntry[]) into DocChunks for RAG.
|
|
3
|
+
*/
|
|
4
|
+
export function buildChunks(index, options = {}) {
|
|
5
|
+
const maxSize = options.maxChunkSize ?? 1800;
|
|
6
|
+
const overlap = Math.min(options.overlap ?? 120, Math.floor(maxSize / 4));
|
|
7
|
+
const chunks = [];
|
|
8
|
+
|
|
9
|
+
for (const entry of index) {
|
|
10
|
+
for (const section of entry.sections) {
|
|
11
|
+
const content = section.content.trim();
|
|
12
|
+
|
|
13
|
+
if (!content) {
|
|
14
|
+
if (section.heading) {
|
|
15
|
+
chunks.push({
|
|
16
|
+
pageId: entry.id,
|
|
17
|
+
pageTitle: entry.title,
|
|
18
|
+
sectionHeading: section.heading,
|
|
19
|
+
sectionAnchor: section.anchor,
|
|
20
|
+
content: section.heading,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (content.length <= maxSize) {
|
|
27
|
+
chunks.push({
|
|
28
|
+
pageId: entry.id,
|
|
29
|
+
pageTitle: entry.title,
|
|
30
|
+
sectionHeading: section.heading,
|
|
31
|
+
sectionAnchor: section.anchor,
|
|
32
|
+
content,
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const subChunks = splitText(content, maxSize, overlap);
|
|
38
|
+
for (let i = 0; i < subChunks.length; i++) {
|
|
39
|
+
chunks.push({
|
|
40
|
+
pageId: entry.id,
|
|
41
|
+
pageTitle: entry.title,
|
|
42
|
+
sectionHeading: subChunks.length > 1
|
|
43
|
+
? `${section.heading} (${i + 1}/${subChunks.length})`
|
|
44
|
+
: section.heading,
|
|
45
|
+
sectionAnchor: section.anchor,
|
|
46
|
+
content: subChunks[i],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return chunks;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function splitText(text, maxSize, overlap) {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
const sentences = text.split(/(?<=[.!?])\s+/);
|
|
58
|
+
let current = '';
|
|
59
|
+
|
|
60
|
+
for (const sentence of sentences) {
|
|
61
|
+
if (sentence.length > maxSize) {
|
|
62
|
+
if (current) {
|
|
63
|
+
chunks.push(current.trim());
|
|
64
|
+
current = '';
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i < sentence.length; i += maxSize - overlap) {
|
|
67
|
+
chunks.push(sentence.slice(i, i + maxSize));
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (current.length + sentence.length + 1 > maxSize && current.length > 0) {
|
|
73
|
+
chunks.push(current.trim());
|
|
74
|
+
current = current.slice(Math.max(0, current.length - overlap)) + ' ' + sentence;
|
|
75
|
+
} else {
|
|
76
|
+
current = current ? current + ' ' + sentence : sentence;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (current.trim()) {
|
|
81
|
+
chunks.push(current.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return chunks.length > 0 ? chunks : [text.slice(0, maxSize)];
|
|
85
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { DocChunk } from './types.js';
|
|
2
|
+
|
|
3
|
+
// Mirror of the SearchEntry type from searchIndexBuilder.js
|
|
4
|
+
// (kept local to avoid creating a circular dependency)
|
|
5
|
+
type SearchSection = {
|
|
6
|
+
heading: string;
|
|
7
|
+
anchor: string;
|
|
8
|
+
content: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type SearchEntry = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
sections: SearchSection[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ChunkerOptions = {
|
|
18
|
+
/**
|
|
19
|
+
* Approximate maximum characters per chunk.
|
|
20
|
+
* ~4 chars ≈ 1 token, so 1800 chars ≈ 450 tokens (safe for most models).
|
|
21
|
+
* Default: 1800
|
|
22
|
+
*/
|
|
23
|
+
maxChunkSize?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Character overlap between consecutive chunks from the same section.
|
|
26
|
+
* Helps avoid losing context at chunk boundaries.
|
|
27
|
+
* Default: 120
|
|
28
|
+
*/
|
|
29
|
+
overlap?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert a flat search index (SearchEntry[]) into DocChunks for RAG.
|
|
34
|
+
*
|
|
35
|
+
* Strategy:
|
|
36
|
+
* - Each section of a page maps to at least one chunk.
|
|
37
|
+
* - Sections whose content fits within `maxChunkSize` become a single chunk.
|
|
38
|
+
* - Larger sections are split into overlapping sub-chunks at sentence boundaries.
|
|
39
|
+
* - Heading-only sections (no body content) still produce a chunk so the LLM
|
|
40
|
+
* can reference that section's existence.
|
|
41
|
+
*/
|
|
42
|
+
export function buildChunks(
|
|
43
|
+
index: SearchEntry[],
|
|
44
|
+
options: ChunkerOptions = {},
|
|
45
|
+
): DocChunk[] {
|
|
46
|
+
const maxSize = options.maxChunkSize ?? 1800;
|
|
47
|
+
const overlap = Math.min(options.overlap ?? 120, Math.floor(maxSize / 4));
|
|
48
|
+
const chunks: DocChunk[] = [];
|
|
49
|
+
|
|
50
|
+
for (const entry of index) {
|
|
51
|
+
for (const section of entry.sections) {
|
|
52
|
+
const content = section.content.trim();
|
|
53
|
+
|
|
54
|
+
if (!content) {
|
|
55
|
+
// Heading-only entry — useful so AI knows the section exists
|
|
56
|
+
if (section.heading) {
|
|
57
|
+
chunks.push({
|
|
58
|
+
pageId: entry.id,
|
|
59
|
+
pageTitle: entry.title,
|
|
60
|
+
sectionHeading: section.heading,
|
|
61
|
+
sectionAnchor: section.anchor,
|
|
62
|
+
content: section.heading,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (content.length <= maxSize) {
|
|
69
|
+
chunks.push({
|
|
70
|
+
pageId: entry.id,
|
|
71
|
+
pageTitle: entry.title,
|
|
72
|
+
sectionHeading: section.heading,
|
|
73
|
+
sectionAnchor: section.anchor,
|
|
74
|
+
content,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Content too long — split into overlapping sub-chunks
|
|
80
|
+
const subChunks = splitText(content, maxSize, overlap);
|
|
81
|
+
for (let i = 0; i < subChunks.length; i++) {
|
|
82
|
+
chunks.push({
|
|
83
|
+
pageId: entry.id,
|
|
84
|
+
pageTitle: entry.title,
|
|
85
|
+
sectionHeading: subChunks.length > 1
|
|
86
|
+
? `${section.heading} (${i + 1}/${subChunks.length})`
|
|
87
|
+
: section.heading,
|
|
88
|
+
sectionAnchor: section.anchor,
|
|
89
|
+
content: subChunks[i],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return chunks;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Split text into overlapping chunks, preferring sentence boundaries.
|
|
100
|
+
* Falls back to character-level splitting when no sentence boundary is found.
|
|
101
|
+
*/
|
|
102
|
+
function splitText(text: string, maxSize: number, overlap: number): string[] {
|
|
103
|
+
const chunks: string[] = [];
|
|
104
|
+
// Split on sentence-ending punctuation followed by whitespace
|
|
105
|
+
const sentences = text.split(/(?<=[.!?])\s+/);
|
|
106
|
+
let current = '';
|
|
107
|
+
|
|
108
|
+
for (const sentence of sentences) {
|
|
109
|
+
if (sentence.length > maxSize) {
|
|
110
|
+
// Single sentence exceeds maxSize — hard split it
|
|
111
|
+
if (current) {
|
|
112
|
+
chunks.push(current.trim());
|
|
113
|
+
current = '';
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i < sentence.length; i += maxSize - overlap) {
|
|
116
|
+
chunks.push(sentence.slice(i, i + maxSize));
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (current.length + sentence.length + 1 > maxSize && current.length > 0) {
|
|
122
|
+
chunks.push(current.trim());
|
|
123
|
+
// Carry the tail of current chunk forward for overlap
|
|
124
|
+
current = current.slice(Math.max(0, current.length - overlap)) + ' ' + sentence;
|
|
125
|
+
} else {
|
|
126
|
+
current = current ? current + ' ' + sentence : sentence;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (current.trim()) {
|
|
131
|
+
chunks.push(current.trim());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return chunks.length > 0 ? chunks : [text.slice(0, maxSize)];
|
|
135
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function extractSources(chunks, limit = 6) {
|
|
2
|
+
const seen = new Set();
|
|
3
|
+
const sources = [];
|
|
4
|
+
|
|
5
|
+
for (const chunk of chunks) {
|
|
6
|
+
const key = `${chunk.pageId}#${chunk.sectionAnchor}`;
|
|
7
|
+
if (seen.has(key)) continue;
|
|
8
|
+
seen.add(key);
|
|
9
|
+
|
|
10
|
+
sources.push({
|
|
11
|
+
pageId: chunk.pageId,
|
|
12
|
+
pageTitle: chunk.pageTitle,
|
|
13
|
+
sectionHeading: chunk.sectionHeading,
|
|
14
|
+
sectionAnchor: chunk.sectionAnchor,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (sources.length >= limit) break;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return sources;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildSourceUrl(source, baseUrl = '') {
|
|
24
|
+
const anchor = source.sectionAnchor ? `#${source.sectionAnchor}` : '';
|
|
25
|
+
return `${baseUrl}${source.pageId}${anchor}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AiSource, RetrievedChunk } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract unique source citations from a list of retrieved chunks.
|
|
5
|
+
*
|
|
6
|
+
* Returns distinct (pageId, sectionAnchor) pairs — up to `limit` sources —
|
|
7
|
+
* ordered by chunk relevance score (highest first).
|
|
8
|
+
* These are rendered as the "Sources" list below the AI answer in the UI.
|
|
9
|
+
*/
|
|
10
|
+
export function extractSources(chunks: RetrievedChunk[], limit = 6): AiSource[] {
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
const sources: AiSource[] = [];
|
|
13
|
+
|
|
14
|
+
for (const chunk of chunks) {
|
|
15
|
+
const key = `${chunk.pageId}#${chunk.sectionAnchor}`;
|
|
16
|
+
if (seen.has(key)) continue;
|
|
17
|
+
seen.add(key);
|
|
18
|
+
|
|
19
|
+
sources.push({
|
|
20
|
+
pageId: chunk.pageId,
|
|
21
|
+
pageTitle: chunk.pageTitle,
|
|
22
|
+
sectionHeading: chunk.sectionHeading,
|
|
23
|
+
sectionAnchor: chunk.sectionAnchor,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (sources.length >= limit) break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return sources;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build a complete URL for a source (page path + optional anchor). */
|
|
33
|
+
export function buildSourceUrl(source: AiSource, baseUrl = ''): string {
|
|
34
|
+
const anchor = source.sectionAnchor ? `#${source.sectionAnchor}` : '';
|
|
35
|
+
return `${baseUrl}${source.pageId}${anchor}`;
|
|
36
|
+
}
|