From 0b9d307e546a325ef6e034644cc804d43200a908 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 21 Jun 2026 19:57:43 +0530 Subject: [PATCH 1/3] chat fixes --- web/app/api/chat/artifacts/[id]/route.ts | 9 +++- web/app/api/chat/route.ts | 47 ++++++++++++++++- .../api/chat/sessions/[id]/messages/route.ts | 14 +++++- web/app/api/chat/sessions/[id]/route.ts | 16 ++++-- web/app/api/chat/sessions/route.ts | 2 + web/app/api/llm-config/route.ts | 5 ++ web/src/components/chat/ChatComposer.tsx | 14 ++++-- web/src/components/chat/ChatMessageList.tsx | 7 +++ web/src/components/chat/deriveChatBlocks.ts | 4 +- .../components/chat/preprocessChatMarkdown.ts | 5 +- web/src/server/chatDb.ts | 43 +++++++++------- web/src/server/llmConfig.test.ts | 50 +++++++++++++++++++ web/src/server/llmConfig.ts | 18 +++++-- web/src/views/Chat.tsx | 36 ++++++++++--- 14 files changed, 228 insertions(+), 42 deletions(-) create mode 100644 web/src/server/llmConfig.test.ts 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/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/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/server/chatDb.ts b/web/src/server/chatDb.ts index fe64ee5c..41fdc748 100644 --- a/web/src/server/chatDb.ts +++ b/web/src/server/chatDb.ts @@ -107,24 +107,31 @@ export async function appendChatMessage( }, ): Promise { return withDb(async (client) => { - const cur = await client.query<{ id: string }>( - `INSERT INTO chat_messages - (session_id, role, content, tool_name, tool_args, tool_result, created_at) - VALUES ($1, $2, $3, $4, $5, $6, now()) RETURNING id`, - [ - sessionId, - role, - content, - meta?.toolName ?? null, - meta?.toolArgs != null ? JSON.stringify(meta.toolArgs) : null, - meta?.toolResult != null ? JSON.stringify(meta.toolResult) : null, - ], - ); - await client.query( - `UPDATE chat_sessions SET updated_at = now() WHERE id = $1`, - [sessionId], - ); - return Number(cur.rows[0]?.id); + await client.query('BEGIN'); + try { + const cur = await client.query<{ id: string }>( + `INSERT INTO chat_messages + (session_id, role, content, tool_name, tool_args, tool_result, created_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) RETURNING id`, + [ + sessionId, + role, + content, + meta?.toolName ?? null, + meta?.toolArgs != null ? JSON.stringify(meta.toolArgs) : null, + meta?.toolResult != null ? JSON.stringify(meta.toolResult) : null, + ], + ); + await client.query( + `UPDATE chat_sessions SET updated_at = now() WHERE id = $1`, + [sessionId], + ); + await client.query('COMMIT'); + return Number(cur.rows[0]?.id); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } }); } diff --git a/web/src/server/llmConfig.test.ts b/web/src/server/llmConfig.test.ts new file mode 100644 index 00000000..b1df4dda --- /dev/null +++ b/web/src/server/llmConfig.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { maskLlmStateForClient } from '@/server/llmConfig'; + +describe('maskLlmStateForClient', () => { + it('masks the legacy llm_api_key AND every per-provider key (regression: H1)', () => { + const masked = maskLlmStateForClient({ + llm_provider: 'openai', + llm_api_key: 'sk-secret-legacy-1234', + llm_api_key_openai: 'sk-openai-abcd1234', + llm_api_key_gemini: 'gem-secret-wxyz5678', + llm_api_key_anthropic: 'sk-ant-secret-7890', + llm_api_key_groq: 'gsk_secret_4321', + }); + + expect(masked.llm_api_key).toBe('β€’β€’β€’β€’1234'); + expect(masked.llm_api_key_openai).toBe('β€’β€’β€’β€’1234'); + expect(masked.llm_api_key_gemini).toBe('β€’β€’β€’β€’5678'); + expect(masked.llm_api_key_anthropic).toBe('β€’β€’β€’β€’7890'); + expect(masked.llm_api_key_groq).toBe('β€’β€’β€’β€’4321'); + + // The masked flag is set so the client knows a value is stored. + expect(masked.llm_api_key_openai_masked).toBe(true); + + // No plaintext secret material survives masking. + for (const value of Object.values(masked)) { + expect(String(value)).not.toContain('secret'); + expect(String(value)).not.toContain('sk-openai'); + } + + // Non-secret fields are untouched. + expect(masked.llm_provider).toBe('openai'); + }); + + it('leaves empty/absent secrets empty and unflagged', () => { + const masked = maskLlmStateForClient({ + llm_provider: 'none', + llm_api_key: '', + llm_api_key_openai: '', + }); + expect(masked.llm_api_key).toBe(''); + expect(masked.llm_api_key_openai).toBe(''); + expect(masked.llm_api_key_masked).toBeUndefined(); + expect(masked.llm_api_key_openai_masked).toBeUndefined(); + }); + + it('does not double-mask an already-masked value', () => { + const masked = maskLlmStateForClient({ llm_api_key_openai: 'β€’β€’β€’β€’1234' }); + expect(masked.llm_api_key_openai).toBe('β€’β€’β€’β€’1234'); + }); +}); diff --git a/web/src/server/llmConfig.ts b/web/src/server/llmConfig.ts index 61cc79b5..bcf7fcc6 100644 --- a/web/src/server/llmConfig.ts +++ b/web/src/server/llmConfig.ts @@ -7,7 +7,6 @@ import { ALL_LLM_SCHEMA_KEYS, getLlmFieldByKey, buildInitialLlmConfigState, - maskLlmSecretForClient, isLlmSecretKey, } from '@/lib/llmConfigSchema'; import { @@ -96,11 +95,24 @@ function writeSecretEntry( if (entries[key]) secretKeys.add(key); } +/** Mask any secret value (generic provider keys + the legacy llm_api_key). */ +function maskSecretValue(value: string | boolean | undefined): string { + if (!value || String(value).trim() === '') return ''; + const s = String(value); + if (s.startsWith('β€’β€’β€’β€’')) return s; + if (s.length <= 4) return 'β€’β€’β€’β€’'; + return `β€’β€’β€’β€’${s.slice(-4)}`; +} + export function maskLlmStateForClient(state: LlmConfigState): LlmConfigState { const out: LlmConfigState = { ...state }; for (const key of Object.keys(out)) { - if (isLlmSecretKey(key)) { - out[key] = maskLlmSecretForClient(key, out[key]); + // Mask BOTH the legacy llm_api_key and every per-provider key + // (llm_api_key_openai/_gemini/_anthropic/_groq). The latter were + // previously returned in plaintext because isLlmSecretKey only covers + // llm_api_key, even though the save path treats them as secrets. + if (isLlmSecretKey(key) || isLlmProviderApiKeyField(key)) { + out[key] = maskSecretValue(out[key]); if (out[key]) out[`${key}_masked`] = true; } } diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx index a8d0e798..b5fa04af 100644 --- a/web/src/views/Chat.tsx +++ b/web/src/views/Chat.tsx @@ -77,6 +77,7 @@ export default function ChatPage() { const [urlSyncEnabled, setUrlSyncEnabled] = useState(false); const messagesLoadGen = useRef(0); const sessionRestoredForProperty = useRef(null); + const abortRef = useRef(null); const searchKey = searchParams.toString(); @@ -183,11 +184,11 @@ export default function ChatPage() { } }, []); - const loadMessages = useCallback(async (sid: number) => { + const loadMessages = useCallback(async (sid: number, pid: number) => { const gen = ++messagesLoadGen.current; setLoadingMessages(true); try { - const res = await fetch(apiUrl(`/chat/sessions/${sid}/messages`)); + const res = await fetch(apiUrl(`/chat/sessions/${sid}/messages?propertyId=${pid}`)); if (!res.ok) return; const data = (await res.json()) as { messages?: Array<{ @@ -287,9 +288,9 @@ export default function ChatPage() { useEffect(() => { if (busy) return; - if (sessionId) void loadMessages(sessionId); - else setMessages([]); - }, [sessionId, loadMessages, busy]); + if (sessionId && propertyId) void loadMessages(sessionId, propertyId); + else if (!sessionId) setMessages([]); + }, [sessionId, propertyId, loadMessages, busy]); useEffect(() => { if (!busy || !startedAt) { @@ -302,6 +303,9 @@ export default function ChatPage() { return () => window.clearInterval(id); }, [busy, startedAt]); + // Abort any in-flight chat stream when the page unmounts. + useEffect(() => () => abortRef.current?.abort(), []); + const createSession = async (): Promise => { if (!propertyId) return null; const res = await fetch(apiUrl('/chat/sessions'), { @@ -352,11 +356,16 @@ export default function ChatPage() { { id: assistantId, role: 'assistant', content: '', streaming: true, toolActivity: [] }, ]); + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + try { const res = await fetch(apiUrl('/chat'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sid, propertyId, message: text }), + signal: controller.signal, }); if (!res.ok) { @@ -483,9 +492,15 @@ export default function ChatPage() { toolActivity: tools, }); } - if (sid) await loadMessages(sid); + if (sid && propertyId) await loadMessages(sid, propertyId); if (propertyId) await loadSessions(propertyId); } catch (e) { + // Aborted (session switch / delete / unmount): not a user-facing error. + // The message-load effect re-runs once busy clears and restores the + // correct session, so leave the assistant placeholder alone here. + if (e instanceof DOMException && e.name === 'AbortError') { + return; + } const msg = e instanceof Error ? e.message : String(e); setError(msg); setMessages((prev) => @@ -496,6 +511,7 @@ export default function ChatPage() { ), ); } finally { + if (abortRef.current === controller) abortRef.current = null; setBusy(false); setActivityText(''); setStartedAt(null); @@ -503,12 +519,14 @@ export default function ChatPage() { }; const handleDeleteSession = async (id: number) => { - await fetch(apiUrl(`/chat/sessions/${id}`), { method: 'DELETE' }); + if (!propertyId) return; + if (sessionId === id) abortRef.current?.abort(); + await fetch(apiUrl(`/chat/sessions/${id}?propertyId=${propertyId}`), { method: 'DELETE' }); if (sessionId === id) { setSessionId(null); setMessages([]); } - if (propertyId) await loadSessions(propertyId); + await loadSessions(propertyId); }; const modelPicker = llmEnabled ? ( @@ -560,6 +578,7 @@ export default function ChatPage() { properties={properties} propertyId={propertyId} onPropertyChange={(id) => { + abortRef.current?.abort(); sessionRestoredForProperty.current = null; setUrlSyncEnabled(false); setPropertyId(id); @@ -571,6 +590,7 @@ export default function ChatPage() { }} onNewChat={() => void handleNewChat()} onSelect={(id) => { + if (id !== sessionId) abortRef.current?.abort(); setSessionId(id); setLoadingMessages(true); }} From 617bd10ab15a8954c91a26b47e74cad6597bb812 Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Sun, 21 Jun 2026 21:16:00 +0530 Subject: [PATCH 2/3] anthropic caching --- .../llm/providers/anthropic.py | 93 +++++++++++++++++ tests/test_llm_provider_anthropic.py | 99 +++++++++++++++++++ 2 files changed, 192 insertions(+) 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 From ac0c865f490a7a3cb9820a9d08d31bf7576d17aa Mon Sep 17 00:00:00 2001 From: PrashantUnity Date: Mon, 22 Jun 2026 00:31:29 +0530 Subject: [PATCH 3/3] some times all we have to do is deleted --- web/app/api/dashboards/ai-generate/route.ts | 164 ---- web/package-lock.json | 88 ++ web/package.json | 4 + .../components/dashboards/DashboardGrid.tsx | 1 - .../dashboards/DashboardSwitcher.tsx | 1 - .../components/dashboards/DashboardWidget.tsx | 1 - .../dashboards/WidgetConfigPanel.tsx | 1 - .../components/dashboards/WidgetPalette.tsx | 1 - web/src/lib/dashboard/ai/generate.test.ts | 214 ---- web/src/lib/dashboard/ai/generate.ts | 280 ------ .../lib/dashboard/builder/AiAssistModal.tsx | 301 ------ web/src/lib/dashboard/builder/ConfigPanel.tsx | 295 ++++++ .../lib/dashboard/builder/DashboardGrid.tsx | 101 -- .../dashboard/builder/DashboardSwitcher.tsx | 177 ---- .../lib/dashboard/builder/DashboardWidget.tsx | 143 --- .../lib/dashboard/builder/DatasetPicker.tsx | 28 + web/src/lib/dashboard/builder/FieldChip.tsx | 47 + web/src/lib/dashboard/builder/FilterBar.tsx | 316 ------ .../lib/dashboard/builder/FilterEditor.tsx | 110 +++ web/src/lib/dashboard/builder/FormatPanel.tsx | 108 +++ .../lib/dashboard/builder/PresetPicker.tsx | 58 -- web/src/lib/dashboard/builder/Shelf.tsx | 36 + web/src/lib/dashboard/builder/ShelfPill.tsx | 45 + web/src/lib/dashboard/builder/VizGallery.tsx | 57 ++ .../dashboard/builder/WidgetConfigPanel.tsx | 449 --------- .../lib/dashboard/builder/WidgetPalette.tsx | 170 ---- .../lib/dashboard/builder/specEdits.test.ts | 55 ++ web/src/lib/dashboard/builder/specEdits.ts | 73 ++ .../lib/dashboard/canvas/DashboardCanvas.tsx | 102 ++ web/src/lib/dashboard/catalog/catalog.test.ts | 84 -- web/src/lib/dashboard/catalog/catalog.ts | 256 ----- .../lib/dashboard/charts/ChartRenderer.tsx | 70 ++ web/src/lib/dashboard/charts/echartsCore.ts | 59 ++ .../{viz/formatters.ts => charts/format.ts} | 8 +- .../lib/dashboard/charts/optionBuilders.ts | 240 +++++ web/src/lib/dashboard/charts/theme.ts | 41 + web/src/lib/dashboard/charts/vizMeta.ts | 52 + .../dashboard/data/fetchWidgetData.test.ts | 78 -- web/src/lib/dashboard/data/fetchWidgetData.ts | 197 ---- .../lib/dashboard/engine/accessors.test.ts | 75 ++ web/src/lib/dashboard/engine/accessors.ts | 67 ++ .../lib/dashboard/engine/aggregate.test.ts | 55 ++ web/src/lib/dashboard/engine/aggregate.ts | 85 ++ web/src/lib/dashboard/engine/coerce.test.ts | 68 ++ web/src/lib/dashboard/engine/coerce.ts | 62 ++ web/src/lib/dashboard/engine/computed.test.ts | 28 + web/src/lib/dashboard/engine/computed.ts | 39 + web/src/lib/dashboard/engine/datasets.test.ts | 111 +++ web/src/lib/dashboard/engine/datasets.ts | 497 ++++++++++ web/src/lib/dashboard/engine/doc.test.ts | 67 ++ web/src/lib/dashboard/engine/doc.ts | 132 +++ web/src/lib/dashboard/engine/filter.test.ts | 66 ++ web/src/lib/dashboard/engine/filter.ts | 82 ++ .../lib/dashboard/engine/inferFields.test.ts | 42 + web/src/lib/dashboard/engine/inferFields.ts | 53 + web/src/lib/dashboard/engine/memo.ts | 56 ++ web/src/lib/dashboard/engine/runQuery.test.ts | 136 +++ web/src/lib/dashboard/engine/runQuery.ts | 219 +++++ web/src/lib/dashboard/engine/specHash.test.ts | 15 + web/src/lib/dashboard/engine/specHash.ts | 15 + web/src/lib/dashboard/engine/types.ts | 176 ++++ web/src/lib/dashboard/export/csv.test.ts | 23 + web/src/lib/dashboard/export/csv.ts | 33 + web/src/lib/dashboard/export/png.ts | 15 + .../dashboard/hooks/usePropertyForDomain.ts | 63 ++ web/src/lib/dashboard/hooks/useWidgetQuery.ts | 67 ++ web/src/lib/dashboard/index.ts | 87 +- .../lib/dashboard/interaction/SlicerBar.tsx | 92 ++ .../dashboard/interaction/SlicerControl.tsx | 75 ++ .../interaction/applyInteractions.test.ts | 89 ++ .../interaction/applyInteractions.ts | 98 ++ web/src/lib/dashboard/presets/presets.test.ts | 41 - web/src/lib/dashboard/presets/presets.ts | 396 ++------ .../lib/dashboard/script/dashScript.test.ts | 83 -- web/src/lib/dashboard/script/eval.ts | 233 ----- web/src/lib/dashboard/script/lexer.ts | 92 -- web/src/lib/dashboard/script/parser.ts | 209 ---- web/src/lib/dashboard/script/types.ts | 48 - web/src/lib/dashboard/types.ts | 200 +--- web/src/lib/dashboard/viz/EmptyData.tsx | 3 - .../lib/dashboard/viz/VizErrorBoundary.tsx | 44 - web/src/lib/dashboard/viz/charts/BarViz.tsx | 66 -- .../viz/charts/CustomChartViz.test.ts | 106 -- .../dashboard/viz/charts/CustomChartViz.tsx | 162 ---- .../dashboard/viz/charts/DashboardChart.tsx | 175 ---- web/src/lib/dashboard/viz/charts/LineViz.tsx | 45 - web/src/lib/dashboard/viz/charts/PartViz.tsx | 86 -- .../lib/dashboard/viz/data/MarkdownViz.tsx | 12 - web/src/lib/dashboard/viz/data/TableViz.tsx | 60 -- web/src/lib/dashboard/viz/formatters.test.ts | 57 -- web/src/lib/dashboard/viz/labels.ts | 29 - .../lib/dashboard/viz/metrics/GaugeViz.tsx | 32 - web/src/lib/dashboard/viz/metrics/KpiViz.tsx | 29 - .../dashboard/viz/metrics/SparklineViz.tsx | 26 - web/src/lib/dashboard/viz/registry.tsx | 43 - web/src/lib/dashboard/viz/series.test.ts | 117 --- web/src/lib/dashboard/viz/series.ts | 168 ---- web/src/lib/dashboard/viz/types.ts | 12 - web/src/lib/dashboard/widgets/KpiWidget.tsx | 32 + web/src/lib/dashboard/widgets/TableWidget.tsx | 47 + web/src/lib/dashboard/widgets/TextWidget.tsx | 18 + web/src/lib/dashboard/widgets/WidgetBody.tsx | 67 ++ .../dashboard/widgets/WidgetErrorBoundary.tsx | 39 + web/src/lib/dashboard/widgets/WidgetFrame.tsx | 135 +++ web/src/lib/dashboardCatalog.test.ts | 145 --- web/src/lib/dashboardCatalog.ts | 1 - web/src/lib/fetchDashboardData.test.ts | 264 ----- web/src/lib/fetchDashboardData.ts | 1 - web/src/server/dashboardsDb.ts | 2 +- web/src/types/dashboard.test.ts | 16 +- web/src/views/Dashboards.tsx | 914 ++++++++---------- 111 files changed, 5121 insertions(+), 6433 deletions(-) delete mode 100644 web/app/api/dashboards/ai-generate/route.ts delete mode 100644 web/src/components/dashboards/DashboardGrid.tsx delete mode 100644 web/src/components/dashboards/DashboardSwitcher.tsx delete mode 100644 web/src/components/dashboards/DashboardWidget.tsx delete mode 100644 web/src/components/dashboards/WidgetConfigPanel.tsx delete mode 100644 web/src/components/dashboards/WidgetPalette.tsx delete mode 100644 web/src/lib/dashboard/ai/generate.test.ts delete mode 100644 web/src/lib/dashboard/ai/generate.ts delete mode 100644 web/src/lib/dashboard/builder/AiAssistModal.tsx create mode 100644 web/src/lib/dashboard/builder/ConfigPanel.tsx delete mode 100644 web/src/lib/dashboard/builder/DashboardGrid.tsx delete mode 100644 web/src/lib/dashboard/builder/DashboardSwitcher.tsx delete mode 100644 web/src/lib/dashboard/builder/DashboardWidget.tsx create mode 100644 web/src/lib/dashboard/builder/DatasetPicker.tsx create mode 100644 web/src/lib/dashboard/builder/FieldChip.tsx delete mode 100644 web/src/lib/dashboard/builder/FilterBar.tsx create mode 100644 web/src/lib/dashboard/builder/FilterEditor.tsx create mode 100644 web/src/lib/dashboard/builder/FormatPanel.tsx delete mode 100644 web/src/lib/dashboard/builder/PresetPicker.tsx create mode 100644 web/src/lib/dashboard/builder/Shelf.tsx create mode 100644 web/src/lib/dashboard/builder/ShelfPill.tsx create mode 100644 web/src/lib/dashboard/builder/VizGallery.tsx delete mode 100644 web/src/lib/dashboard/builder/WidgetConfigPanel.tsx delete mode 100644 web/src/lib/dashboard/builder/WidgetPalette.tsx create mode 100644 web/src/lib/dashboard/builder/specEdits.test.ts create mode 100644 web/src/lib/dashboard/builder/specEdits.ts create mode 100644 web/src/lib/dashboard/canvas/DashboardCanvas.tsx delete mode 100644 web/src/lib/dashboard/catalog/catalog.test.ts delete mode 100644 web/src/lib/dashboard/catalog/catalog.ts create mode 100644 web/src/lib/dashboard/charts/ChartRenderer.tsx create mode 100644 web/src/lib/dashboard/charts/echartsCore.ts rename web/src/lib/dashboard/{viz/formatters.ts => charts/format.ts} (87%) create mode 100644 web/src/lib/dashboard/charts/optionBuilders.ts create mode 100644 web/src/lib/dashboard/charts/theme.ts create mode 100644 web/src/lib/dashboard/charts/vizMeta.ts delete mode 100644 web/src/lib/dashboard/data/fetchWidgetData.test.ts delete mode 100644 web/src/lib/dashboard/data/fetchWidgetData.ts create mode 100644 web/src/lib/dashboard/engine/accessors.test.ts create mode 100644 web/src/lib/dashboard/engine/accessors.ts create mode 100644 web/src/lib/dashboard/engine/aggregate.test.ts create mode 100644 web/src/lib/dashboard/engine/aggregate.ts create mode 100644 web/src/lib/dashboard/engine/coerce.test.ts create mode 100644 web/src/lib/dashboard/engine/coerce.ts create mode 100644 web/src/lib/dashboard/engine/computed.test.ts create mode 100644 web/src/lib/dashboard/engine/computed.ts create mode 100644 web/src/lib/dashboard/engine/datasets.test.ts create mode 100644 web/src/lib/dashboard/engine/datasets.ts create mode 100644 web/src/lib/dashboard/engine/doc.test.ts create mode 100644 web/src/lib/dashboard/engine/doc.ts create mode 100644 web/src/lib/dashboard/engine/filter.test.ts create mode 100644 web/src/lib/dashboard/engine/filter.ts create mode 100644 web/src/lib/dashboard/engine/inferFields.test.ts create mode 100644 web/src/lib/dashboard/engine/inferFields.ts create mode 100644 web/src/lib/dashboard/engine/memo.ts create mode 100644 web/src/lib/dashboard/engine/runQuery.test.ts create mode 100644 web/src/lib/dashboard/engine/runQuery.ts create mode 100644 web/src/lib/dashboard/engine/specHash.test.ts create mode 100644 web/src/lib/dashboard/engine/specHash.ts create mode 100644 web/src/lib/dashboard/engine/types.ts create mode 100644 web/src/lib/dashboard/export/csv.test.ts create mode 100644 web/src/lib/dashboard/export/csv.ts create mode 100644 web/src/lib/dashboard/export/png.ts create mode 100644 web/src/lib/dashboard/hooks/usePropertyForDomain.ts create mode 100644 web/src/lib/dashboard/hooks/useWidgetQuery.ts create mode 100644 web/src/lib/dashboard/interaction/SlicerBar.tsx create mode 100644 web/src/lib/dashboard/interaction/SlicerControl.tsx create mode 100644 web/src/lib/dashboard/interaction/applyInteractions.test.ts create mode 100644 web/src/lib/dashboard/interaction/applyInteractions.ts delete mode 100644 web/src/lib/dashboard/presets/presets.test.ts delete mode 100644 web/src/lib/dashboard/script/dashScript.test.ts delete mode 100644 web/src/lib/dashboard/script/eval.ts delete mode 100644 web/src/lib/dashboard/script/lexer.ts delete mode 100644 web/src/lib/dashboard/script/parser.ts delete mode 100644 web/src/lib/dashboard/script/types.ts delete mode 100644 web/src/lib/dashboard/viz/EmptyData.tsx delete mode 100644 web/src/lib/dashboard/viz/VizErrorBoundary.tsx delete mode 100644 web/src/lib/dashboard/viz/charts/BarViz.tsx delete mode 100644 web/src/lib/dashboard/viz/charts/CustomChartViz.test.ts delete mode 100644 web/src/lib/dashboard/viz/charts/CustomChartViz.tsx delete mode 100644 web/src/lib/dashboard/viz/charts/DashboardChart.tsx delete mode 100644 web/src/lib/dashboard/viz/charts/LineViz.tsx delete mode 100644 web/src/lib/dashboard/viz/charts/PartViz.tsx delete mode 100644 web/src/lib/dashboard/viz/data/MarkdownViz.tsx delete mode 100644 web/src/lib/dashboard/viz/data/TableViz.tsx delete mode 100644 web/src/lib/dashboard/viz/formatters.test.ts delete mode 100644 web/src/lib/dashboard/viz/labels.ts delete mode 100644 web/src/lib/dashboard/viz/metrics/GaugeViz.tsx delete mode 100644 web/src/lib/dashboard/viz/metrics/KpiViz.tsx delete mode 100644 web/src/lib/dashboard/viz/metrics/SparklineViz.tsx delete mode 100644 web/src/lib/dashboard/viz/registry.tsx delete mode 100644 web/src/lib/dashboard/viz/series.test.ts delete mode 100644 web/src/lib/dashboard/viz/series.ts delete mode 100644 web/src/lib/dashboard/viz/types.ts create mode 100644 web/src/lib/dashboard/widgets/KpiWidget.tsx create mode 100644 web/src/lib/dashboard/widgets/TableWidget.tsx create mode 100644 web/src/lib/dashboard/widgets/TextWidget.tsx create mode 100644 web/src/lib/dashboard/widgets/WidgetBody.tsx create mode 100644 web/src/lib/dashboard/widgets/WidgetErrorBoundary.tsx create mode 100644 web/src/lib/dashboard/widgets/WidgetFrame.tsx delete mode 100644 web/src/lib/dashboardCatalog.test.ts delete mode 100644 web/src/lib/dashboardCatalog.ts delete mode 100644 web/src/lib/fetchDashboardData.test.ts delete mode 100644 web/src/lib/fetchDashboardData.ts 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/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/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 */} -
-
- -