@dominikcz/greg 0.9.40 → 0.9.44
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 +40 -12
- package/docs/guide/deploying.md +1 -0
- package/docs/guide/markdown/links-and-toc.md +4 -4
- package/docs/reference/markdowndocs.md +3 -3
- package/docs/reference/search.md +71 -8
- package/package.json +1 -1
- package/src/lib/MarkdownDocs/AiChat.svelte +37 -79
- package/src/lib/MarkdownDocs/BackToTop.svelte +1 -1
- package/src/lib/MarkdownDocs/DocsNavigation.svelte +1 -1
- package/src/lib/MarkdownDocs/DocsSiteHeader.svelte +38 -1
- package/src/lib/MarkdownDocs/MarkdownDocs.svelte +87 -1
- package/src/lib/MarkdownDocs/SearchModal.svelte +56 -1
- package/src/lib/MarkdownDocs/TreeViewItem.svelte +1 -1
- package/src/lib/MarkdownDocs/__tests__/vitePluginCopyDocs.test.js +215 -0
- package/src/lib/MarkdownDocs/ai/promptBuilder.js +5 -6
- package/src/lib/MarkdownDocs/ai/promptBuilder.ts +5 -6
- package/src/lib/MarkdownDocs/ai/ragPipeline.js +40 -1
- package/src/lib/MarkdownDocs/ai/ragPipeline.ts +40 -3
- package/src/lib/MarkdownDocs/searchServer.js +209 -10
- package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +9 -2
- package/src/lib/MarkdownDocs/vitePluginSearchIndex.js +167 -9
- package/src/lib/components/Link.svelte +1 -1
- package/types/index.d.ts +9 -0
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
|
-
...
|
|
94
|
-
PATH:
|
|
95
|
-
|
|
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
|
-
...
|
|
110
|
-
PATH:
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
197
|
+
const forceSingle = hasSingleBuildFlag(args);
|
|
170
198
|
const passthroughArgs = args.filter((a) => a !== '--single');
|
|
171
199
|
const shouldBuildVersions = !forceSingle && await hasVersioningBuildConfig();
|
|
172
200
|
|
package/docs/guide/deploying.md
CHANGED
|
@@ -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/
|
|
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
|
-

|
|
74
|
+

|
|
75
|
+

|
|
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
|
|
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
|
|
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
|
|
296
|
+
### 3 Render handlers (post-HTML stage)
|
|
297
297
|
|
|
298
298
|
After HTML is rendered, handlers are executed from ordered lists:
|
|
299
299
|
|
package/docs/reference/search.md
CHANGED
|
@@ -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: '
|
|
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 | 👔
|
|
196
|
-
| `friendly` | Friendly | 😊
|
|
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 | 🥋
|
|
199
|
-
| `concise` | Concise | ✂️
|
|
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
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return
|
|
344
|
-
.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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
|
-
>
|
|
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; }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import TreeView from "./TreeView.svelte";
|
|
3
|
-
import
|
|
3
|
+
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
|
4
4
|
import { handleSectionClick } from "./navigationUtils";
|
|
5
5
|
|
|
6
6
|
import type { TreeViewItem } from "./treeViewTypes";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import Languages from "@lucide/svelte/icons/languages";
|
|
3
|
+
import Sun from "@lucide/svelte/icons/sun";
|
|
4
|
+
import Moon from "@lucide/svelte/icons/moon";
|
|
3
5
|
import SocialLink from "../components/SocialLink.svelte";
|
|
4
6
|
import DocsVersionSwitcher from "./DocsVersionSwitcher.svelte";
|
|
5
7
|
import { withBase } from "./common";
|
|
@@ -44,6 +46,8 @@
|
|
|
44
46
|
navigate: (path: string) => void;
|
|
45
47
|
navigateHome?: (path: string) => void;
|
|
46
48
|
onOpenSearch: () => void;
|
|
49
|
+
showAiButton?: boolean;
|
|
50
|
+
onOpenAiPage?: () => void;
|
|
47
51
|
};
|
|
48
52
|
|
|
49
53
|
const EXTERNAL_RE = /^(?:[a-z][a-z\d+\-.]*:|\/\/{2})/i;
|
|
@@ -94,6 +98,8 @@
|
|
|
94
98
|
navigate,
|
|
95
99
|
navigateHome = navigate,
|
|
96
100
|
onOpenSearch,
|
|
101
|
+
showAiButton = false,
|
|
102
|
+
onOpenAiPage,
|
|
97
103
|
}: Props = $props();
|
|
98
104
|
|
|
99
105
|
function resolveLogoSrc(
|
|
@@ -372,6 +378,13 @@
|
|
|
372
378
|
<span class="search-trigger-hint"><kbd>Ctrl</kbd><kbd>K</kbd></span>
|
|
373
379
|
</button>
|
|
374
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}
|
|
375
388
|
</div>
|
|
376
389
|
</header>
|
|
377
390
|
|
|
@@ -697,6 +710,30 @@
|
|
|
697
710
|
}
|
|
698
711
|
}
|
|
699
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
|
+
|
|
700
737
|
@media (max-width: 900px) {
|
|
701
738
|
.header-right {
|
|
702
739
|
max-width: none;
|
|
@@ -20,12 +20,13 @@
|
|
|
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";
|
|
26
27
|
import PrevNext from "./PrevNext.svelte";
|
|
27
28
|
import VersionOutdatedNotice from "./VersionOutdatedNotice.svelte";
|
|
28
|
-
import
|
|
29
|
+
import EllipsisVertical from "@lucide/svelte/icons/ellipsis-vertical";
|
|
29
30
|
import gregConfig from "virtual:greg-config";
|
|
30
31
|
import {
|
|
31
32
|
type LocaleConfig,
|
|
@@ -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;
|