diff --git a/AGENTS.md b/AGENTS.md index 76ba6277..d73be455 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ alwaysApply: true - [GT1a-l] Git, history safety, hooks/signing, lock files, and clean commits - [CC1a-d] Clean Code & DDD (Mandatory) - [ID1a-d] Idiomatic Patterns & Defaults +- [EV1a-f] Secrets and Env Vars (no secrets in properties; OpenAI/Qdrant via .env/env) - [RC1a-f] Root Cause Resolution (single implementation, no fallbacks, no shims/workarounds) - [FS1a-k] File Creation & Clean Architecture (search first, strict types, single responsibility) - [TY1a-d] Type Safety (strict generics, no raw types, no unchecked casts) @@ -70,6 +71,17 @@ alwaysApply: true - [ID1c] **No Reinventing**: Do not build custom utilities for things the platform already does (e.g., use standard `Optional`, `Stream`, Spring `RestTemplate`/`WebClient`). - [ID1d] **Dependencies**: Make careful use of dependencies. Do not make assumptions—use the correct idiomatic behavior to avoid boilerplate. +## [EV1] Secrets and Env Vars + +- [EV1a] **No Secrets In Properties/YAML**: Secrets are PROHIBITED in `.properties` and `.yml/.yaml` (including examples). Do not add secret-looking keys with blank defaults to property files. +- [EV1b] **Secrets Source Of Truth**: Secrets MUST be provided via `.env` (local) and environment variables (deployment). Deployment uses Coolify containers with BuildKit secrets; keep secrets out of tracked config. +- [EV1c] **Non-Secret Defaults In Properties**: All non-secret defaults and environment-specific overrides MUST live in Spring property files (`src/main/resources/application*.properties`) and Spring profiles. +- [EV1d] **Allowed `.env`/Env Vars For External Services**: OpenAI/GitHub Models and Qdrant connectivity MUST come from `.env`/environment variables: + - LLM (model/base-url/api-key): `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`, `GITHUB_TOKEN`, `GITHUB_MODELS_BASE_URL`, `GITHUB_MODELS_CHAT_MODEL` + - Qdrant (host/ports/tls/api-key): `QDRANT_HOST`, `QDRANT_PORT`, `QDRANT_REST_PORT`, `QDRANT_SSL`, `QDRANT_API_KEY` +- [EV1e] **No New Env Var Settings**: Do not introduce any additional env-var-driven settings without explicit written approval. +- [EV1f] **Dotenv Handling**: `.env` loading MUST remain supported for local development and scripts. Do not add dotenv libraries into the Java runtime; load env vars at the process level (Makefile/scripts/Coolify). + ## [RC1] Root Cause Resolution — No Fallbacks - [RC1a] **One Way**: Ship one proven implementation—no fallback paths, no "try X then Y", no silent degradation. @@ -183,13 +195,13 @@ alwaysApply: true - [TL1b] **Docker**: `docker compose up -d` for Qdrant vector store. - [TL1c] **Ingest**: `curl -X POST http://localhost:8080/api/ingest ...`. - [TL1d] **Stream**: `curl -N http://localhost:8080/api/chat/stream ...`. -- [TL1e] **Secrets**: `.env` for secrets (`GITHUB_TOKEN`, `QDRANT_URL`); never commit secrets. +- [TL1e] **Secrets**: Never commit secrets. Secrets MUST live in `.env` (local) and environment variables (deployment). Secrets are PROHIBITED in `.properties`/`.yml` files. ## [LM1] LLM & Streaming - [LM1a] **Settings**: Do not change any LLM settings without explicit written approval. - [LM1b] **No Fallback**: Do not auto-fallback or regress models across providers; surface error to user. -- [LM1c] **Config**: Use values from environment variables and `application.properties` exactly as configured. +- [LM1c] **Config**: OpenAI/GitHub Models model/base-url/api-key MUST come from `.env`/environment variables (see [EV1d]). All other LLM settings MUST come from Spring property files and `@ConfigurationProperties`. - [LM1d] **Behavior**: Allowed: logging diagnostics. Not allowed: silently changing LLM behavior. - [LM1e] **Streaming**: TTFB < 200ms, streaming start < 500ms. - [LM1f] **Events**: `text`, `citation`, `code`, `enrichment`, `suggestion`, `status`. diff --git a/frontend/config/oxlintrc.json b/frontend/config/oxlintrc.json index e28bec1b..fc460a63 100644 --- a/frontend/config/oxlintrc.json +++ b/frontend/config/oxlintrc.json @@ -31,9 +31,12 @@ "rules": { "no-empty": ["error", { "allowEmptyCatch": false }], "no-await-in-loop": "off", - "import/no-unassigned-import": ["error", { - "allow": ["**/*.css", "@testing-library/**"] - }], + "import/no-unassigned-import": [ + "error", + { + "allow": ["**/*.css", "@testing-library/**"] + } + ], "typescript/no-explicit-any": "error", "typescript/no-unsafe-type-assertion": "warn", "typescript/no-floating-promises": "warn", diff --git a/frontend/index.html b/frontend/index.html index 060a2e69..06ce72b3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - +
@@ -12,7 +12,10 @@ name="description" content="Learn Java faster with an AI tutor: streaming answers, code examples, and citations to official docs." /> - +')
- expect(renderedHtml).toContain(' {
- const markdown = ''
- const renderedHtml = parseMarkdown(markdown)
- expect(renderedHtml).not.toContain('';
+ const renderedHtml = parseMarkdown(markdown);
+ expect(renderedHtml).not.toContain("'
- const escapedHtml = escapeHtml(input)
- expect(escapedHtml).not.toContain('<')
- expect(escapedHtml).not.toContain('>')
- expect(escapedHtml).toContain('<')
- expect(escapedHtml).toContain('>')
- })
-
- it('is SSR-safe - uses pure string operations', () => {
+ });
+});
+
+describe("escapeHtml", () => {
+ it("escapes HTML special characters", () => {
+ expect(escapeHtml("")).toBe("<div>");
+ expect(escapeHtml('"quoted"')).toBe(""quoted"");
+ expect(escapeHtml("it's")).toBe("it's");
+ expect(escapeHtml("a & b")).toBe("a & b");
+ });
+
+ it("returns empty string for empty input", () => {
+ expect(escapeHtml("")).toBe("");
+ });
+
+ it("handles complex mixed content", () => {
+ const input = '';
+ const escapedHtml = escapeHtml(input);
+ expect(escapedHtml).not.toContain("<");
+ expect(escapedHtml).not.toContain(">");
+ expect(escapedHtml).toContain("<");
+ expect(escapedHtml).toContain(">");
+ });
+
+ it("is SSR-safe - uses pure string operations", () => {
// This works without document APIs
- const escapedHtml = escapeHtml('')
- expect(escapedHtml).toBe('<div class="test">')
- })
-})
+ const escapedHtml = escapeHtml('');
+ expect(escapedHtml).toBe("<div class="test">");
+ });
+});
diff --git a/frontend/src/lib/services/markdown.ts b/frontend/src/lib/services/markdown.ts
index e2f2d6b1..2a10efcb 100644
--- a/frontend/src/lib/services/markdown.ts
+++ b/frontend/src/lib/services/markdown.ts
@@ -1,5 +1,5 @@
-import { marked, type TokenizerExtension, type RendererExtension, type Tokens } from 'marked'
-import DOMPurify from 'dompurify'
+import { marked, type TokenizerExtension, type RendererExtension, type Tokens } from "marked";
+import DOMPurify from "dompurify";
/**
* Enrichment kinds with their display metadata.
@@ -7,111 +7,111 @@ import DOMPurify from 'dompurify'
*/
const ENRICHMENT_KINDS: Record = {
hint: {
- title: 'Helpful Hints',
- icon: ''
+ title: "Helpful Hints",
+ icon: '',
},
background: {
- title: 'Background Context',
- icon: ''
+ title: "Background Context",
+ icon: '',
},
reminder: {
- title: 'Important Reminders',
- icon: ''
+ title: "Important Reminders",
+ icon: '',
},
warning: {
- title: 'Warning',
- icon: ''
+ title: "Warning",
+ icon: '',
},
example: {
- title: 'Example',
- icon: ''
- }
-}
+ title: "Example",
+ icon: '',
+ },
+};
interface EnrichmentToken extends Tokens.Generic {
- type: 'enrichment'
- raw: string
- kind: string
- content: string
+ type: "enrichment";
+ raw: string;
+ kind: string;
+ content: string;
}
/** Pattern matching code fence delimiters (3+ backticks or tildes at line start). */
-const FENCE_PATTERN = /^[ \t]*(`{3,}|~{3,})/
-const FENCE_MIN_LENGTH = 3
-const NEWLINE = '\n'
+const FENCE_PATTERN = /^[ \t]*(`{3,}|~{3,})/;
+const FENCE_MIN_LENGTH = 3;
+const NEWLINE = "\n";
-type FenceMarker = { character: string; length: number }
+type FenceMarker = { character: string; length: number };
function scanFenceMarker(src: string, index: number): FenceMarker | null {
if (index < 0 || index >= src.length) {
- return null
+ return null;
}
- const markerChar = src[index]
- if (markerChar !== '`' && markerChar !== '~') {
- return null
+ const markerChar = src[index];
+ if (markerChar !== "`" && markerChar !== "~") {
+ return null;
}
- let markerLength = 0
+ let markerLength = 0;
while (index + markerLength < src.length && src[index + markerLength] === markerChar) {
- markerLength++
+ markerLength++;
}
if (markerLength < FENCE_MIN_LENGTH) {
- return null
+ return null;
}
- return { character: markerChar, length: markerLength }
+ return { character: markerChar, length: markerLength };
}
function isFenceLanguageCharacter(character: string): boolean {
if (character.length !== 1) {
- return false
+ return false;
}
- const charCode = character.charCodeAt(0)
- const isLowerAlpha = charCode >= 97 && charCode <= 122
- const isUpperAlpha = charCode >= 65 && charCode <= 90
- const isDigit = charCode >= 48 && charCode <= 57
- return isLowerAlpha || isUpperAlpha || isDigit || character === '-' || character === '_'
+ const charCode = character.charCodeAt(0);
+ const isLowerAlpha = charCode >= 97 && charCode <= 122;
+ const isUpperAlpha = charCode >= 65 && charCode <= 90;
+ const isDigit = charCode >= 48 && charCode <= 57;
+ return isLowerAlpha || isUpperAlpha || isDigit || character === "-" || character === "_";
}
function isAttachedFenceStart(src: string, index: number): boolean {
if (index <= 0 || index >= src.length) {
- return false
+ return false;
}
- return !/\s/.test(src[index - 1])
+ return !/\s/.test(src[index - 1]);
}
function appendLineBreakIfNeeded(text: string): string {
if (text.length === 0 || text.endsWith(NEWLINE)) {
- return text
+ return text;
}
- return `${text}${NEWLINE}`
+ return `${text}${NEWLINE}`;
}
/** Result of consuming a fence marker and its trailing language tag or newline. */
-type ConsumedFence = { text: string; nextCursor: number }
+type ConsumedFence = { text: string; nextCursor: number };
/** Consumes an opening fence marker plus any language tag, ensuring a trailing newline. */
function consumeOpeningFence(content: string, cursor: number, marker: FenceMarker): ConsumedFence {
- let text = content.slice(cursor, cursor + marker.length)
- let pos = cursor + marker.length
+ let text = content.slice(cursor, cursor + marker.length);
+ let pos = cursor + marker.length;
while (pos < content.length && isFenceLanguageCharacter(content[pos])) {
- text += content[pos]
- pos++
+ text += content[pos];
+ pos++;
}
if (pos < content.length && content[pos] !== NEWLINE) {
- text += NEWLINE
+ text += NEWLINE;
}
- return { text, nextCursor: pos }
+ return { text, nextCursor: pos };
}
/** Consumes a closing fence marker, ensuring a trailing newline. */
function consumeClosingFence(content: string, cursor: number, marker: FenceMarker): ConsumedFence {
- const text = content.slice(cursor, cursor + marker.length)
- const pos = cursor + marker.length
- const suffix = pos < content.length && content[pos] !== NEWLINE ? NEWLINE : ''
- return { text: text + suffix, nextCursor: pos }
+ const text = content.slice(cursor, cursor + marker.length);
+ const pos = cursor + marker.length;
+ const suffix = pos < content.length && content[pos] !== NEWLINE ? NEWLINE : "";
+ return { text: text + suffix, nextCursor: pos };
}
/**
@@ -122,49 +122,55 @@ function consumeClosingFence(content: string, cursor: number, marker: FenceMarke
*/
function normalizeMarkdownForStreaming(content: string): string {
if (!content) {
- return ''
+ return "";
}
- let normalized = ''
- let inFence = false
- let fenceChar = ''
- let fenceLength = 0
+ let normalized = "";
+ let inFence = false;
+ let fenceChar = "";
+ let fenceLength = 0;
for (let cursor = 0; cursor < content.length; ) {
- const startOfLine = cursor === 0 || content[cursor - 1] === NEWLINE
- const marker = scanFenceMarker(content, cursor)
+ const startOfLine = cursor === 0 || content[cursor - 1] === NEWLINE;
+ const marker = scanFenceMarker(content, cursor);
if (marker && !inFence && (startOfLine || isAttachedFenceStart(content, cursor))) {
- normalized = appendLineBreakIfNeeded(normalized)
- const consumed = consumeOpeningFence(content, cursor, marker)
- normalized += consumed.text
- cursor = consumed.nextCursor
- inFence = true
- fenceChar = marker.character
- fenceLength = marker.length
- continue
+ normalized = appendLineBreakIfNeeded(normalized);
+ const consumed = consumeOpeningFence(content, cursor, marker);
+ normalized += consumed.text;
+ cursor = consumed.nextCursor;
+ inFence = true;
+ fenceChar = marker.character;
+ fenceLength = marker.length;
+ continue;
}
- if (marker && inFence && startOfLine && marker.character === fenceChar && marker.length >= fenceLength) {
- normalized = appendLineBreakIfNeeded(normalized)
- const consumed = consumeClosingFence(content, cursor, marker)
- normalized += consumed.text
- cursor = consumed.nextCursor
- inFence = false
- fenceChar = ''
- fenceLength = 0
- continue
+ if (
+ marker &&
+ inFence &&
+ startOfLine &&
+ marker.character === fenceChar &&
+ marker.length >= fenceLength
+ ) {
+ normalized = appendLineBreakIfNeeded(normalized);
+ const consumed = consumeClosingFence(content, cursor, marker);
+ normalized += consumed.text;
+ cursor = consumed.nextCursor;
+ inFence = false;
+ fenceChar = "";
+ fenceLength = 0;
+ continue;
}
- normalized += content[cursor]
- cursor++
+ normalized += content[cursor];
+ cursor++;
}
if (inFence && fenceChar) {
- normalized += `${NEWLINE}${fenceChar.repeat(Math.max(fenceLength, FENCE_MIN_LENGTH))}`
+ normalized += `${NEWLINE}${fenceChar.repeat(Math.max(fenceLength, FENCE_MIN_LENGTH))}`;
}
- return normalized
+ return normalized;
}
/**
@@ -172,47 +178,47 @@ function normalizeMarkdownForStreaming(content: string): string {
* Uses simple line-by-line scan with toggle semantics.
*/
function hasBalancedCodeFences(content: string): boolean {
- let depth = 0
- let openChar = ''
- let openLen = 0
+ let depth = 0;
+ let openChar = "";
+ let openLen = 0;
- for (const line of content.split('\n')) {
- const match = line.match(FENCE_PATTERN)
- if (!match) continue
+ for (const line of content.split("\n")) {
+ const match = line.match(FENCE_PATTERN);
+ if (!match) continue;
- const fence = match[1]
+ const fence = match[1];
if (depth === 0) {
// Opening fence
- depth = 1
- openChar = fence[0]
- openLen = fence.length
+ depth = 1;
+ openChar = fence[0];
+ openLen = fence.length;
} else if (fence[0] === openChar && fence.length >= openLen) {
// Matching closing fence
- depth = 0
- openChar = ''
- openLen = 0
+ depth = 0;
+ openChar = "";
+ openLen = 0;
}
}
- return depth === 0
+ return depth === 0;
}
/** Enrichment close marker. */
-const ENRICHMENT_CLOSE = '}}'
+const ENRICHMENT_CLOSE = "}}";
/**
* Resolves the close marker position for a run of closing braces.
* For runs like "}}}", this picks the final "}}" so a trailing content "}" is preserved.
*/
function resolveCloseIndexFromBraceRun(src: string, runStart: number): number {
- let runLength = 0
- while (runStart + runLength < src.length && src[runStart + runLength] === '}') {
- runLength++
+ let runLength = 0;
+ while (runStart + runLength < src.length && src[runStart + runLength] === "}") {
+ runLength++;
}
if (runLength < ENRICHMENT_CLOSE.length) {
- return -1
+ return -1;
}
- return runStart + (runLength - ENRICHMENT_CLOSE.length)
+ return runStart + (runLength - ENRICHMENT_CLOSE.length);
}
/**
@@ -220,40 +226,40 @@ function resolveCloseIndexFromBraceRun(src: string, runStart: number): number {
* Scans character-by-character, tracking fence state at line boundaries.
*/
function findEnrichmentClose(src: string, startIndex: number): number {
- let inFence = false
- let fenceChar = ''
- let fenceLen = 0
+ let inFence = false;
+ let fenceChar = "";
+ let fenceLen = 0;
for (let cursor = startIndex; cursor < src.length - 1; cursor++) {
// At line boundaries, check for fence delimiters
- if (cursor === startIndex || src[cursor - 1] === '\n') {
- const lineMatch = src.slice(cursor).match(FENCE_PATTERN)
+ if (cursor === startIndex || src[cursor - 1] === "\n") {
+ const lineMatch = src.slice(cursor).match(FENCE_PATTERN);
if (lineMatch) {
- const fence = lineMatch[1]
+ const fence = lineMatch[1];
if (!inFence) {
- inFence = true
- fenceChar = fence[0]
- fenceLen = fence.length
+ inFence = true;
+ fenceChar = fence[0];
+ fenceLen = fence.length;
} else if (fence[0] === fenceChar && fence.length >= fenceLen) {
- inFence = false
- fenceChar = ''
- fenceLen = 0
+ inFence = false;
+ fenceChar = "";
+ fenceLen = 0;
}
- cursor += fence.length - 1 // -1 because loop will increment
- continue
+ cursor += fence.length - 1; // -1 because loop will increment
+ continue;
}
}
// Check for closing marker only outside fences
- if (!inFence && src[cursor] === '}') {
- const closeIndex = resolveCloseIndexFromBraceRun(src, cursor)
+ if (!inFence && src[cursor] === "}") {
+ const closeIndex = resolveCloseIndexFromBraceRun(src, cursor);
if (closeIndex >= 0) {
- return closeIndex
+ return closeIndex;
}
}
}
- return -1
+ return -1;
}
/**
@@ -262,58 +268,62 @@ function findEnrichmentClose(src: string, startIndex: number): number {
*/
function createEnrichmentExtension(): TokenizerExtension & RendererExtension {
return {
- name: 'enrichment',
- level: 'block',
+ name: "enrichment",
+ level: "block",
start(src: string) {
- return src.indexOf('{{')
+ return src.indexOf("{{");
},
tokenizer(src: string): EnrichmentToken | undefined {
// Match opening {{kind: pattern
- const openingRule = /^\{\{(hint|warning|background|example|reminder):/
- const openingMatch = openingRule.exec(src)
+ const openingRule = /^\{\{(hint|warning|background|example|reminder):/;
+ const openingMatch = openingRule.exec(src);
if (!openingMatch) {
- return undefined
+ return undefined;
}
- const kind = openingMatch[1].toLowerCase()
- const contentStart = openingMatch[0].length
+ const kind = openingMatch[1].toLowerCase();
+ const contentStart = openingMatch[0].length;
// Find closing }} that's not inside a code fence
- const closeIndex = findEnrichmentClose(src, contentStart)
+ const closeIndex = findEnrichmentClose(src, contentStart);
if (closeIndex === -1) {
- return undefined
+ return undefined;
}
- const content = src.slice(contentStart, closeIndex)
- const raw = src.slice(0, closeIndex + 2)
+ const content = src.slice(contentStart, closeIndex);
+ const raw = src.slice(0, closeIndex + 2);
return {
- type: 'enrichment',
+ type: "enrichment",
raw,
kind,
- content: content.trim()
- }
+ content: content.trim(),
+ };
},
renderer(token: Tokens.Generic): string {
- const enrichmentToken = token as EnrichmentToken
- const meta = ENRICHMENT_KINDS[enrichmentToken.kind]
+ if (token.type !== "enrichment") {
+ return token.raw;
+ }
+
+ const kind = typeof token.kind === "string" ? token.kind : "";
+ const contentToRender = typeof token.content === "string" ? token.content : "";
+ const meta = ENRICHMENT_KINDS[kind];
if (!meta) {
- return token.raw
+ return token.raw;
}
- const contentToRender = enrichmentToken.content
- const normalizedContent = normalizeMarkdownForStreaming(contentToRender)
+ const normalizedContent = normalizeMarkdownForStreaming(contentToRender);
// DIAGNOSTIC: Log enrichment content to identify malformed markdown
if (import.meta.env.DEV) {
- const hasFences = normalizedContent.includes('```') || normalizedContent.includes('~~~')
- const isBalanced = hasBalancedCodeFences(normalizedContent)
+ const hasFences = normalizedContent.includes("```") || normalizedContent.includes("~~~");
+ const isBalanced = hasBalancedCodeFences(normalizedContent);
if (hasFences && !isBalanced) {
- console.warn('[markdown] Unbalanced code fences in enrichment:', {
- kind: enrichmentToken.kind,
+ console.warn("[markdown] Unbalanced code fences in enrichment:", {
+ kind,
content: normalizedContent,
- raw: enrichmentToken.raw
- })
+ raw: token.raw,
+ });
}
}
@@ -322,32 +332,40 @@ function createEnrichmentExtension(): TokenizerExtension & RendererExtension {
const innerHtml = marked.parse(normalizedContent, {
async: false,
gfm: true,
- breaks: false // Preserve fence detection accuracy
- }) as string
+ breaks: false, // Preserve fence detection accuracy
+ });
- return `
-${meta.icon}${meta.title}
-${innerHtml}
-`
- }
- }
+ return `
+ ${meta.icon}${meta.title}
+ ${innerHtml}
+`;
+ },
+ };
}
-
// Configure marked once at module load
marked.use({
gfm: true,
breaks: true,
- extensions: [createEnrichmentExtension()]
-})
+ extensions: [createEnrichmentExtension()],
+});
/** Keywords indicating Java code for auto-detection. */
-const JAVA_KEYWORDS = ['public', 'private', 'class', 'import', 'void', 'String', 'int', 'boolean'] as const
+const JAVA_KEYWORDS = [
+ "public",
+ "private",
+ "class",
+ "import",
+ "void",
+ "String",
+ "int",
+ "boolean",
+] as const;
/** CSS class applied to detected Java code blocks for syntax highlighting. */
-const JAVA_LANGUAGE_CLASS = 'language-java'
+const JAVA_LANGUAGE_CLASS = "language-java";
/** Selector for unmarked code blocks eligible for language detection. */
-const UNMARKED_CODE_SELECTOR = 'pre > code:not([class])'
+const UNMARKED_CODE_SELECTOR = "pre > code:not([class])";
/**
* Parse markdown to sanitized HTML. SSR-safe - no DOM APIs used.
@@ -355,35 +373,35 @@ const UNMARKED_CODE_SELECTOR = 'pre > code:not([class])'
*
* @throws Never throws - returns empty string on parse failure and logs error in dev mode
*/
-export function parseMarkdown(content: string): string {
+export function parseMarkdown(content: string | null | undefined): string {
if (!content) {
- return ''
+ return "";
}
- const normalizedContent = normalizeMarkdownForStreaming(content)
+ const normalizedContent = normalizeMarkdownForStreaming(content);
// DIAGNOSTIC: Log content with unbalanced fences before parsing
if (import.meta.env.DEV) {
- const hasFences = normalizedContent.includes('```') || normalizedContent.includes('~~~')
+ const hasFences = normalizedContent.includes("```") || normalizedContent.includes("~~~");
if (hasFences && !hasBalancedCodeFences(normalizedContent)) {
- console.warn('[markdown] Unbalanced code fences in input:', {
+ console.warn("[markdown] Unbalanced code fences in input:", {
contentLength: normalizedContent.length,
contentPreview: normalizedContent.slice(0, 500),
- contentEnd: normalizedContent.slice(-200)
- })
+ contentEnd: normalizedContent.slice(-200),
+ });
}
}
try {
- const rawHtml = marked.parse(normalizedContent, { async: false }) as string
+ const rawHtml = marked.parse(normalizedContent, { async: false });
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
- ADD_ATTR: ['class', 'data-enrichment-type']
- })
+ ADD_ATTR: ["class", "data-enrichment-type"],
+ });
} catch (parseError) {
- console.error('[markdown] Failed to parse markdown content:', parseError)
- return ''
+ console.error("[markdown] Failed to parse markdown content:", parseError);
+ return "";
}
}
@@ -395,20 +413,23 @@ export function parseMarkdown(content: string): string {
* @throws Never throws - logs warning if container is invalid and returns early
*/
export function applyJavaLanguageDetection(container: HTMLElement | null | undefined): void {
- if (!container || typeof container.querySelectorAll !== 'function') {
+ if (!container || typeof container.querySelectorAll !== "function") {
if (import.meta.env.DEV) {
- console.warn('[markdown] applyJavaLanguageDetection called with invalid container:', container)
+ console.warn(
+ "[markdown] applyJavaLanguageDetection called with invalid container:",
+ container,
+ );
}
- return
+ return;
}
- const codeBlocks = container.querySelectorAll(UNMARKED_CODE_SELECTOR)
- codeBlocks.forEach(code => {
- const text = code.textContent ?? ''
- if (JAVA_KEYWORDS.some(kw => text.includes(kw))) {
- code.className = JAVA_LANGUAGE_CLASS
+ const codeBlocks = container.querySelectorAll(UNMARKED_CODE_SELECTOR);
+ codeBlocks.forEach((code) => {
+ const text = code.textContent ?? "";
+ if (JAVA_KEYWORDS.some((kw) => text.includes(kw))) {
+ code.className = JAVA_LANGUAGE_CLASS;
}
- })
+ });
}
/**
@@ -416,9 +437,9 @@ export function applyJavaLanguageDetection(container: HTMLElement | null | undef
*/
export function escapeHtml(text: string): string {
return text
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
diff --git a/frontend/src/lib/services/sse.test.ts b/frontend/src/lib/services/sse.test.ts
index da64a444..abaca75b 100644
--- a/frontend/src/lib/services/sse.test.ts
+++ b/frontend/src/lib/services/sse.test.ts
@@ -1,59 +1,59 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { streamSse } from './sse'
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { streamSse } from "./sse";
-describe('streamSse abort handling', () => {
+describe("streamSse abort handling", () => {
afterEach(() => {
- vi.unstubAllGlobals()
- vi.restoreAllMocks()
- })
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
- it('returns without invoking callbacks when fetch is aborted', async () => {
- const abortController = new AbortController()
- abortController.abort()
+ it("returns without invoking callbacks when fetch is aborted", async () => {
+ const abortController = new AbortController();
+ abortController.abort();
- const fetchMock = vi.fn().mockRejectedValue(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
- vi.stubGlobal('fetch', fetchMock)
+ const fetchMock = vi
+ .fn()
+ .mockRejectedValue(Object.assign(new Error("Aborted"), { name: "AbortError" }));
+ vi.stubGlobal("fetch", fetchMock);
- const onText = vi.fn()
- const onError = vi.fn()
+ const onText = vi.fn();
+ const onError = vi.fn();
- await streamSse(
- '/api/test/stream',
- { hello: 'world' },
- { onText, onError },
- 'sse.test.ts',
- { signal: abortController.signal }
- )
+ await streamSse("/api/test/stream", { hello: "world" }, { onText, onError }, "sse.test.ts", {
+ signal: abortController.signal,
+ });
- expect(onText).not.toHaveBeenCalled()
- expect(onError).not.toHaveBeenCalled()
- })
+ expect(onText).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ });
- it('treats AbortError during read as a cancellation (no onError)', async () => {
- const encoder = new TextEncoder()
- const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' })
- let didEnqueue = false
+ it("treats AbortError during read as a cancellation (no onError)", async () => {
+ const encoder = new TextEncoder();
+ const abortError = Object.assign(new Error("Aborted"), { name: "AbortError" });
+ let didEnqueue = false;
const responseBody = new ReadableStream({
pull(controller) {
if (!didEnqueue) {
- didEnqueue = true
- controller.enqueue(encoder.encode('data: {"text":"Hello"}\n\n'))
- return
+ didEnqueue = true;
+ controller.enqueue(encoder.encode('data: {"text":"Hello"}\n\n'));
+ return;
}
- controller.error(abortError)
- }
- })
+ controller.error(abortError);
+ },
+ });
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, body: responseBody, status: 200, statusText: 'OK' })
- vi.stubGlobal('fetch', fetchMock)
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({ ok: true, body: responseBody, status: 200, statusText: "OK" });
+ vi.stubGlobal("fetch", fetchMock);
- const onText = vi.fn()
- const onError = vi.fn()
+ const onText = vi.fn();
+ const onError = vi.fn();
- await streamSse('/api/test/stream', { hello: 'world' }, { onText, onError }, 'sse.test.ts')
+ await streamSse("/api/test/stream", { hello: "world" }, { onText, onError }, "sse.test.ts");
- expect(onText).toHaveBeenCalledWith('Hello')
- expect(onError).not.toHaveBeenCalled()
- })
-})
+ expect(onText).toHaveBeenCalledWith("Hello");
+ expect(onError).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/lib/services/sse.ts b/frontend/src/lib/services/sse.ts
index b7ec082f..2c3672aa 100644
--- a/frontend/src/lib/services/sse.ts
+++ b/frontend/src/lib/services/sse.ts
@@ -15,33 +15,33 @@ import {
type StreamStatus,
type StreamError,
type ProviderEvent,
- type Citation
-} from '../validation/schemas'
-import { validateWithSchema } from '../validation/validate'
-import { csrfHeader, extractApiErrorMessage, fetchWithCsrfRetry } from './csrf'
+ type Citation,
+} from "../validation/schemas";
+import { validateWithSchema } from "../validation/validate";
+import { csrfHeader, extractApiErrorMessage, fetchWithCsrfRetry } from "./csrf";
/** SSE event types emitted by streaming endpoints. */
-const SSE_EVENT_STATUS = 'status'
-const SSE_EVENT_ERROR = 'error'
-const SSE_EVENT_CITATION = 'citation'
-const SSE_EVENT_PROVIDER = 'provider'
+const SSE_EVENT_STATUS = "status";
+const SSE_EVENT_ERROR = "error";
+const SSE_EVENT_CITATION = "citation";
+const SSE_EVENT_PROVIDER = "provider";
/** Optional request options for streaming fetch calls. */
export interface StreamSseRequestOptions {
- signal?: AbortSignal
+ signal?: AbortSignal;
}
/** Callbacks for SSE stream processing. */
export interface SseCallbacks {
- onText: (content: string) => void
- onStatus?: (status: StreamStatus) => void
- onError?: (error: StreamError) => void
- onCitations?: (citations: Citation[]) => void
- onProvider?: (provider: ProviderEvent) => void
+ onText: (content: string) => void;
+ onStatus?: (status: StreamStatus) => void;
+ onError?: (error: StreamError) => void;
+ onCitations?: (citations: Citation[]) => void;
+ onProvider?: (provider: ProviderEvent) => void;
}
function isAbortError(error: unknown): boolean {
- return error instanceof Error && error.name === 'AbortError'
+ return error instanceof Error && error.name === "AbortError";
}
/**
@@ -50,19 +50,19 @@ function isAbortError(error: unknown): boolean {
* Logs parse errors for debugging without interrupting stream processing.
*/
export function tryParseJson(content: string, source: string): unknown {
- const trimmed = content.trim()
- if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
- return null
+ const trimmed = content.trim();
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
+ return null;
}
try {
- return JSON.parse(trimmed)
+ return JSON.parse(trimmed);
} catch (parseError) {
// Log for debugging but don't throw - allows graceful fallback to raw text
console.warn(`[${source}] JSON parse failed for content that looked like JSON:`, {
preview: trimmed.slice(0, 100),
- error: parseError instanceof Error ? parseError.message : String(parseError)
- })
- return null
+ error: parseError instanceof Error ? parseError.message : String(parseError),
+ });
+ return null;
}
}
@@ -78,51 +78,53 @@ function processEvent(
eventType: string,
eventData: string,
callbacks: SseCallbacks,
- source: string
+ source: string,
): void {
- const normalizedType = eventType.trim().toLowerCase()
+ const normalizedType = eventType.trim().toLowerCase();
if (normalizedType === SSE_EVENT_STATUS) {
- const parsed = tryParseJson(eventData, source)
- const validated = validateWithSchema(StreamStatusSchema, parsed, `${source}:status`)
- callbacks.onStatus?.(validated.success ? validated.validated : { message: eventData })
- return
+ const parsed = tryParseJson(eventData, source);
+ const validated = validateWithSchema(StreamStatusSchema, parsed, `${source}:status`);
+ callbacks.onStatus?.(validated.success ? validated.validated : { message: eventData });
+ return;
}
if (normalizedType === SSE_EVENT_ERROR) {
- const parsed = tryParseJson(eventData, source)
- const validated = validateWithSchema(StreamErrorSchema, parsed, `${source}:error`)
- const streamError: StreamError = validated.success ? validated.validated : { message: eventData }
- callbacks.onError?.(streamError)
- const errorWithDetails: Error & { details?: string } = new Error(streamError.message)
- errorWithDetails.details = streamError.details ?? undefined
- throw errorWithDetails
+ const parsed = tryParseJson(eventData, source);
+ const validated = validateWithSchema(StreamErrorSchema, parsed, `${source}:error`);
+ const streamError: StreamError = validated.success
+ ? validated.validated
+ : { message: eventData };
+ callbacks.onError?.(streamError);
+ const errorWithDetails: Error & { details?: string } = new Error(streamError.message);
+ errorWithDetails.details = streamError.details ?? undefined;
+ throw errorWithDetails;
}
if (normalizedType === SSE_EVENT_CITATION) {
- const parsed = tryParseJson(eventData, source)
- const validated = validateWithSchema(CitationsArraySchema, parsed, `${source}:citations`)
+ const parsed = tryParseJson(eventData, source);
+ const validated = validateWithSchema(CitationsArraySchema, parsed, `${source}:citations`);
if (validated.success) {
- callbacks.onCitations?.(validated.validated)
+ callbacks.onCitations?.(validated.validated);
}
- return
+ return;
}
if (normalizedType === SSE_EVENT_PROVIDER) {
- const parsed = tryParseJson(eventData, source)
- const validated = validateWithSchema(ProviderEventSchema, parsed, `${source}:provider`)
+ const parsed = tryParseJson(eventData, source);
+ const validated = validateWithSchema(ProviderEventSchema, parsed, `${source}:provider`);
if (validated.success) {
- callbacks.onProvider?.(validated.validated)
+ callbacks.onProvider?.(validated.validated);
}
- return
+ return;
}
// Default and "text" events - extract text from JSON wrapper if present
- const parsed = tryParseJson(eventData, source)
- const validated = validateWithSchema(TextEventPayloadSchema, parsed, `${source}:text`)
- const textContent = validated.success ? validated.validated.text : eventData
- if (textContent !== '') {
- callbacks.onText(textContent)
+ const parsed = tryParseJson(eventData, source);
+ const validated = validateWithSchema(TextEventPayloadSchema, parsed, `${source}:text`);
+ const textContent = validated.success ? validated.validated.text : eventData;
+ if (textContent !== "") {
+ callbacks.onText(textContent);
}
}
@@ -146,150 +148,150 @@ export async function streamSse(
body: object,
callbacks: SseCallbacks,
source: string,
- options: StreamSseRequestOptions = {}
+ options: StreamSseRequestOptions = {},
): Promise {
- const abortSignal = options.signal
- let response: Response
+ const abortSignal = options.signal;
+ let response: Response;
try {
response = await fetchWithCsrfRetry(
url,
{
- method: 'POST',
+ method: "POST",
headers: {
- 'Content-Type': 'application/json',
- ...csrfHeader()
+ "Content-Type": "application/json",
+ ...csrfHeader(),
},
body: JSON.stringify(body),
- signal: abortSignal
+ signal: abortSignal,
},
- `streamSse:${source}`
- )
+ `streamSse:${source}`,
+ );
} catch (fetchError) {
if (abortSignal?.aborted || isAbortError(fetchError)) {
- return
+ return;
}
- throw fetchError
+ throw fetchError;
}
if (!response.ok) {
- const apiMessage = await extractApiErrorMessage(response, `streamSse:${source}`)
+ const apiMessage = await extractApiErrorMessage(response, `streamSse:${source}`);
const errorMessage =
- apiMessage ?? `HTTP ${response.status}: ${response.statusText || 'Request failed'}`
- const httpError = new Error(errorMessage)
- callbacks.onError?.({ message: httpError.message })
- throw httpError
+ apiMessage ?? `HTTP ${response.status}: ${response.statusText || "Request failed"}`;
+ const httpError = new Error(errorMessage);
+ callbacks.onError?.({ message: httpError.message });
+ throw httpError;
}
- const reader = response.body?.getReader()
+ const reader = response.body?.getReader();
if (!reader) {
- const bodyError = new Error('No response body')
- callbacks.onError?.({ message: bodyError.message })
- throw bodyError
+ const bodyError = new Error("No response body");
+ callbacks.onError?.({ message: bodyError.message });
+ throw bodyError;
}
- const decoder = new TextDecoder()
- let streamCompletedNormally = false
- let buffer = ''
- let eventBuffer = ''
- let hasEventData = false
- let currentEventType: string | null = null
+ const decoder = new TextDecoder();
+ let streamCompletedNormally = false;
+ let buffer = "";
+ let eventBuffer = "";
+ let hasEventData = false;
+ let currentEventType: string | null = null;
const flushEvent = () => {
if (!hasEventData) {
- currentEventType = null
- return
+ currentEventType = null;
+ return;
}
- const eventType = currentEventType ?? ''
- const rawEventData = eventBuffer
- eventBuffer = ''
- hasEventData = false
- currentEventType = null
+ const eventType = currentEventType ?? "";
+ const rawEventData = eventBuffer;
+ eventBuffer = "";
+ hasEventData = false;
+ currentEventType = null;
- processEvent(eventType, rawEventData, callbacks, source)
- }
+ processEvent(eventType, rawEventData, callbacks, source);
+ };
try {
while (true) {
- const { done, value } = await reader.read()
+ const { done, value } = await reader.read();
if (done) {
- streamCompletedNormally = true
+ streamCompletedNormally = true;
// Flush any remaining bytes from the TextDecoder (handles multi-byte chars split across chunks)
- const remaining = decoder.decode()
+ const remaining = decoder.decode();
if (remaining) {
- buffer += remaining
+ buffer += remaining;
}
// Commit any remaining buffered line before flushing event data
if (buffer.length > 0) {
- eventBuffer = eventBuffer ? `${eventBuffer}\n${buffer}` : buffer
- hasEventData = true
- buffer = ''
+ eventBuffer = eventBuffer ? `${eventBuffer}\n${buffer}` : buffer;
+ hasEventData = true;
+ buffer = "";
}
- flushEvent()
- break
+ flushEvent();
+ break;
}
- const chunk = decoder.decode(value, { stream: true })
- buffer += chunk
- const lines = buffer.split('\n')
- buffer = lines[lines.length - 1]
+ const chunk = decoder.decode(value, { stream: true });
+ buffer += chunk;
+ const lines = buffer.split("\n");
+ buffer = lines[lines.length - 1];
for (let lineIndex = 0; lineIndex < lines.length - 1; lineIndex++) {
- let line = lines[lineIndex]
- if (line.endsWith('\r')) {
- line = line.slice(0, -1)
+ let line = lines[lineIndex];
+ if (line.endsWith("\r")) {
+ line = line.slice(0, -1);
}
// Skip SSE comments (keepalive heartbeats)
- if (line.startsWith(':')) {
- continue
+ if (line.startsWith(":")) {
+ continue;
}
// Track event type
- if (line.startsWith('event:')) {
- currentEventType = line.startsWith('event: ') ? line.slice(7) : line.slice(6)
- continue
+ if (line.startsWith("event:")) {
+ currentEventType = line.startsWith("event: ") ? line.slice(7) : line.slice(6);
+ continue;
}
// Accumulate data within current SSE event
- if (line.startsWith('data:')) {
+ if (line.startsWith("data:")) {
// Per SSE spec, strip optional space after "data:" prefix
- const eventPayload = line.startsWith('data: ') ? line.slice(6) : line.slice(5)
+ const eventPayload = line.startsWith("data: ") ? line.slice(6) : line.slice(5);
// Skip [DONE] token
- if (eventPayload === '[DONE]') {
- continue
+ if (eventPayload === "[DONE]") {
+ continue;
}
// Accumulate within current SSE event
if (hasEventData) {
- eventBuffer += '\n'
+ eventBuffer += "\n";
}
- eventBuffer += eventPayload
- hasEventData = true
- } else if (line.trim() === '') {
+ eventBuffer += eventPayload;
+ hasEventData = true;
+ } else if (line.trim() === "") {
// Blank line marks end of SSE event - commit accumulated data
- flushEvent()
+ flushEvent();
}
}
}
} catch (streamError) {
if (abortSignal?.aborted || isAbortError(streamError)) {
- return
+ return;
}
- throw streamError
+ throw streamError;
} finally {
// Cancel reader on abnormal exit to prevent dangling connections
if (!streamCompletedNormally) {
try {
- await reader.cancel()
+ await reader.cancel();
} catch {
// Expected: cancel() throws if stream already closed by abort signal or server.
// Safe to ignore - we're in cleanup and the stream is already terminated.
}
}
- reader.releaseLock()
+ reader.releaseLock();
}
}
diff --git a/frontend/src/lib/services/streamRecovery.test.ts b/frontend/src/lib/services/streamRecovery.test.ts
index c08235a7..e4000470 100644
--- a/frontend/src/lib/services/streamRecovery.test.ts
+++ b/frontend/src/lib/services/streamRecovery.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it } from "vitest";
import {
buildStreamRecoverySucceededStatus,
buildStreamRetryStatus,
@@ -6,114 +6,121 @@ import {
shouldRetryStreamRequest,
StreamFailureError,
toStreamError,
- toStreamFailureException
-} from './streamRecovery'
+ toStreamFailureException,
+} from "./streamRecovery";
-describe('streamRecovery', () => {
- it('allows one retry for overflow failures before any chunk is rendered', () => {
+describe("streamRecovery", () => {
+ it("allows one retry for overflow failures before any chunk is rendered", () => {
const retryDecision = shouldRetryStreamRequest(
- new Error('OverflowException while decoding response'),
+ new Error("OverflowException while decoding response"),
null,
null,
false,
0,
- 1
- )
+ 1,
+ );
- expect(retryDecision).toBe(true)
- })
+ expect(retryDecision).toBe(true);
+ });
- it('refuses retry after content has started streaming', () => {
+ it("refuses retry after content has started streaming", () => {
const retryDecision = shouldRetryStreamRequest(
- new Error('OverflowException while decoding response'),
+ new Error("OverflowException while decoding response"),
null,
null,
true,
0,
- 1
- )
+ 1,
+ );
- expect(retryDecision).toBe(false)
- })
+ expect(retryDecision).toBe(false);
+ });
- it('refuses retry for non-recoverable quota errors', () => {
+ it("refuses retry for non-recoverable quota errors", () => {
const retryDecision = shouldRetryStreamRequest(
- new Error('HTTP 429 rate limit exceeded'),
+ new Error("HTTP 429 rate limit exceeded"),
null,
null,
false,
0,
- 1
- )
+ 1,
+ );
- expect(retryDecision).toBe(false)
- })
+ expect(retryDecision).toBe(false);
+ });
- it('builds a user-visible retry status message', () => {
- const retryStatus = buildStreamRetryStatus(1, 1)
+ it("builds a user-visible retry status message", () => {
+ const retryStatus = buildStreamRetryStatus(1, 1);
- expect(retryStatus.message).toBe('Temporary stream issue detected')
- expect(retryStatus.details).toContain('Retrying your request (1/1)')
- })
+ expect(retryStatus.message).toBe("Temporary stream issue detected");
+ expect(retryStatus.details).toContain("Retrying your request (1/1)");
+ });
- it('builds a user-visible recovery status message after retry succeeds', () => {
- const recoveryStatus = buildStreamRecoverySucceededStatus(1)
+ it("builds a user-visible recovery status message after retry succeeds", () => {
+ const recoveryStatus = buildStreamRecoverySucceededStatus(1);
- expect(recoveryStatus.message).toBe('Streaming recovered')
- expect(recoveryStatus.details).toContain('Recovered after retry (1)')
- })
+ expect(recoveryStatus.message).toBe("Streaming recovered");
+ expect(recoveryStatus.details).toContain("Recovered after retry (1)");
+ });
- it('maps thrown errors into StreamError shape', () => {
- const mappedStreamError = toStreamError(new Error('OverflowException'), null)
+ it("maps thrown errors into StreamError shape", () => {
+ const mappedStreamError = toStreamError(new Error("OverflowException"), null);
- expect(mappedStreamError).toEqual({ message: 'OverflowException' })
- })
+ expect(mappedStreamError).toEqual({ message: "OverflowException" });
+ });
- it('retries once for recoverable network failures before any chunk is rendered', () => {
- const retryDecision = shouldRetryStreamRequest(new Error('TypeError: Failed to fetch'), null, null, false, 0, 1)
+ it("retries once for recoverable network failures before any chunk is rendered", () => {
+ const retryDecision = shouldRetryStreamRequest(
+ new Error("TypeError: Failed to fetch"),
+ null,
+ null,
+ false,
+ 0,
+ 1,
+ );
- expect(retryDecision).toBe(true)
- })
+ expect(retryDecision).toBe(true);
+ });
- it('respects backend retry metadata when stage is stream', () => {
+ it("respects backend retry metadata when stage is stream", () => {
const retryDecision = shouldRetryStreamRequest(
- new Error('Some fatal backend error'),
+ new Error("Some fatal backend error"),
null,
{
- message: 'Provider fallback succeeded',
+ message: "Provider fallback succeeded",
retryable: true,
- stage: 'stream'
+ stage: "stream",
},
false,
0,
- 1
- )
-
- expect(retryDecision).toBe(true)
- })
-
- it('preserves stream error details in thrown stream failure exception', () => {
- const streamFailureException = toStreamFailureException(new Error('Transport failed'), {
- message: 'OverflowException',
- details: 'Malformed response frame at byte 512'
- })
-
- expect(streamFailureException).toBeInstanceOf(StreamFailureError)
- expect(streamFailureException.message).toBe('OverflowException')
- expect(streamFailureException.details).toBe('Malformed response frame at byte 512')
- })
-
- it('uses default retry count for missing config', () => {
- expect(resolveStreamRecoveryRetryCount(undefined)).toBe(1)
- })
-
- it('clamps configured retry count into safe range', () => {
- expect(resolveStreamRecoveryRetryCount(-1)).toBe(0)
- expect(resolveStreamRecoveryRetryCount(9)).toBe(3)
- })
-
- it('falls back to default retry count for non-numeric config', () => {
- expect(resolveStreamRecoveryRetryCount('abc')).toBe(1)
- expect(resolveStreamRecoveryRetryCount('')).toBe(1)
- })
-})
+ 1,
+ );
+
+ expect(retryDecision).toBe(true);
+ });
+
+ it("preserves stream error details in thrown stream failure exception", () => {
+ const streamFailureException = toStreamFailureException(new Error("Transport failed"), {
+ message: "OverflowException",
+ details: "Malformed response frame at byte 512",
+ });
+
+ expect(streamFailureException).toBeInstanceOf(StreamFailureError);
+ expect(streamFailureException.message).toBe("OverflowException");
+ expect(streamFailureException.details).toBe("Malformed response frame at byte 512");
+ });
+
+ it("uses default retry count for missing config", () => {
+ expect(resolveStreamRecoveryRetryCount(undefined)).toBe(1);
+ });
+
+ it("clamps configured retry count into safe range", () => {
+ expect(resolveStreamRecoveryRetryCount(-1)).toBe(0);
+ expect(resolveStreamRecoveryRetryCount(9)).toBe(3);
+ });
+
+ it("falls back to default retry count for non-numeric config", () => {
+ expect(resolveStreamRecoveryRetryCount("abc")).toBe(1);
+ expect(resolveStreamRecoveryRetryCount("")).toBe(1);
+ });
+});
diff --git a/frontend/src/lib/services/streamRecovery.ts b/frontend/src/lib/services/streamRecovery.ts
index 1cb54e4e..62b9b75b 100644
--- a/frontend/src/lib/services/streamRecovery.ts
+++ b/frontend/src/lib/services/streamRecovery.ts
@@ -1,19 +1,19 @@
-import type { StreamError, StreamStatus, Citation } from '../validation/schemas'
-import { streamSse } from './sse'
+import type { StreamError, StreamStatus, Citation } from "../validation/schemas";
+import { streamSse } from "./sse";
-const GENERIC_STREAM_FAILURE_MESSAGE = 'Streaming request failed'
+const GENERIC_STREAM_FAILURE_MESSAGE = "Streaming request failed";
/**
* Error subclass carrying structured SSE stream failure details.
* Replaces unsafe `as` casts that monkey-patched `details` onto plain Error objects.
*/
export class StreamFailureError extends Error {
- readonly details?: string
+ readonly details?: string;
constructor(message: string, details?: string) {
- super(message)
- this.name = 'StreamFailureError'
- this.details = details
+ super(message);
+ this.name = "StreamFailureError";
+ this.details = details;
}
}
@@ -30,19 +30,25 @@ const RECOVERABLE_STREAM_ERROR_PATTERNS = [
/\btimeout\b/i,
/\btimed out\b/i,
/\bhttp\s*5\d{2}\b/i,
- /\b5\d{2}\s+(internal|bad gateway|service unavailable|gateway timeout)\b/i
-]
+ /\b5\d{2}\s+(internal|bad gateway|service unavailable|gateway timeout)\b/i,
+];
-const NON_RECOVERABLE_STREAM_ERROR_PATTERNS = [/rate limit/i, /\b429\b/, /\b401\b/, /\b403\b/, /providers unavailable/i]
-const ABORT_STREAM_ERROR_PATTERNS = [/aborterror/i, /\baborted\b/i, /\bcancelled\b/i]
+const NON_RECOVERABLE_STREAM_ERROR_PATTERNS = [
+ /rate limit/i,
+ /\b429\b/,
+ /\b401\b/,
+ /\b403\b/,
+ /providers unavailable/i,
+];
+const ABORT_STREAM_ERROR_PATTERNS = [/aborterror/i, /\baborted\b/i, /\bcancelled\b/i];
-const DEFAULT_STREAM_RECOVERY_RETRY_COUNT = 1
-const MIN_STREAM_RECOVERY_RETRY_COUNT = 0
-const MAX_STREAM_RECOVERY_RETRY_COUNT = 3
+const DEFAULT_STREAM_RECOVERY_RETRY_COUNT = 1;
+const MIN_STREAM_RECOVERY_RETRY_COUNT = 0;
+const MAX_STREAM_RECOVERY_RETRY_COUNT = 3;
export const MAX_STREAM_RECOVERY_RETRIES = resolveStreamRecoveryRetryCount(
- import.meta.env.VITE_STREAM_RECOVERY_MAX_RETRIES
-)
+ import.meta.env.VITE_STREAM_RECOVERY_MAX_RETRIES,
+);
/**
* Decides whether a stream request should be retried for a likely recoverable provider response issue.
@@ -58,39 +64,42 @@ export function shouldRetryStreamRequest(
latestStreamStatus: StreamStatus | null,
hasStreamedAnyChunk: boolean,
attemptedRetries: number,
- maxRecoveryRetries: number
+ maxRecoveryRetries: number,
): boolean {
if (hasStreamedAnyChunk) {
- return false
+ return false;
}
if (attemptedRetries >= maxRecoveryRetries) {
- return false
+ return false;
}
- if (latestStreamStatus?.stage === 'stream' && latestStreamStatus.retryable === false) {
- return false
+ if (latestStreamStatus?.stage === "stream" && latestStreamStatus.retryable === false) {
+ return false;
}
- if (latestStreamStatus?.stage === 'stream' && latestStreamStatus.retryable === true) {
- return true
+ if (latestStreamStatus?.stage === "stream" && latestStreamStatus.retryable === true) {
+ return true;
}
- const failureDescription = describeStreamFailure(streamFailure, streamErrorEvent)
+ const failureDescription = describeStreamFailure(streamFailure, streamErrorEvent);
if (ABORT_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription))) {
- return false
+ return false;
}
if (NON_RECOVERABLE_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription))) {
- return false
+ return false;
}
- return RECOVERABLE_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription))
+ return RECOVERABLE_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription));
}
/**
* Shows users that the client detected a transient stream fault and is retrying automatically.
*/
-export function buildStreamRetryStatus(nextAttemptNumber: number, maxRecoveryRetries: number): StreamStatus {
+export function buildStreamRetryStatus(
+ nextAttemptNumber: number,
+ maxRecoveryRetries: number,
+): StreamStatus {
return {
- message: 'Temporary stream issue detected',
- details: `The API response or network stream was temporarily invalid. Retrying your request (${nextAttemptNumber}/${maxRecoveryRetries}).`
- }
+ message: "Temporary stream issue detected",
+ details: `The API response or network stream was temporarily invalid. Retrying your request (${nextAttemptNumber}/${maxRecoveryRetries}).`,
+ };
}
/**
@@ -98,22 +107,25 @@ export function buildStreamRetryStatus(nextAttemptNumber: number, maxRecoveryRet
*/
export function buildStreamRecoverySucceededStatus(recoveryAttemptCount: number): StreamStatus {
return {
- message: 'Streaming recovered',
- details: `Recovered after retry (${recoveryAttemptCount}). Continuing your response.`
- }
+ message: "Streaming recovered",
+ details: `Recovered after retry (${recoveryAttemptCount}). Continuing your response.`,
+ };
}
/**
* Converts thrown stream failures into the canonical StreamError shape used by UI components.
*/
-export function toStreamError(streamFailure: unknown, streamErrorEvent: StreamError | null): StreamError {
+export function toStreamError(
+ streamFailure: unknown,
+ streamErrorEvent: StreamError | null,
+): StreamError {
if (streamErrorEvent) {
- return streamErrorEvent
+ return streamErrorEvent;
}
if (streamFailure instanceof Error) {
- return { message: streamFailure.message }
+ return { message: streamFailure.message };
}
- return { message: GENERIC_STREAM_FAILURE_MESSAGE }
+ return { message: GENERIC_STREAM_FAILURE_MESSAGE };
}
/**
@@ -121,37 +133,37 @@ export function toStreamError(streamFailure: unknown, streamErrorEvent: StreamEr
*/
export function toStreamFailureException(
streamFailure: unknown,
- streamErrorEvent: StreamError | null
+ streamErrorEvent: StreamError | null,
): StreamFailureError {
- const mappedStreamError = toStreamError(streamFailure, streamErrorEvent)
- return new StreamFailureError(mappedStreamError.message, mappedStreamError.details ?? undefined)
+ const mappedStreamError = toStreamError(streamFailure, streamErrorEvent);
+ return new StreamFailureError(mappedStreamError.message, mappedStreamError.details ?? undefined);
}
export function resolveStreamRecoveryRetryCount(rawRetrySetting: unknown): number {
- if (rawRetrySetting === null || rawRetrySetting === undefined || rawRetrySetting === '') {
- return DEFAULT_STREAM_RECOVERY_RETRY_COUNT
+ if (rawRetrySetting === null || rawRetrySetting === undefined || rawRetrySetting === "") {
+ return DEFAULT_STREAM_RECOVERY_RETRY_COUNT;
}
- const parsedRetryCount = Number(rawRetrySetting)
+ const parsedRetryCount = Number(rawRetrySetting);
if (!Number.isInteger(parsedRetryCount)) {
- return DEFAULT_STREAM_RECOVERY_RETRY_COUNT
+ return DEFAULT_STREAM_RECOVERY_RETRY_COUNT;
}
if (parsedRetryCount < MIN_STREAM_RECOVERY_RETRY_COUNT) {
- return MIN_STREAM_RECOVERY_RETRY_COUNT
+ return MIN_STREAM_RECOVERY_RETRY_COUNT;
}
if (parsedRetryCount > MAX_STREAM_RECOVERY_RETRY_COUNT) {
- return MAX_STREAM_RECOVERY_RETRY_COUNT
+ return MAX_STREAM_RECOVERY_RETRY_COUNT;
}
- return parsedRetryCount
+ return parsedRetryCount;
}
/** Callbacks for the stream-with-retry wrapper. */
export interface StreamWithRetryCallbacks {
- onChunk: (chunk: string) => void
- onStatus?: (status: StreamStatus) => void
- onError?: (error: StreamError) => void
- onCitations?: (citations: Citation[]) => void
- signal?: AbortSignal
+ onChunk: (chunk: string) => void;
+ onStatus?: (status: StreamStatus) => void;
+ onError?: (error: StreamError) => void;
+ onCitations?: (citations: Citation[]) => void;
+ signal?: AbortSignal;
}
/**
@@ -162,16 +174,16 @@ export async function streamWithRetry(
endpoint: string,
body: object,
callbacks: StreamWithRetryCallbacks,
- sourceLabel: string
+ sourceLabel: string,
): Promise {
- const { onChunk, onStatus, onError, onCitations, signal } = callbacks
- let attemptedRecoveryRetries = 0
- let hasPendingRecoverySuccessNotice = false
+ const { onChunk, onStatus, onError, onCitations, signal } = callbacks;
+ let attemptedRecoveryRetries = 0;
+ let hasPendingRecoverySuccessNotice = false;
while (true) {
- let hasStreamedAnyChunk = false
- let streamErrorEvent: StreamError | null = null
- let latestStreamStatus: StreamStatus | null = null
+ let hasStreamedAnyChunk = false;
+ let streamErrorEvent: StreamError | null = null;
+ let latestStreamStatus: StreamStatus | null = null;
try {
await streamSse(
@@ -179,26 +191,26 @@ export async function streamWithRetry(
body,
{
onText: (chunk) => {
- hasStreamedAnyChunk = true
+ hasStreamedAnyChunk = true;
if (hasPendingRecoverySuccessNotice) {
- onStatus?.(buildStreamRecoverySucceededStatus(attemptedRecoveryRetries))
- hasPendingRecoverySuccessNotice = false
+ onStatus?.(buildStreamRecoverySucceededStatus(attemptedRecoveryRetries));
+ hasPendingRecoverySuccessNotice = false;
}
- onChunk(chunk)
+ onChunk(chunk);
},
onStatus: (status) => {
- latestStreamStatus = status
- onStatus?.(status)
+ latestStreamStatus = status;
+ onStatus?.(status);
},
onError: (streamError) => {
- streamErrorEvent = streamError
+ streamErrorEvent = streamError;
},
- onCitations
+ onCitations,
},
sourceLabel,
- { signal }
- )
- return
+ { signal },
+ );
+ return;
} catch (streamFailure) {
if (
shouldRetryStreamRequest(
@@ -207,39 +219,67 @@ export async function streamWithRetry(
latestStreamStatus,
hasStreamedAnyChunk,
attemptedRecoveryRetries,
- MAX_STREAM_RECOVERY_RETRIES
+ MAX_STREAM_RECOVERY_RETRIES,
)
) {
- attemptedRecoveryRetries++
- hasPendingRecoverySuccessNotice = true
- onStatus?.(buildStreamRetryStatus(attemptedRecoveryRetries, MAX_STREAM_RECOVERY_RETRIES))
- continue
+ attemptedRecoveryRetries++;
+ hasPendingRecoverySuccessNotice = true;
+ onStatus?.(buildStreamRetryStatus(attemptedRecoveryRetries, MAX_STREAM_RECOVERY_RETRIES));
+ continue;
}
- const mappedStreamError = toStreamError(streamFailure, streamErrorEvent)
- onError?.(mappedStreamError)
- throw toStreamFailureException(streamFailure, streamErrorEvent)
+ const mappedStreamError = toStreamError(streamFailure, streamErrorEvent);
+ onError?.(mappedStreamError);
+ throw toStreamFailureException(streamFailure, streamErrorEvent);
}
}
}
-function describeStreamFailure(streamFailure: unknown, streamErrorEvent: StreamError | null): string {
- const diagnosticTokens: string[] = []
+function describeStreamFailure(
+ streamFailure: unknown,
+ streamErrorEvent: StreamError | null,
+): string {
+ const diagnosticTokens: string[] = [];
if (streamErrorEvent?.message) {
- diagnosticTokens.push(streamErrorEvent.message)
+ diagnosticTokens.push(streamErrorEvent.message);
}
if (streamErrorEvent?.details) {
- diagnosticTokens.push(streamErrorEvent.details)
+ diagnosticTokens.push(streamErrorEvent.details);
}
if (streamErrorEvent?.code) {
- diagnosticTokens.push(streamErrorEvent.code)
+ diagnosticTokens.push(streamErrorEvent.code);
}
if (streamFailure instanceof Error) {
- diagnosticTokens.push(streamFailure.message)
- if ('details' in streamFailure && typeof streamFailure.details === 'string') {
- diagnosticTokens.push(streamFailure.details)
+ diagnosticTokens.push(streamFailure.message);
+ if ("details" in streamFailure && typeof streamFailure.details === "string") {
+ diagnosticTokens.push(streamFailure.details);
}
} else if (streamFailure !== null && streamFailure !== undefined) {
- diagnosticTokens.push(String(streamFailure))
+ diagnosticTokens.push(formatUnknownDiagnostic(streamFailure));
+ }
+ return diagnosticTokens.join(" ").trim();
+}
+
+function formatUnknownDiagnostic(value: unknown): string {
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ if (typeof value === "bigint") {
+ return value.toString();
+ }
+ if (typeof value === "symbol") {
+ return value.description ?? "Symbol";
+ }
+ if (typeof value === "function") {
+ return value.name ? `[function ${value.name}]` : "[function]";
+ }
+
+ try {
+ const json = JSON.stringify(value);
+ return json === undefined ? "" : json;
+ } catch {
+ return Object.prototype.toString.call(value);
}
- return diagnosticTokens.join(' ').trim()
}
diff --git a/frontend/src/lib/stores/toastStore.ts b/frontend/src/lib/stores/toastStore.ts
index c1a7ea2a..6533c8b2 100644
--- a/frontend/src/lib/stores/toastStore.ts
+++ b/frontend/src/lib/stores/toastStore.ts
@@ -1,50 +1,50 @@
-import { derived, writable } from 'svelte/store'
+import { derived, writable } from "svelte/store";
-export type ToastSeverity = 'error' | 'info'
+export type ToastSeverity = "error" | "info";
export interface ToastAction {
- label: string
- href: string
+ label: string;
+ href: string;
}
export interface ToastNotice {
- id: string
- message: string
- severity: ToastSeverity
- detail?: string
- action?: ToastAction
+ id: string;
+ message: string;
+ severity: ToastSeverity;
+ detail?: string;
+ action?: ToastAction;
}
-const TOAST_DURATION_MS = 6_000
+const TOAST_DURATION_MS = 6_000;
-let nextToastId = 0
-const toastQueue = writable([])
+let nextToastId = 0;
+const toastQueue = writable([]);
export function pushToast(
message: string,
- options: { severity?: ToastSeverity; detail?: string; action?: ToastAction } = {}
+ options: { severity?: ToastSeverity; detail?: string; action?: ToastAction } = {},
): string {
- const id = `toast-${++nextToastId}`
+ const id = `toast-${++nextToastId}`;
const notice: ToastNotice = {
id,
message,
- severity: options.severity ?? 'error'
- }
+ severity: options.severity ?? "error",
+ };
if (options.detail) {
- notice.detail = options.detail
+ notice.detail = options.detail;
}
if (options.action) {
- notice.action = options.action
+ notice.action = options.action;
}
- toastQueue.update((queue) => [...queue, notice])
- if (typeof window !== 'undefined') {
- window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS)
+ toastQueue.update((queue) => [...queue, notice]);
+ if (typeof window !== "undefined") {
+ window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS);
}
- return id
+ return id;
}
export function dismissToast(id: string): void {
- toastQueue.update((queue) => queue.filter((notice) => notice.id !== id))
+ toastQueue.update((queue) => queue.filter((notice) => notice.id !== id));
}
-export const toasts = derived(toastQueue, ($queue) => $queue)
+export const toasts = derived(toastQueue, ($queue) => $queue);
diff --git a/frontend/src/lib/utils/chatMessageId.ts b/frontend/src/lib/utils/chatMessageId.ts
index c3b49416..9f0e0e9a 100644
--- a/frontend/src/lib/utils/chatMessageId.ts
+++ b/frontend/src/lib/utils/chatMessageId.ts
@@ -5,20 +5,20 @@
* chat and guided chat rendering paths.
*/
-type MessageContext = 'chat' | 'guided'
+type MessageContext = "chat" | "guided";
-let sequenceNumber = 0
+let sequenceNumber = 0;
function nextSequenceNumber(): number {
- sequenceNumber = (sequenceNumber + 1) % 1_000_000
- return sequenceNumber
+ sequenceNumber = (sequenceNumber + 1) % 1_000_000;
+ return sequenceNumber;
}
function createRandomSuffix(): string {
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
- return crypto.randomUUID()
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
}
- return Math.random().toString(36).slice(2, 12)
+ return Math.random().toString(36).slice(2, 12);
}
/**
@@ -29,9 +29,8 @@ function createRandomSuffix(): string {
* @returns A stable unique message identifier
*/
export function createChatMessageId(context: MessageContext, sessionId: string): string {
- const timestampMs = Date.now()
- const sequence = nextSequenceNumber()
- const randomSuffix = createRandomSuffix()
- return `msg-${context}-${sessionId}-${timestampMs}-${sequence}-${randomSuffix}`
+ const timestampMs = Date.now();
+ const sequence = nextSequenceNumber();
+ const randomSuffix = createRandomSuffix();
+ return `msg-${context}-${sessionId}-${timestampMs}-${sequence}-${randomSuffix}`;
}
-
diff --git a/frontend/src/lib/utils/highlight.ts b/frontend/src/lib/utils/highlight.ts
index f9b2957c..562c2fa7 100644
--- a/frontend/src/lib/utils/highlight.ts
+++ b/frontend/src/lib/utils/highlight.ts
@@ -6,13 +6,13 @@
*/
/** Selector for unhighlighted code blocks within a container. */
-const UNHIGHLIGHTED_CODE_SELECTOR = 'pre code:not(.hljs)'
+const UNHIGHLIGHTED_CODE_SELECTOR = "pre code:not(.hljs)";
/** Track whether languages have been registered to avoid re-registration. */
-let languagesRegistered = false
+let languagesRegistered = false;
/** Cached highlight.js instance after first load. */
-let hljsInstance: typeof import('highlight.js/lib/core').default | null = null
+let hljsInstance: typeof import("highlight.js/lib/core").default | null = null;
/**
* Dynamically imports highlight.js core and registers all supported languages.
@@ -20,31 +20,31 @@ let hljsInstance: typeof import('highlight.js/lib/core').default | null = null
*
* @returns Promise resolving to the highlight.js instance
*/
-async function loadHighlightJs(): Promise {
+async function loadHighlightJs(): Promise {
if (hljsInstance && languagesRegistered) {
- return hljsInstance
+ return hljsInstance;
}
const [hljs, java, xml, json, bash] = await Promise.all([
- import('highlight.js/lib/core'),
- import('highlight.js/lib/languages/java'),
- import('highlight.js/lib/languages/xml'),
- import('highlight.js/lib/languages/json'),
- import('highlight.js/lib/languages/bash')
- ])
+ import("highlight.js/lib/core"),
+ import("highlight.js/lib/languages/java"),
+ import("highlight.js/lib/languages/xml"),
+ import("highlight.js/lib/languages/json"),
+ import("highlight.js/lib/languages/bash"),
+ ]);
- hljsInstance = hljs.default
+ hljsInstance = hljs.default;
// Register languages only once
if (!languagesRegistered) {
- if (!hljsInstance.getLanguage('java')) hljsInstance.registerLanguage('java', java.default)
- if (!hljsInstance.getLanguage('xml')) hljsInstance.registerLanguage('xml', xml.default)
- if (!hljsInstance.getLanguage('json')) hljsInstance.registerLanguage('json', json.default)
- if (!hljsInstance.getLanguage('bash')) hljsInstance.registerLanguage('bash', bash.default)
- languagesRegistered = true
+ if (!hljsInstance.getLanguage("java")) hljsInstance.registerLanguage("java", java.default);
+ if (!hljsInstance.getLanguage("xml")) hljsInstance.registerLanguage("xml", xml.default);
+ if (!hljsInstance.getLanguage("json")) hljsInstance.registerLanguage("json", json.default);
+ if (!hljsInstance.getLanguage("bash")) hljsInstance.registerLanguage("bash", bash.default);
+ languagesRegistered = true;
}
- return hljsInstance
+ return hljsInstance;
}
/**
@@ -54,15 +54,15 @@ async function loadHighlightJs(): Promise {
- const codeBlocks = container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR)
+ const codeBlocks = container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR);
if (codeBlocks.length === 0) {
- return
+ return;
}
- const hljs = await loadHighlightJs()
+ const hljs = await loadHighlightJs();
codeBlocks.forEach((block) => {
- hljs.highlightElement(block as HTMLElement)
- })
+ hljs.highlightElement(block);
+ });
}
/**
@@ -70,16 +70,16 @@ export async function highlightCodeBlocks(container: HTMLElement): Promise
*/
export interface HighlightConfig {
/** Delay in ms during active streaming (longer to batch updates). */
- streamingDelay: number
+ streamingDelay: number;
/** Delay in ms after streaming completes (shorter for quick finalization). */
- settledDelay: number
+ settledDelay: number;
}
/** Default highlighting delays matching MessageBubble behavior. */
export const DEFAULT_HIGHLIGHT_CONFIG: HighlightConfig = {
streamingDelay: 300,
- settledDelay: 50
-} as const
+ settledDelay: 50,
+} as const;
/**
* Creates a debounced highlighting function with automatic cleanup.
@@ -88,7 +88,7 @@ export const DEFAULT_HIGHLIGHT_CONFIG: HighlightConfig = {
* @returns Object with highlight function and cleanup function
*/
export function createDebouncedHighlighter(config: HighlightConfig = DEFAULT_HIGHLIGHT_CONFIG) {
- let timer: ReturnType | null = null
+ let timer: ReturnType | null = null;
/**
* Schedules highlighting for a container element.
@@ -97,25 +97,25 @@ export function createDebouncedHighlighter(config: HighlightConfig = DEFAULT_HIG
* @param isStreaming - Whether content is actively streaming
*/
function scheduleHighlight(container: HTMLElement | null, isStreaming: boolean): void {
- if (!container) return
+ if (!container) return;
// Clear pending highlight
if (timer) {
- clearTimeout(timer)
+ clearTimeout(timer);
}
- const delay = isStreaming ? config.streamingDelay : config.settledDelay
+ const delay = isStreaming ? config.streamingDelay : config.settledDelay;
timer = setTimeout(() => {
highlightCodeBlocks(container).catch((highlightError: unknown) => {
// Log with context for debugging - highlighting failures are non-fatal
// (content remains readable, just without syntax coloring)
- console.warn('[highlight] Code highlighting failed:', {
+ console.warn("[highlight] Code highlighting failed:", {
error: highlightError instanceof Error ? highlightError.message : String(highlightError),
- codeBlockCount: container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR).length
- })
- })
- }, delay)
+ codeBlockCount: container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR).length,
+ });
+ });
+ }, delay);
}
/**
@@ -124,10 +124,10 @@ export function createDebouncedHighlighter(config: HighlightConfig = DEFAULT_HIG
*/
function cleanup(): void {
if (timer) {
- clearTimeout(timer)
- timer = null
+ clearTimeout(timer);
+ timer = null;
}
}
- return { scheduleHighlight, cleanup }
+ return { scheduleHighlight, cleanup };
}
diff --git a/frontend/src/lib/utils/scroll.test.ts b/frontend/src/lib/utils/scroll.test.ts
index 3bc6612f..c1e7e1e3 100644
--- a/frontend/src/lib/utils/scroll.test.ts
+++ b/frontend/src/lib/utils/scroll.test.ts
@@ -1,14 +1,14 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { isNearBottom, scrollToBottom } from './scroll'
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { isNearBottom, scrollToBottom } from "./scroll";
/**
* Mocks window.matchMedia for testing prefers-reduced-motion behavior.
*/
function mockMatchMedia(prefersReducedMotion: boolean): void {
- Object.defineProperty(window, 'matchMedia', {
+ Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
- matches: query === '(prefers-reduced-motion: reduce)' ? prefersReducedMotion : false,
+ matches: query === "(prefers-reduced-motion: reduce)" ? prefersReducedMotion : false,
media: query,
onchange: null,
addListener: vi.fn(),
@@ -17,145 +17,145 @@ function mockMatchMedia(prefersReducedMotion: boolean): void {
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
- })
+ });
}
beforeEach(() => {
// Default to no reduced motion preference
- mockMatchMedia(false)
-})
+ mockMatchMedia(false);
+});
-describe('isNearBottom', () => {
- it('returns true when container is null', () => {
- expect(isNearBottom(null)).toBe(true)
- })
+describe("isNearBottom", () => {
+ it("returns true when container is null", () => {
+ expect(isNearBottom(null)).toBe(true);
+ });
- it('returns true when scrolled to bottom', () => {
+ it("returns true when scrolled to bottom", () => {
const container = createMockContainer({
scrollTop: 900,
scrollHeight: 1000,
- clientHeight: 100
- })
- expect(isNearBottom(container)).toBe(true)
- })
+ clientHeight: 100,
+ });
+ expect(isNearBottom(container)).toBe(true);
+ });
- it('returns true when within threshold of bottom', () => {
+ it("returns true when within threshold of bottom", () => {
const container = createMockContainer({
scrollTop: 850,
scrollHeight: 1000,
- clientHeight: 100
- })
+ clientHeight: 100,
+ });
// 1000 - 850 - 100 = 50, which is less than default threshold (100)
- expect(isNearBottom(container)).toBe(true)
- })
+ expect(isNearBottom(container)).toBe(true);
+ });
- it('returns false when far from bottom', () => {
+ it("returns false when far from bottom", () => {
const container = createMockContainer({
scrollTop: 0,
scrollHeight: 1000,
- clientHeight: 100
- })
+ clientHeight: 100,
+ });
// 1000 - 0 - 100 = 900, which is greater than threshold
- expect(isNearBottom(container)).toBe(false)
- })
+ expect(isNearBottom(container)).toBe(false);
+ });
- it('respects custom threshold', () => {
+ it("respects custom threshold", () => {
const container = createMockContainer({
scrollTop: 700,
scrollHeight: 1000,
- clientHeight: 100
- })
+ clientHeight: 100,
+ });
// 1000 - 700 - 100 = 200
- expect(isNearBottom(container, 150)).toBe(false)
- expect(isNearBottom(container, 250)).toBe(true)
- })
-})
+ expect(isNearBottom(container, 150)).toBe(false);
+ expect(isNearBottom(container, 250)).toBe(true);
+ });
+});
-describe('scrollToBottom', () => {
- it('does nothing when container is null', async () => {
- await expect(scrollToBottom(null, true)).resolves.toBeUndefined()
- })
+describe("scrollToBottom", () => {
+ it("does nothing when container is null", async () => {
+ await expect(scrollToBottom(null, true)).resolves.toBeUndefined();
+ });
- it('does nothing when shouldScroll is false', async () => {
+ it("does nothing when shouldScroll is false", async () => {
const container = createMockContainer({
scrollTop: 0,
scrollHeight: 1000,
- clientHeight: 100
- })
- const scrollToSpy = vi.spyOn(container, 'scrollTo')
+ clientHeight: 100,
+ });
+ const scrollToSpy = vi.spyOn(container, "scrollTo");
- await scrollToBottom(container, false)
+ await scrollToBottom(container, false);
- expect(scrollToSpy).not.toHaveBeenCalled()
- })
+ expect(scrollToSpy).not.toHaveBeenCalled();
+ });
- it('scrolls smoothly when prefers-reduced-motion is not set', async () => {
- mockMatchMedia(false) // User prefers motion
+ it("scrolls smoothly when prefers-reduced-motion is not set", async () => {
+ mockMatchMedia(false); // User prefers motion
const container = createMockContainer({
scrollTop: 0,
scrollHeight: 1000,
- clientHeight: 100
- })
- const scrollToSpy = vi.spyOn(container, 'scrollTo')
+ clientHeight: 100,
+ });
+ const scrollToSpy = vi.spyOn(container, "scrollTo");
- await scrollToBottom(container, true)
+ await scrollToBottom(container, true);
expect(scrollToSpy).toHaveBeenCalledWith({
top: 1000,
- behavior: 'smooth'
- })
- })
+ behavior: "smooth",
+ });
+ });
- it('scrolls instantly when prefers-reduced-motion is set', async () => {
- mockMatchMedia(true) // User prefers reduced motion
+ it("scrolls instantly when prefers-reduced-motion is set", async () => {
+ mockMatchMedia(true); // User prefers reduced motion
const container = createMockContainer({
scrollTop: 0,
scrollHeight: 1000,
- clientHeight: 100
- })
- const scrollToSpy = vi.spyOn(container, 'scrollTo')
+ clientHeight: 100,
+ });
+ const scrollToSpy = vi.spyOn(container, "scrollTo");
- await scrollToBottom(container, true)
+ await scrollToBottom(container, true);
expect(scrollToSpy).toHaveBeenCalledWith({
top: 1000,
- behavior: 'auto'
- })
- })
-})
+ behavior: "auto",
+ });
+ });
+});
-describe('isNearBottom threshold', () => {
- it('uses default threshold of 100 pixels', () => {
+describe("isNearBottom threshold", () => {
+ it("uses default threshold of 100 pixels", () => {
const container = createMockContainer({
scrollTop: 850,
scrollHeight: 1000,
- clientHeight: 100
- })
+ clientHeight: 100,
+ });
// 1000 - 850 - 100 = 50, within default threshold of 100
- expect(isNearBottom(container)).toBe(true)
+ expect(isNearBottom(container)).toBe(true);
const farContainer = createMockContainer({
scrollTop: 700,
scrollHeight: 1000,
- clientHeight: 100
- })
+ clientHeight: 100,
+ });
// 1000 - 700 - 100 = 200, outside default threshold
- expect(isNearBottom(farContainer)).toBe(false)
- })
-})
+ expect(isNearBottom(farContainer)).toBe(false);
+ });
+});
/**
* Creates a mock HTMLElement with scroll properties.
*/
function createMockContainer(props: {
- scrollTop: number
- scrollHeight: number
- clientHeight: number
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
}): HTMLElement {
- const element = document.createElement('div')
- Object.defineProperty(element, 'scrollTop', { value: props.scrollTop, writable: true })
- Object.defineProperty(element, 'scrollHeight', { value: props.scrollHeight })
- Object.defineProperty(element, 'clientHeight', { value: props.clientHeight })
- element.scrollTo = vi.fn()
- return element
+ const element = document.createElement("div");
+ Object.defineProperty(element, "scrollTop", { value: props.scrollTop, writable: true });
+ Object.defineProperty(element, "scrollHeight", { value: props.scrollHeight });
+ Object.defineProperty(element, "clientHeight", { value: props.clientHeight });
+ element.scrollTo = vi.fn();
+ return element;
}
diff --git a/frontend/src/lib/utils/scroll.ts b/frontend/src/lib/utils/scroll.ts
index 0e2dd392..bc7a3fd7 100644
--- a/frontend/src/lib/utils/scroll.ts
+++ b/frontend/src/lib/utils/scroll.ts
@@ -5,10 +5,10 @@
* enabling smooth streaming experiences without hijacking manual scrolling.
*/
-import { tick } from 'svelte'
+import { tick } from "svelte";
/** Default threshold in pixels for determining if user is "at bottom". */
-const AUTO_SCROLL_THRESHOLD = 100
+const AUTO_SCROLL_THRESHOLD = 100;
/**
* Checks if the user is scrolled near the bottom of a container.
@@ -18,10 +18,13 @@ const AUTO_SCROLL_THRESHOLD = 100
* @param threshold - Distance from bottom in pixels to consider "at bottom"
* @returns true if within threshold of bottom, false otherwise
*/
-export function isNearBottom(container: HTMLElement | null, threshold = AUTO_SCROLL_THRESHOLD): boolean {
- if (!container) return true // Default to auto-scroll if no container
- const { scrollTop, scrollHeight, clientHeight } = container
- return scrollHeight - scrollTop - clientHeight < threshold
+export function isNearBottom(
+ container: HTMLElement | null,
+ threshold = AUTO_SCROLL_THRESHOLD,
+): boolean {
+ if (!container) return true; // Default to auto-scroll if no container
+ const { scrollTop, scrollHeight, clientHeight } = container;
+ return scrollHeight - scrollTop - clientHeight < threshold;
}
/**
@@ -34,14 +37,17 @@ export function isNearBottom(container: HTMLElement | null, threshold = AUTO_SCR
* @param container - The scrollable container element
* @param shouldScroll - Whether to actually perform the scroll
*/
-export async function scrollToBottom(container: HTMLElement | null, shouldScroll: boolean): Promise {
- await tick()
+export async function scrollToBottom(
+ container: HTMLElement | null,
+ shouldScroll: boolean,
+): Promise {
+ await tick();
if (container && shouldScroll) {
- const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({
top: container.scrollHeight,
- behavior: prefersReducedMotion ? 'auto' : 'smooth'
- })
+ behavior: prefersReducedMotion ? "auto" : "smooth",
+ });
}
}
@@ -53,33 +59,33 @@ export async function scrollToBottom(container: HTMLElement | null, shouldScroll
* @returns Object with scroll handlers and state accessor
*/
export function createScrollManager(threshold = AUTO_SCROLL_THRESHOLD) {
- let shouldAutoScroll = true
- let container: HTMLElement | null = null
+ let shouldAutoScroll = true;
+ let container: HTMLElement | null = null;
return {
/** Sets the container element to manage. */
setContainer(element: HTMLElement | null): void {
- container = element
+ container = element;
},
/** Checks scroll position and updates auto-scroll state. Bind to onscroll. */
checkAutoScroll(): void {
- shouldAutoScroll = isNearBottom(container, threshold)
+ shouldAutoScroll = isNearBottom(container, threshold);
},
/** Scrolls to bottom if auto-scroll is enabled. Call after content updates. */
async scrollToBottom(): Promise {
- await scrollToBottom(container, shouldAutoScroll)
+ await scrollToBottom(container, shouldAutoScroll);
},
/** Forces auto-scroll to be enabled (e.g., when user sends a message). */
enableAutoScroll(): void {
- shouldAutoScroll = true
+ shouldAutoScroll = true;
},
/** Returns current auto-scroll state. */
isAutoScrollEnabled(): boolean {
- return shouldAutoScroll
- }
- }
+ return shouldAutoScroll;
+ },
+ };
}
diff --git a/frontend/src/lib/utils/session.test.ts b/frontend/src/lib/utils/session.test.ts
index 1217e6fd..c2fbea3d 100644
--- a/frontend/src/lib/utils/session.test.ts
+++ b/frontend/src/lib/utils/session.test.ts
@@ -1,52 +1,51 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { generateSessionId } from './session'
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { generateSessionId } from "./session";
-describe('generateSessionId', () => {
+describe("generateSessionId", () => {
beforeEach(() => {
- vi.useFakeTimers()
- vi.setSystemTime(new Date('2026-02-09T12:00:00.000Z'))
- })
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-02-09T12:00:00.000Z"));
+ });
afterEach(() => {
- vi.useRealTimers()
- vi.restoreAllMocks()
- vi.unstubAllGlobals()
- })
-
- it('uses crypto.randomUUID when available', () => {
- vi.stubGlobal('crypto', {
- randomUUID: () => 'uuid-test-value',
- } as unknown as Crypto)
-
- const sessionId = generateSessionId('chat')
- expect(sessionId).toBe('chat-1770638400000-uuid-test-value')
- })
-
- it('uses crypto.getRandomValues when randomUUID is unavailable', () => {
- vi.stubGlobal('crypto', {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("uses crypto.randomUUID when available", () => {
+ vi.stubGlobal("crypto", {
+ randomUUID: () => "uuid-test-value",
+ });
+
+ const sessionId = generateSessionId("chat");
+ expect(sessionId).toBe("chat-1770638400000-uuid-test-value");
+ });
+
+ it("uses crypto.getRandomValues when randomUUID is unavailable", () => {
+ vi.stubGlobal("crypto", {
getRandomValues: (randomBytes: Uint8Array) => {
- randomBytes.fill(15)
- return randomBytes
+ randomBytes.fill(15);
+ return randomBytes;
},
- } as unknown as Crypto)
-
- const sessionId = generateSessionId('chat')
- const sessionParts = sessionId.split('-')
- const randomSuffix = sessionParts[sessionParts.length - 1]
- expect(randomSuffix).toHaveLength(32)
- expect(/^[0-9a-f]+$/.test(randomSuffix)).toBe(true)
- })
-
- it('falls back to padded Math.random output when crypto is unavailable', () => {
- vi.stubGlobal('crypto', undefined)
- vi.spyOn(Math, 'random').mockReturnValue(0)
-
- const sessionId = generateSessionId('chat')
- const sessionParts = sessionId.split('-')
- const randomSuffix = sessionParts[sessionParts.length - 1]
- expect(sessionId.endsWith('-')).toBe(false)
- expect(randomSuffix).toHaveLength(12)
- expect(randomSuffix).toBe('000000000000')
- })
-})
-
+ });
+
+ const sessionId = generateSessionId("chat");
+ const sessionParts = sessionId.split("-");
+ const randomSuffix = sessionParts[sessionParts.length - 1];
+ expect(randomSuffix).toHaveLength(32);
+ expect(/^[0-9a-f]+$/.test(randomSuffix)).toBe(true);
+ });
+
+ it("falls back to padded Math.random output when crypto is unavailable", () => {
+ vi.stubGlobal("crypto", undefined);
+ vi.spyOn(Math, "random").mockReturnValue(0);
+
+ const sessionId = generateSessionId("chat");
+ const sessionParts = sessionId.split("-");
+ const randomSuffix = sessionParts[sessionParts.length - 1];
+ expect(sessionId.endsWith("-")).toBe(false);
+ expect(randomSuffix).toHaveLength(12);
+ expect(randomSuffix).toBe("000000000000");
+ });
+});
diff --git a/frontend/src/lib/utils/session.ts b/frontend/src/lib/utils/session.ts
index 534e54bf..23e03f73 100644
--- a/frontend/src/lib/utils/session.ts
+++ b/frontend/src/lib/utils/session.ts
@@ -3,17 +3,19 @@
*/
function createSessionRandomPart(): string {
- if (typeof crypto !== 'undefined') {
- if (typeof crypto.randomUUID === 'function') {
- return crypto.randomUUID()
+ if (typeof crypto !== "undefined") {
+ if (typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
}
- if (typeof crypto.getRandomValues === 'function') {
- const randomBytes = new Uint8Array(16)
- crypto.getRandomValues(randomBytes)
- return Array.from(randomBytes, (randomByte) => randomByte.toString(16).padStart(2, '0')).join('')
+ if (typeof crypto.getRandomValues === "function") {
+ const randomBytes = new Uint8Array(16);
+ crypto.getRandomValues(randomBytes);
+ return Array.from(randomBytes, (randomByte) => randomByte.toString(16).padStart(2, "0")).join(
+ "",
+ );
}
}
- return Math.random().toString(36).slice(2, 14).padEnd(12, '0')
+ return Math.random().toString(36).slice(2, 14).padEnd(12, "0");
}
/**
@@ -26,6 +28,6 @@ function createSessionRandomPart(): string {
* @returns Unique session ID string in format "{prefix}-{timestamp}-{random}"
*/
export function generateSessionId(prefix: string): string {
- const randomPart = createSessionRandomPart()
- return `${prefix}-${Date.now()}-${randomPart}`
+ const randomPart = createSessionRandomPart();
+ return `${prefix}-${Date.now()}-${randomPart}`;
}
diff --git a/frontend/src/lib/utils/url.test.ts b/frontend/src/lib/utils/url.test.ts
index 8d8dede2..013bef02 100644
--- a/frontend/src/lib/utils/url.test.ts
+++ b/frontend/src/lib/utils/url.test.ts
@@ -1,120 +1,120 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect } from "vitest";
import {
sanitizeUrl,
buildFullUrl,
deduplicateCitations,
getCitationType,
- getDisplaySource
-} from './url'
-
-describe('sanitizeUrl', () => {
- it('returns fallback for empty/null/undefined input', () => {
- expect(sanitizeUrl('')).toBe('#')
- expect(sanitizeUrl(null)).toBe('#')
- expect(sanitizeUrl(undefined)).toBe('#')
- })
-
- it('allows https URLs', () => {
- const url = 'https://example.com/path'
- expect(sanitizeUrl(url)).toBe(url)
- })
-
- it('allows http URLs', () => {
- const url = 'http://example.com/path'
- expect(sanitizeUrl(url)).toBe(url)
- })
-
- it('blocks javascript URLs', () => {
- expect(sanitizeUrl('javascript:alert(1)')).toBe('#')
- })
-
- it('blocks data URLs', () => {
- expect(sanitizeUrl('data:text/html,')).toBe('#')
- })
-
- it('trims whitespace', () => {
- expect(sanitizeUrl(' https://example.com ')).toBe('https://example.com')
- })
-})
-
-describe('buildFullUrl', () => {
- it('returns sanitized URL without anchor', () => {
- expect(buildFullUrl('https://example.com', undefined)).toBe('https://example.com')
- expect(buildFullUrl('https://example.com', '')).toBe('https://example.com')
- })
-
- it('appends anchor with hash', () => {
- expect(buildFullUrl('https://example.com', 'section')).toBe('https://example.com#section')
- })
-
- it('handles anchor - appends with hash separator', () => {
+ getDisplaySource,
+} from "./url";
+
+describe("sanitizeUrl", () => {
+ it("returns fallback for empty/null/undefined input", () => {
+ expect(sanitizeUrl("")).toBe("#");
+ expect(sanitizeUrl(null)).toBe("#");
+ expect(sanitizeUrl(undefined)).toBe("#");
+ });
+
+ it("allows https URLs", () => {
+ const url = "https://example.com/path";
+ expect(sanitizeUrl(url)).toBe(url);
+ });
+
+ it("allows http URLs", () => {
+ const url = "http://example.com/path";
+ expect(sanitizeUrl(url)).toBe(url);
+ });
+
+ it("blocks javascript URLs", () => {
+ expect(sanitizeUrl("javascript:alert(1)")).toBe("#");
+ });
+
+ it("blocks data URLs", () => {
+ expect(sanitizeUrl("data:text/html,")).toBe("#");
+ });
+
+ it("trims whitespace", () => {
+ expect(sanitizeUrl(" https://example.com ")).toBe("https://example.com");
+ });
+});
+
+describe("buildFullUrl", () => {
+ it("returns sanitized URL without anchor", () => {
+ expect(buildFullUrl("https://example.com", undefined)).toBe("https://example.com");
+ expect(buildFullUrl("https://example.com", "")).toBe("https://example.com");
+ });
+
+ it("appends anchor with hash", () => {
+ expect(buildFullUrl("https://example.com", "section")).toBe("https://example.com#section");
+ });
+
+ it("handles anchor - appends with hash separator", () => {
// Note: buildFullUrl always prepends # to anchor, so #section becomes ##section
// Callers should strip # from anchors before passing
- expect(buildFullUrl('https://example.com', 'section')).toBe('https://example.com#section')
- })
-})
+ expect(buildFullUrl("https://example.com", "section")).toBe("https://example.com#section");
+ });
+});
-describe('deduplicateCitations', () => {
- it('returns empty array for empty input', () => {
- expect(deduplicateCitations([])).toEqual([])
- })
+describe("deduplicateCitations", () => {
+ it("returns empty array for empty input", () => {
+ expect(deduplicateCitations([])).toEqual([]);
+ });
- it('returns empty array for null/undefined input', () => {
- expect(deduplicateCitations(null)).toEqual([])
- expect(deduplicateCitations(undefined)).toEqual([])
- })
+ it("returns empty array for null/undefined input", () => {
+ expect(deduplicateCitations(null)).toEqual([]);
+ expect(deduplicateCitations(undefined)).toEqual([]);
+ });
- it('removes duplicate URLs', () => {
+ it("removes duplicate URLs", () => {
const citations = [
- { url: 'https://a.com', title: 'A' },
- { url: 'https://b.com', title: 'B' },
- { url: 'https://a.com', title: 'A duplicate' }
- ]
- const deduplicatedCitations = deduplicateCitations(citations)
- expect(deduplicatedCitations).toHaveLength(2)
- expect(deduplicatedCitations.map(c => c.url)).toEqual(['https://a.com', 'https://b.com'])
- })
-
- it('keeps first occurrence when deduplicating', () => {
+ { url: "https://a.com", title: "A" },
+ { url: "https://b.com", title: "B" },
+ { url: "https://a.com", title: "A duplicate" },
+ ];
+ const deduplicatedCitations = deduplicateCitations(citations);
+ expect(deduplicatedCitations).toHaveLength(2);
+ expect(deduplicatedCitations.map((c) => c.url)).toEqual(["https://a.com", "https://b.com"]);
+ });
+
+ it("keeps first occurrence when deduplicating", () => {
const citations = [
- { url: 'https://a.com', title: 'First' },
- { url: 'https://a.com', title: 'Second' }
- ]
- const deduplicatedCitations = deduplicateCitations(citations)
- expect(deduplicatedCitations[0].title).toBe('First')
- })
-})
-
-describe('getCitationType', () => {
- it('detects PDF files', () => {
- expect(getCitationType('https://example.com/doc.pdf')).toBe('pdf')
- expect(getCitationType('https://example.com/DOC.PDF')).toBe('pdf')
- })
-
- it('detects API documentation', () => {
- expect(getCitationType('https://docs.oracle.com/javase/8/docs/api/')).toBe('api-doc')
- expect(getCitationType('https://developer.mozilla.org/api/something')).toBe('api-doc')
- })
-
- it('detects repositories', () => {
- expect(getCitationType('https://github.com/user/repo')).toBe('repo')
- expect(getCitationType('https://gitlab.com/user/repo')).toBe('repo')
- })
-
- it('returns external for generic URLs', () => {
- expect(getCitationType('https://example.com')).toBe('external')
- expect(getCitationType('https://blog.example.com/post')).toBe('external')
- })
-})
-
-describe('getDisplaySource', () => {
- it('extracts hostname from URL', () => {
- expect(getDisplaySource('https://docs.oracle.com/javase/8/')).toBe('docs.oracle.com')
- })
-
- it('returns fallback label for invalid URLs', () => {
- expect(getDisplaySource('not-a-url')).toBe('Source')
- expect(getDisplaySource('')).toBe('Source')
- expect(getDisplaySource(null)).toBe('Source')
- })
-})
+ { url: "https://a.com", title: "First" },
+ { url: "https://a.com", title: "Second" },
+ ];
+ const deduplicatedCitations = deduplicateCitations(citations);
+ expect(deduplicatedCitations[0].title).toBe("First");
+ });
+});
+
+describe("getCitationType", () => {
+ it("detects PDF files", () => {
+ expect(getCitationType("https://example.com/doc.pdf")).toBe("pdf");
+ expect(getCitationType("https://example.com/DOC.PDF")).toBe("pdf");
+ });
+
+ it("detects API documentation", () => {
+ expect(getCitationType("https://docs.oracle.com/javase/8/docs/api/")).toBe("api-doc");
+ expect(getCitationType("https://developer.mozilla.org/api/something")).toBe("api-doc");
+ });
+
+ it("detects repositories", () => {
+ expect(getCitationType("https://github.com/user/repo")).toBe("repo");
+ expect(getCitationType("https://gitlab.com/user/repo")).toBe("repo");
+ });
+
+ it("returns external for generic URLs", () => {
+ expect(getCitationType("https://example.com")).toBe("external");
+ expect(getCitationType("https://blog.example.com/post")).toBe("external");
+ });
+});
+
+describe("getDisplaySource", () => {
+ it("extracts hostname from URL", () => {
+ expect(getDisplaySource("https://docs.oracle.com/javase/8/")).toBe("docs.oracle.com");
+ });
+
+ it("returns fallback label for invalid URLs", () => {
+ expect(getDisplaySource("not-a-url")).toBe("Source");
+ expect(getDisplaySource("")).toBe("Source");
+ expect(getDisplaySource(null)).toBe("Source");
+ });
+});
diff --git a/frontend/src/lib/utils/url.ts b/frontend/src/lib/utils/url.ts
index e635feb3..f5ed965c 100644
--- a/frontend/src/lib/utils/url.ts
+++ b/frontend/src/lib/utils/url.ts
@@ -7,42 +7,42 @@
*/
/** Citation type for styling and icon selection. */
-export type CitationType = 'pdf' | 'api-doc' | 'repo' | 'external' | 'local' | 'unknown'
+export type CitationType = "pdf" | "api-doc" | "repo" | "external" | "local" | "unknown";
/** URL protocol constants. */
-const URL_SCHEME_HTTP = 'http://'
-const URL_SCHEME_HTTPS = 'https://'
-const LOCAL_PATH_PREFIX = '/'
-const ANCHOR_SEPARATOR = '#'
-const PDF_EXTENSION = '.pdf'
+const URL_SCHEME_HTTP = "http://";
+const URL_SCHEME_HTTPS = "https://";
+const LOCAL_PATH_PREFIX = "/";
+const ANCHOR_SEPARATOR = "#";
+const PDF_EXTENSION = ".pdf";
/** Fallback value for invalid or dangerous URLs. */
-export const FALLBACK_LINK_TARGET = '#'
+export const FALLBACK_LINK_TARGET = "#";
/** Fallback label when URL cannot be parsed for display. */
-export const FALLBACK_SOURCE_LABEL = 'Source'
+export const FALLBACK_SOURCE_LABEL = "Source";
/** Safe URL schemes - only http and https are allowed for external links. */
-const SAFE_URL_SCHEMES = ['http:', 'https:'] as const
+const SAFE_URL_SCHEMES = ["http:", "https:"] as const;
/** Domain patterns that indicate API documentation sources. */
-const API_DOC_PATTERNS = ['docs.oracle.com', 'javadoc', '/api/', '/docs/api/'] as const
+const API_DOC_PATTERNS = ["docs.oracle.com", "javadoc", "/api/", "/docs/api/"] as const;
/** Domain patterns that indicate repository sources. */
-const REPO_PATTERNS = ['github.com', 'gitlab.com', 'bitbucket.org'] as const
+const REPO_PATTERNS = ["github.com", "gitlab.com", "bitbucket.org"] as const;
/**
* Checks if URL starts with http:// or https://.
*/
export function isHttpUrl(url: string): boolean {
- return url.startsWith(URL_SCHEME_HTTP) || url.startsWith(URL_SCHEME_HTTPS)
+ return url.startsWith(URL_SCHEME_HTTP) || url.startsWith(URL_SCHEME_HTTPS);
}
/**
* Checks if URL contains any of the given patterns (case-sensitive).
*/
export function matchesAnyPattern(url: string, patterns: readonly string[]): boolean {
- return patterns.some(pattern => url.includes(pattern))
+ return patterns.some((pattern) => url.includes(pattern));
}
/**
@@ -53,36 +53,36 @@ export function matchesAnyPattern(url: string, patterns: readonly string[]): boo
* @returns The original URL if safe, or FALLBACK_LINK_TARGET for dangerous schemes
*/
export function sanitizeUrl(url: string | undefined | null): string {
- if (!url) return FALLBACK_LINK_TARGET
- const trimmedUrl = url.trim()
- if (!trimmedUrl) return FALLBACK_LINK_TARGET
+ if (!url) return FALLBACK_LINK_TARGET;
+ const trimmedUrl = url.trim();
+ if (!trimmedUrl) return FALLBACK_LINK_TARGET;
// Allow relative paths (start with / but not // which is protocol-relative)
- if (trimmedUrl.startsWith(LOCAL_PATH_PREFIX) && !trimmedUrl.startsWith('//')) {
- return trimmedUrl
+ if (trimmedUrl.startsWith(LOCAL_PATH_PREFIX) && !trimmedUrl.startsWith("//")) {
+ return trimmedUrl;
}
// Check for safe schemes via URL parsing
try {
- const parsedUrl = new URL(trimmedUrl)
- const scheme = parsedUrl.protocol.toLowerCase()
- if (SAFE_URL_SCHEMES.some(safe => scheme === safe)) {
- return trimmedUrl
+ const parsedUrl = new URL(trimmedUrl);
+ const scheme = parsedUrl.protocol.toLowerCase();
+ if (SAFE_URL_SCHEMES.some((safe) => scheme === safe)) {
+ return trimmedUrl;
}
// Parsed successfully but has dangerous scheme (javascript:, data:, etc.)
- return FALLBACK_LINK_TARGET
+ return FALLBACK_LINK_TARGET;
} catch {
// URL parsing failed - might be a relative path or malformed
// Block protocol-relative URLs (//example.com) that inherit page scheme
- if (trimmedUrl.startsWith('//')) {
- return FALLBACK_LINK_TARGET
+ if (trimmedUrl.startsWith("//")) {
+ return FALLBACK_LINK_TARGET;
}
// Only allow if it doesn't look like a dangerous scheme
- const lowerUrl = trimmedUrl.toLowerCase()
- if (lowerUrl.includes(':') && !isHttpUrl(lowerUrl)) {
- return FALLBACK_LINK_TARGET
+ const lowerUrl = trimmedUrl.toLowerCase();
+ if (lowerUrl.includes(":") && !isHttpUrl(lowerUrl)) {
+ return FALLBACK_LINK_TARGET;
}
- return trimmedUrl
+ return trimmedUrl;
}
}
@@ -93,17 +93,17 @@ export function sanitizeUrl(url: string | undefined | null): string {
* @returns CitationType for styling decisions
*/
export function getCitationType(url: string | undefined | null): CitationType {
- if (!url) return 'unknown'
- const lowerUrl = url.toLowerCase()
+ if (!url) return "unknown";
+ const lowerUrl = url.toLowerCase();
- if (lowerUrl.endsWith(PDF_EXTENSION)) return 'pdf'
+ if (lowerUrl.endsWith(PDF_EXTENSION)) return "pdf";
if (isHttpUrl(lowerUrl)) {
- if (matchesAnyPattern(lowerUrl, API_DOC_PATTERNS)) return 'api-doc'
- if (matchesAnyPattern(lowerUrl, REPO_PATTERNS)) return 'repo'
- return 'external'
+ if (matchesAnyPattern(lowerUrl, API_DOC_PATTERNS)) return "api-doc";
+ if (matchesAnyPattern(lowerUrl, REPO_PATTERNS)) return "repo";
+ return "external";
}
- if (lowerUrl.startsWith(LOCAL_PATH_PREFIX)) return 'local'
- return 'unknown'
+ if (lowerUrl.startsWith(LOCAL_PATH_PREFIX)) return "local";
+ return "unknown";
}
/**
@@ -113,39 +113,39 @@ export function getCitationType(url: string | undefined | null): CitationType {
* @returns Human-readable source label (domain, filename, or fallback)
*/
export function getDisplaySource(url: string | undefined | null): string {
- if (!url) return FALLBACK_SOURCE_LABEL
+ if (!url) return FALLBACK_SOURCE_LABEL;
- const lowerUrl = url.toLowerCase()
+ const lowerUrl = url.toLowerCase();
// PDF filenames - extract and clean the filename
if (lowerUrl.endsWith(PDF_EXTENSION)) {
- const segments = url.split(LOCAL_PATH_PREFIX)
- const filename = segments[segments.length - 1]
+ const segments = url.split(LOCAL_PATH_PREFIX);
+ const filename = segments[segments.length - 1];
const cleanName = filename
- .replace(/\.pdf$/i, '')
- .replace(/[-_]/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- return cleanName || 'PDF Document'
+ .replace(/\.pdf$/i, "")
+ .replace(/[-_]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ return cleanName || "PDF Document";
}
// HTTP URLs - extract hostname
if (isHttpUrl(url)) {
try {
- const urlObj = new URL(url)
- return urlObj.hostname.replace(/^www\./, '')
+ const urlObj = new URL(url);
+ return urlObj.hostname.replace(/^www\./, "");
} catch {
- return FALLBACK_SOURCE_LABEL
+ return FALLBACK_SOURCE_LABEL;
}
}
// Local paths - extract last segment
if (url.startsWith(LOCAL_PATH_PREFIX)) {
- const segments = url.split(LOCAL_PATH_PREFIX)
- return segments[segments.length - 1] || 'Local Resource'
+ const segments = url.split(LOCAL_PATH_PREFIX);
+ return segments[segments.length - 1] || "Local Resource";
}
- return FALLBACK_SOURCE_LABEL
+ return FALLBACK_SOURCE_LABEL;
}
/**
@@ -157,20 +157,20 @@ export function getDisplaySource(url: string | undefined | null): string {
* @returns Safe URL with anchor, or FALLBACK_LINK_TARGET if unsafe
*/
export function buildFullUrl(baseUrl: string | undefined | null, anchor?: string): string {
- if (!baseUrl) return FALLBACK_LINK_TARGET
+ if (!baseUrl) return FALLBACK_LINK_TARGET;
- const safeUrl = sanitizeUrl(baseUrl)
- if (safeUrl === FALLBACK_LINK_TARGET) return FALLBACK_LINK_TARGET
+ const safeUrl = sanitizeUrl(baseUrl);
+ if (safeUrl === FALLBACK_LINK_TARGET) return FALLBACK_LINK_TARGET;
if (anchor && !safeUrl.includes(ANCHOR_SEPARATOR)) {
- return `${safeUrl}${ANCHOR_SEPARATOR}${anchor}`
+ return `${safeUrl}${ANCHOR_SEPARATOR}${anchor}`;
}
- return safeUrl
+ return safeUrl;
}
/** Constraint for objects with a URL property (used in deduplication). */
interface HasUrl {
- url?: string | null
+ url?: string | null;
}
/**
@@ -180,16 +180,18 @@ interface HasUrl {
* @param citations - Array of objects with url property (null/undefined treated as empty)
* @returns Deduplicated array preserving original order
*/
-export function deduplicateCitations(citations: readonly T[] | null | undefined): T[] {
+export function deduplicateCitations(
+ citations: readonly T[] | null | undefined,
+): T[] {
if (!citations || citations.length === 0) {
- return []
+ return [];
}
- const seen = new Set()
+ const seen = new Set();
return citations.filter((citation) => {
- if (!citation || typeof citation.url !== 'string') return false
- const key = citation.url.toLowerCase()
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
+ if (!citation || typeof citation.url !== "string") return false;
+ const key = citation.url.toLowerCase();
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
}
diff --git a/frontend/src/lib/validation/schemas.ts b/frontend/src/lib/validation/schemas.ts
index 76ce9826..46013f49 100644
--- a/frontend/src/lib/validation/schemas.ts
+++ b/frontend/src/lib/validation/schemas.ts
@@ -8,7 +8,7 @@
* @see {@link docs/type-safety-zod-validation.md} for validation patterns
*/
-import { z } from 'zod/v4'
+import { z } from "zod/v4";
// =============================================================================
// SSE Stream Event Schemas
@@ -23,24 +23,24 @@ const sseEventFieldShape = {
provider: z.string().nullish(),
stage: z.string().nullish(),
attempt: z.int().positive().nullish(),
- maxAttempts: z.int().positive().nullish()
-}
+ maxAttempts: z.int().positive().nullish(),
+};
/** Status message from SSE status events. */
-export const StreamStatusSchema = z.object(sseEventFieldShape)
+export const StreamStatusSchema = z.object(sseEventFieldShape);
/** Error response from SSE error events. */
-export const StreamErrorSchema = z.object(sseEventFieldShape)
+export const StreamErrorSchema = z.object(sseEventFieldShape);
/** Text event payload wrapper. */
export const TextEventPayloadSchema = z.object({
- text: z.string()
-})
+ text: z.string(),
+});
/** Provider metadata from SSE provider events. */
export const ProviderEventSchema = z.object({
- provider: z.string()
-})
+ provider: z.string(),
+});
// =============================================================================
// Citation Schemas
@@ -51,11 +51,11 @@ export const CitationSchema = z.object({
url: z.string(),
title: z.string(),
anchor: z.string().optional(),
- snippet: z.string().optional()
-})
+ snippet: z.string().optional(),
+});
/** Array of citations from citation endpoints. */
-export const CitationsArraySchema = z.array(CitationSchema)
+export const CitationsArraySchema = z.array(CitationSchema);
// =============================================================================
// Guided Learning Schemas
@@ -66,17 +66,17 @@ export const GuidedLessonSchema = z.object({
slug: z.string(),
title: z.string(),
summary: z.string(),
- keywords: z.array(z.string())
-})
+ keywords: z.array(z.string()),
+});
/** Array of lessons for TOC endpoint. */
-export const GuidedTOCSchema = z.array(GuidedLessonSchema)
+export const GuidedTOCSchema = z.array(GuidedLessonSchema);
/** Response from the lesson content endpoint. */
export const LessonContentResponseSchema = z.object({
markdown: z.string(),
- cached: z.boolean()
-})
+ cached: z.boolean(),
+});
// =============================================================================
// Error Response Schemas
@@ -86,18 +86,18 @@ export const LessonContentResponseSchema = z.object({
export const ApiErrorResponseSchema = z.object({
status: z.string(),
message: z.string(),
- details: z.string().nullable().optional()
-})
+ details: z.string().nullable().optional(),
+});
// =============================================================================
// Inferred Types (export for service layer)
// =============================================================================
-export type StreamStatus = z.infer
-export type StreamError = z.infer
-export type TextEventPayload = z.infer
-export type ProviderEvent = z.infer
-export type Citation = z.infer
-export type GuidedLesson = z.infer
-export type LessonContentResponse = z.infer
-export type ApiErrorResponse = z.infer
+export type StreamStatus = z.infer;
+export type StreamError = z.infer;
+export type TextEventPayload = z.infer;
+export type ProviderEvent = z.infer;
+export type Citation = z.infer;
+export type GuidedLesson = z.infer;
+export type LessonContentResponse = z.infer;
+export type ApiErrorResponse = z.infer;
diff --git a/frontend/src/lib/validation/validate.ts b/frontend/src/lib/validation/validate.ts
index eff062e2..d1fbbc53 100644
--- a/frontend/src/lib/validation/validate.ts
+++ b/frontend/src/lib/validation/validate.ts
@@ -8,7 +8,7 @@
* @see {@link docs/type-safety-zod-validation.md} for validation patterns
*/
-import { z } from 'zod/v4'
+import { z } from "zod/v4";
// =============================================================================
// Result Types (Discriminated Unions)
@@ -16,18 +16,18 @@ import { z } from 'zod/v4'
/** Success result with validated data. */
interface ValidationSuccess {
- success: true
- validated: T
+ success: true;
+ validated: T;
}
/** Failure result with Zod error. */
interface ValidationFailure {
- success: false
- error: z.ZodError
+ success: false;
+ error: z.ZodError;
}
/** Discriminated union - never null, always explicit success/failure. */
-export type ValidationResult = ValidationSuccess | ValidationFailure
+export type ValidationResult = ValidationSuccess | ValidationFailure;
// =============================================================================
// Error Logging
@@ -45,38 +45,39 @@ export type ValidationResult = ValidationSuccess | ValidationFailure
*/
export function logZodFailure(context: string, error: unknown, rawInput?: unknown): void {
const inputKeys =
- typeof rawInput === 'object' && rawInput !== null ? Object.keys(rawInput).slice(0, 20) : []
+ typeof rawInput === "object" && rawInput !== null ? Object.keys(rawInput).slice(0, 20) : [];
if (error instanceof z.ZodError) {
const issueSummaries = error.issues.slice(0, 10).map((issue) => {
- const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
// Zod v4: 'input' contains the failing value, 'received' for type errors
- const inputValue = 'input' in issue ? issue.input : undefined
- const receivedValue = 'received' in issue ? issue.received : undefined
- const actualValue = receivedValue ?? inputValue
+ const inputValue = "input" in issue ? issue.input : undefined;
+ const receivedValue = "received" in issue ? issue.received : undefined;
+ const actualValue = receivedValue ?? inputValue;
- const received = actualValue !== undefined ? ` (received: ${JSON.stringify(actualValue)})` : ''
- const expected = 'expected' in issue ? ` (expected: ${issue.expected})` : ''
+ const received =
+ actualValue !== undefined ? ` (received: ${JSON.stringify(actualValue)})` : "";
+ const expected = "expected" in issue ? ` (expected: ${issue.expected})` : "";
- return ` - ${path}: ${issue.message}${expected}${received}`
- })
+ return ` - ${path}: ${issue.message}${expected}${received}`;
+ });
// Log as readable string - NOT collapsed object
console.error(
`[Zod] ${context} validation failed\n` +
- `Issues:\n${issueSummaries.join('\n')}\n` +
- `Payload keys: ${inputKeys.join(', ')}`
- )
+ `Issues:\n${issueSummaries.join("\n")}\n` +
+ `Payload keys: ${inputKeys.join(", ")}`,
+ );
// Full details for deep debugging
console.error(`[Zod] ${context} - full details:`, {
prettifiedError: z.prettifyError(error),
issues: error.issues,
- rawInput
- })
+ rawInput,
+ });
} else {
- console.error(`[Zod] ${context} validation failed (non-ZodError):`, error)
+ console.error(`[Zod] ${context} validation failed (non-ZodError):`, error);
}
}
@@ -98,16 +99,16 @@ export function logZodFailure(context: string, error: unknown, rawInput?: unknow
export function validateWithSchema(
schema: z.ZodType,
rawInput: unknown,
- recordId: string
+ recordId: string,
): ValidationResult {
- const result = schema.safeParse(rawInput)
+ const result = schema.safeParse(rawInput);
if (!result.success) {
- logZodFailure(`validateWithSchema [${recordId}]`, result.error, rawInput)
- return { success: false, error: result.error }
+ logZodFailure(`validateWithSchema [${recordId}]`, result.error, rawInput);
+ return { success: false, error: result.error };
}
- return { success: true, validated: result.data }
+ return { success: true, validated: result.data };
}
/**
@@ -124,30 +125,30 @@ export function validateWithSchema(
export async function validateFetchJson(
response: Response,
schema: z.ZodType,
- recordId: string
+ recordId: string,
): Promise<{ success: true; validated: T } | { success: false; error: string }> {
if (!response.ok) {
- const errorMessage = `HTTP ${response.status}: ${response.statusText}`
- console.error(`[Fetch] ${recordId} failed: ${errorMessage}`)
- return { success: false, error: errorMessage }
+ const errorMessage = `HTTP ${response.status}: ${response.statusText}`;
+ console.error(`[Fetch] ${recordId} failed: ${errorMessage}`);
+ return { success: false, error: errorMessage };
}
- let fetchedJson: unknown
+ let fetchedJson: unknown;
try {
- fetchedJson = await response.json()
+ fetchedJson = await response.json();
} catch (parseError) {
- const errorMessage = `JSON parse failed: ${parseError instanceof Error ? parseError.message : String(parseError)}`
- console.error(`[Fetch] ${recordId} ${errorMessage}`)
- return { success: false, error: errorMessage }
+ const errorMessage = `JSON parse failed: ${parseError instanceof Error ? parseError.message : String(parseError)}`;
+ console.error(`[Fetch] ${recordId} ${errorMessage}`);
+ return { success: false, error: errorMessage };
}
- const validationResult = validateWithSchema(schema, fetchedJson, recordId)
+ const validationResult = validateWithSchema(schema, fetchedJson, recordId);
if (!validationResult.success) {
- return { success: false, error: `Validation failed for ${recordId}` }
+ return { success: false, error: `Validation failed for ${recordId}` };
}
- return { success: true, validated: validationResult.validated }
+ return { success: true, validated: validationResult.validated };
}
/**
@@ -156,5 +157,5 @@ export async function validateFetchJson(
* Use this instead of unsafe `as Record` casts.
*/
export function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
+ return typeof value === "object" && value !== null && !Array.isArray(value);
}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 4a086a1f..95cda7e2 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,9 +1,9 @@
-import { mount } from 'svelte'
-import App from './App.svelte'
-import './styles/global.css'
+import { mount } from "svelte";
+import App from "./App.svelte";
+import "./styles/global.css";
const app = mount(App, {
- target: document.getElementById('app')!
-})
+ target: document.getElementById("app")!,
+});
-export default app
+export default app;
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index e486509d..e02e4489 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -35,7 +35,7 @@
/* Borders */
--color-border-subtle: rgba(255, 252, 247, 0.06);
- --color-border-default: rgba(255, 252, 247, 0.10);
+ --color-border-default: rgba(255, 252, 247, 0.1);
--color-border-strong: rgba(255, 252, 247, 0.16);
/* Semantic */
@@ -49,18 +49,21 @@
* ═══════════════════════════════════════════════════════════════════════════ */
@font-face {
- font-family: 'Fraunces';
- src: url('/fonts/Fraunces-Variable.ttf') format('truetype');
+ font-family: "Fraunces";
+ src: url("/fonts/Fraunces-Variable.ttf") format("truetype");
font-weight: 100 900;
font-display: swap;
font-style: normal italic;
/* Force "Clean" axes defaults: no wonky, no soft, text-optimized opsz base */
- font-variation-settings: 'SOFT' 0, 'WONK' 0, 'opsz' 9;
+ font-variation-settings:
+ "SOFT" 0,
+ "WONK" 0,
+ "opsz" 9;
}
- --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
- --font-serif: 'Fraunces', 'Georgia', 'Times New Roman', serif;
- --font-mono: 'DM Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
+ --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --font-serif: "Fraunces", "Georgia", "Times New Roman", serif;
+ --font-mono: "DM Mono", "SF Mono", "Fira Code", "Consolas", monospace;
/* Type scale - Minor third (1.2) */
--text-xs: 0.694rem;
@@ -89,18 +92,18 @@
* ═══════════════════════════════════════════════════════════════════════════ */
--space-0: 0;
- --space-1: 0.25rem; /* 4px */
- --space-2: 0.5rem; /* 8px */
- --space-3: 0.75rem; /* 12px */
- --space-4: 1rem; /* 16px */
- --space-5: 1.25rem; /* 20px */
- --space-6: 1.5rem; /* 24px */
- --space-8: 2rem; /* 32px */
- --space-10: 2.5rem; /* 40px */
- --space-12: 3rem; /* 48px */
- --space-16: 4rem; /* 64px */
- --space-20: 5rem; /* 80px */
- --space-24: 6rem; /* 96px */
+ --space-1: 0.25rem; /* 4px */
+ --space-2: 0.5rem; /* 8px */
+ --space-3: 0.75rem; /* 12px */
+ --space-4: 1rem; /* 16px */
+ --space-5: 1.25rem; /* 20px */
+ --space-6: 1.5rem; /* 24px */
+ --space-8: 2rem; /* 32px */
+ --space-10: 2.5rem; /* 40px */
+ --space-12: 3rem; /* 48px */
+ --space-16: 4rem; /* 64px */
+ --space-20: 5rem; /* 80px */
+ --space-24: 6rem; /* 96px */
/* ═══════════════════════════════════════════════════════════════════════════
* RADIUS & SHADOWS
@@ -165,7 +168,9 @@
* RESET & BASE
* ═══════════════════════════════════════════════════════════════════════════ */
-*, *::before, *::after {
+*,
+*::before,
+*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@@ -270,8 +275,12 @@ body {
* ═══════════════════════════════════════════════════════════════════════════ */
@keyframes fade-in {
- from { opacity: 0; }
- to { opacity: 1; }
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
}
@keyframes fade-in-up {
@@ -297,17 +306,29 @@ body {
}
@keyframes pulse-subtle {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.6; }
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.6;
+ }
}
@keyframes typing-cursor {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0; }
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
}
@keyframes bounce {
- 0%, 80%, 100% {
+ 0%,
+ 80%,
+ 100% {
transform: scale(0.8);
opacity: 0.5;
}
@@ -329,7 +350,8 @@ body {
* CODE HIGHLIGHTING - Tokyo Night inspired
* ═══════════════════════════════════════════════════════════════════════════ */
-pre, code {
+pre,
+code {
font-family: var(--font-mono);
}
@@ -362,19 +384,46 @@ pre code {
}
/* Syntax highlighting colors - warm palette */
-.hljs-keyword { color: #c45d3a; }
-.hljs-string { color: #a8c28a; }
-.hljs-number { color: #d4a04a; }
-.hljs-comment { color: var(--color-text-muted); font-style: italic; }
-.hljs-function { color: #7aade9; }
-.hljs-class { color: #d4a04a; }
-.hljs-variable { color: #e0d0b8; }
-.hljs-operator { color: var(--color-text-tertiary); }
-.hljs-punctuation { color: var(--color-text-tertiary); }
-.hljs-type { color: #7aade9; }
-.hljs-built_in { color: #c9a04a; }
-.hljs-attr { color: #c45d3a; }
-.hljs-meta { color: var(--color-text-muted); }
+.hljs-keyword {
+ color: #c45d3a;
+}
+.hljs-string {
+ color: #a8c28a;
+}
+.hljs-number {
+ color: #d4a04a;
+}
+.hljs-comment {
+ color: var(--color-text-muted);
+ font-style: italic;
+}
+.hljs-function {
+ color: #7aade9;
+}
+.hljs-class {
+ color: #d4a04a;
+}
+.hljs-variable {
+ color: #e0d0b8;
+}
+.hljs-operator {
+ color: var(--color-text-tertiary);
+}
+.hljs-punctuation {
+ color: var(--color-text-tertiary);
+}
+.hljs-type {
+ color: #7aade9;
+}
+.hljs-built_in {
+ color: #c9a04a;
+}
+.hljs-attr {
+ color: #c45d3a;
+}
+.hljs-meta {
+ color: var(--color-text-muted);
+}
/* ═══════════════════════════════════════════════════════════════════════════
* ENRICHMENT CARDS - Hint, Warning, Background, Example, Reminder
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index b545bbe0..4d9051ee 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -1,7 +1,7 @@
-import '@testing-library/jest-dom/vitest'
+import "@testing-library/jest-dom/vitest";
// Mock window.matchMedia for components that use media queries
-Object.defineProperty(window, 'matchMedia', {
+Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
@@ -11,18 +11,19 @@ Object.defineProperty(window, 'matchMedia', {
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
- dispatchEvent: () => false
- })
-})
+ dispatchEvent: () => false,
+ }),
+});
// jsdom doesn't implement scrollTo on elements; components use it for chat auto-scroll.
// oxlint-disable-next-line no-extend-native -- jsdom polyfill, not production code
-Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
+Object.defineProperty(HTMLElement.prototype, "scrollTo", {
writable: true,
- value: () => {}
-})
+ value: () => {},
+});
// requestAnimationFrame is used for post-update DOM adjustments; provide a safe fallback.
-if (typeof window.requestAnimationFrame !== 'function') {
- window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)
+if (typeof window.requestAnimationFrame !== "function") {
+ window.requestAnimationFrame = (callback: FrameRequestCallback) =>
+ window.setTimeout(() => callback(performance.now()), 0);
}
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
index 09a1bf7a..d0e64483 100644
--- a/frontend/svelte.config.js
+++ b/frontend/svelte.config.js
@@ -1,5 +1,5 @@
-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
- preprocess: vitePreprocess()
-}
+ preprocess: vitePreprocess(),
+};
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 8f53b1e0..c3d22a91 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,9 +1,9 @@
-import { defineConfig, type HtmlTagDescriptor } from 'vite'
-import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { defineConfig, type HtmlTagDescriptor } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
-const SIMPLE_ANALYTICS_QUEUE_ORIGIN = 'https://queue.simpleanalyticscdn.com'
-const SIMPLE_ANALYTICS_HOSTNAME = 'javachat.ai'
-const SIMPLE_ANALYTICS_SCRIPT_URL = 'https://scripts.simpleanalyticscdn.com/latest.js'
+const SIMPLE_ANALYTICS_QUEUE_ORIGIN = "https://queue.simpleanalyticscdn.com";
+const SIMPLE_ANALYTICS_HOSTNAME = "javachat.ai";
+const SIMPLE_ANALYTICS_SCRIPT_URL = "https://scripts.simpleanalyticscdn.com/latest.js";
const SIMPLE_ANALYTICS_RUNTIME_GUARD_SCRIPT = `;(function () {
if (globalThis.location.hostname !== '${SIMPLE_ANALYTICS_HOSTNAME}') {
return
@@ -14,80 +14,79 @@ const SIMPLE_ANALYTICS_RUNTIME_GUARD_SCRIPT = `;(function () {
analyticsScript.src = '${SIMPLE_ANALYTICS_SCRIPT_URL}'
analyticsScript.setAttribute('data-hostname', '${SIMPLE_ANALYTICS_HOSTNAME}')
document.head.appendChild(analyticsScript)
-})()`
+})()`;
function buildSimpleAnalyticsTags(mode: string): HtmlTagDescriptor[] {
- if (mode !== 'production') {
- return []
+ if (mode !== "production") {
+ return [];
}
- const noScriptImageUrl =
- `${SIMPLE_ANALYTICS_QUEUE_ORIGIN}/noscript.gif?hostname=${encodeURIComponent(SIMPLE_ANALYTICS_HOSTNAME)}`
+ const noScriptImageUrl = `${SIMPLE_ANALYTICS_QUEUE_ORIGIN}/noscript.gif?hostname=${encodeURIComponent(SIMPLE_ANALYTICS_HOSTNAME)}`;
return [
{
- tag: 'script',
+ tag: "script",
children: SIMPLE_ANALYTICS_RUNTIME_GUARD_SCRIPT,
- injectTo: 'body',
+ injectTo: "body",
},
{
- tag: 'noscript',
+ tag: "noscript",
children: [
{
- tag: 'img',
+ tag: "img",
attrs: {
src: noScriptImageUrl,
- alt: '',
- referrerpolicy: 'no-referrer-when-downgrade',
+ alt: "",
+ referrerpolicy: "no-referrer-when-downgrade",
},
},
],
- injectTo: 'body',
+ injectTo: "body",
},
- ]
+ ];
}
export default defineConfig(({ mode }) => ({
plugins: [
svelte(),
{
- name: 'simple-analytics',
+ name: "simple-analytics",
transformIndexHtml() {
- return buildSimpleAnalyticsTags(mode)
+ return buildSimpleAnalyticsTags(mode);
},
},
],
- base: '/',
+ base: "/",
server: {
port: 5173,
proxy: {
- '/api': {
- target: 'http://localhost:8085',
- changeOrigin: true
+ "/api": {
+ target: "http://localhost:8085",
+ changeOrigin: true,
},
- '/actuator': {
- target: 'http://localhost:8085',
- changeOrigin: true
- }
- }
+ "/actuator": {
+ target: "http://localhost:8085",
+ changeOrigin: true,
+ },
+ },
},
build: {
// Build directly to Spring Boot static resources
- outDir: '../src/main/resources/static',
+ outDir: "../src/main/resources/static",
emptyOutDir: false, // Don't delete favicons
rollupOptions: {
output: {
manualChunks: {
- 'highlight': [
- 'highlight.js/lib/core',
- 'highlight.js/lib/languages/java',
- 'highlight.js/lib/languages/xml',
- 'highlight.js/lib/languages/json',
- 'highlight.js/lib/languages/bash'
+ highlight: [
+ "highlight.js/lib/core",
+ "highlight.js/lib/languages/java",
+ "highlight.js/lib/languages/xml",
+ "highlight.js/lib/languages/json",
+ "highlight.js/lib/languages/bash",
],
- 'markdown': ['marked']
- }
- }
- }
- }
-}))
+ markdown: ["marked"],
+ },
+ },
+ },
+ },
+}));
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index b1337b1c..5feb2d2c 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -1,5 +1,5 @@
-import { defineConfig } from 'vitest/config'
-import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { defineConfig } from "vitest/config";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
@@ -7,18 +7,18 @@ export default defineConfig({
// conditional exports to resolve Svelte's server entry (where `mount()` is unavailable).
// Force browser conditions so component tests can mount under jsdom.
resolve: {
- conditions: ['module', 'browser', 'development']
+ conditions: ["module", "browser", "development"],
},
test: {
- environment: 'jsdom',
+ environment: "jsdom",
globals: true,
- include: ['src/**/*.{test,spec}.{js,ts}'],
- setupFiles: ['./src/test/setup.ts'],
+ include: ["src/**/*.{test,spec}.{js,ts}"],
+ setupFiles: ["./src/test/setup.ts"],
coverage: {
- provider: 'v8',
- reporter: ['text', 'html'],
- include: ['src/lib/**/*.{ts,svelte}'],
- exclude: ['src/lib/**/*.test.ts', 'src/test/**']
- }
- }
-})
+ provider: "v8",
+ reporter: ["text", "html"],
+ include: ["src/lib/**/*.{ts,svelte}"],
+ exclude: ["src/lib/**/*.test.ts", "src/test/**"],
+ },
+ },
+});
diff --git a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java
index e0cdcf75..01681589 100644
--- a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java
+++ b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java
@@ -35,6 +35,7 @@ public class AppProperties {
private static final String QDRANT_KEY = "app.qdrant";
private static final String CORS_KEY = "app.cors";
private static final String PUBLIC_BASE_URL_KEY = "app.public-base-url";
+ private static final String CLICKY_KEY = "app.clicky";
private static final String EMBEDDINGS_KEY = "app.embeddings";
private static final String LLM_KEY = "app.llm";
private static final String GUIDED_LEARNING_KEY = "app.guided-learning";
@@ -57,6 +58,7 @@ public class AppProperties {
private Embeddings embeddings = new Embeddings();
private Llm llm = new Llm();
private GuidedLearning guidedLearning = new GuidedLearning();
+ private Clicky clicky = new Clicky();
private String publicBaseUrl = DEFAULT_PUBLIC_BASE_URL;
/**
@@ -81,6 +83,7 @@ void validateConfiguration() {
requireConfiguredSection(embeddings, EMBEDDINGS_KEY).validateConfiguration();
requireConfiguredSection(llm, LLM_KEY).validateConfiguration();
requireConfiguredSection(guidedLearning, GUIDED_LEARNING_KEY).validateConfiguration();
+ requireConfiguredSection(clicky, CLICKY_KEY).validateConfiguration();
this.publicBaseUrl = validatePublicBaseUrl(publicBaseUrl);
}
@@ -102,6 +105,14 @@ public void setPublicBaseUrl(final String publicBaseUrl) {
this.publicBaseUrl = publicBaseUrl;
}
+ public Clicky getClicky() {
+ return clicky;
+ }
+
+ public void setClicky(Clicky clicky) {
+ this.clicky = requireConfiguredSection(clicky, CLICKY_KEY);
+ }
+
public GuidedLearning getGuidedLearning() {
return guidedLearning;
}
@@ -249,6 +260,54 @@ private static T requireConfiguredSection(T section, String sectionKey) {
return section;
}
+ /** Clicky analytics configuration. */
+ public static class Clicky {
+ private boolean enabled = false;
+ private String siteId = "";
+ private long parsedSiteId = -1L;
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getSiteId() {
+ return siteId;
+ }
+
+ public void setSiteId(String siteId) {
+ this.siteId = siteId;
+ }
+
+ public long getParsedSiteId() {
+ return parsedSiteId;
+ }
+
+ Clicky validateConfiguration() {
+ if (!enabled) {
+ parsedSiteId = -1L;
+ return this;
+ }
+
+ if (siteId == null || siteId.isBlank()) {
+ throw new IllegalArgumentException("app.clicky.site-id must not be blank when app.clicky.enabled=true");
+ }
+
+ String trimmedSiteId = siteId.trim();
+ boolean allDigits = trimmedSiteId.chars().allMatch(Character::isDigit);
+ if (!allDigits) {
+ throw new IllegalArgumentException(
+ "app.clicky.site-id must contain digits only, got: " + trimmedSiteId);
+ }
+
+ parsedSiteId = Long.parseLong(trimmedSiteId);
+ return this;
+ }
+ }
+
/** Qdrant vector store settings. */
public static class Qdrant {
private boolean ensurePayloadIndexes = true;
diff --git a/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java
new file mode 100644
index 00000000..5b53dca6
--- /dev/null
+++ b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java
@@ -0,0 +1,72 @@
+package com.williamcallahan.javachat.web;
+
+import com.williamcallahan.javachat.config.AppProperties;
+import java.util.Objects;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.springframework.stereotype.Component;
+
+/**
+ * Injects or removes Clicky analytics script tags from server-rendered HTML documents.
+ *
+ * When Clicky analytics is enabled, this component appends the site-ID initializer
+ * and the async script loader to the document {@code
}. When disabled, it strips
+ * any existing Clicky tags to prevent double-injection from cached templates.
+ *
+ * Owns all Clicky-specific DOM mutations so that controllers remain free of
+ * analytics concerns.
+ */
+@Component
+public class ClickyAnalyticsInjector {
+
+ private static final String CLICKY_SCRIPT_URL = "https://static.getclicky.com/js";
+ private static final String CLICKY_INITIALIZER_TEMPLATE =
+ "var clicky_site_ids = clicky_site_ids || []; clicky_site_ids.push(%d);";
+
+ private final boolean clickyEnabled;
+ private final long clickySiteId;
+
+ /**
+ * Reads Clicky configuration from the validated application properties.
+ */
+ public ClickyAnalyticsInjector(AppProperties appProperties) {
+ AppProperties.Clicky clicky =
+ Objects.requireNonNull(appProperties, "appProperties").getClicky();
+ this.clickyEnabled = clicky.isEnabled();
+ this.clickySiteId = clicky.getParsedSiteId();
+ }
+
+ /**
+ * Applies Clicky analytics to the document: injects tags when enabled, removes them when disabled.
+ *
+ * @param document the Jsoup document whose {@code
} will be modified in place
+ */
+ public void applyTo(Document document) {
+ Element existingClickyLoader = document.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]");
+
+ if (!clickyEnabled) {
+ removeClickyTags(document, existingClickyLoader);
+ return;
+ }
+
+ if (existingClickyLoader != null) {
+ return;
+ }
+
+ String initializer = String.format(CLICKY_INITIALIZER_TEMPLATE, clickySiteId);
+ document.head().appendElement("script").text(initializer);
+ document.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL);
+ }
+
+ private void removeClickyTags(Document document, Element existingLoader) {
+ if (existingLoader != null) {
+ existingLoader.remove();
+ }
+ document.head().select("script").forEach(scriptTag -> {
+ String scriptBody = scriptTag.html();
+ if (scriptBody != null && scriptBody.contains("clicky_site_ids")) {
+ scriptTag.remove();
+ }
+ });
+ }
+}
diff --git a/src/main/java/com/williamcallahan/javachat/web/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java
index 5cc5cd80..e22fa15a 100644
--- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java
+++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java
@@ -11,6 +11,8 @@
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
@@ -30,19 +32,27 @@
@PreAuthorize("permitAll()")
public class SeoController {
+ private static final Logger log = LoggerFactory.getLogger(SeoController.class);
+
private final Resource indexHtml;
private final SiteUrlResolver siteUrlResolver;
+ private final ClickyAnalyticsInjector clickyAnalyticsInjector;
private final Map metadataMap = new ConcurrentHashMap<>();
// Cache the parsed document to avoid re-reading files, but clone it per request to modify
private Document cachedIndexDocument;
/**
- * Creates the SEO controller using the built SPA index.html template and a base URL resolver.
+ * Creates the SEO controller using the built SPA index.html template, a base URL resolver,
+ * and an analytics injector for Clicky script management.
*/
- public SeoController(@Value("classpath:/static/index.html") Resource indexHtml, SiteUrlResolver siteUrlResolver) {
+ public SeoController(
+ @Value("classpath:/static/index.html") Resource indexHtml,
+ SiteUrlResolver siteUrlResolver,
+ ClickyAnalyticsInjector clickyAnalyticsInjector) {
this.indexHtml = indexHtml;
this.siteUrlResolver = siteUrlResolver;
+ this.clickyAnalyticsInjector = Objects.requireNonNull(clickyAnalyticsInjector, "clickyAnalyticsInjector");
initMetadata();
}
@@ -91,6 +101,7 @@ public ResponseEntity serveIndexWithSeo(HttpServletRequest request) {
return ResponseEntity.ok(doc.html());
} catch (IOException contentLoadException) {
+ log.error("Failed to load SPA index.html from classpath", contentLoadException);
return ResponseEntity.internalServerError().body("Error loading content");
}
}
@@ -128,6 +139,9 @@ private void updateDocumentMetadata(Document doc, PageMetadata metadata, String
// Structured Data (JSON-LD)
updateJsonLd(doc, fullUrl, metadata.description);
+
+ // Analytics
+ clickyAnalyticsInjector.applyTo(doc);
}
private void updateCanonicalLink(Document doc, String fullUrl) {
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
index b5e79e2a..832531fd 100644
--- a/src/main/resources/application-dev.properties
+++ b/src/main/resources/application-dev.properties
@@ -9,6 +9,9 @@ app.diagnostics.streamChunkSample=0
# Override with PUBLIC_BASE_URL=https://dev.javachat.ai for deployed dev.
app.public-base-url=${PUBLIC_BASE_URL:http://localhost:8085}
+# Disable Clicky by default for local development.
+app.clicky.enabled=false
+
# OpenAI Java SDK streaming configuration (OpenAIStreamingService)
# Base URLs are used by the SDK directly (not Spring AI).
# Defaults are set via @Value annotations in OpenAIStreamingService.java;
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 2bf5297c..943089c4 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -10,6 +10,10 @@ server.forward-headers-strategy=framework
# Canonical public base URL used for SEO responses (sitemap.xml, robots.txt, OpenGraph/canonical tags)
app.public-base-url=${PUBLIC_BASE_URL:https://javachat.ai}
+# Clicky analytics (enabled by default in prod; disable in dev profile)
+app.clicky.enabled=true
+app.clicky.site-id=101501246
+
# Memory-sensitive defaults for 512MB container budgets
# Note: lazy-initialization=true defers bean creation to first use, reducing startup memory but moving errors to runtime
spring.main.lazy-initialization=${SPRING_MAIN_LAZY_INITIALIZATION:true}
diff --git a/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java b/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java
index 2cbc9211..af8a99e0 100644
--- a/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java
+++ b/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java
@@ -23,7 +23,7 @@
* Verifies SEO HTML responses include expected metadata.
*/
@WebMvcTest(controllers = SeoController.class)
-@Import({SiteUrlResolver.class, com.williamcallahan.javachat.config.AppProperties.class})
+@Import({SiteUrlResolver.class, ClickyAnalyticsInjector.class, com.williamcallahan.javachat.config.AppProperties.class})
@TestPropertySource(properties = "app.public-base-url=https://example.com")
@WithMockUser
class SeoControllerTest {