diff --git a/src/website_profiling/llm/providers/anthropic.py b/src/website_profiling/llm/providers/anthropic.py index 6505e415..59d45752 100644 --- a/src/website_profiling/llm/providers/anthropic.py +++ b/src/website_profiling/llm/providers/anthropic.py @@ -2,10 +2,98 @@ from __future__ import annotations import json +import os +import sys from typing import Any from ..base import ChatResult, TokenCallback, ToolCall, parse_json_response +# Ephemeral (5-minute) prompt-cache marker. Placed on the static request prefix +# (tools -> system -> conversation) so Anthropic bills repeated prefix tokens at +# ~10% of base input price across the multi-round tool loop. Mirrors how Claude +# Code caches its tool/system prefix. +_CACHE_CONTROL = {"type": "ephemeral"} + + +def _truthy(value: str | None, *, default: bool) -> bool: + raw = (value or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def _prompt_cache_enabled() -> bool: + """Prompt caching is on by default; set WP_LLM_PROMPT_CACHE=0 to disable.""" + return _truthy(os.environ.get("WP_LLM_PROMPT_CACHE"), default=True) + + +def _cache_debug_enabled() -> bool: + return _truthy(os.environ.get("WP_LLM_DEBUG_CACHE"), default=False) + + +def _log_cache_usage(usage: Any) -> None: + """When WP_LLM_DEBUG_CACHE is set, print cache token counts to stderr.""" + if usage is None or not _cache_debug_enabled(): + return + created = getattr(usage, "cache_creation_input_tokens", None) + read = getattr(usage, "cache_read_input_tokens", None) + inp = getattr(usage, "input_tokens", None) + print( + f"[wp-cache] input={inp} cache_creation={created} cache_read={read}", + file=sys.stderr, + flush=True, + ) + + +def _apply_prompt_caching( + system: str, + tools: list[dict[str, Any]], + messages: list[dict[str, Any]], +) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: + """Add cache_control breakpoints to the static request prefix. + + Returns ``(system, tools, messages)`` unchanged when caching is disabled, so + behavior is byte-identical to the no-cache path. Otherwise places three + breakpoints (the limit is four) in Anthropic's prefix order: + + 1. the last tool definition (caches the whole tools array), + 2. the system prompt (caches tools+system), + 3. the last content block of the last message (rolls forward each round, + reading the prior conversation prefix from cache and writing the suffix). + + Builds new copies — never mutates the caller's lists/dicts — so the pure + converter outputs stay clean. + """ + if not _prompt_cache_enabled(): + return system, tools, messages + + # 1. System prompt -> single text block carrying the cache marker. + system_blocks: Any = [ + {"type": "text", "text": system, "cache_control": _CACHE_CONTROL}, + ] + + # 2. Last tool definition. + tools_out = list(tools) + if tools_out: + tools_out[-1] = {**tools_out[-1], "cache_control": _CACHE_CONTROL} + + # 3. Last content block of the last message. + messages_out = list(messages) + if messages_out: + last = dict(messages_out[-1]) + content = last.get("content") + if isinstance(content, list) and content: + blocks = list(content) + blocks[-1] = {**blocks[-1], "cache_control": _CACHE_CONTROL} + last["content"] = blocks + elif isinstance(content, str): + last["content"] = [ + {"type": "text", "text": content, "cache_control": _CACHE_CONTROL}, + ] + messages_out[-1] = last + + return system_blocks, tools_out, messages_out + def _to_anthropic_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: """Convert OpenAI-shaped chat messages to ``(system, anthropic_messages)``. @@ -122,6 +210,9 @@ def chat_with_tools( system, anthropic_messages = _to_anthropic_messages(messages) anthropic_tools = _to_anthropic_tools(tools) + system, anthropic_tools, anthropic_messages = _apply_prompt_caching( + system, anthropic_tools, anthropic_messages, + ) kwargs: dict[str, Any] = { "model": self._model, @@ -154,6 +245,7 @@ def chat_with_tools( prev = tool_calls[-1].arguments.get("_partial", "") tool_calls[-1].arguments["_partial"] = prev + partial final = stream.get_final_message() + _log_cache_usage(getattr(final, "usage", None)) for tc in tool_calls: partial = tc.arguments.pop("_partial", "") if partial: @@ -168,6 +260,7 @@ def chat_with_tools( return ChatResult(content="".join(content_parts) or "".join(text_parts), tool_calls=tool_calls) msg = client.messages.create(**kwargs) + _log_cache_usage(getattr(msg, "usage", None)) content_parts: list[str] = [] tool_calls = [] for block in msg.content: diff --git a/tests/test_llm_provider_anthropic.py b/tests/test_llm_provider_anthropic.py index 86b5be9f..2f1daa28 100644 --- a/tests/test_llm_provider_anthropic.py +++ b/tests/test_llm_provider_anthropic.py @@ -7,11 +7,16 @@ """ from __future__ import annotations +import pytest + from website_profiling.llm.providers.anthropic import ( + _apply_prompt_caching, _to_anthropic_messages, _to_anthropic_tools, ) +_EPHEMERAL = {"type": "ephemeral"} + def test_assistant_tool_calls_become_matching_tool_use_blocks() -> None: messages = [ @@ -75,3 +80,97 @@ def test_to_anthropic_tools_maps_schema() -> None: assert _to_anthropic_tools(tools) == [ {"name": "t", "description": "d", "input_schema": {"type": "object", "properties": {}}}, ] + + +# --- prompt caching -------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _cache_on(monkeypatch: pytest.MonkeyPatch) -> None: + """Caching defaults to on; pin it for deterministic tests.""" + monkeypatch.setenv("WP_LLM_PROMPT_CACHE", "1") + + +def test_caching_marks_last_tool_only() -> None: + tools = [{"name": "a"}, {"name": "b"}, {"name": "c"}] + _, tools_out, _ = _apply_prompt_caching("sys", tools, []) + assert "cache_control" not in tools_out[0] + assert "cache_control" not in tools_out[1] + assert tools_out[-1]["cache_control"] == _EPHEMERAL + # original list/dicts are untouched + assert all("cache_control" not in t for t in tools) + + +def test_caching_empty_tools_is_safe() -> None: + system, tools_out, _ = _apply_prompt_caching("sys", [], []) + assert tools_out == [] + assert system == [{"type": "text", "text": "sys", "cache_control": _EPHEMERAL}] + + +def test_caching_system_becomes_text_block() -> None: + system, _, _ = _apply_prompt_caching("the system prompt", [], []) + assert system == [ + {"type": "text", "text": "the system prompt", "cache_control": _EPHEMERAL}, + ] + + +def test_caching_last_message_string_content_becomes_block() -> None: + messages = [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + _, _, out = _apply_prompt_caching("sys", [], messages) + # earlier message untouched + assert out[0] == {"role": "user", "content": "first"} + assert out[-1]["content"] == [ + {"type": "text", "text": "second", "cache_control": _EPHEMERAL}, + ] + # caller's list/dicts not mutated + assert messages[-1] == {"role": "user", "content": "second"} + + +def test_caching_last_message_list_content_marks_last_block() -> None: + messages = [{ + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "{}"}, + {"type": "tool_result", "tool_use_id": "c2", "content": "{}"}, + ], + }] + _, _, out = _apply_prompt_caching("sys", [], messages) + blocks = out[-1]["content"] + assert "cache_control" not in blocks[0] + assert blocks[-1]["cache_control"] == _EPHEMERAL + # original untouched + assert all("cache_control" not in b for b in messages[0]["content"]) + + +def test_caching_disabled_returns_inputs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WP_LLM_PROMPT_CACHE", "0") + tools = [{"name": "a"}] + messages = [{"role": "user", "content": "hi"}] + system, tools_out, msgs_out = _apply_prompt_caching("sys", tools, messages) + assert system == "sys" # stays a plain string + assert tools_out is tools + assert msgs_out is messages + + +def test_caching_uses_at_most_four_breakpoints() -> None: + tools = [{"name": "a"}, {"name": "b"}] + messages = [ + {"role": "user", "content": "u"}, + {"role": "assistant", "content": [{"type": "text", "text": "a"}]}, + ] + system, tools_out, msgs_out = _apply_prompt_caching("sys", tools, messages) + + def _count(obj: object) -> int: + if isinstance(obj, dict): + n = 1 if obj.get("cache_control") == _EPHEMERAL else 0 + return n + sum(_count(v) for v in obj.values()) + if isinstance(obj, list): + return sum(_count(v) for v in obj) + return 0 + + total = _count(system) + _count(tools_out) + _count(msgs_out) + assert total == 3 + assert total <= 4 diff --git a/web/app/api/chat/artifacts/[id]/route.ts b/web/app/api/chat/artifacts/[id]/route.ts index 7b60731f..336162ec 100644 --- a/web/app/api/chat/artifacts/[id]/route.ts +++ b/web/app/api/chat/artifacts/[id]/route.ts @@ -82,13 +82,18 @@ export const GET: ApiRouteHandlerWithParams<{ id: string }> = async ( return; } const body = Buffer.from(parsed.data_base64, 'base64'); - const filename = parsed.filename || 'export.bin'; + const rawName = parsed.filename || 'export.bin'; + // Sanitize the ASCII fallback (strip non-printable/quote/slash chars so + // a CR/LF or quote can't break or inject the header) and provide an + // RFC 5987 filename* for the full UTF-8 name. + const asciiName = + rawName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\/]/g, '_') || 'export.bin'; const mime = parsed.mime_type || 'application/octet-stream'; resolve( new NextResponse(body, { headers: { 'Content-Type': mime, - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(rawName)}`, }, }), ); diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts index ffed8cb0..f48b8d48 100644 --- a/web/app/api/chat/route.ts +++ b/web/app/api/chat/route.ts @@ -120,6 +120,31 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise | null = null; + let activeKillTimer: ReturnType | null = null; + let cancelled = false; + + const cancelChild = () => { + cancelled = true; + const p = activeProc; + if (!p) return; + try { + p.kill('SIGTERM'); + activeKillTimer = setTimeout(() => { + try { + p.kill('SIGKILL'); + } catch { + /* already exited */ + } + }, 2000); + (activeKillTimer as { unref?: () => void }).unref?.(); + } catch { + /* already exited */ + } + }; + const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); @@ -170,6 +195,7 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { timedOut = true; @@ -182,6 +208,14 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { + clearTimeout(timer); + push('error', { message: `Failed to send request to assistant: ${err.message}` }); + closeStream(); + }); proc.stdin?.write(stdinPayload); proc.stdin?.end(); @@ -265,7 +299,13 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise { clearTimeout(timer); - if (timedOut) return; + if (activeKillTimer) { + clearTimeout(activeKillTimer); + activeKillTimer = null; + } + // On client cancel we drop the partial turn (the user navigated away); + // on timeout the error was already streamed. + if (timedOut || cancelled) return; exitCode = code; if (!sawError && !assistantText.trim() && !narrative) { @@ -327,6 +367,11 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise }, @@ -22,8 +22,18 @@ export const GET: ApiRouteHandler = async ( if (!sessionId) { return NextResponse.json({ error: 'invalid session id' }, { status: 400 }); } + const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } try { + // Scope conversation history to the caller's property to avoid leaking + // another property's messages by enumerating session ids. + const session = await getChatSession(sessionId); + if (!session || session.property_id !== propertyId) { + return NextResponse.json({ error: 'session not found' }, { status: 404 }); + } const messages = await getChatMessages(sessionId); return NextResponse.json({ messages }); } catch (e) { diff --git a/web/app/api/chat/sessions/[id]/route.ts b/web/app/api/chat/sessions/[id]/route.ts index 366d1397..0ed68a00 100644 --- a/web/app/api/chat/sessions/[id]/route.ts +++ b/web/app/api/chat/sessions/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { requireApiAuthForChat } from '@/server/auth'; +import { requireApiAuth, requireApiAuthForChat } from '@/server/auth'; import { deleteChatSession, getChatSession } from '@/server/chatDb'; import type { ApiRouteHandler } from '@/types/api'; @@ -35,14 +35,15 @@ export const GET: ApiRouteHandler = async ( } }; -/** DELETE /api/chat/sessions/[id] */ +/** DELETE /api/chat/sessions/[id]?propertyId= */ export const DELETE: ApiRouteHandler = async ( request: NextRequest, context?: { params?: Promise<{ id: string }> }, ): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; - const authDenied = requireApiAuthForChat(request); + // Deleting a session is a destructive mutation: require a non-read-only role. + const authDenied = requireApiAuth(request); if (authDenied) return authDenied; const params = context?.params ? await context.params : { id: '' }; @@ -50,8 +51,17 @@ export const DELETE: ApiRouteHandler = async ( if (!sessionId) { return NextResponse.json({ error: 'invalid session id' }, { status: 400 }); } + const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } try { + // Scope the delete to the caller's property (consistent with POST /api/chat). + const session = await getChatSession(sessionId); + if (!session || session.property_id !== propertyId) { + return NextResponse.json({ error: 'session not found' }, { status: 404 }); + } const deleted = await deleteChatSession(sessionId); if (!deleted) { return NextResponse.json({ error: 'session not found' }, { status: 404 }); diff --git a/web/app/api/chat/sessions/route.ts b/web/app/api/chat/sessions/route.ts index 01cb9195..0a3cace5 100644 --- a/web/app/api/chat/sessions/route.ts +++ b/web/app/api/chat/sessions/route.ts @@ -32,6 +32,8 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; + // Chat (incl. starting a session) is intentionally available to the + // read-only client role; only destructive deletes are restricted (see DELETE). const authDenied = requireApiAuthForChat(request); if (authDenied) return authDenied; diff --git a/web/app/api/dashboards/ai-generate/route.ts b/web/app/api/dashboards/ai-generate/route.ts deleted file mode 100644 index d5461d2d..00000000 --- a/web/app/api/dashboards/ai-generate/route.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { spawn } from 'child_process'; -import { forbiddenIfNotLocal } from '@/server/localOnly'; -import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; -import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; -import { DASHBOARD_CATALOG, dimensions, measures } from '@/lib/dashboard/catalog/catalog'; -import { VIZ_LABELS } from '@/lib/dashboard/viz/labels'; -import { spawnAuditTool } from '@/server/spawnAuditTool'; -import type { ApiRouteHandler } from '@/types/api'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; - -const DASHSCRIPT_HELP = ` -DashScript is a lightweight formula language for dashboard widgets. - -MEASURE (scalar formula, produces a single number or string): - field("key") — value from root result by dot-path key - sum("col") — sum of numeric column across all rows - avg("col") — average - count() — number of rows - min("col") / max("col") — min / max of column - if(cond, thenVal, elseVal) — conditional - coalesce(a, b, c) — first non-null value - Arithmetic: + - * / (division by zero returns null) - Comparison: == != < <= > >= - Logical: && || ! - -TRANSFORM (row pipeline, applied to rows array before rendering): - filter(expr) — keep rows where expr is truthy (use row column names directly) - sort(col, asc|desc) — sort rows by column (default asc) - take(N) — keep first N rows - skip(N) — drop first N rows - project(col1, col2) — keep only listed columns - Stages are joined with | e.g. filter(count > 0) | sort(count, desc) | take(10) - -Examples: - measure: field("health_score") - measure: sum("issues") / count() - transform: filter(severity == "critical") | sort(count, desc) | take(5) -`.trim(); - -const PYTHON_SCRIPT = ` -import json, sys -from website_profiling.llm.dashboard_ai import generate_dashboard_ai -payload = json.load(sys.stdin) -print(json.dumps(generate_dashboard_ai(payload))) -`; - -/** - * POST /api/dashboards/ai-generate - * Body: { mode, prompt, toolName?, propertyId?, reportId? } - */ -export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { - const denied = forbiddenIfNotLocal(request); - if (denied) return denied; - - let body: { - mode?: string; - prompt?: string; - toolName?: string; - propertyId?: number; - reportId?: number | null; - current?: unknown; - }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const mode = String(body.mode || 'widget').trim().toLowerCase(); - if (!['script', 'widget', 'dashboard'].includes(mode)) { - return NextResponse.json({ error: 'mode must be script, widget, or dashboard' }, { status: 400 }); - } - const prompt = String(body.prompt || '').trim(); - if (!prompt) { - return NextResponse.json({ error: 'prompt required' }, { status: 400 }); - } - - // Optionally fetch a sample result so the LLM knows the real schema - let sample: Record | null = null; - const toolName = String(body.toolName || '').trim(); - const propertyId = Number(body.propertyId || 0); - const reportId = body.reportId != null ? Number(body.reportId) : null; - - if (toolName && propertyId && (mode === 'script' || mode === 'widget')) { - try { - const result = await spawnAuditTool({ toolName, propertyId, reportId }); - if (result.ok) { - // Truncate to keep payload small — first 2 rows of arrays, top-level scalars - sample = truncateSample(result.data); - } - } catch { - // non-fatal — proceed without sample - } - } - - const payload = { - mode, - prompt, - catalog: DASHBOARD_CATALOG.map((e) => ({ - toolName: e.toolName, - label: e.label, - section: e.section, - fields: e.fields, - dimensions: dimensions(e).map((f) => ({ key: f.key, label: f.label, defaultAgg: f.defaultAgg, format: f.format })), - measures: measures(e).map((f) => ({ key: f.key, label: f.label, defaultAgg: f.defaultAgg, format: f.format })), - rowsPath: e.rowsPath, - compatibleViz: e.compatibleViz, - })), - viz_types: VIZ_LABELS, - dashscript_help: DASHSCRIPT_HELP, - current: body.current ?? null, - sample, - }; - - const repoRoot = getRepoRoot(); - const pythonExe = resolvePythonExecutable(null, repoRoot); - - return new Promise((resolve) => { - const proc = spawn(pythonExe, ['-c', PYTHON_SCRIPT], { - cwd: repoRoot, - env: getPipelineSpawnEnv(repoRoot), - shell: false, - }); - let stdout = ''; - proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); - proc.stdin?.write(JSON.stringify(payload)); - proc.stdin?.end(); - proc.on('error', () => { - clearTimeout(timer); - resolve(NextResponse.json({ error: 'AI generation failed: could not start Python process' }, { status: 500 })); - }); - proc.on('close', (code) => { - clearTimeout(timer); - const parsed = parsePythonJsonStdout(stdout); - if (code === 0 && parsed) { - if ((parsed as { ok?: boolean }).ok === false) { - const err = parsed as { error?: string; missing?: boolean }; - return resolve(NextResponse.json(parsed, { status: err.missing ? 503 : 500 })); - } - return resolve(NextResponse.json(parsed)); - } - resolve(NextResponse.json({ error: 'AI generation failed' }, { status: 500 })); - }); - const timer = setTimeout(() => { - try { proc.kill(); } catch { /* ignore */ } - resolve(NextResponse.json({ error: 'AI generation timed out after 120s' }, { status: 504 })); - }, 120_000); - }); -}; - -function truncateSample(data: Record): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(data)) { - if (Array.isArray(v)) { - out[k] = v.slice(0, 2); - } else { - out[k] = v; - } - } - return out; -} diff --git a/web/app/api/llm-config/route.ts b/web/app/api/llm-config/route.ts index ba085610..204d22f0 100644 --- a/web/app/api/llm-config/route.ts +++ b/web/app/api/llm-config/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { requireApiAuth, requireApiAuthForChat } from '@/server/auth'; import { loadLlmConfig, saveLlmConfig } from '@/server/llmConfig'; import { ALL_LLM_SCHEMA_KEYS, getLlmFieldByKey } from '@/lib/llmConfigSchema'; import type { ApiRouteHandler, LlmConfigPutBody, LlmConfigState } from '@/types/api'; @@ -10,6 +11,8 @@ export const runtime = 'nodejs'; export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; + const authDenied = requireApiAuthForChat(request); + if (authDenied) return authDenied; try { const result = await loadLlmConfig(); @@ -24,6 +27,8 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const denied = forbiddenIfNotLocal(request); if (denied) return denied; + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; let body: LlmConfigPutBody; try { diff --git a/web/package-lock.json b/web/package-lock.json index 04b7eef9..eccd015f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.13.23", "@tiptap/extension-image": "^3.26.1", "@tiptap/extension-link": "^3.26.1", @@ -27,6 +30,7 @@ "3d-force-graph": "^1.79.1", "chart.js": "^4.5.1", "d3": "^7.9.0", + "echarts": "^6.1.0", "lucide-react": "^0.577.0", "next": "15.5.14", "pg": "^8.21.0", @@ -76,6 +80,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -5162,6 +5219,22 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -11263,6 +11336,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web/package.json b/web/package.json index 7cc274fe..682d4cc3 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,9 @@ "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-virtual": "^3.13.23", "@tiptap/extension-image": "^3.26.1", "@tiptap/extension-link": "^3.26.1", @@ -31,6 +34,7 @@ "3d-force-graph": "^1.79.1", "chart.js": "^4.5.1", "d3": "^7.9.0", + "echarts": "^6.1.0", "lucide-react": "^0.577.0", "next": "15.5.14", "pg": "^8.21.0", diff --git a/web/src/components/chat/ChatComposer.tsx b/web/src/components/chat/ChatComposer.tsx index 745505f9..07c6f237 100644 --- a/web/src/components/chat/ChatComposer.tsx +++ b/web/src/components/chat/ChatComposer.tsx @@ -42,9 +42,15 @@ export default function ChatComposer({ useEffect(() => { if (!draftMessage) return; setText(draftMessage); - resizeTextarea(); onDraftApplied?.(); - textareaRef.current?.focus(); + // Resize/focus after the controlled value has committed to the DOM, + // otherwise scrollHeight reflects the previous (empty) content and a + // multi-line draft renders collapsed to a single row. + const raf = requestAnimationFrame(() => { + resizeTextarea(); + textareaRef.current?.focus(); + }); + return () => cancelAnimationFrame(raf); }, [draftMessage, onDraftApplied, resizeTextarea]); const handleSubmit = (e: FormEvent) => { @@ -102,7 +108,9 @@ export default function ChatComposer({ isHero ? 'min-h-[2.5rem] text-[15px]' : 'min-h-[2.25rem] text-sm' }`} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { + // Ignore the Enter that confirms an IME composition (CJK etc.), + // otherwise a half-composed message gets submitted prematurely. + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSubmit(e); } diff --git a/web/src/components/chat/ChatMessageList.tsx b/web/src/components/chat/ChatMessageList.tsx index f6128eda..cf7dd425 100644 --- a/web/src/components/chat/ChatMessageList.tsx +++ b/web/src/components/chat/ChatMessageList.tsx @@ -32,6 +32,13 @@ export default function ChatMessageList({ messages, empty }: ChatMessageListProp useEffect(() => { const node = scrollRef.current; if (!node) return; + // Only stick to the bottom if the user is already near it; otherwise a + // user scrolling up to read history during streaming would be yanked down + // on every token. + const NEAR_BOTTOM_PX = 120; + const nearBottom = + node.scrollHeight - node.scrollTop - node.clientHeight <= NEAR_BOTTOM_PX; + if (!nearBottom) return; const raf = requestAnimationFrame(() => { node.scrollTop = node.scrollHeight; }); diff --git a/web/src/components/chat/deriveChatBlocks.ts b/web/src/components/chat/deriveChatBlocks.ts index 42e53b94..a6bf48fb 100644 --- a/web/src/components/chat/deriveChatBlocks.ts +++ b/web/src/components/chat/deriveChatBlocks.ts @@ -229,7 +229,9 @@ export function blockKey(block: ChatBlock): string { case 'label_value_chart': return `label_value:${block.title}`; case 'health_trend': - return block.categoryId ? `health_trend:${block.categoryId}` : 'health_trend'; + // Fall back to the title so a site-wide trend and an all-categories + // trend (both lacking a categoryId) don't collide and drop one another. + return block.categoryId ? `health_trend:${block.categoryId}` : `health_trend:${block.title}`; case 'file_download': return `file_download:${block.files.map((f) => f.filename).join(',')}`; case 'audit_run_confirm': diff --git a/web/src/components/chat/preprocessChatMarkdown.ts b/web/src/components/chat/preprocessChatMarkdown.ts index bd4345e8..5c8cdce3 100644 --- a/web/src/components/chat/preprocessChatMarkdown.ts +++ b/web/src/components/chat/preprocessChatMarkdown.ts @@ -105,8 +105,11 @@ export function preprocessChatMarkdown(content: string): string { let out = content.trim(); if (!out) return out; + // NOTE: the leading character class already includes \s, so a trailing \s* + // here would overlap it and cause quadratic backtracking (ReDoS) on long + // whitespace-only lines. Keep the whitespace handled by the class only. out = out.replace( - /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s🔎📸💡]*\s*(Power Insights|Key takeaways|Executive summary)(?:\s+for\s+[\w.-]+)?\s*$/gimu, + /^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s🔎📸💡]*(Power Insights|Key takeaways|Executive summary)(?:\s+for\s+[\w.-]+)?\s*$/gimu, '### $1', ); diff --git a/web/src/components/dashboards/DashboardGrid.tsx b/web/src/components/dashboards/DashboardGrid.tsx deleted file mode 100644 index fc8f44c9..00000000 --- a/web/src/components/dashboards/DashboardGrid.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/lib/dashboard/builder/DashboardGrid'; diff --git a/web/src/components/dashboards/DashboardSwitcher.tsx b/web/src/components/dashboards/DashboardSwitcher.tsx deleted file mode 100644 index a8ab8f4a..00000000 --- a/web/src/components/dashboards/DashboardSwitcher.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/lib/dashboard/builder/DashboardSwitcher'; diff --git a/web/src/components/dashboards/DashboardWidget.tsx b/web/src/components/dashboards/DashboardWidget.tsx deleted file mode 100644 index 060fb682..00000000 --- a/web/src/components/dashboards/DashboardWidget.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/lib/dashboard/builder/DashboardWidget'; diff --git a/web/src/components/dashboards/WidgetConfigPanel.tsx b/web/src/components/dashboards/WidgetConfigPanel.tsx deleted file mode 100644 index 9a4ecaad..00000000 --- a/web/src/components/dashboards/WidgetConfigPanel.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/lib/dashboard/builder/WidgetConfigPanel'; diff --git a/web/src/components/dashboards/WidgetPalette.tsx b/web/src/components/dashboards/WidgetPalette.tsx deleted file mode 100644 index ee0812c8..00000000 --- a/web/src/components/dashboards/WidgetPalette.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/lib/dashboard/builder/WidgetPalette'; diff --git a/web/src/lib/dashboard/ai/generate.test.ts b/web/src/lib/dashboard/ai/generate.test.ts deleted file mode 100644 index 83e41324..00000000 --- a/web/src/lib/dashboard/ai/generate.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - sanitizeChartSpec, - validateMeasure, - validateTransform, - assignLayouts, - generateWidget, - AiGenerateError, -} from '@/lib/dashboard/ai/generate'; -import type { Widget } from '@/lib/dashboard/types'; - -// ────────────────────────────────────────────────────────────────────────────── -// sanitizeChartSpec -// ────────────────────────────────────────────────────────────────────────────── - -describe('sanitizeChartSpec', () => { - it('accepts a valid minimal spec', () => { - const spec = sanitizeChartSpec({ type: 'bar' }); - expect(spec.type).toBe('bar'); - }); - - it('throws when type is missing', () => { - expect(() => sanitizeChartSpec({ labelField: 'x' })).toThrow(/type/); - }); - - it('throws when input is not an object', () => { - expect(() => sanitizeChartSpec('bar')).toThrow(); - }); - - it('drops undefined and function values via JSON round-trip', () => { - const raw = { - type: 'pie', - options: { onClick: undefined }, - }; - const spec = sanitizeChartSpec(raw); - // undefined props dropped by JSON serialization - expect(spec.options).not.toHaveProperty('onClick'); - }); - - it('caps dataset labels at 500', () => { - const labels = Array.from({ length: 600 }, (_, i) => `label-${i}`); - const spec = sanitizeChartSpec({ - type: 'bar', - data: { labels, datasets: [] }, - }); - expect(spec.data!.labels).toHaveLength(500); - }); - - it('caps dataset rows at 500 and datasets at 20', () => { - const manyDatasets = Array.from({ length: 25 }, (_, i) => ({ - label: `ds-${i}`, - data: Array.from({ length: 600 }, (_, j) => j), - })); - const spec = sanitizeChartSpec({ - type: 'radar', - data: { labels: [], datasets: manyDatasets }, - }); - expect(spec.data!.datasets).toHaveLength(20); - expect((spec.data!.datasets as { data: unknown[] }[])[0].data).toHaveLength(500); - }); - - it('caps series at 20', () => { - const series = Array.from({ length: 30 }, (_, i) => ({ label: `s${i}`, field: `f${i}` })); - const spec = sanitizeChartSpec({ type: 'line', series }); - expect(spec.series).toHaveLength(20); - }); - - it('passes through chartSpec type unchanged', () => { - const spec = sanitizeChartSpec({ type: 'polarArea', series: [] }); - expect(spec.type).toBe('polarArea'); - }); -}); - -// ────────────────────────────────────────────────────────────────────────────── -// DashScript validation -// ────────────────────────────────────────────────────────────────────────────── - -describe('validateMeasure', () => { - it('accepts a valid field call', () => { - expect(validateMeasure('field("health_score")')).toBeNull(); - }); - - it('accepts arithmetic', () => { - expect(validateMeasure('sum("count") / count()')).toBeNull(); - }); - - it('accepts an if expression', () => { - expect(validateMeasure('if(score >= 80, "Good", "Poor")')).toBeNull(); - }); - - it('returns an error string for invalid syntax', () => { - expect(validateMeasure('field(')).not.toBeNull(); - }); - - it('returns null for empty string', () => { - expect(validateMeasure('')).toBeNull(); - }); -}); - -describe('validateTransform', () => { - it('accepts a simple pipeline', () => { - expect(validateTransform('filter(count > 0) | sort(count, desc) | take(10)')).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(validateTransform('')).toBeNull(); - }); - - it('returns an error for malformed pipeline', () => { - expect(validateTransform('filter( | sort')).not.toBeNull(); - }); -}); - -// ────────────────────────────────────────────────────────────────────────────── -// assignLayouts -// ────────────────────────────────────────────────────────────────────────────── - -describe('assignLayouts', () => { - type PartialWidget = Omit & { layout?: Widget['layout'] }; - const base: PartialWidget = { - title: 'W', - viz: 'kpi' as const, - binding: { source: 'audit-tool' as const, toolName: 'get_report_summary' }, - }; - - it('replaces Infinity y with bottomY', () => { - const widgets = assignLayouts([{ ...base, layout: { x: 0, y: Infinity, w: 3, h: 2 } }], 5); - expect(widgets[0].layout.y).toBe(5); - }); - - it('assigns unique ids', () => { - const widgets = assignLayouts([base, base]); - expect(widgets[0].id).not.toBe(widgets[1].id); - }); - - it('wraps widgets that exceed 12 columns', () => { - const wide: PartialWidget = { ...base, layout: { x: 0, y: 0, w: 8, h: 4 } }; - const narrow: PartialWidget = { ...base, layout: { x: 0, y: 0, w: 8, h: 4 } }; - const widgets = assignLayouts([wide, narrow], 0); - // Second widget should wrap to x: 0 on a new row - expect(widgets[1].layout.x).toBe(0); - expect(widgets[1].layout.y).toBeGreaterThan(0); - }); - - it('uses defaultWidgetLayout when layout is missing', () => { - const widgets = assignLayouts([base], 0); - expect(widgets[0].layout.w).toBeGreaterThan(0); - expect(Number.isFinite(widgets[0].layout.y)).toBe(true); - }); -}); - -// ────────────────────────────────────────────────────────────────────────────── -// generateWidget (mocked fetch) -// ────────────────────────────────────────────────────────────────────────────── - -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -beforeEach(() => { - mockFetch.mockReset(); -}); - -describe('generateWidget', () => { - it('returns a widget with concrete layout', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - ok: true, - widget: { - title: 'Health', - toolName: 'get_report_summary', - viz: 'kpi', - binding: { source: 'audit-tool', toolName: 'get_report_summary', valueField: 'health_score' }, - options: {}, - }, - explanation: 'Shows health score.', - }), - }); - const { widget } = await generateWidget('show health score'); - expect(widget.viz).toBe('kpi'); - expect(widget.id).toBeTruthy(); - expect(Number.isFinite(widget.layout.y)).toBe(true); - }); - - it('throws AiGenerateError on missing/disabled', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - json: async () => ({ ok: false, error: 'AI insights are disabled.', missing: true }), - }); - await expect(generateWidget('test')).rejects.toBeInstanceOf(AiGenerateError); - }); - - it('sanitizes chartSpec in widget options', async () => { - const manyLabels = Array.from({ length: 600 }, (_, i) => `l-${i}`); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - ok: true, - widget: { - title: 'Custom', - toolName: 'get_report_summary', - viz: 'custom-chart', - binding: { source: 'audit-tool', toolName: 'get_report_summary' }, - options: { - chartSpec: { type: 'bar', data: { labels: manyLabels, datasets: [] } }, - }, - }, - explanation: 'Chart', - }), - }); - const { widget } = await generateWidget('custom chart'); - expect(widget.options?.chartSpec?.data?.labels).toHaveLength(500); - }); -}); diff --git a/web/src/lib/dashboard/ai/generate.ts b/web/src/lib/dashboard/ai/generate.ts deleted file mode 100644 index 90d5e7f5..00000000 --- a/web/src/lib/dashboard/ai/generate.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Client-side helpers for the Dashboard AI generation API. - * Calls POST /api/dashboards/ai-generate and validates / sanitizes the response. - */ -import { tokenize } from '@/lib/dashboard/script/lexer'; -import { Parser } from '@/lib/dashboard/script/parser'; -import { newWidgetId, defaultWidgetLayout } from '@/lib/dashboard/types'; -import type { - Widget, - WidgetBinding, - WidgetOptions, - DashboardDoc, - VizType, - CustomChartSpec, -} from '@/lib/dashboard/types'; - -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -export interface AiScriptResult { - measure?: string; - transform?: string; - chartSpec?: CustomChartSpec | null; - explanation: string; -} - -export interface AiWidgetResult { - widget: Omit & { layout?: Widget['layout']; title: string; viz: VizType }; - explanation: string; -} - -export interface AiDashboardResult { - name: string; - widgets: (Omit & { layout?: Widget['layout'] })[]; - explanation: string; -} - -export interface AiGenerateOptions { - mode: 'script' | 'widget' | 'dashboard'; - prompt: string; - toolName?: string; - propertyId?: number; - reportId?: number | null; - /** Current widget binding / options to pass as context for script mode. */ - current?: { binding?: WidgetBinding; options?: WidgetOptions }; -} - -export class AiGenerateError extends Error { - constructor( - message: string, - public readonly missing?: boolean, - ) { - super(message); - this.name = 'AiGenerateError'; - } -} - -// --------------------------------------------------------------------------- -// Sanitization -// --------------------------------------------------------------------------- - -/** - * JSON-round-trip the spec to strip functions / undefined; validate required - * fields and enforce size caps. - */ -export function sanitizeChartSpec(raw: unknown): CustomChartSpec { - if (raw == null || typeof raw !== 'object') { - throw new Error('chartSpec must be an object'); - } - // Round-trip through JSON to drop functions/undefined - const spec = JSON.parse(JSON.stringify(raw)) as Record; - - if (!spec.type || typeof spec.type !== 'string') { - throw new Error('chartSpec.type must be a non-empty string'); - } - - // Cap explicit dataset point counts - if (spec.data && typeof spec.data === 'object') { - const d = spec.data as { datasets?: { data?: unknown[] }[]; labels?: unknown[] }; - if (Array.isArray(d.labels) && d.labels.length > 500) { - d.labels = d.labels.slice(0, 500); - } - if (Array.isArray(d.datasets)) { - d.datasets = d.datasets.slice(0, 20).map((ds) => ({ - ...ds, - data: Array.isArray(ds.data) ? ds.data.slice(0, 500) : ds.data, - })); - } - } - - // Cap series - if (Array.isArray(spec.series)) { - spec.series = (spec.series as unknown[]).slice(0, 20); - } - - return spec as unknown as CustomChartSpec; -} - -// --------------------------------------------------------------------------- -// DashScript validation -// --------------------------------------------------------------------------- - -/** Attempt to parse a measure expression; returns an error message or null on success. */ -export function validateMeasure(source: string): string | null { - if (!source.trim()) return null; - try { - const tokens = tokenize(source.trim()); - new Parser(tokens).parseExpr(); - return null; - } catch (e) { - return e instanceof Error ? e.message : String(e); - } -} - -/** Attempt to parse a transform pipeline; returns an error message or null on success. */ -export function validateTransform(source: string): string | null { - if (!source.trim()) return null; - try { - const tokens = tokenize(source.trim()); - new Parser(tokens).parsePipeline(); - return null; - } catch (e) { - return e instanceof Error ? e.message : String(e); - } -} - -// --------------------------------------------------------------------------- -// Layout assignment -// --------------------------------------------------------------------------- - -/** Assign concrete bottom-row y positions to a list of widget layout hints. */ -export function assignLayouts( - widgets: (Omit & { layout?: Widget['layout'] })[], - bottomY = 0, -): Widget[] { - let currentY = bottomY; - let rowMaxH = 0; - let rowX = 0; - - return widgets.map((w) => { - const viz = w.viz as VizType; - const hint = w.layout ?? defaultWidgetLayout(viz); - const layout = { ...hint }; - - // Replace Infinity y with computed bottom - if (!Number.isFinite(layout.y)) { - layout.y = currentY; - } - - // Ensure the widget fits in the row; wrap if needed - if (rowX + layout.w > 12) { - currentY += rowMaxH; - rowMaxH = 0; - rowX = 0; - layout.x = 0; - layout.y = currentY; - } else { - layout.x = rowX; - } - - rowX += layout.w; - rowMaxH = Math.max(rowMaxH, layout.h); - - const id = newWidgetId(); - return { ...w, id, layout } as Widget; - }); -} - -// --------------------------------------------------------------------------- -// API calls -// --------------------------------------------------------------------------- - -async function callAiGenerate(opts: AiGenerateOptions): Promise> { - const res = await fetch('/api/dashboards/ai-generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - mode: opts.mode, - prompt: opts.prompt, - toolName: opts.toolName, - propertyId: opts.propertyId, - reportId: opts.reportId, - current: opts.current, - }), - }); - const data = (await res.json()) as Record; - if (!res.ok || data.ok === false) { - const msg = String(data.error || 'AI generation failed'); - const missing = Boolean(data.missing); - throw new AiGenerateError(msg, missing); - } - return data; -} - -/** - * Generate or improve a DashScript formula (+ optional chartSpec) for the widget being configured. - */ -export async function generateWidgetScript( - prompt: string, - opts: Pick = {}, -): Promise { - const data = await callAiGenerate({ mode: 'script', prompt, ...opts }); - const measure = typeof data.measure === 'string' ? data.measure : ''; - const transform = typeof data.transform === 'string' ? data.transform : ''; - const explanation = typeof data.explanation === 'string' ? data.explanation : ''; - - // Validate DashScript - const measureErr = validateMeasure(measure); - if (measureErr) throw new AiGenerateError(`Invalid measure: ${measureErr}`); - const transformErr = validateTransform(transform); - if (transformErr) throw new AiGenerateError(`Invalid transform: ${transformErr}`); - - let chartSpec: CustomChartSpec | null = null; - if (data.chartSpec) { - chartSpec = sanitizeChartSpec(data.chartSpec); - } - - return { measure, transform, chartSpec, explanation }; -} - -/** - * Generate a full single widget definition from a natural-language prompt. - */ -export async function generateWidget( - prompt: string, - opts: Pick = {}, - bottomY = 0, -): Promise<{ widget: Widget; explanation: string }> { - const data = await callAiGenerate({ mode: 'widget', prompt, ...opts }); - - const raw = data.widget as Omit & { layout?: Widget['layout']; title: string; viz: VizType }; - if (!raw || typeof raw !== 'object') { - throw new AiGenerateError('AI returned no widget definition'); - } - - // Sanitize chartSpec if present in options - if (raw.options?.chartSpec) { - raw.options = { - ...raw.options, - chartSpec: sanitizeChartSpec(raw.options.chartSpec), - }; - } - - const [widget] = assignLayouts([raw], bottomY); - widget.options = { ...(widget.options ?? {}), aiPrompt: prompt }; - - return { widget, explanation: String(data.explanation ?? '') }; -} - -/** - * Generate a full dashboard (name + widgets) from a natural-language prompt. - */ -export async function generateDashboard( - prompt: string, - opts: Pick = {}, -): Promise<{ name: string; doc: DashboardDoc; explanation: string }> { - const data = await callAiGenerate({ mode: 'dashboard', prompt, ...opts }); - - const name = String(data.name || 'AI Dashboard'); - const rawWidgets = ( - Array.isArray(data.widgets) ? data.widgets : [] - ) as (Omit & { layout?: Widget['layout'] })[]; - - // Sanitize any chartSpecs - const sanitized = rawWidgets.map((w) => { - if (w.options?.chartSpec) { - return { - ...w, - options: { ...w.options, chartSpec: sanitizeChartSpec(w.options.chartSpec) }, - }; - } - return w; - }); - - const widgets = assignLayouts(sanitized, 0); - const doc: DashboardDoc = { version: 1, widgets }; - - return { name, doc, explanation: String(data.explanation ?? '') }; -} diff --git a/web/src/lib/dashboard/builder/AiAssistModal.tsx b/web/src/lib/dashboard/builder/AiAssistModal.tsx deleted file mode 100644 index d4dcae1a..00000000 --- a/web/src/lib/dashboard/builder/AiAssistModal.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { X, Sparkles, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; -import { - generateWidgetScript, - generateWidget, - generateDashboard, - AiGenerateError, - type AiScriptResult, -} from '@/lib/dashboard/ai/generate'; -import type { Widget, DashboardDoc, WidgetBinding, WidgetOptions } from '@/lib/dashboard/types'; - -// ────────────────────────────────────────────────────────────────────────────── -// Types -// ────────────────────────────────────────────────────────────────────────────── - -type AiMode = 'script' | 'widget' | 'dashboard'; - -interface AiAssistModalBaseProps { - propertyId?: number; - reportId?: number | null; - onClose: () => void; -} - -interface ScriptModeProps extends AiAssistModalBaseProps { - mode: 'script'; - toolName: string; - currentBinding: WidgetBinding; - currentOptions: WidgetOptions; - onApplyScript: (result: AiScriptResult) => void; -} - -interface WidgetModeProps extends AiAssistModalBaseProps { - mode: 'widget'; - bottomY?: number; - onAddWidget: (widget: Widget) => void; -} - -interface DashboardModeProps extends AiAssistModalBaseProps { - mode: 'dashboard'; - onCreateDashboard: (name: string, doc: DashboardDoc) => void; -} - -export type AiAssistModalProps = ScriptModeProps | WidgetModeProps | DashboardModeProps; - -// ────────────────────────────────────────────────────────────────────────────── -// Component -// ────────────────────────────────────────────────────────────────────────────── - -const MODE_LABELS: Record = { - script: 'Improve script', - widget: 'Generate widget', - dashboard: 'Generate dashboard', -}; - -const PLACEHOLDERS: Record = { - script: 'e.g. "Show me the ratio of 4xx to total URLs as a percentage" or "Only count critical issues"', - widget: 'e.g. "Show top 10 broken links by page" or "KPI card for overall health score"', - dashboard: 'e.g. "Performance-focused dashboard with Core Web Vitals and Lighthouse scores"', -}; - -export default function AiAssistModal(props: AiAssistModalProps) { - const [prompt, setPrompt] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [explanation, setExplanation] = useState(null); - const [showExplanation, setShowExplanation] = useState(true); - const [pending, setPending] = useState<{ - script?: AiScriptResult; - widget?: Widget; - dashboard?: { name: string; doc: DashboardDoc }; - } | null>(null); - - const { mode, propertyId, reportId, onClose } = props; - - const handleGenerate = async () => { - if (!prompt.trim()) return; - setLoading(true); - setError(null); - setPending(null); - setExplanation(null); - - try { - if (mode === 'script') { - const sp = props as ScriptModeProps; - const result = await generateWidgetScript(prompt, { - toolName: sp.toolName, - propertyId, - reportId, - current: { binding: sp.currentBinding, options: sp.currentOptions }, - }); - setPending({ script: result }); - setExplanation(result.explanation); - } else if (mode === 'widget') { - const wp = props as WidgetModeProps; - const { widget, explanation: expl } = await generateWidget( - prompt, - { propertyId, reportId }, - wp.bottomY ?? 0, - ); - setPending({ widget }); - setExplanation(expl); - } else { - const { name, doc, explanation: expl } = await generateDashboard( - prompt, - { propertyId, reportId }, - ); - setPending({ dashboard: { name, doc } }); - setExplanation(expl); - } - } catch (e) { - if (e instanceof AiGenerateError && e.missing) { - setError('AI insights are disabled. Enable them in Settings → AI insights.'); - } else { - setError(e instanceof Error ? e.message : 'Generation failed'); - } - } finally { - setLoading(false); - } - }; - - const handleApply = () => { - if (!pending) return; - if (mode === 'script' && pending.script) { - (props as ScriptModeProps).onApplyScript(pending.script); - onClose(); - } else if (mode === 'widget' && pending.widget) { - (props as WidgetModeProps).onAddWidget(pending.widget); - onClose(); - } else if (mode === 'dashboard' && pending.dashboard) { - const dp = props as DashboardModeProps; - dp.onCreateDashboard(pending.dashboard.name, pending.dashboard.doc); - onClose(); - } - }; - - return ( -
-
- {/* Header */} -
-
- -

{MODE_LABELS[mode]}

-
- -
- - {/* Body */} -
-
- -