@dominikcz/greg 0.9.41 → 0.9.45

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/bin/greg.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * greg CLI
4
4
  *
@@ -84,16 +84,20 @@ function help() {
84
84
 
85
85
  function run(cmd, extraArgs = [], options = {}) {
86
86
  const { exit = true } = options;
87
+ const envBase = { ...process.env, ...(options.env || {}) };
88
+ const pathValue = envBase.PATH ?? envBase.Path ?? process.env.PATH ?? process.env.Path ?? '';
89
+ const resolvedPath = resolve(__dirname, '../node_modules/.bin') +
90
+ (process.platform === 'win32' ? ';' : ':') +
91
+ pathValue;
87
92
  const result = spawnSync(cmd, extraArgs, {
88
93
  stdio: 'inherit',
89
94
  shell: true,
90
95
  // Ensure the local node_modules/.bin is on the PATH so that
91
96
  // the project-local vite binary is preferred.
92
97
  env: {
93
- ...process.env,
94
- PATH: resolve(__dirname, '../node_modules/.bin') +
95
- (process.platform === 'win32' ? ';' : ':') +
96
- (process.env.PATH ?? ''),
98
+ ...envBase,
99
+ PATH: resolvedPath,
100
+ Path: resolvedPath,
97
101
  },
98
102
  });
99
103
  const status = result.status ?? 0;
@@ -103,13 +107,17 @@ function run(cmd, extraArgs = [], options = {}) {
103
107
 
104
108
  function runNodeScript(scriptPath, extraArgs = [], options = {}) {
105
109
  const { exit = true } = options;
110
+ const envBase = { ...process.env, ...(options.env || {}) };
111
+ const pathValue = envBase.PATH ?? envBase.Path ?? process.env.PATH ?? process.env.Path ?? '';
112
+ const resolvedPath = resolve(__dirname, '../node_modules/.bin') +
113
+ (process.platform === 'win32' ? ';' : ':') +
114
+ pathValue;
106
115
  const result = spawnSync(process.execPath, [scriptPath, ...extraArgs], {
107
116
  stdio: 'inherit',
108
117
  env: {
109
- ...process.env,
110
- PATH: resolve(__dirname, '../node_modules/.bin') +
111
- (process.platform === 'win32' ? ';' : ':') +
112
- (process.env.PATH ?? ''),
118
+ ...envBase,
119
+ PATH: resolvedPath,
120
+ Path: resolvedPath,
113
121
  },
114
122
  });
115
123
  const status = result.status ?? 0;
@@ -122,6 +130,20 @@ function printElapsedSeconds(startMs) {
122
130
  console.log(`\n${infoTag()} ${color(`Done in ${elapsed}s`, '32')}`);
123
131
  }
124
132
 
133
+ function parseBooleanFlag(value) {
134
+ if (value == null) return false;
135
+ const normalized = String(value).trim().toLowerCase();
136
+ if (!normalized) return false;
137
+ return !['0', 'false', 'no', 'off'].includes(normalized);
138
+ }
139
+
140
+ function hasSingleBuildFlag(passthroughArgs) {
141
+ if (passthroughArgs.includes('--single')) return true;
142
+ if (parseBooleanFlag(process.env.npm_config_single)) return true;
143
+ if (parseBooleanFlag(process.env.GREG_SINGLE_BUILD)) return true;
144
+ return false;
145
+ }
146
+
125
147
  async function hasVersioningBuildConfig() {
126
148
  try {
127
149
  const config = await loadGregConfig();
@@ -161,12 +183,18 @@ switch (command) {
161
183
  child.on('exit', code => process.exit(code ?? 0));
162
184
  break;
163
185
  }
164
- case 'dev':
165
- run('vite', args);
186
+ case 'dev': {
187
+ const existingOpts = process.env.NODE_OPTIONS ?? '';
188
+ const heapFlag = '--max-old-space-size=4096';
189
+ const nodeOptions = existingOpts.includes('max-old-space-size')
190
+ ? existingOpts
191
+ : `${existingOpts} ${heapFlag}`.trim();
192
+ run('vite', args, { env: { NODE_OPTIONS: nodeOptions } });
166
193
  break;
194
+ }
167
195
  case 'build': {
168
196
  const startedAt = Date.now();
169
- const forceSingle = args.includes('--single');
197
+ const forceSingle = hasSingleBuildFlag(args);
170
198
  const passthroughArgs = args.filter((a) => a !== '--single');
171
199
  const shouldBuildVersions = !forceSingle && await hasVersioningBuildConfig();
172
200
 
@@ -23,6 +23,7 @@ dist/
23
23
  index.html
24
24
  assets/ ← JS, CSS, fonts (content-hashed)
25
25
  search-index.json
26
+ search-index/ ← sharded index files + manifest for greg search-server (optional)
26
27
  <any public/ files>
27
28
  ```
28
29
 
@@ -18,7 +18,7 @@ Internal links use SPA navigation and update the page without a full reload:
18
18
  Output:
19
19
 
20
20
  [Getting Started](../getting-started)
21
- [API reference](/reference/api)
21
+ [API reference](/reference/markdowndocs)
22
22
  [Back to top](#)
23
23
 
24
24
  `.md` and `.html` extensions are stripped automatically.
@@ -70,9 +70,9 @@ Each non-empty line inside the block becomes one flex item.
70
70
 
71
71
  ```md
72
72
  [[
73
- ![Freezer A](/know-how/gastro-orlen/mroznia/freezer-a.webp)
74
- ![Freezer B](/know-how/gastro-orlen/mroznia/freezer-b.webp)
75
- ![Freezer C](/know-how/gastro-orlen/mroznia/freezer-c.webp)
73
+ ![img A](/some/path/img-a.webp)
74
+ ![img B](/some/path/img-b.webp)
75
+ ![img C](/some/path/img-c.webp)
76
76
  ]]
77
77
  ```
78
78
 
@@ -268,7 +268,7 @@ const gregConfig = {
268
268
  branches inline. This keeps runtime rendering predictable and makes new
269
269
  features easier to add.
270
270
 
271
- ### 1) Component hydration registry
271
+ ### 1 Component hydration registry
272
272
 
273
273
  `COMPONENT_REGISTRY` maps a tag name to:
274
274
 
@@ -278,7 +278,7 @@ features easier to add.
278
278
  This is used for custom component tags like `badge`, `button`, `image`,
279
279
  `link`, and `codegroup`.
280
280
 
281
- ### 2) Markdown plugin registries
281
+ ### 2 Markdown plugin registries
282
282
 
283
283
  The markdown pipeline is assembled from two ordered registries:
284
284
 
@@ -293,7 +293,7 @@ These cover, among others:
293
293
  - Steps normalization (`rehypeStepsWrapper`)
294
294
  - headings/TOC (`rehypeSlug`, `rehypeAutolinkHeadings`, `rehypeTocPlaceholder`)
295
295
 
296
- ### 3) Render handlers (post-HTML stage)
296
+ ### 3 Render handlers (post-HTML stage)
297
297
 
298
298
  After HTML is rendered, handlers are executed from ordered lists:
299
299
 
@@ -77,6 +77,60 @@ search: {
77
77
  }
78
78
  ```
79
79
 
80
+ ### Server tuning for large indices
81
+
82
+ For very large documentation sets, tune standalone `greg search-server` directly
83
+ from `greg.config.js`:
84
+
85
+ ```js
86
+ search: {
87
+ provider: 'server',
88
+ serverUrl: 'http://localhost:3100/api/search',
89
+ server: {
90
+ preloadShards: true,
91
+ maxLoadedShards: 32,
92
+ shardCandidates: 6,
93
+ }
94
+ }
95
+ ```
96
+
97
+ - `preloadShards` (default: `true`) preloads shard indexes at startup to reduce query latency.
98
+ - `maxLoadedShards` limits how many shard Fuse indexes stay in memory.
99
+ - `shardCandidates` controls how many likely shards are searched first per query.
100
+
101
+ Resolution order is:
102
+
103
+ 1. CLI flags (`greg search-server --...`)
104
+ 2. Environment variables (`GREG_SEARCH_*`)
105
+ 3. `greg.config.js > search.server`
106
+ 4. Built-in defaults
107
+
108
+ Supported runtime overrides:
109
+
110
+ - `--preload-shards` / `GREG_SEARCH_PRELOAD_SHARDS`
111
+ - `--max-loaded-shards` / `GREG_SEARCH_MAX_LOADED_SHARDS`
112
+ - `--shard-candidates` / `GREG_SEARCH_SHARD_CANDIDATES`
113
+
114
+ Build output now includes size logs for the generated search assets (full index + shards).
115
+
116
+ You can tune shard generation at build time with `GREG_SEARCH_SHARDS`:
117
+
118
+ - `GREG_SEARCH_SHARDS=32` (default) generates 32 shard files.
119
+ - `GREG_SEARCH_SHARDS=64` increases shard count.
120
+ - `GREG_SEARCH_SHARDS=0` (or `false` / `off` / `no`) disables shard generation and keeps only `search-index.json`.
121
+
122
+ For very large documentation sets, you may also need a higher Node.js heap limit during build:
123
+
124
+ ```bash
125
+ NODE_OPTIONS=--max-old-space-size=8192 npm run build
126
+ ```
127
+
128
+ On Windows PowerShell:
129
+
130
+ ```powershell
131
+ $env:NODE_OPTIONS='--max-old-space-size=8192'; npm run build
132
+ ```
133
+
80
134
  In production, you will usually place the search server behind a reverse proxy
81
135
  so your frontend can keep using `/api/search`.
82
136
 
@@ -140,9 +194,9 @@ Results are scored by Fuse.js using weighted fields:
140
194
 
141
195
  | Field | Weight |
142
196
  | ----------------- | ------ |
143
- | Page title | 3× |
144
- | Section heading | 2× |
145
- | Section body text | 1× |
197
+ | Page title | 3× |
198
+ | Section heading | 2× |
199
+ | Section body text | 1× |
146
200
 
147
201
  A fuzzy threshold of `0.4` is used — tighter than the default, so only genuine
148
202
  matches surface. `ignoreLocation: true` means the match can appear anywhere in
@@ -177,7 +231,7 @@ search: {
177
231
  ai: {
178
232
  enabled: true,
179
233
  provider: 'ollama', // or 'openai'
180
- ollama: { model: 'phi4' },
234
+ ollama: { model: 'gpt-oss' },
181
235
  }
182
236
  }
183
237
  ```
@@ -185,6 +239,15 @@ search: {
185
239
  See the [Getting started guide](/docs/guide/getting-started) for the required Vite plugin
186
240
  (`vitePluginAiServer`) and the production AI server setup.
187
241
 
242
+ ### AI runtime storage
243
+
244
+ In dev/preview (`vitePluginAiServer`), Greg uses an in-memory vector store
245
+ (`MemoryStore`). Chunks are rebuilt on startup and whenever Markdown files change,
246
+ so indexed AI data is not persisted across process restarts.
247
+
248
+ For production, prefer the standalone `greg ai-server`, which can use a persistent
249
+ SQLite-backed store (`search.ai.store = 'sqlite'`).
250
+
188
251
 
189
252
  ### AI characters (personas)
190
253
 
@@ -192,11 +255,11 @@ Greg ships five built-in personas that users can pick in the chat UI:
192
255
 
193
256
  | ID | Name | Icon | Description |
194
257
  | -------------- | ------------ | ---- | ---------------------------------- |
195
- | `professional` | Professional | 👔 | Precise, formal, technical answers |
196
- | `friendly` | Friendly | 😊 | Warm, approachable explanations |
258
+ | `professional` | Professional | 👔 | Precise, formal, technical answers |
259
+ | `friendly` | Friendly | 😊 | Warm, approachable explanations |
197
260
  | `pirate` | Pirate | 🏴‍☠️ | Arr! Knowledge on the high seas! |
198
- | `sensei` | Sensei | 🥋 | Patient teacher, step-by-step |
199
- | `concise` | Concise | ✂️ | Maximum density, minimum words |
261
+ | `sensei` | Sensei | 🥋 | Patient teacher, step-by-step |
262
+ | `concise` | Concise | ✂️ | Maximum density, minimum words |
200
263
 
201
264
  #### Limiting which characters are available
202
265
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dominikcz/greg",
3
- "version": "0.9.41",
3
+ "version": "0.9.45",
4
4
  "type": "module",
5
5
  "types": "./types/index.d.ts",
6
6
  "bin": {
@@ -44,23 +44,23 @@
44
44
  "fakedocs": "node fakeDocsGenerator/generate_docs.js"
45
45
  },
46
46
  "devDependencies": {
47
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
47
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
48
48
  "@types/better-sqlite3": "^7.6.13",
49
49
  "@types/js-yaml": "^4.0.9",
50
- "better-sqlite3": "^12.6.2",
51
- "sqlite-vec": "^0.1.7-alpha.2",
52
- "svelte": "^5.53.7",
53
- "vite": "^7.3.1",
54
- "vitest": "^4.0.18"
50
+ "better-sqlite3": "^12.8.0",
51
+ "sqlite-vec": "^0.1.9",
52
+ "svelte": "^5.55.1",
53
+ "vite": "^8.0.3",
54
+ "vitest": "^4.1.2"
55
55
  },
56
56
  "dependencies": {
57
- "@clack/prompts": "^0.9.1",
58
- "@lucide/svelte": "^0.575.0",
59
- "esbuild": "^0.25.10",
60
- "fuse.js": "^7.1.0",
57
+ "@clack/prompts": "^1.2.0",
58
+ "@lucide/svelte": "^1.7.0",
59
+ "esbuild": "^0.27.0",
60
+ "fuse.js": "^7.2.0",
61
61
  "js-yaml": "^4.1.1",
62
- "mdsvex": "^0.12.6",
63
- "mermaid": "^11.12.3",
62
+ "mdsvex": "^0.12.7",
63
+ "mermaid": "^11.14.0",
64
64
  "rehype-autolink-headings": "^7.1.0",
65
65
  "rehype-mathjax": "^7.1.0",
66
66
  "rehype-slug": "^6.0.0",
@@ -69,13 +69,13 @@
69
69
  "remark-math": "^6.0.0",
70
70
  "remark-parse": "^11.0.0",
71
71
  "remark-rehype": "^11.1.2",
72
- "sass-embedded": "^1.97.3",
73
- "shiki": "^4.0.1",
72
+ "sass-embedded": "^1.99.0",
73
+ "shiki": "^4.0.2",
74
74
  "unified": "^11.0.5",
75
75
  "unist-util-visit": "^5.1.0"
76
76
  },
77
77
  "peerDependencies": {
78
- "better-sqlite3": "^11.0.0",
78
+ "better-sqlite3": "^12.8.0",
79
79
  "sqlite-vec": "^0.1.0"
80
80
  },
81
81
  "peerDependenciesMeta": {
@@ -2,6 +2,7 @@
2
2
  import { onMount } from "svelte";
3
3
  import gregConfig from "virtual:greg-config";
4
4
  import { withBase } from "./common";
5
+ import MarkdownRenderer from "./MarkdownRenderer.svelte";
5
6
 
6
7
  // ── Types ──────────────────────────────────────────────────────────────────
7
8
 
@@ -337,86 +338,33 @@
337
338
  return characters.find(c => c.id === id);
338
339
  }
339
340
 
340
- // ── Markdown answer renderer ────────────────────────────────────────────────
341
-
342
- function escHtml(s: string): string {
343
- return s
344
- .replace(/&/g, '&amp;')
345
- .replace(/</g, '&lt;')
346
- .replace(/>/g, '&gt;')
347
- .replace(/"/g, '&quot;');
348
- }
349
-
350
- /** Sanitize a URL from LLM output: allow relative paths and http(s), normalize //. */
351
- function sanitizeUrl(url: string): string {
352
- const trimmed = url.trim();
353
- if (/^https?:\/\//i.test(trimmed)) return trimmed;
354
- if (/^\//.test(trimmed)) return trimmed.replace(/\/\/+/g, '/');
355
- if (/^#/.test(trimmed)) return trimmed;
356
- return '#';
357
- }
358
-
359
- /** Apply inline markdown (bold, italic, code) and links to an already-HTML-escaped string. */
360
- function processInlineEscaped(escaped: string): string {
361
- // Bold **text** or __text__
362
- let r = escaped
363
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
364
- .replace(/__(.+?)__/g, '<strong>$1</strong>');
365
- // Italic *text* (not ** boundary)
366
- r = r.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
367
- // Inline code `code`
368
- r = r.replace(/`([^`\n]+)`/g, '<code>$1</code>');
369
- return r;
370
- }
371
-
372
- /** Process a line of text: handle links first (before escaping) then escape+inline. */
373
- function renderLine(line: string): string {
374
- let result = '';
375
- let pos = 0;
376
- // Match markdown links: [text](url)
377
- const linkRe = /\[([^\]\n]+)\]\(([^)\n]+)\)/g;
378
- let m: RegExpExecArray | null;
379
-
380
- while ((m = linkRe.exec(line)) !== null) {
381
- // Process the plain text segment before this link
382
- result += processInlineEscaped(escHtml(line.slice(pos, m.index)));
383
-
384
- const linkText = m[1];
385
- const url = sanitizeUrl(m[2]);
386
- const isExternal = /^https?:\/\//i.test(url);
387
- result += `<a href="${escHtml(url)}"${
388
- isExternal
389
- ? ' target="_blank" rel="noopener noreferrer"'
390
- : ' data-nav="1"'
391
- }>${escHtml(linkText)}</a>`;
392
- pos = m.index + m[0].length;
393
- }
394
- result += processInlineEscaped(escHtml(line.slice(pos)));
395
- return result;
396
- }
397
-
398
- /**
399
- * Convert LLM markdown answer to safe HTML.
400
- * Handles: paragraphs, **bold**, *italic*, `code`, [links](url).
401
- * Lists and headings are rendered as paragraphs with basic formatting.
402
- */
403
- function renderAnswer(text: string): string {
404
- // Split on blank lines into paragraph blocks
405
- const blocks = text.split(/\n{2,}/);
406
- return blocks.map(block => {
407
- const lines = block.split('\n');
408
- const rendered = lines.map(line => renderLine(line)).join('<br>');
409
- return `<p>${rendered}</p>`;
410
- }).join('');
411
- }
412
-
413
- /** Handle clicks on internal navigation links inside `.ai-answer`. */
341
+ /** Remove or neutralize links pointing to placeholder/invented domains from LLM output. */
342
+ function sanitizeMarkdownLinks(md: string): string {
343
+ // Pattern: [text](url) strip links whose URL is not a real doc path
344
+ return md.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, (match, text, url) => {
345
+ const trimmed = url.trim();
346
+ // Allow relative paths and fragment links
347
+ if (/^[/#]/.test(trimmed)) return match;
348
+ // Allow only https links that are NOT placeholder domains
349
+ if (/^https?:\/\//i.test(trimmed)) {
350
+ const placeholderDomains = /\/\/(www\.)?(example\.(com|org|net)|placeholder\.com|lorem\.ipsum|test\.com|foo\.bar)/i;
351
+ if (placeholderDomains.test(trimmed)) return text;
352
+ return match;
353
+ }
354
+ // Anything else (no scheme, not relative) render as plain text
355
+ return text;
356
+ });
357
+ }
358
+
359
+ // ── Markdown answer navigation handler ────────────────────────────────────
360
+
361
+ /** Handle clicks on internal links inside the AI answer. */
414
362
  function handleAnswerClick(e: MouseEvent) {
415
- const anchor = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[data-nav]');
363
+ const anchor = (e.target as HTMLElement).closest<HTMLAnchorElement>('a');
416
364
  if (!anchor) return;
417
- e.preventDefault();
418
365
  const href = anchor.getAttribute('href') ?? '';
419
- if (!href) return;
366
+ if (!href || /^https?:\/\//i.test(href)) return;
367
+ e.preventDefault();
420
368
  const hashIdx = href.indexOf('#');
421
369
  const path = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
422
370
  const section = hashIdx >= 0 ? href.slice(hashIdx + 1) : undefined;
@@ -482,14 +430,15 @@
482
430
  {#if msg.role === "user"}
483
431
  <p class="ai-user-text">{msg.content}</p>
484
432
  {:else}
485
- <!-- Render answer with markdown links as clickable elements -->
486
433
  <!-- svelte-ignore a11y_click_events_have_key_events -->
487
434
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
488
435
  <div
489
436
  class="ai-answer"
490
437
  onclick={handleAnswerClick}
491
438
  role="article"
492
- >{@html renderAnswer(msg.content)}</div>
439
+ >
440
+ <MarkdownRenderer markdown={sanitizeMarkdownLinks(msg.content)} />
441
+ </div>
493
442
 
494
443
  {#if msg.sources && msg.sources.length > 0}
495
444
  <div class="ai-sources">
@@ -739,6 +688,15 @@
739
688
  padding: 0.65rem 0.9rem;
740
689
  border-radius: 2px 12px 12px 12px;
741
690
 
691
+ :global(.markdown-body) {
692
+ background: transparent;
693
+ font-size: inherit;
694
+ line-height: inherit;
695
+ color: inherit;
696
+ padding: 0;
697
+ margin: 0;
698
+ }
699
+
742
700
  :global(p) {
743
701
  margin: 0 0 0.45em;
744
702
  &:last-child { margin-bottom: 0; }
@@ -46,6 +46,8 @@
46
46
  navigate: (path: string) => void;
47
47
  navigateHome?: (path: string) => void;
48
48
  onOpenSearch: () => void;
49
+ showAiButton?: boolean;
50
+ onOpenAiPage?: () => void;
49
51
  };
50
52
 
51
53
  const EXTERNAL_RE = /^(?:[a-z][a-z\d+\-.]*:|\/\/{2})/i;
@@ -96,6 +98,8 @@
96
98
  navigate,
97
99
  navigateHome = navigate,
98
100
  onOpenSearch,
101
+ showAiButton = false,
102
+ onOpenAiPage,
99
103
  }: Props = $props();
100
104
 
101
105
  function resolveLogoSrc(
@@ -374,6 +378,13 @@
374
378
  <span class="search-trigger-hint"><kbd>Ctrl</kbd><kbd>K</kbd></span>
375
379
  </button>
376
380
  {/if}
381
+ {#if showAiButton && onOpenAiPage}
382
+ <button class="ai-page-btn" onclick={onOpenAiPage} type="button" title="Open AI chat in new tab" aria-label="Open AI chat">
383
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
384
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
385
+ </svg>
386
+ </button>
387
+ {/if}
377
388
  </div>
378
389
  </header>
379
390
 
@@ -699,6 +710,30 @@
699
710
  }
700
711
  }
701
712
 
713
+ .ai-page-btn {
714
+ display: inline-flex;
715
+ align-items: center;
716
+ justify-content: center;
717
+ padding: 0.38rem;
718
+ background: none;
719
+ border: 1px solid var(--greg-border-color);
720
+ border-radius: 6px;
721
+ color: var(--greg-menu-section-color);
722
+ cursor: pointer;
723
+ transition: border-color 0.15s, color 0.15s;
724
+ flex-shrink: 0;
725
+
726
+ svg {
727
+ width: 16px;
728
+ height: 16px;
729
+ }
730
+
731
+ &:hover {
732
+ border-color: var(--greg-accent);
733
+ color: var(--greg-accent);
734
+ }
735
+ }
736
+
702
737
  @media (max-width: 900px) {
703
738
  .header-right {
704
739
  max-width: none;
@@ -20,6 +20,7 @@
20
20
  import { handleCodeGroupClick, handleCodeGroupKeydown } from "./codeGroup";
21
21
  import allFrontmatters from "virtual:greg-frontmatter";
22
22
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
23
+ import AiChat from "./AiChat.svelte";
23
24
  import LayoutHome from "./layouts/LayoutHome.svelte";
24
25
  import BackToTop from "./BackToTop.svelte";
25
26
  import Breadcrumb from "./Breadcrumb.svelte";
@@ -594,6 +595,7 @@
594
595
  Boolean(searchProvider) ||
595
596
  (gregConfig as any)?.search?.provider !== "none",
596
597
  );
598
+ const aiEnabled = $derived(!!(gregConfig as any)?.search?.ai?.enabled);
597
599
  let searchOpen = $state(false);
598
600
 
599
601
  $effect(() => {
@@ -727,6 +729,10 @@
727
729
  }),
728
730
  );
729
731
  const currentSrcDir = $derived(localeContext.srcDir);
732
+
733
+ /** The SPA path that renders the full-page AI chat, e.g. "/docs/ai". */
734
+ const aiRoute = $derived(withBase(currentSrcDir).replace(/\/$/, '') + '/ai');
735
+ const aiPageMode = $derived(aiEnabled && router.active === aiRoute);
730
736
  function applyCurrentVersionPrefix(pathname: string): string {
731
737
  const normalizedPath = normalizeSrcDir(pathname);
732
738
  const currentPath = withoutBase(window.location.pathname);
@@ -1610,6 +1616,32 @@
1610
1616
  </script>
1611
1617
 
1612
1618
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_static_element_interactions -->
1619
+ {#if aiPageMode && aiEnabled}
1620
+ <div class="greg greg-ai-page" data-theme={theme}>
1621
+ <header class="ai-page-header">
1622
+ <button class="ai-page-close" type="button" onclick={() => history.back()} title="Close">
1623
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" width="16" height="16">
1624
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
1625
+ </svg>
1626
+ </button>
1627
+ <span class="ai-page-title">{aiTabLabel}</span>
1628
+ </header>
1629
+ <div class="ai-page-body">
1630
+ <AiChat
1631
+ onNavigate={(path, anchor) => { navigateInternalWithAnchor(path, anchor); }}
1632
+ onClose={() => history.back()}
1633
+ localeSrcDir={currentSrcDir}
1634
+ placeholder={aiPlaceholder}
1635
+ loadingText={aiLoadingText}
1636
+ errorText={aiErrorText}
1637
+ startText={aiStartText}
1638
+ sourcesLabel={aiSourcesLabel}
1639
+ clearChatLabel={aiClearChatLabel}
1640
+ sendLabel={aiSendLabel}
1641
+ />
1642
+ </div>
1643
+ </div>
1644
+ {:else}
1613
1645
  <div
1614
1646
  class="greg"
1615
1647
  data-theme={theme}
@@ -1636,6 +1668,8 @@
1636
1668
  {darkModeSwitchTitle}
1637
1669
  {searchButtonLabel}
1638
1670
  showSearch={searchEnabled}
1671
+ showAiButton={aiEnabled && !aiPageMode}
1672
+ onOpenAiPage={() => router.navigate(aiRoute)}
1639
1673
  versionOptions={manifestVersionOptions}
1640
1674
  activeVersion={activeDocsVersion}
1641
1675
  {versionMenuLabel}
@@ -1922,8 +1956,60 @@
1922
1956
  <BackToTop target={mainEl} label={returnToTopLabel} />
1923
1957
  {/if}
1924
1958
  </div>
1959
+ {/if}
1925
1960
 
1926
1961
  <style lang="scss">
1962
+ /* ── Full-page AI chat ──────────────────────────────────── */
1963
+ .greg-ai-page {
1964
+ display: flex;
1965
+ flex-direction: column;
1966
+ height: 100dvh;
1967
+ overflow: hidden;
1968
+ background: var(--greg-menu-background);
1969
+ }
1970
+
1971
+ .ai-page-header {
1972
+ display: flex;
1973
+ align-items: center;
1974
+ gap: 0.5rem;
1975
+ padding: 0 1rem;
1976
+ height: var(--greg-header-height, 56px);
1977
+ border-bottom: 1px solid var(--greg-border-color);
1978
+ background: var(--greg-header-background);
1979
+ flex-shrink: 0;
1980
+ }
1981
+
1982
+ .ai-page-close {
1983
+ display: inline-flex;
1984
+ align-items: center;
1985
+ justify-content: center;
1986
+ padding: 0.3rem;
1987
+ border: none;
1988
+ background: none;
1989
+ color: var(--greg-menu-section-color);
1990
+ cursor: pointer;
1991
+ border-radius: 4px;
1992
+ transition: color 0.15s, background 0.15s;
1993
+ &:hover { color: var(--greg-color); background: var(--greg-menu-hover-background); }
1994
+ }
1995
+
1996
+ .ai-page-title {
1997
+ font-size: 0.9rem;
1998
+ font-weight: 600;
1999
+ color: var(--greg-color);
2000
+ }
2001
+
2002
+ .ai-page-body {
2003
+ flex: 1;
2004
+ min-height: 0;
2005
+ display: flex;
2006
+ flex-direction: column;
2007
+ max-width: 860px;
2008
+ width: 100%;
2009
+ margin: 0 auto;
2010
+ padding: 0 1rem;
2011
+ }
2012
+
1927
2013
  .skip-link {
1928
2014
  position: absolute;
1929
2015
  left: 0.75rem;