@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,936 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
|
+
import gregConfig from "virtual:greg-config";
|
|
4
|
+
import { withBase } from "./common";
|
|
5
|
+
|
|
6
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
type AiCharacter = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
icon: string;
|
|
12
|
+
description: string;
|
|
13
|
+
systemPrompt: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type AiSource = {
|
|
17
|
+
pageId: string;
|
|
18
|
+
pageTitle: string;
|
|
19
|
+
sectionHeading: string;
|
|
20
|
+
sectionAnchor: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type AiResponse = {
|
|
24
|
+
answer: string;
|
|
25
|
+
sources: AiSource[];
|
|
26
|
+
character: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ChatMessage = {
|
|
30
|
+
id: number;
|
|
31
|
+
role: "user" | "assistant";
|
|
32
|
+
content: string;
|
|
33
|
+
sources?: AiSource[];
|
|
34
|
+
character?: string;
|
|
35
|
+
error?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── Props ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
type Props = {
|
|
41
|
+
onNavigate: (path: string, anchor?: string) => void;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
localeSrcDir?: string;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
loadingText?: string;
|
|
46
|
+
errorText?: string;
|
|
47
|
+
startText?: string;
|
|
48
|
+
sourcesLabel?: string;
|
|
49
|
+
aiLabel?: string;
|
|
50
|
+
clearChatLabel?: string;
|
|
51
|
+
sendLabel?: string;
|
|
52
|
+
/** Exposed handle so the parent can read hasMessages and call clear(). */
|
|
53
|
+
chatHandle?: { clear: () => void; hasMessages: boolean };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let {
|
|
57
|
+
onNavigate,
|
|
58
|
+
onClose,
|
|
59
|
+
localeSrcDir = "/docs",
|
|
60
|
+
placeholder = "Ask a question about the docs…",
|
|
61
|
+
loadingText = "Thinking…",
|
|
62
|
+
errorText = "Something went wrong. Please try again.",
|
|
63
|
+
startText = "Ask me anything about this documentation. My answers are based exclusively on the docs.",
|
|
64
|
+
sourcesLabel = "Sources",
|
|
65
|
+
aiLabel = "Ask AI",
|
|
66
|
+
clearChatLabel = "Clear chat",
|
|
67
|
+
sendLabel = "Send",
|
|
68
|
+
chatHandle = $bindable<{ clear: () => void; hasMessages: boolean } | undefined>(undefined),
|
|
69
|
+
}: Props = $props();
|
|
70
|
+
|
|
71
|
+
// ── Config ─────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const aiCfg = (gregConfig as any)?.search?.ai ?? {};
|
|
74
|
+
const aiUrl: string = aiCfg.serverUrl ?? "/api/ai";
|
|
75
|
+
|
|
76
|
+
// ── State ──────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const STORAGE_KEY = 'greg-ai-chat-history';
|
|
79
|
+
const MAX_STORED_MESSAGES = 100;
|
|
80
|
+
const CHARACTER_KEY = 'greg-ai-character';
|
|
81
|
+
|
|
82
|
+
function loadStoredCharacter(): string {
|
|
83
|
+
try {
|
|
84
|
+
return localStorage.getItem(CHARACTER_KEY) ?? '';
|
|
85
|
+
} catch { return ''; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveCharacter(id: string) {
|
|
89
|
+
try { localStorage.setItem(CHARACTER_KEY, id); } catch { /* ignore */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function loadStoredMessages(): ChatMessage[] {
|
|
93
|
+
try {
|
|
94
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
95
|
+
if (raw) {
|
|
96
|
+
const parsed = JSON.parse(raw) as ChatMessage[];
|
|
97
|
+
if (Array.isArray(parsed)) return parsed;
|
|
98
|
+
}
|
|
99
|
+
} catch { /* ignore */ }
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function saveMessages(msgs: ChatMessage[]) {
|
|
104
|
+
try {
|
|
105
|
+
// Never persist error messages or excess entries
|
|
106
|
+
const toSave = msgs
|
|
107
|
+
.filter(m => !m.error)
|
|
108
|
+
.slice(-MAX_STORED_MESSAGES);
|
|
109
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
|
|
110
|
+
} catch { /* storage unavailable */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let characters = $state<AiCharacter[]>([]);
|
|
114
|
+
let selectedCharacter = $state<string>(loadStoredCharacter() || (aiCfg.defaultCharacter ?? "professional"));
|
|
115
|
+
let query = $state("");
|
|
116
|
+
const _storedMessages = loadStoredMessages();
|
|
117
|
+
let messages = $state<ChatMessage[]>(_storedMessages);
|
|
118
|
+
let isLoading = $state(false);
|
|
119
|
+
let inputEl = $state<HTMLTextAreaElement | undefined>(undefined);
|
|
120
|
+
let messagesEl = $state<HTMLDivElement | undefined>(undefined);
|
|
121
|
+
let counter = _storedMessages.length > 0 ? Math.max(..._storedMessages.map(m => m.id)) : 0;
|
|
122
|
+
|
|
123
|
+
// ── Query history (arrow-key navigation) ───────────────────────────────────
|
|
124
|
+
|
|
125
|
+
const QUERY_HISTORY_KEY = 'greg-ai-query-history';
|
|
126
|
+
const MAX_QUERY_HISTORY = 50;
|
|
127
|
+
|
|
128
|
+
function loadQueryHistory(): string[] {
|
|
129
|
+
try {
|
|
130
|
+
const raw = localStorage.getItem(QUERY_HISTORY_KEY);
|
|
131
|
+
if (raw) {
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
if (Array.isArray(parsed)) return parsed as string[];
|
|
134
|
+
}
|
|
135
|
+
} catch { /* ignore */ }
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function saveQueryHistory(history: string[]) {
|
|
140
|
+
try { localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history)); } catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let queryHistory: string[] = loadQueryHistory();
|
|
144
|
+
let historyIdx = -1; // -1 = editing fresh draft
|
|
145
|
+
let historyDraft = ''; // text saved on entering history navigation
|
|
146
|
+
|
|
147
|
+
// ── Icon helpers ─────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** Returns true when the icon string looks like an image URL / path. */
|
|
150
|
+
function isImageIcon(icon: string): boolean {
|
|
151
|
+
return (
|
|
152
|
+
/^(?:https?:\/\/|\/|\.\/|data:image\/)/.test(icon) ||
|
|
153
|
+
/\.(?:png|jpe?g|gif|webp|svg|avif)$/i.test(icon)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Load available characters on mount ─────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
onMount(async () => {
|
|
160
|
+
// Focus the textarea immediately so arrow-key history works on open
|
|
161
|
+
inputEl?.focus();
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(withBase(`${aiUrl}/characters`));
|
|
164
|
+
if (res.ok) {
|
|
165
|
+
const data = await res.json();
|
|
166
|
+
characters = data.characters ?? [];
|
|
167
|
+
// Restore saved character if it exists, else fall back to defaultCharacter or first
|
|
168
|
+
const savedChar = loadStoredCharacter();
|
|
169
|
+
const preferred = savedChar || aiCfg.defaultCharacter;
|
|
170
|
+
if (characters.length > 0) {
|
|
171
|
+
if (preferred && characters.find(c => c.id === preferred)) {
|
|
172
|
+
selectedCharacter = preferred;
|
|
173
|
+
} else {
|
|
174
|
+
selectedCharacter = characters[0].id;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Characters list unavailable — the selector will be hidden
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Persist messages to localStorage whenever they change
|
|
184
|
+
$effect(() => {
|
|
185
|
+
saveMessages(messages);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Persist selected character whenever it changes
|
|
189
|
+
$effect(() => {
|
|
190
|
+
saveCharacter(selectedCharacter);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Expose handle to parent (hasMessages + clear fn)
|
|
194
|
+
$effect(() => {
|
|
195
|
+
chatHandle = { clear: clearHistory, hasMessages: messages.length > 0 };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Scroll to bottom whenever a new message appears
|
|
199
|
+
$effect(() => {
|
|
200
|
+
if (messages.length > 0) {
|
|
201
|
+
void messages[messages.length - 1]; // track reactively
|
|
202
|
+
requestAnimationFrame(() => {
|
|
203
|
+
if (messagesEl) {
|
|
204
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Submit ─────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async function submit() {
|
|
213
|
+
const q = query.trim();
|
|
214
|
+
if (!q || isLoading) return;
|
|
215
|
+
|
|
216
|
+
// Save to history (avoid consecutive duplicates)
|
|
217
|
+
if (q !== queryHistory[queryHistory.length - 1]) {
|
|
218
|
+
queryHistory = [...queryHistory, q].slice(-MAX_QUERY_HISTORY);
|
|
219
|
+
saveQueryHistory(queryHistory);
|
|
220
|
+
}
|
|
221
|
+
historyIdx = -1;
|
|
222
|
+
historyDraft = '';
|
|
223
|
+
|
|
224
|
+
query = "";
|
|
225
|
+
messages = [...messages, { id: ++counter, role: "user", content: q }];
|
|
226
|
+
isLoading = true;
|
|
227
|
+
const assistantId = ++counter;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const res = await fetch(withBase(`${aiUrl}/ask`), {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
query: q,
|
|
235
|
+
character: selectedCharacter,
|
|
236
|
+
locale: localeSrcDir,
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
241
|
+
|
|
242
|
+
const data: AiResponse = await res.json();
|
|
243
|
+
messages = [
|
|
244
|
+
...messages,
|
|
245
|
+
{
|
|
246
|
+
id: assistantId,
|
|
247
|
+
role: "assistant",
|
|
248
|
+
content: data.answer,
|
|
249
|
+
sources: data.sources,
|
|
250
|
+
character: data.character,
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error("[AI Chat]", err);
|
|
255
|
+
messages = [
|
|
256
|
+
...messages,
|
|
257
|
+
{
|
|
258
|
+
id: assistantId,
|
|
259
|
+
role: "assistant",
|
|
260
|
+
content: errorText,
|
|
261
|
+
error: true,
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
} finally {
|
|
265
|
+
isLoading = false;
|
|
266
|
+
requestAnimationFrame(() => inputEl?.focus());
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
271
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
submit();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (e.key === "Escape") {
|
|
277
|
+
onClose();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Arrow-key history navigation
|
|
282
|
+
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && queryHistory.length > 0) {
|
|
283
|
+
const ta = inputEl;
|
|
284
|
+
const atTopLine = !ta || ta.selectionStart === 0 ||
|
|
285
|
+
!ta.value.slice(0, ta.selectionStart).includes('\n');
|
|
286
|
+
const atBottomLine = !ta || ta.selectionEnd === ta.value.length ||
|
|
287
|
+
!ta.value.slice(ta.selectionEnd).includes('\n');
|
|
288
|
+
|
|
289
|
+
if (e.key === "ArrowUp" && (atTopLine || historyIdx >= 0)) {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
if (historyIdx === -1) {
|
|
292
|
+
historyDraft = query;
|
|
293
|
+
historyIdx = 0;
|
|
294
|
+
} else if (historyIdx < queryHistory.length - 1) {
|
|
295
|
+
historyIdx++;
|
|
296
|
+
}
|
|
297
|
+
query = queryHistory[queryHistory.length - 1 - historyIdx];
|
|
298
|
+
requestAnimationFrame(() => {
|
|
299
|
+
if (inputEl) inputEl.setSelectionRange(0, inputEl.value.length);
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (e.key === "ArrowDown" && historyIdx >= 0 && atBottomLine) {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
if (historyIdx === 0) {
|
|
307
|
+
historyIdx = -1;
|
|
308
|
+
query = historyDraft;
|
|
309
|
+
} else {
|
|
310
|
+
historyIdx--;
|
|
311
|
+
query = queryHistory[queryHistory.length - 1 - historyIdx];
|
|
312
|
+
}
|
|
313
|
+
requestAnimationFrame(() => {
|
|
314
|
+
if (inputEl) inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function clearHistory() {
|
|
322
|
+
messages = [];
|
|
323
|
+
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
|
|
324
|
+
requestAnimationFrame(() => inputEl?.focus());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function navigateTo(source: AiSource) {
|
|
328
|
+
onNavigate(source.pageId, source.sectionAnchor || undefined);
|
|
329
|
+
onClose();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function currentCharacter(): AiCharacter | undefined {
|
|
333
|
+
return characters.find(c => c.id === selectedCharacter);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function characterById(id?: string): AiCharacter | undefined {
|
|
337
|
+
return characters.find(c => c.id === id);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Markdown answer renderer ────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
function escHtml(s: string): string {
|
|
343
|
+
return s
|
|
344
|
+
.replace(/&/g, '&')
|
|
345
|
+
.replace(/</g, '<')
|
|
346
|
+
.replace(/>/g, '>')
|
|
347
|
+
.replace(/"/g, '"');
|
|
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`. */
|
|
414
|
+
function handleAnswerClick(e: MouseEvent) {
|
|
415
|
+
const anchor = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[data-nav]');
|
|
416
|
+
if (!anchor) return;
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
419
|
+
if (!href) return;
|
|
420
|
+
const hashIdx = href.indexOf('#');
|
|
421
|
+
const path = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
|
|
422
|
+
const section = hashIdx >= 0 ? href.slice(hashIdx + 1) : undefined;
|
|
423
|
+
onNavigate(path, section || undefined);
|
|
424
|
+
onClose();
|
|
425
|
+
}
|
|
426
|
+
</script>
|
|
427
|
+
|
|
428
|
+
<div class="ai-chat">
|
|
429
|
+
{#snippet iconDisplay(ic: string, cls: string)}
|
|
430
|
+
{#if isImageIcon(ic)}
|
|
431
|
+
<img class="{cls} icon-img" src={ic} alt="" aria-hidden="true" />
|
|
432
|
+
{:else}
|
|
433
|
+
<span class={cls} aria-hidden="true">{ic}</span>
|
|
434
|
+
{/if}
|
|
435
|
+
{/snippet}
|
|
436
|
+
|
|
437
|
+
<!-- ── Header: character selector (only when > 1 character) ──────────────────── -->
|
|
438
|
+
{#if characters.length > 1}
|
|
439
|
+
<div class="ai-character-bar" role="group" aria-label="AI character">
|
|
440
|
+
{#each characters as char}
|
|
441
|
+
<button
|
|
442
|
+
class="ai-character-btn"
|
|
443
|
+
class:active={selectedCharacter === char.id}
|
|
444
|
+
onclick={() => (selectedCharacter = char.id)}
|
|
445
|
+
title={char.description}
|
|
446
|
+
aria-pressed={selectedCharacter === char.id}
|
|
447
|
+
type="button"
|
|
448
|
+
>
|
|
449
|
+
{@render iconDisplay(char.icon, 'char-icon')}
|
|
450
|
+
<span class="char-name">{char.name}</span>
|
|
451
|
+
</button>
|
|
452
|
+
{/each}
|
|
453
|
+
</div>
|
|
454
|
+
{/if}
|
|
455
|
+
|
|
456
|
+
<!-- ── Message area ─────────────────────────────────────────────────── -->
|
|
457
|
+
<div class="ai-messages" bind:this={messagesEl} aria-live="polite" aria-label={aiLabel}>
|
|
458
|
+
{#if messages.length === 0}
|
|
459
|
+
<div class="ai-start-hint">
|
|
460
|
+
{#if currentCharacter()}
|
|
461
|
+
{@render iconDisplay(currentCharacter()!.icon, 'ai-start-icon')}
|
|
462
|
+
{/if}
|
|
463
|
+
<p>{startText}</p>
|
|
464
|
+
</div>
|
|
465
|
+
{:else}
|
|
466
|
+
{#each messages as msg (msg.id)}
|
|
467
|
+
<div
|
|
468
|
+
class="ai-message"
|
|
469
|
+
class:user={msg.role === "user"}
|
|
470
|
+
class:assistant={msg.role === "assistant"}
|
|
471
|
+
class:error={msg.error}
|
|
472
|
+
role={msg.role === "assistant" ? "article" : undefined}
|
|
473
|
+
>
|
|
474
|
+
{#if msg.role === "assistant"}
|
|
475
|
+
{@const msgChar = characterById(msg.character)}
|
|
476
|
+
{#if msgChar}
|
|
477
|
+
{@render iconDisplay(msgChar.icon, 'ai-msg-icon')}
|
|
478
|
+
{/if}
|
|
479
|
+
{/if}
|
|
480
|
+
|
|
481
|
+
<div class="ai-message-body">
|
|
482
|
+
{#if msg.role === "user"}
|
|
483
|
+
<p class="ai-user-text">{msg.content}</p>
|
|
484
|
+
{:else}
|
|
485
|
+
<!-- Render answer with markdown links as clickable elements -->
|
|
486
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
487
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
488
|
+
<div
|
|
489
|
+
class="ai-answer"
|
|
490
|
+
onclick={handleAnswerClick}
|
|
491
|
+
role="article"
|
|
492
|
+
>{@html renderAnswer(msg.content)}</div>
|
|
493
|
+
|
|
494
|
+
{#if msg.sources && msg.sources.length > 0}
|
|
495
|
+
<div class="ai-sources">
|
|
496
|
+
<span class="ai-sources-label"
|
|
497
|
+
>{sourcesLabel}:</span
|
|
498
|
+
>
|
|
499
|
+
<ul class="ai-sources-list">
|
|
500
|
+
{#each msg.sources as src}
|
|
501
|
+
<li>
|
|
502
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
503
|
+
<button
|
|
504
|
+
class="ai-source-btn"
|
|
505
|
+
onclick={() => navigateTo(src)}
|
|
506
|
+
type="button"
|
|
507
|
+
title={src.pageId +
|
|
508
|
+
(src.sectionAnchor
|
|
509
|
+
? "#" +
|
|
510
|
+
src.sectionAnchor
|
|
511
|
+
: "")}
|
|
512
|
+
>
|
|
513
|
+
<svg
|
|
514
|
+
class="ai-source-icon"
|
|
515
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
516
|
+
viewBox="0 0 24 24"
|
|
517
|
+
fill="none"
|
|
518
|
+
stroke="currentColor"
|
|
519
|
+
stroke-width="2"
|
|
520
|
+
aria-hidden="true"
|
|
521
|
+
>
|
|
522
|
+
<path
|
|
523
|
+
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
|
524
|
+
/>
|
|
525
|
+
<polyline
|
|
526
|
+
points="14 2 14 8 20 8"
|
|
527
|
+
/>
|
|
528
|
+
</svg>
|
|
529
|
+
<span class="ai-source-title"
|
|
530
|
+
>{src.pageTitle}</span
|
|
531
|
+
>
|
|
532
|
+
{#if src.sectionHeading}
|
|
533
|
+
<span
|
|
534
|
+
class="ai-source-section"
|
|
535
|
+
> › {src.sectionHeading}</span
|
|
536
|
+
>
|
|
537
|
+
{/if}
|
|
538
|
+
</button>
|
|
539
|
+
</li>
|
|
540
|
+
{/each}
|
|
541
|
+
</ul>
|
|
542
|
+
</div>
|
|
543
|
+
{/if}
|
|
544
|
+
{/if}
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
{/each}
|
|
548
|
+
|
|
549
|
+
{#if isLoading}
|
|
550
|
+
<div class="ai-message assistant">
|
|
551
|
+
{#if currentCharacter()}
|
|
552
|
+
{@render iconDisplay(currentCharacter()!.icon, 'ai-msg-icon')}
|
|
553
|
+
{/if}
|
|
554
|
+
<div class="ai-loading">
|
|
555
|
+
<span class="ai-spinner" aria-hidden="true"></span>
|
|
556
|
+
{loadingText}
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
{/if}
|
|
560
|
+
{/if}
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
<!-- ── Input area ───────────────────────────────────────────────────── -->
|
|
564
|
+
<div class="ai-input-area">
|
|
565
|
+
<textarea
|
|
566
|
+
bind:this={inputEl}
|
|
567
|
+
bind:value={query}
|
|
568
|
+
onkeydown={handleKeydown}
|
|
569
|
+
rows={2}
|
|
570
|
+
class="ai-textarea"
|
|
571
|
+
{placeholder}
|
|
572
|
+
disabled={isLoading}
|
|
573
|
+
autocomplete="off"
|
|
574
|
+
spellcheck="false"
|
|
575
|
+
aria-label={placeholder}
|
|
576
|
+
></textarea>
|
|
577
|
+
<button
|
|
578
|
+
class="ai-send-btn"
|
|
579
|
+
onclick={submit}
|
|
580
|
+
disabled={!query.trim() || isLoading}
|
|
581
|
+
type="button"
|
|
582
|
+
title={sendLabel}
|
|
583
|
+
aria-label={sendLabel}
|
|
584
|
+
>
|
|
585
|
+
<svg
|
|
586
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
587
|
+
viewBox="0 0 24 24"
|
|
588
|
+
fill="none"
|
|
589
|
+
stroke="currentColor"
|
|
590
|
+
stroke-width="2"
|
|
591
|
+
aria-hidden="true"
|
|
592
|
+
>
|
|
593
|
+
<line x1="22" y1="2" x2="11" y2="13" />
|
|
594
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
|
595
|
+
</svg>
|
|
596
|
+
</button>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
|
|
600
|
+
<style lang="scss">
|
|
601
|
+
.ai-chat {
|
|
602
|
+
display: flex;
|
|
603
|
+
flex-direction: column;
|
|
604
|
+
flex: 1;
|
|
605
|
+
min-height: 0;
|
|
606
|
+
overflow: hidden;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* ── Character selector ──────────────────────────────────── */
|
|
610
|
+
|
|
611
|
+
.ai-character-bar {
|
|
612
|
+
display: flex;
|
|
613
|
+
flex-wrap: wrap;
|
|
614
|
+
gap: 0.3rem;
|
|
615
|
+
padding: 0.5rem 0.75rem;
|
|
616
|
+
border-bottom: 1px solid var(--greg-border-color);
|
|
617
|
+
flex-shrink: 0;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.ai-character-btn {
|
|
621
|
+
display: inline-flex;
|
|
622
|
+
align-items: center;
|
|
623
|
+
gap: 0.3rem;
|
|
624
|
+
padding: 0.25rem 0.6rem;
|
|
625
|
+
border-radius: 999px;
|
|
626
|
+
border: 1px solid var(--greg-border-color);
|
|
627
|
+
background: transparent;
|
|
628
|
+
color: var(--greg-menu-section-color);
|
|
629
|
+
cursor: pointer;
|
|
630
|
+
font-size: 0.72rem;
|
|
631
|
+
font-family: inherit;
|
|
632
|
+
white-space: nowrap;
|
|
633
|
+
transition:
|
|
634
|
+
border-color 0.15s,
|
|
635
|
+
color 0.15s,
|
|
636
|
+
background 0.15s;
|
|
637
|
+
|
|
638
|
+
&:hover {
|
|
639
|
+
border-color: var(--greg-accent);
|
|
640
|
+
color: var(--greg-accent);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
&.active {
|
|
644
|
+
background: var(--greg-accent);
|
|
645
|
+
border-color: var(--greg-accent);
|
|
646
|
+
color: #fff;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.char-icon {
|
|
651
|
+
font-size: 2rem;
|
|
652
|
+
line-height: 1;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.char-name {
|
|
656
|
+
font-weight: 500;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/* ── Message area ─────────────────────────────────────────── */
|
|
660
|
+
|
|
661
|
+
.ai-messages {
|
|
662
|
+
flex: 1;
|
|
663
|
+
overflow-y: auto;
|
|
664
|
+
padding: 0.75rem;
|
|
665
|
+
display: flex;
|
|
666
|
+
flex-direction: column;
|
|
667
|
+
gap: 0.65rem;
|
|
668
|
+
min-height: 120px;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.ai-start-hint {
|
|
672
|
+
display: flex;
|
|
673
|
+
flex-direction: column;
|
|
674
|
+
align-items: center;
|
|
675
|
+
justify-content: center;
|
|
676
|
+
flex: 1;
|
|
677
|
+
gap: 0.5rem;
|
|
678
|
+
color: var(--greg-menu-section-color);
|
|
679
|
+
font-size: 0.8rem;
|
|
680
|
+
text-align: center;
|
|
681
|
+
padding: 2rem 1.5rem;
|
|
682
|
+
|
|
683
|
+
p {
|
|
684
|
+
margin: 0;
|
|
685
|
+
line-height: 1.5;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.ai-start-icon {
|
|
690
|
+
font-size: 3.2rem;
|
|
691
|
+
line-height: 1;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.ai-message {
|
|
695
|
+
display: flex;
|
|
696
|
+
align-items: flex-start;
|
|
697
|
+
gap: 0.4rem;
|
|
698
|
+
|
|
699
|
+
&.user {
|
|
700
|
+
flex-direction: row-reverse;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
&.assistant {
|
|
704
|
+
flex-direction: row;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.ai-msg-icon {
|
|
709
|
+
font-size: 3rem;
|
|
710
|
+
line-height: 1;
|
|
711
|
+
padding-top: 0.1rem;
|
|
712
|
+
flex-shrink: 0;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.ai-message-body {
|
|
716
|
+
max-width: 90%;
|
|
717
|
+
display: flex;
|
|
718
|
+
flex-direction: column;
|
|
719
|
+
gap: 0.4rem;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.ai-user-text {
|
|
723
|
+
background: var(--greg-accent);
|
|
724
|
+
color: #fff;
|
|
725
|
+
padding: 0.5rem 0.85rem;
|
|
726
|
+
border-radius: 12px 12px 2px 12px;
|
|
727
|
+
font-size: 0.875rem;
|
|
728
|
+
line-height: 1.5;
|
|
729
|
+
margin: 0;
|
|
730
|
+
word-break: break-word;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.ai-answer {
|
|
734
|
+
font-size: 0.875rem;
|
|
735
|
+
line-height: 1.65;
|
|
736
|
+
color: var(--greg-color);
|
|
737
|
+
word-break: break-word;
|
|
738
|
+
background: var(--greg-menu-hover-background);
|
|
739
|
+
padding: 0.65rem 0.9rem;
|
|
740
|
+
border-radius: 2px 12px 12px 12px;
|
|
741
|
+
|
|
742
|
+
:global(p) {
|
|
743
|
+
margin: 0 0 0.45em;
|
|
744
|
+
&:last-child { margin-bottom: 0; }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
:global(strong) { font-weight: 700; }
|
|
748
|
+
:global(em) { font-style: italic; }
|
|
749
|
+
|
|
750
|
+
:global(code) {
|
|
751
|
+
font-family: var(--vp-font-family-mono, 'Fira Code', 'Cascadia Code', monospace);
|
|
752
|
+
font-size: 0.82em;
|
|
753
|
+
background: var(--greg-code-block-bg, rgba(0, 0, 0, 0.06));
|
|
754
|
+
padding: 0.1em 0.3em;
|
|
755
|
+
border-radius: 3px;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
:global(a[data-nav]) {
|
|
759
|
+
color: var(--greg-accent);
|
|
760
|
+
text-decoration: underline;
|
|
761
|
+
cursor: pointer;
|
|
762
|
+
&:hover { opacity: 0.8; }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
:global(a[target='_blank']) {
|
|
766
|
+
color: var(--greg-accent);
|
|
767
|
+
text-decoration: underline;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.error .ai-answer {
|
|
772
|
+
color: var(--greg-danger-text, #e53e3e);
|
|
773
|
+
border-left-color: var(--greg-danger-text, #e53e3e);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/* ── Sources ──────────────────────────────────────────────── */
|
|
777
|
+
|
|
778
|
+
.ai-sources {
|
|
779
|
+
font-size: 0.72rem;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.ai-sources-label {
|
|
783
|
+
color: var(--greg-menu-section-color);
|
|
784
|
+
font-weight: 700;
|
|
785
|
+
text-transform: uppercase;
|
|
786
|
+
letter-spacing: 0.06em;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
.ai-sources-list {
|
|
790
|
+
list-style: none;
|
|
791
|
+
margin: 0.3rem 0 0;
|
|
792
|
+
padding: 0;
|
|
793
|
+
display: flex;
|
|
794
|
+
flex-direction: column;
|
|
795
|
+
gap: 0.15rem;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.ai-source-btn {
|
|
799
|
+
display: inline-flex;
|
|
800
|
+
align-items: center;
|
|
801
|
+
gap: 0.3rem;
|
|
802
|
+
color: var(--greg-accent);
|
|
803
|
+
background: none;
|
|
804
|
+
border: none;
|
|
805
|
+
cursor: pointer;
|
|
806
|
+
padding: 0;
|
|
807
|
+
font-size: 0.72rem;
|
|
808
|
+
font-family: inherit;
|
|
809
|
+
text-align: left;
|
|
810
|
+
|
|
811
|
+
&:hover {
|
|
812
|
+
text-decoration: underline;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.ai-source-icon {
|
|
817
|
+
width: 11px;
|
|
818
|
+
height: 11px;
|
|
819
|
+
flex-shrink: 0;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
.ai-source-title {
|
|
823
|
+
font-weight: 600;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.ai-source-section {
|
|
827
|
+
color: var(--greg-menu-section-color);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/* ── Loading state ────────────────────────────────────────── */
|
|
831
|
+
|
|
832
|
+
.ai-loading {
|
|
833
|
+
display: flex;
|
|
834
|
+
align-items: center;
|
|
835
|
+
gap: 0.5rem;
|
|
836
|
+
color: var(--greg-menu-section-color);
|
|
837
|
+
font-size: 0.8rem;
|
|
838
|
+
padding: 0.4rem 0;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.ai-spinner {
|
|
842
|
+
display: inline-block;
|
|
843
|
+
width: 1em;
|
|
844
|
+
height: 1em;
|
|
845
|
+
border: 2px solid var(--greg-border-color);
|
|
846
|
+
border-top-color: var(--greg-accent);
|
|
847
|
+
border-radius: 50%;
|
|
848
|
+
animation: spin 0.6s linear infinite;
|
|
849
|
+
flex-shrink: 0;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
@keyframes spin {
|
|
853
|
+
to {
|
|
854
|
+
transform: rotate(360deg);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/* ── Input area ───────────────────────────────────────────── */
|
|
859
|
+
|
|
860
|
+
.ai-input-area {
|
|
861
|
+
display: flex;
|
|
862
|
+
align-items: flex-end;
|
|
863
|
+
gap: 0.5rem;
|
|
864
|
+
padding: 0.625rem 0.75rem;
|
|
865
|
+
border-top: 1px solid var(--greg-border-color);
|
|
866
|
+
flex-shrink: 0;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
.ai-textarea {
|
|
870
|
+
flex: 1;
|
|
871
|
+
background: var(--greg-menu-hover-background);
|
|
872
|
+
border: 1px solid var(--greg-border-color);
|
|
873
|
+
border-radius: 8px;
|
|
874
|
+
padding: 0.5rem 0.7rem;
|
|
875
|
+
color: var(--greg-color);
|
|
876
|
+
font-size: 0.875rem;
|
|
877
|
+
font-family: inherit;
|
|
878
|
+
line-height: 1.5;
|
|
879
|
+
resize: none;
|
|
880
|
+
outline: none;
|
|
881
|
+
transition: border-color 0.15s;
|
|
882
|
+
min-width: 0;
|
|
883
|
+
|
|
884
|
+
&::placeholder {
|
|
885
|
+
color: var(--greg-menu-section-color);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
&:focus {
|
|
889
|
+
border-color: var(--greg-accent);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
&:disabled {
|
|
893
|
+
opacity: 0.6;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
.ai-send-btn {
|
|
898
|
+
width: 36px;
|
|
899
|
+
align-self: stretch;
|
|
900
|
+
min-height: 36px;
|
|
901
|
+
border-radius: 8px;
|
|
902
|
+
border: none;
|
|
903
|
+
background: var(--greg-accent);
|
|
904
|
+
color: #fff;
|
|
905
|
+
cursor: pointer;
|
|
906
|
+
display: flex;
|
|
907
|
+
align-items: center;
|
|
908
|
+
justify-content: center;
|
|
909
|
+
flex-shrink: 0;
|
|
910
|
+
transition: opacity 0.15s;
|
|
911
|
+
|
|
912
|
+
svg {
|
|
913
|
+
width: 14px;
|
|
914
|
+
height: 14px;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
&:disabled {
|
|
918
|
+
opacity: 0.35;
|
|
919
|
+
cursor: default;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
&:not(:disabled):hover {
|
|
923
|
+
opacity: 0.82;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* ── Image icons ──────────────────────────────────────────── */
|
|
928
|
+
|
|
929
|
+
.icon-img {
|
|
930
|
+
width: 1em;
|
|
931
|
+
height: 1em;
|
|
932
|
+
object-fit: cover;
|
|
933
|
+
border-radius: 50%;
|
|
934
|
+
display: block;
|
|
935
|
+
}
|
|
936
|
+
</style>
|