@dominikcz/greg 0.9.41 → 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.
@@ -146,6 +146,8 @@
146
146
  let tabMode = $state<"search" | "ai">("search");
147
147
  /** Exposed handle from AiChat — gives access to clear() and hasMessages. */
148
148
  let aiChatHandle = $state<{ clear: () => void; hasMessages: boolean } | undefined>(undefined);
149
+ /** Whether the AI chat panel is expanded to full screen. */
150
+ let aiExpanded = $state(false);
149
151
  const serverUrl: string = cfgSearch.serverUrl ?? "/api/search";
150
152
  const fuzzyCfg = cfgSearch.fuzzy ?? {};
151
153
  const localThreshold: number = Number.isFinite(Number(fuzzyCfg.threshold))
@@ -566,6 +568,7 @@
566
568
  {#if open}
567
569
  <div
568
570
  class="search-backdrop"
571
+ class:ai-expanded={aiExpanded}
569
572
  onclick={handleBackdropClick}
570
573
  onkeydown={handleKeydown}
571
574
  role="dialog"
@@ -573,7 +576,7 @@
573
576
  aria-label={searchModalLabel}
574
577
  tabindex="-1"
575
578
  >
576
- <div class="search-modal" class:ai-mode={aiEnabled && tabMode === "ai"}>
579
+ <div class="search-modal" class:ai-mode={aiEnabled && tabMode === "ai"} class:ai-expanded={aiExpanded}>
577
580
 
578
581
  <!-- ── Tab switcher (only when AI is enabled) ── -->
579
582
  {#if aiEnabled}
@@ -601,6 +604,19 @@
601
604
  {aiTabLabel}
602
605
  </button>
603
606
  <div class="modal-tabs-end">
607
+ {#if tabMode === "ai"}
608
+ <button class="ai-expand-btn" type="button" onclick={() => (aiExpanded = !aiExpanded)} title={aiExpanded ? 'Minimize' : 'Expand'} aria-label={aiExpanded ? 'Minimize chat' : 'Expand chat'}>
609
+ {#if aiExpanded}
610
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
611
+ <polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/>
612
+ </svg>
613
+ {:else}
614
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
615
+ <polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
616
+ </svg>
617
+ {/if}
618
+ </button>
619
+ {/if}
604
620
  {#if tabMode === "ai" && aiChatHandle?.hasMessages}
605
621
  <button class="ai-clear-btn" type="button" onclick={() => aiChatHandle?.clear()} title={aiClearChatLabel}>
606
622
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
@@ -853,6 +869,20 @@
853
869
  max-height: calc(100vh - 5rem);
854
870
  min-height: min(540px, calc(100vh - 5rem));
855
871
  }
872
+
873
+ &.ai-expanded {
874
+ max-width: 100%;
875
+ width: 100%;
876
+ height: calc(100vh - 2rem);
877
+ max-height: calc(100vh - 2rem);
878
+ border-radius: 10px;
879
+ }
880
+ }
881
+
882
+ .search-backdrop.ai-expanded {
883
+ padding-top: 0;
884
+ align-items: center;
885
+ padding: 1rem;
856
886
  }
857
887
 
858
888
  /* ── Mode tabs ─────────────────────────────────────────────── */
@@ -912,6 +942,31 @@
912
942
 
913
943
  /* ── Clear chat button (in modal-tabs) ──────────────────── */
914
944
 
945
+ .ai-expand-btn {
946
+ display: inline-flex;
947
+ align-items: center;
948
+ justify-content: center;
949
+ background: none;
950
+ border: none;
951
+ color: var(--greg-menu-section-color);
952
+ cursor: pointer;
953
+ padding: 0.2rem 0.3rem;
954
+ border-radius: 4px;
955
+ opacity: 0.6;
956
+ transition: opacity 0.15s, color 0.15s;
957
+ flex-shrink: 0;
958
+
959
+ svg {
960
+ width: 13px;
961
+ height: 13px;
962
+ }
963
+
964
+ &:hover {
965
+ opacity: 1;
966
+ color: var(--greg-color);
967
+ }
968
+ }
969
+
915
970
  .ai-clear-btn {
916
971
  display: inline-flex;
917
972
  align-items: center;
@@ -0,0 +1,215 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { vitePluginCopyDocs } from '../vitePluginCopyDocs.js';
6
+
7
+ /**
8
+ * Build the plugin, call configResolved to set the project root, then
9
+ * register the dev-server middleware and return it directly.
10
+ */
11
+ function createMiddleware(options, root) {
12
+ const plugin = vitePluginCopyDocs(options);
13
+ plugin.configResolved({ root, build: { outDir: 'dist' }, base: '/' });
14
+ let middleware = null;
15
+ plugin.configureServer({ middlewares: { use(fn) { middleware = fn; } } });
16
+ return middleware;
17
+ }
18
+
19
+ /**
20
+ * Simulate a single GET request through the middleware.
21
+ * Resolves with the captured response, or with `{ passedThrough: true }` when
22
+ * the middleware calls next() without writing a response.
23
+ */
24
+ function request(middleware, url) {
25
+ return new Promise((resolve) => {
26
+ const response = { headers: {}, body: null };
27
+ const res = {
28
+ setHeader(name, value) { response.headers[name.toLowerCase()] = value; },
29
+ end(data) { response.body = data; resolve(response); },
30
+ };
31
+ middleware({ url }, res, () => resolve({ passedThrough: true }));
32
+ });
33
+ }
34
+
35
+ /** Convert a response body (Buffer or string) to a UTF-8 string. */
36
+ function bodyText(body) {
37
+ return Buffer.isBuffer(body) ? body.toString('utf8') : body;
38
+ }
39
+
40
+ // ─── helpers ──────────────────────────────────────────────────────────────────
41
+
42
+ describe('vitePluginCopyDocs › configureServer', () => {
43
+ const tempDirs = [];
44
+
45
+ afterEach(() => {
46
+ for (const dir of tempDirs.splice(0)) {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ // ── fix: decodeURIComponent in the docs-path handler (line 117) ───────────
52
+
53
+ it('serves a file whose name contains Polish characters (percent-encoded URL)', async () => {
54
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
55
+ tempDirs.push(root);
56
+
57
+ mkdirSync(join(root, 'docs', 'przewodnik'), { recursive: true });
58
+ // File name with Polish letters: ścieżki.md (ś = U+015B, ż = U+017C)
59
+ writeFileSync(join(root, 'docs', 'przewodnik', 'ścieżki.md'), '# Ścieżki', 'utf8');
60
+
61
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
62
+
63
+ // A browser sends Polish characters percent-encoded in the request URL.
64
+ // %C5%9B = ś, %C5%BCki = żki
65
+ const res = await request(mw, '/docs/przewodnik/%C5%9Bcie%C5%BCki.md');
66
+
67
+ expect(res.passedThrough).toBeUndefined();
68
+ expect(res.headers['content-type']).toContain('text/plain');
69
+ expect(bodyText(res.body)).toBe('# Ścieżki');
70
+ });
71
+
72
+ it('serves a file inside a directory whose name contains Polish characters', async () => {
73
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
74
+ tempDirs.push(root);
75
+
76
+ // Directory name with Polish letters: ścieżki (ś = U+015B, ż = U+017C)
77
+ mkdirSync(join(root, 'docs', 'ścieżki'), { recursive: true });
78
+ writeFileSync(join(root, 'docs', 'ścieżki', 'index.md'), '# Start', 'utf8');
79
+
80
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
81
+
82
+ // %C5%9B = ś, %C5%BC = ż
83
+ const res = await request(mw, '/docs/%C5%9Bcie%C5%BCki/index.md');
84
+
85
+ expect(res.passedThrough).toBeUndefined();
86
+ expect(bodyText(res.body)).toBe('# Start');
87
+ });
88
+
89
+ it('serves a file whose path contains multiple Polish-character segments', async () => {
90
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
91
+ tempDirs.push(root);
92
+
93
+ // Both the directory and the file name contain Polish letters.
94
+ // directory: przewodnik, file: właściwości.md (ł = U+0142, ś = U+015B, ć = U+0107)
95
+ mkdirSync(join(root, 'docs', 'przewodnik'), { recursive: true });
96
+ writeFileSync(
97
+ join(root, 'docs', 'przewodnik', 'właściwości.md'),
98
+ '# Właściwości',
99
+ 'utf8',
100
+ );
101
+
102
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
103
+
104
+ // właściwości → w%C5%82a%C5%9Bciwo%C5%9Bci
105
+ const res = await request(mw, '/docs/przewodnik/w%C5%82a%C5%9Bciwo%C5%9Bci.md');
106
+
107
+ expect(res.passedThrough).toBeUndefined();
108
+ expect(bodyText(res.body)).toBe('# Właściwości');
109
+ });
110
+
111
+ // ── regression guard: ASCII paths must still work after the fix ───────────
112
+
113
+ it('serves a plain ASCII path correctly', async () => {
114
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
115
+ tempDirs.push(root);
116
+
117
+ mkdirSync(join(root, 'docs', 'guide'), { recursive: true });
118
+ writeFileSync(join(root, 'docs', 'guide', 'intro.md'), '# Introduction', 'utf8');
119
+
120
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
121
+ const res = await request(mw, '/docs/guide/intro.md');
122
+
123
+ expect(res.passedThrough).toBeUndefined();
124
+ expect(bodyText(res.body)).toBe('# Introduction');
125
+ });
126
+
127
+ it('calls next() when the decoded path matches no file', async () => {
128
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
129
+ tempDirs.push(root);
130
+
131
+ mkdirSync(join(root, 'docs'), { recursive: true });
132
+
133
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
134
+ const res = await request(mw, '/docs/%C5%9Bcie%C5%BCka-nie-istnieje.md');
135
+
136
+ expect(res.passedThrough).toBe(true);
137
+ });
138
+
139
+ // ── fix: URIError on malformed percent-encoding ────────────────────────────
140
+
141
+ it('calls next() and does not throw when URL contains malformed percent-encoding', async () => {
142
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
143
+ tempDirs.push(root);
144
+
145
+ mkdirSync(join(root, 'docs'), { recursive: true });
146
+
147
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
148
+ // %GG is not valid percent-encoding – decodeURIComponent throws URIError
149
+ const res = await request(mw, '/docs/%GGinvalid.md');
150
+
151
+ expect(res.passedThrough).toBe(true);
152
+ });
153
+
154
+ // ── fix: path traversal protection ────────────────────────────────────────
155
+
156
+ it('blocks path traversal attempt via %2F..%2F in docs URL', async () => {
157
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
158
+ tempDirs.push(root);
159
+
160
+ mkdirSync(join(root, 'docs'), { recursive: true });
161
+ // Create a sensitive file outside the docs dir
162
+ writeFileSync(join(root, 'secret.md'), '# SECRET', 'utf8');
163
+
164
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs' }, root);
165
+ // Encoded path traversal: /docs/../secret.md → decodes to /docs/../secret.md
166
+ const res = await request(mw, '/docs/%2E%2E%2Fsecret.md');
167
+
168
+ // Must NOT serve the file outside docs/
169
+ expect(res.passedThrough).toBe(true);
170
+ });
171
+
172
+ // ── fix: staticDirs – decodeURIComponent + path traversal ─────────────────
173
+
174
+ it('serves a snippet file with Polish characters (percent-encoded URL)', async () => {
175
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
176
+ tempDirs.push(root);
177
+
178
+ mkdirSync(join(root, 'snippets'), { recursive: true });
179
+ writeFileSync(join(root, 'snippets', 'przykład.js'), '// przykład', 'utf8');
180
+
181
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs', staticDirs: ['snippets'] }, root);
182
+ // przykład → przyk%C5%82ad
183
+ const res = await request(mw, '/snippets/przyk%C5%82ad.js');
184
+
185
+ expect(res.passedThrough).toBeUndefined();
186
+ expect(res.headers['content-type']).toContain('text/plain');
187
+ expect(bodyText(res.body)).toBe('// przykład');
188
+ });
189
+
190
+ it('calls next() and does not throw when staticDirs URL has malformed percent-encoding', async () => {
191
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
192
+ tempDirs.push(root);
193
+
194
+ mkdirSync(join(root, 'snippets'), { recursive: true });
195
+
196
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs', staticDirs: ['snippets'] }, root);
197
+ const res = await request(mw, '/snippets/%GGinvalid.js');
198
+
199
+ expect(res.passedThrough).toBe(true);
200
+ });
201
+
202
+ it('blocks path traversal attempt via %2F..%2F in staticDirs URL', async () => {
203
+ const root = mkdtempSync(join(tmpdir(), 'greg-plugin-'));
204
+ tempDirs.push(root);
205
+
206
+ mkdirSync(join(root, 'snippets'), { recursive: true });
207
+ writeFileSync(join(root, 'secret.js'), '// SECRET', 'utf8');
208
+
209
+ const mw = createMiddleware({ docsDir: 'docs', srcDir: '/docs', staticDirs: ['snippets'] }, root);
210
+ // Encoded path traversal: /snippets/../secret.js
211
+ const res = await request(mw, '/snippets/%2E%2E%2Fsecret.js');
212
+
213
+ expect(res.passedThrough).toBe(true);
214
+ });
215
+ });
@@ -4,20 +4,19 @@ export function buildSystemPrompt(character, chunks, baseUrl = '') {
4
4
  const link = `${baseUrl}${c.pageId}${anchor}`;
5
5
  const heading = c.sectionHeading ? ` › ${c.sectionHeading}` : '';
6
6
  return (
7
- `[${i + 1}] Page: "${c.pageTitle}"${heading}\n` +
8
- ` Link: ${link}\n` +
9
- ` ${c.content}`
7
+ `SOURCE ${i + 1}: [${c.pageTitle}${heading}](${link})\n` +
8
+ c.content
10
9
  );
11
10
  }).join('\n\n');
12
11
 
13
12
  return `${character.systemPrompt}
14
13
 
15
14
  STRICT RULES — follow these without exception:
15
+ - LANGUAGE: You MUST respond in the exact same language as the user's question. Polish question = Polish answer. English question = English answer. This rule overrides everything else including your persona.
16
16
  - Base your answer EXCLUSIVELY on the DOCUMENTATION CONTEXT provided below.
17
- - If the context does not contain enough information to fully answer, say so clearly instead of guessing.
18
- - ALWAYS include at least one inline markdown link citation: [Section Title](link)
17
+ - If the context does not contain enough information to fully answer, say so clearly. Do NOT include any source links in that case.
18
+ - When you cite information, use the exact markdown links from the context: [Title](link). Do NOT invent, fabricate, or modify any URLs.
19
19
  - Do NOT invent, hallucinate, or add information absent from the context.
20
- - Respond in the same language the user used in their question.
21
20
  - Format your response in markdown.
22
21
 
23
22
  DOCUMENTATION CONTEXT:
@@ -15,20 +15,19 @@ export function buildSystemPrompt(
15
15
  const link = `${baseUrl}${c.pageId}${anchor}`;
16
16
  const heading = c.sectionHeading ? ` › ${c.sectionHeading}` : '';
17
17
  return (
18
- `[${i + 1}] Page: "${c.pageTitle}"${heading}\n` +
19
- ` Link: ${link}\n` +
20
- ` ${c.content}`
18
+ `SOURCE ${i + 1}: [${c.pageTitle}${heading}](${link})\n` +
19
+ c.content
21
20
  );
22
21
  }).join('\n\n');
23
22
 
24
23
  return `${character.systemPrompt}
25
24
 
26
25
  STRICT RULES — follow these without exception:
26
+ - LANGUAGE: You MUST respond in the exact same language as the user's question. Polish question = Polish answer. English question = English answer. This rule overrides everything else including your persona.
27
27
  - Base your answer EXCLUSIVELY on the DOCUMENTATION CONTEXT provided below.
28
- - If the context does not contain enough information to fully answer, say so clearly instead of guessing.
29
- - ALWAYS include at least one inline markdown link citation: [Section Title](link)
28
+ - If the context does not contain enough information to fully answer, say so clearly. Do NOT include any source links in that case.
29
+ - When you cite information, use the exact markdown links from the context: [Title](link). Do NOT invent, fabricate, or modify any URLs.
30
30
  - Do NOT invent, hallucinate, or add information absent from the context.
31
- - Respond in the same language the user used in their question.
32
31
  - Format your response in markdown.
33
32
 
34
33
  DOCUMENTATION CONTEXT:
@@ -1,6 +1,38 @@
1
1
  import { buildMessages } from './promptBuilder.js';
2
2
  import { extractSources } from './docLinker.js';
3
3
 
4
+ /**
5
+ * Return the set of pageIds that were actually cited in the answer.
6
+ * Handles both inline markdown links [text](url) and numeric references [N]
7
+ * mapped back to the chunks array.
8
+ */
9
+ function extractCitedPageIds(answer, baseUrl, chunks) {
10
+ const cited = new Set();
11
+
12
+ // 1. Inline markdown links: [text](url)
13
+ const linkRe = /\]\(([^)\s]+)\)/g;
14
+ let m;
15
+ while ((m = linkRe.exec(answer)) !== null) {
16
+ let url = m[1].split('#')[0].trim();
17
+ if (!url) continue;
18
+ if (baseUrl && url.startsWith(baseUrl)) url = url.slice(baseUrl.length);
19
+ url = url.replace(/^\/\/+/, '/');
20
+ if (url) cited.add(url);
21
+ }
22
+
23
+ // 2. Numeric references [N] — map back to chunk pageId
24
+ const numRe = /\[(\d+)\]/g;
25
+ while ((m = numRe.exec(answer)) !== null) {
26
+ const idx = parseInt(m[1], 10) - 1; // prompt uses 1-based SOURCE N
27
+ if (idx >= 0 && idx < chunks.length) {
28
+ const pageId = chunks[idx].pageId.replace(/^\/\/+/, '/');
29
+ cited.add(pageId);
30
+ }
31
+ }
32
+
33
+ return cited;
34
+ }
35
+
4
36
  export class RagPipeline {
5
37
  constructor(provider, store, characters) {
6
38
  this.provider = provider;
@@ -43,7 +75,14 @@ export class RagPipeline {
43
75
 
44
76
  const messages = buildMessages(character, chunks, query, [], baseUrl);
45
77
  const answer = await this.provider.chat(messages, options.llm);
46
- const sources = extractSources(chunks);
78
+
79
+ // Show only sources whose pageId was actually cited inline by the LLM.
80
+ const citedPageIds = extractCitedPageIds(answer, baseUrl, chunks);
81
+ const citedChunks = chunks.filter(c => {
82
+ const normalised = c.pageId.replace(/^\/\/+/, '/');
83
+ return citedPageIds.has(normalised);
84
+ });
85
+ const sources = extractSources(citedChunks);
47
86
 
48
87
  return { answer, sources, character: character.id };
49
88
  }
@@ -1,9 +1,40 @@
1
1
  import type { AiProvider } from './aiProvider.js';
2
2
  import type { ChunkStore } from './chunkStore.js';
3
- import type { AiCharacter, AiProviderOptions, AiResponse } from './types.js';
3
+ import type { AiCharacter, AiProviderOptions, AiResponse, RetrievedChunk } from './types.js';
4
4
  import { buildMessages } from './promptBuilder.js';
5
5
  import { extractSources } from './docLinker.js';
6
6
 
7
+ /**
8
+ * Parse all markdown link URLs from the LLM answer and return the set of
9
+ * pageIds (URL without anchor and without baseUrl prefix) that were cited.
10
+ */
11
+ function extractCitedPageIds(answer: string, baseUrl: string, chunks: RetrievedChunk[]): Set<string> {
12
+ const cited = new Set<string>();
13
+
14
+ // 1. Inline markdown links: [text](url)
15
+ const linkRe = /\]\(([^)\s]+)\)/g;
16
+ let m: RegExpExecArray | null;
17
+ while ((m = linkRe.exec(answer)) !== null) {
18
+ let url = m[1].split('#')[0].trim();
19
+ if (!url) continue;
20
+ if (baseUrl && url.startsWith(baseUrl)) url = url.slice(baseUrl.length);
21
+ url = url.replace(/^\/\/+/, '/');
22
+ if (url) cited.add(url);
23
+ }
24
+
25
+ // 2. Numeric references [N] — map back to chunk pageId
26
+ const numRe = /\[(\d+)\]/g;
27
+ while ((m = numRe.exec(answer)) !== null) {
28
+ const idx = parseInt(m[1], 10) - 1;
29
+ if (idx >= 0 && idx < chunks.length) {
30
+ const pageId = chunks[idx].pageId.replace(/^\/\/+/, '/');
31
+ cited.add(pageId);
32
+ }
33
+ }
34
+
35
+ return cited;
36
+ }
37
+
7
38
  export type RagOptions = {
8
39
  /** Number of chunks to retrieve from the store. Default: 8 */
9
40
  topK?: number;
@@ -93,8 +124,14 @@ export class RagPipeline {
93
124
  const messages = buildMessages(character, chunks, query, [], baseUrl);
94
125
  const answer = await this.provider.chat(messages, options.llm);
95
126
 
96
- // 4. Extract source citations from retrieved chunks
97
- const sources = extractSources(chunks);
127
+ // 4. Collect only sources whose link was actually cited in the answer.
128
+ // This is deterministic and doesn't rely on LLM marker compliance.
129
+ const citedPageIds = extractCitedPageIds(answer, baseUrl, chunks);
130
+ const citedChunks = chunks.filter(c => {
131
+ const normalised = c.pageId.replace(/^\/\/+/, '/');
132
+ return citedPageIds.has(normalised);
133
+ });
134
+ const sources = extractSources(citedChunks);
98
135
 
99
136
  return { answer, sources, character: character.id };
100
137
  }