diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 4a3833f6..0fa88449 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -82,10 +82,11 @@ def build_effective_tool_lists( if name == "openswarm-ui": policy = builtin_perms.get("ShowUI", "always_allow") - if policy == "always_allow": - effective_allowed.append("mcp__openswarm-ui__ShowUI") - else: - effective_disallowed.append("mcp__openswarm-ui__ShowUI") + for ui_tool in ("ShowUI", "AskUI"): + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-ui__{ui_tool}") + else: + effective_disallowed.append(f"mcp__openswarm-ui__{ui_tool}") continue if name == "openswarm-web": diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index 80d54966..32e25239 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -159,16 +159,20 @@ def register_builtin_mcp_servers( "type": "stdio", } - # Display-only ShowUI server: renders rich inline components (weather, plan, stats, links) - # in the transcript. Pure display, no state mutation; the frontend renders from the - # tool_call input, the server only validates. Gated on the ShowUI builtin perm. + # ShowUI renders rich inline components from the tool_call input (display only, server just + # validates); AskUI renders an interactive component and BLOCKS on /api/ui-requests/wait until + # the user answers in the transcript. Gated on the ShowUI builtin perm. show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny" if not show_ui_denied: show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py") mcp_servers["openswarm-ui"] = { "command": sys.executable, "args": [show_ui_server_path], - "env": {}, + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, "type": "stdio", } diff --git a/backend/apps/agents/show_ui_mcp_server.py b/backend/apps/agents/show_ui_mcp_server.py index e00504d3..cdf22bf9 100644 --- a/backend/apps/agents/show_ui_mcp_server.py +++ b/backend/apps/agents/show_ui_mcp_server.py @@ -7,40 +7,84 @@ backend round-trip and nothing here can mutate state. """ import json +import os import sys +import urllib.error +import urllib.request + +BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") +PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") +WAIT_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/ui-requests/wait" +ASK_TIMEOUT_S = 600 MAX_PROPS_BYTES = 20_000 COMPONENT_SPECS = { "weather": "props: {location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high: number, low?: number}] (max 7)}", - "plan": "props: {title?: str, steps: [{label: str, status: 'pending'|'in_progress'|'completed'}] (max 20)}", "stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}", "links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}", - # tool-ui vendored set: props follow the upstream Serializable contracts (https://tool-ui.com); - # the client validates strictly and shows a validation note instead of rendering on mismatch. - "data-table": "tabular results. props: {id: str, columns: [{key: str, label: str}], data: [{: str|number|bool}]}", - "citation": "sourced claims. props: {id: str, citations: [{id: str, title: str, url?: str, snippet?: str}]}", - "item-carousel": "browsable items. props: {id: str, items: [{id: str, title: str, description?: str, imageUrl?: str, badge?: str}]}", - "link-preview": "one rich link card. props: {id: str, url: str, title: str, description?: str, imageUrl?: str, siteName?: str}", - "progress-tracker": "multi-stage progress. props: {id: str, stages: [{id: str, label: str, status: 'pending'|'active'|'complete'|'error'}]}", - "order-summary": "purchase/receipt breakdown. props: {id: str, items: [{id: str, label: str, amount: number}], total?: number, currency?: str}", - "terminal": "command output. props: {id: str, command?: str, output: str}", - "image": "single image. props: {id: str, src: str, alt?: str, caption?: str}", - "image-gallery": "several images. props: {id: str, images: [{src: str, alt?: str}]}", - "video": "video embed. props: {id: str, src: str, poster?: str, title?: str}", - "message-draft": "email/message draft for review. props: {id: str, to?: [str], subject?: str, body: str}", - "x-post": "an X/Twitter post preview. props: {id: str, author: {name: str, handle: str}, text: str}", - "linkedin-post": "a LinkedIn post preview. props: {id: str, author: {name: str, headline?: str}, text: str}", - "instagram-post": "an Instagram post preview. props: {id: str, username: str, imageUrl: str, caption?: str}", - "option-list": "choices for the user (display for now). props: {id: str, options: [{id: str, label: str, description?: str}], selectionMode?: 'single'|'multi'}", - "question-flow": "step-by-step question sequence (display for now). props follow the upstream question-flow contract", - "parameter-slider": "adjustable parameters (display for now). props: {id: str, parameters: [{id: str, label: str, min: number, max: number, value: number, step?: number}]}", - "preferences-panel": "grouped preference toggles (display for now). props follow the upstream preferences-panel contract", - "approval-card": "an approve/reject summary card. props follow the upstream approval-card contract", - "stats-display": "upstream stats-display contract (prefer 'stats' unless you need its exact shape)", + # Vendored tool-ui set (MIT, https://tool-ui.com); hints are AUTO-GENERATED from the shipped zod + # contracts by frontend/scripts/gen-toolui-hints.ts. Regenerate after upgrading src/toolui. + "approval-card": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title: str, description?: str, icon?: str, metadata?: [{key: str, value: str}], variant?: 'default'|'destructive', confirmLabel?: str, cancelLabel?: str, choice?: 'approved'|'denied'}", + "audio": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, title?: str, description?: str, artwork?: str, durationMs?: num, fileSizeBytes?: num, createdAt?: str, locale?: str, source?: {label: str, iconUrl?: str, u...", + "chart": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, type: 'bar'|'line', title?: str, description?: str, data: [{}], xKey: str, series: [{key: str, label: str, color?: str}], colors?: [str], showLegend?: bool, showGrid?: bool}", + "citation": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, href: str, title: str, snippet?: str, domain?: str, favicon?: str, author?: str, publishedAt?: str, type?: 'webpage'|'document'|'article'|'api'|'code'|'other', locale?: str}", + "code-block": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, code: str, language?: str, lineNumbers?: 'visible'|'hidden', filename?: str, highlightLines?: [num], maxCollapsedLines?: num}", + "code-diff": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, oldCode?: str, newCode?: str, patch?: str, language?: str, filename?: str, lineNumbers?: 'visible'|'hidden', diffStyle?: 'unified'|'split', maxCollapsedLines?: num}", + "data-table": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, columns: [{key: str, label: str, abbr?: str, sortable?: bool, align?: 'left'|'right'|'center', width?: str, truncate?: bool, priority?: 'primary'|'secondary'|'tertiary', hideOnMob...", + "geo-map": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title?: str, description?: str, markers: [{id?: str, lat: num, lng: num, label?: str, description?: str, tooltip?: 'none'|'hover'|'always', icon?: obj|obj|obj}], routes?: [{id?: s...", + "image": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, alt: str, title?: str, description?: str, href?: str, domain?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', fileSizeBytes?: num,...", + "image-gallery": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, images: [{id: str, src: str, alt: str, width: num, height: num, title?: str, caption?: str, source?: obj}], title?: str, description?: str}", + "instagram-post": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: [{type: 'image'|'video', url: str, alt: str}], stats?: {likes?: num, isLiked?: bool}, createdAt?: str}", + "item-carousel": "props: {id: str, name: str, subtitle?: str, image?: str, color?: str, actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|'outline', loading?: bool, disabled?: bool, shortcut?: str}]}", + "link-preview": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, href: str, title?: str, description?: str, image?: str, domain?: str, favicon?: str, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain', createdAt?: str, locale?: str}", + "linkedin-post": "props: {id: str, author: {name: str, avatarUrl: str, headline?: str}, text?: str, media?: {type: 'image'|'video', url: str, alt: str}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, stats?: {likes?: num, isLiked?: bool}, createdAt?: str}", + "message-draft": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', body: str, outcome?: 'sent'|'cancelled', channel: str, subject: str, from?: str, to: [str], cc?: [str], bcc?: [str]}", + "option-list": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, options: [{id: str, label: str, description?: str, disabled?: bool}], selectionMode?: 'multi'|'single', defaultValue?: [str]|str|any, choice?: [str]|str|any, actions?: [{id: str, ...", + "order-summary": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, variant?: 'summary'|'receipt', items: [{id: str, name: str, description?: str, imageUrl?: str, quantity?: num, unitPrice: num}], pricing: {subtotal: num, tax?: num, taxLabel?: str, shipping?: num, discount?: num, discountLabel?: str, total: num, currency?: str}, choice?:...", + "parameter-slider": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', sliders: [{id: str, label: str, min: num, max: num, step?: num, value: num, unit?: str, precision?: num, disabled?: bool, trackClassName?: str, fillClassName?: str, handleClassName?: str}], actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'dest...", + "plan": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title: str, description?: str, todos: [{id: str, label: str, status: 'pending'|'in_progress'|'completed'|'cancelled', description?: str}], maxVisibleTodos?: num}", + "preferences-panel": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, title?: str, sections: [{heading?: str, items: [any]}], actions?: [{id: str, label: str, sentence?: str, confirmLabel?: str, variant?: 'default'|'destructive'|'secondary'|'ghost'|...", + "progress-tracker": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', steps: [{id: str, label: str, description?: str, status: 'pending'|'in-progress'|'completed'|'failed'}], elapsedTime?: num, choice?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}}", + "question-flow": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', step: num, title: str, description?: str, options: [{id: str, label: str, description?: str, disabled?: bool}], selectionMode?: 'single'|'multi'}", + "stats-display": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', title?: str, description?: str, stats: [{key: str, label: str, value: str|num, format?: any, diff?: obj, sparkline?: obj}]}", + "terminal": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, command: str, stdout?: str, stderr?: str, exitCode: num, durationMs?: num, cwd?: str, truncated?: bool, maxCollapsedLines?: num}", + "video": "props: {id: str, role?: 'information'|'decision'|'control'|'state'|'composite', receipt?: {outcome: 'success'|'partial'|'failed'|'cancelled', summary: str, identifiers?: obj, at: str}, assetId: str, src: str, poster?: str, title?: str, description?: str, href?: str, domain?: str, durationMs?: num, ratio?: 'auto'|'1:1'|'4:3'|'16:9'|'9:16', fit?: 'cover'|'contain'...", + "x-post": "props: {id: str, author: {name: str, handle: str, avatarUrl: str, verified?: bool}, text?: str, media?: {type: 'image'|'video', url: str, alt: str, aspectRatio?: '1:1'|'4:3'|'16:9'|'9:16'}, linkPreview?: {url: str, title?: str, description?: str, imageUrl?: str, domain?: str}, quotedPost?: any, stats?: {likes?: num, isLiked?: bool, isReposted?: bool, isBookmarke...", } +INTERACTIVE_COMPONENTS = ( + "option-list", "question-flow", "parameter-slider", "preferences-panel", "approval-card", +) + TOOLS = [ + { + "name": "AskUI", + "description": ( + "Render an INTERACTIVE component in the chat and WAIT for the user's answer (up to 10 " + "minutes); the tool result is their response. Use this instead of plain-text questions " + "when the choice fits a component. Components: " + + ", ".join(f"'{name}'" for name in INTERACTIVE_COMPONENTS) + + ". Props follow the same shapes as ShowUI (props.id is REQUIRED, it correlates the " + "answer). The response contains the action taken and the user's selection/values." + ), + "inputSchema": { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": list(INTERACTIVE_COMPONENTS), + "description": "Which interactive component to render.", + }, + "props": { + "type": "object", + "description": "Data for the component; must include a stable string id.", + }, + }, + "required": ["component", "props"], + }, + }, { "name": "ShowUI", "description": ( @@ -90,8 +134,6 @@ def validate(component: str, props: dict) -> str: return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})." if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))): return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}" - if component == "plan" and not (isinstance(props.get("steps"), list) and props["steps"]): - return f"plan needs a non-empty steps list. {COMPONENT_SPECS['plan']}" if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]): return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}" if component == "links" and not (isinstance(props.get("links"), list) and props["links"]): @@ -101,7 +143,46 @@ def validate(component: str, props: dict) -> str: return "" +def p_post(url: str, body: dict, timeout: float) -> dict: + payload = json.dumps(body).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" + req = urllib.request.Request(url, data=payload, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body_txt = e.read().decode(errors="replace") if e.fp else str(e) + return {"error": f"HTTP {e.code}: {body_txt[:300]}"} + except Exception as e: + return {"error": str(e)} + + +def handle_ask_ui(arguments: dict) -> dict: + component = str(arguments.get("component", "")).strip() + props = arguments.get("props") + if not isinstance(props, dict): + return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True} + if component not in INTERACTIVE_COMPONENTS: + return {"content": [{"type": "text", "text": f"AskUI only supports: {', '.join(INTERACTIVE_COMPONENTS)}. Use ShowUI for display-only components."}], "isError": True} + component_id = str(props.get("id", "")).strip() + if not component_id: + return {"content": [{"type": "text", "text": "props.id (a stable string) is required so the answer can be correlated."}], "isError": True} + problem = validate(component, props) + if problem: + return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True} + r = p_post(WAIT_URL, {"session_id": PARENT_SESSION_ID, "component_id": component_id, "timeout_s": ASK_TIMEOUT_S}, timeout=ASK_TIMEOUT_S + 20) + if "error" in r: + return {"content": [{"type": "text", "text": f"AskUI failed: {r['error']}"}], "isError": True} + if not r.get("ok"): + return {"content": [{"type": "text", "text": "The user didn't respond within 10 minutes. Continue without their input or ask again."}], "isError": True} + return {"content": [{"type": "text", "text": json.dumps(r.get("response"))}]} + + def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name == "AskUI": + return handle_ask_ui(arguments) if tool_name != "ShowUI": return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} component = str(arguments.get("component", "")).strip() diff --git a/backend/apps/agents/ui_request_bridge.py b/backend/apps/agents/ui_request_bridge.py new file mode 100644 index 00000000..4f14722c --- /dev/null +++ b/backend/apps/agents/ui_request_bridge.py @@ -0,0 +1,56 @@ +"""Blocking bridge for interactive tool-ui components: AskUI parks here until the user +answers in the transcript (or the wait times out). Keyed by (session_id, component props.id), +so the frontend can respond without ever learning a server-side request id.""" + +import asyncio +from typing import Any, Dict, Optional, Tuple + +from pydantic import BaseModel, ConfigDict, InstanceOf +from typeguard import typechecked + +MAX_PENDING = 50 +MAX_WAIT_SECONDS = 600.0 + + +class PendingUiRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + event: InstanceOf[asyncio.Event] + response: Optional[Dict[str, Any]] = None + + +p_pending: Dict[Tuple[str, str], PendingUiRequest] = {} + + +@typechecked +async def wait_for_ui_response(session_id: str, component_id: str, timeout_s: float) -> Optional[Dict[str, Any]]: + """Registers the request and blocks until respond_to_ui_request fires it; None on timeout.""" + if len(p_pending) >= MAX_PENDING: + raise ValueError("too many pending UI requests") + key = (session_id, component_id) + # A retried tool call for the same component replaces the stale wait; the old waiter times out. + pending = PendingUiRequest(event=asyncio.Event()) + p_pending[key] = pending + try: + await asyncio.wait_for(pending.event.wait(), timeout=min(timeout_s, MAX_WAIT_SECONDS)) + return pending.response + except asyncio.TimeoutError: + return None + finally: + if p_pending.get(key) is pending: + p_pending.pop(key, None) + + +@typechecked +def respond_to_ui_request(session_id: str, component_id: str, response: Dict[str, Any]) -> bool: + """Delivers the user's answer to the parked wait; False when nothing is waiting.""" + pending = p_pending.get((session_id, component_id)) + if pending is None: + return False + pending.response = response + pending.event.set() + return True + + +@typechecked +def has_pending_ui_request(session_id: str, component_id: str) -> bool: + return (session_id, component_id) in p_pending diff --git a/backend/main.py b/backend/main.py index d4f793fe..23c07edd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -902,6 +902,43 @@ async def spawn_agent_run(request: Request): return JSONResponse({"error": str(e)}, status_code=500) +@app.post("/api/ui-requests/wait") +async def ui_request_wait(request: Request): + """AskUI's blocking half: parks until the user answers the interactive component in the + transcript. Called by the show_ui_mcp_server stdio subprocess.""" + body = await request.json() + session_id = str(body.get("session_id", "")) + component_id = str(body.get("component_id", "")) + timeout_s = float(body.get("timeout_s", 600) or 600) + if not session_id or not component_id: + return JSONResponse({"error": "session_id and component_id are required"}, status_code=400) + try: + from backend.apps.agents.ui_request_bridge import wait_for_ui_response + response = await wait_for_ui_response(session_id, component_id, timeout_s) + return JSONResponse({"ok": response is not None, "response": response}) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=429) + except Exception as e: + logger.exception("ui_request_wait failed") + return JSONResponse({"error": str(e)}, status_code=500) + + +@app.post("/api/ui-requests/respond") +async def ui_request_respond(request: Request): + """The user's answer from the rendered component; releases the matching parked wait.""" + body = await request.json() + session_id = str(body.get("session_id", "")) + component_id = str(body.get("component_id", "")) + response = body.get("response") + if not session_id or not component_id or not isinstance(response, dict): + return JSONResponse({"error": "session_id, component_id and response object are required"}, status_code=400) + from backend.apps.agents.ui_request_bridge import respond_to_ui_request + delivered = respond_to_ui_request(session_id, component_id, response) + if not delivered: + return JSONResponse({"error": "no pending request for that component"}, status_code=404) + return JSONResponse({"ok": True}) + + @app.post("/api/invoke-agent/run") async def invoke_agent_run(request: Request): """Fork an existing agent session and send it a new message. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33a69a28..e4608790 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", + "@pierre/diffs": "^1.0.11", "@reduxjs/toolkit": "^2.8.2", "@types/react-syntax-highlighter": "^15.5.13", "ansi-to-react": "^6.2.6", @@ -26,16 +27,20 @@ "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", + "leaflet": "^1.9.4", "lucide-react": "^1.17.0", "radix-ui": "^1.6.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "react-syntax-highlighter": "^16.1.1", - "recharts": "^3.9.2", + "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "shiki": "^3.23.0", + "supercluster": "^8.0.1", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, @@ -45,9 +50,11 @@ "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@tailwindcss/postcss": "^4.3.3", + "@types/leaflet": "^1.9.21", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-redux": "^7.1.34", + "@types/supercluster": "^7.1.3", "babel-loader": "^9.2.1", "copy-webpack-plugin": "^14.0.0", "css-loader": "^6.8.0", @@ -59,6 +66,7 @@ "sass-loader": "^16.0.5", "style-loader": "^3.3.0", "tailwindcss": "^4.3.3", + "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", "typescript": "^5.0.0", "webpack": "^5.88.0", @@ -2077,6 +2085,448 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -3130,6 +3580,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pierre/diffs": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.11.tgz", + "integrity": "sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw==", + "license": "apache-2.0", + "dependencies": { + "@shikijs/core": "^3.0.0", + "@shikijs/engine-javascript": "^3.0.0", + "@shikijs/transformers": "^3.0.0", + "diff": "8.0.3", + "hast-util-to-html": "9.0.5", + "lru_map": "0.4.1", + "shiki": "^3.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4636,6 +5105,17 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -4662,6 +5142,83 @@ } } }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", + "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5135,6 +5692,13 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -5188,6 +5752,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5383,6 +5957,16 @@ "@types/node": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6924,6 +7508,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -7161,15 +7754,47 @@ "node": ">= 0.4" } }, - "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } }, "node_modules/escalade": { "version": "3.2.0", @@ -7287,7 +7912,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -7411,6 +8035,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -7883,6 +8516,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -8081,6 +8737,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.7", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", @@ -8738,6 +9404,12 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -8759,6 +9431,12 @@ "shell-quote": "^1.8.3" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -9069,7 +9747,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -9125,6 +9802,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10393,6 +11076,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -11067,6 +11767,20 @@ "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -11202,6 +11916,21 @@ "react-dom": ">=18" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -11290,39 +12019,42 @@ } }, "node_modules/recharts": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", - "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", "license": "MIT", - "workspaces": [ - "www" - ], "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^11.1.8", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.2.0", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" }, "engines": { - "node": ">=18" + "node": ">=14" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/recharts/node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/rechoir": { @@ -11389,6 +12121,30 @@ "node": ">=4" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -11977,6 +12733,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -12258,6 +13030,15 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -12456,6 +13237,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -12804,9 +13604,9 @@ } }, "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/frontend/package.json b/frontend/package.json index b9b9ad14..5bfcd358 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", + "@pierre/diffs": "^1.0.11", "@reduxjs/toolkit": "^2.8.2", "@types/react-syntax-highlighter": "^15.5.13", "ansi-to-react": "^6.2.6", @@ -27,16 +28,20 @@ "codemirror": "^6.0.2", "framer-motion": "^12.35.2", "html-to-image": "^1.11.13", + "leaflet": "^1.9.4", "lucide-react": "^1.17.0", "radix-ui": "^1.6.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "react-syntax-highlighter": "^16.1.1", - "recharts": "^3.9.2", + "recharts": "^2.15.4", "remark-gfm": "^4.0.1", + "shiki": "^3.23.0", + "supercluster": "^8.0.1", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, @@ -46,9 +51,11 @@ "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@tailwindcss/postcss": "^4.3.3", + "@types/leaflet": "^1.9.21", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/react-redux": "^7.1.34", + "@types/supercluster": "^7.1.3", "babel-loader": "^9.2.1", "copy-webpack-plugin": "^14.0.0", "css-loader": "^6.8.0", @@ -60,6 +67,7 @@ "sass-loader": "^16.0.5", "style-loader": "^3.3.0", "tailwindcss": "^4.3.3", + "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", "typescript": "^5.0.0", "webpack": "^5.88.0", diff --git a/frontend/scripts/gen-toolui-hints.ts b/frontend/scripts/gen-toolui-hints.ts new file mode 100644 index 00000000..4ada1643 --- /dev/null +++ b/frontend/scripts/gen-toolui-hints.ts @@ -0,0 +1,73 @@ +/* Generates terse per-component prop hints for the ShowUI tool description straight from the + vendored tool-ui zod contracts, so the server spec can never drift from what validates. + Run: npx tsx --tsconfig tsconfig.json scripts/gen-toolui-hints.ts */ +import { z } from 'zod'; + +const TARGETS: Record = { + 'approval-card': ['../src/toolui/components/approval-card/schema', 'SerializableApprovalCardSchema'], + 'audio': ['../src/toolui/components/audio/schema', 'SerializableAudioSchema'], + 'chart': ['../src/toolui/components/chart/schema', 'SerializableChartSchema'], + 'citation': ['../src/toolui/components/citation/schema', 'SerializableCitationSchema'], + 'code-block': ['../src/toolui/components/code-block/schema', 'SerializableCodeBlockSchema'], + 'code-diff': ['../src/toolui/components/code-diff/schema', 'SerializableCodeDiffSchema'], + 'data-table': ['../src/toolui/components/data-table/schema', 'SerializableDataTableSchema'], + 'geo-map': ['../src/toolui/components/geo-map/schema', 'SerializableGeoMapSchema'], + 'image': ['../src/toolui/components/image/schema', 'SerializableImageSchema'], + 'image-gallery': ['../src/toolui/components/image-gallery/schema', 'SerializableImageGallerySchema'], + 'instagram-post': ['../src/toolui/components/instagram-post/schema', 'SerializableInstagramPostSchema'], + 'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemSchema'], + 'link-preview': ['../src/toolui/components/link-preview/schema', 'SerializableLinkPreviewSchema'], + 'linkedin-post': ['../src/toolui/components/linkedin-post/schema', 'SerializableLinkedInPostSchema'], + 'message-draft': ['../src/toolui/components/message-draft/schema', 'SerializableEmailDraftSchema'], + 'option-list': ['../src/toolui/components/option-list/schema', 'SerializableOptionListSchema'], + 'order-summary': ['../src/toolui/components/order-summary/schema', 'SerializableOrderSummarySchema'], + 'parameter-slider': ['../src/toolui/components/parameter-slider/schema', 'SerializableParameterSliderSchema'], + 'plan': ['../src/toolui/components/plan/schema', 'SerializablePlanSchema'], + 'preferences-panel': ['../src/toolui/components/preferences-panel/schema', 'SerializablePreferencesPanelSchema'], + 'progress-tracker': ['../src/toolui/components/progress-tracker/schema', 'SerializableProgressTrackerSchema'], + 'question-flow': ['../src/toolui/components/question-flow/schema', 'SerializableProgressiveModeSchema'], + 'stats-display': ['../src/toolui/components/stats-display/schema', 'SerializableStatsDisplaySchema'], + 'terminal': ['../src/toolui/components/terminal/schema', 'SerializableTerminalSchema'], + 'video': ['../src/toolui/components/video/schema', 'SerializableVideoSchema'], + 'x-post': ['../src/toolui/components/x-post/schema', 'SerializableXPostSchema'], +}; + +function describe(node: any, depth: number): string { + if (!node || typeof node !== 'object') return 'any'; + if (Array.isArray(node.enum)) return node.enum.map((v: unknown) => `'${v}'`).join('|'); + if (Array.isArray(node.anyOf)) return node.anyOf.map((n: any) => describe(n, depth)).join('|'); + const t = node.type; + if (t === 'array') return `[${describe(node.items, depth)}]`; + if (t === 'object') { + if (depth >= 2) return 'obj'; + const req = new Set(node.required || []); + const props = node.properties || {}; + const parts = Object.keys(props).map((k) => `${k}${req.has(k) ? '' : '?'}: ${describe(props[k], depth + 1)}`); + return `{${parts.join(', ')}}`; + } + if (t === 'string') return 'str'; + if (t === 'number' || t === 'integer') return 'num'; + if (t === 'boolean') return 'bool'; + return 'any'; +} + +async function main(): Promise { + const out: Record = {}; + for (const [name, [path, exportName]] of Object.entries(TARGETS)) { + try { + const mod = await import(path); + const schema = mod[exportName]; + const js = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' } as any) as any; + let hint = describe(js, 0); + if (hint.length > 360) hint = hint.slice(0, 357) + '...'; + out[name] = hint; + } catch (e) { + out[name] = `ERROR: ${(e as Error).message.slice(0, 80)}`; + } + } + for (const [name, hint] of Object.entries(out)) { + console.log(` "${name}": "props: ${hint.replace(/"/g, "'")}",`); + } +} + +void main(); diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 06b1886f..b830a890 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -59,7 +59,8 @@ import MessageActionBar from './shell/MessageActionBar'; import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble'; import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble'; import ToolUiBubble from './tool-ui/ToolUiBubble'; -import { isShowUiPair } from './tool-ui/showUiPayload'; +import AskUiBubble from './tool-ui/AskUiBubble'; +import { isShowUiPair, isAskUiPair } from './tool-ui/showUiPayload'; import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; import ForceStopAgentBar from './ForceStopAgentBar'; import { RateLimitPill } from './shell/RateLimitPill'; @@ -1079,10 +1080,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose result: results[idx] || null, })); - // ShowUI calls render as inline components, never buried inside a collapsed group. + // ShowUI/AskUI calls render as inline components, never buried inside a collapsed group. // They typically cap a run of work, so the quiet group row stays above the widget. - const showUiPairs = allPairs.filter(isShowUiPair); - const pairs = allPairs.filter((p) => !isShowUiPair(p)); + const showUiPairs = allPairs.filter((p) => isShowUiPair(p) || isAskUiPair(p)); + const pairs = allPairs.filter((p) => !isShowUiPair(p) && !isAskUiPair(p)); const calls = pairs.map((p) => p.call); const mcpServers = new Set( @@ -1607,6 +1608,14 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } if (isToolPair(item)) { const isPending = item.result === null && sessionRunning; + if (isAskUiPair(item)) { + return ( + + + {compactionChip} + + ); + } if (isShowUiPair(item)) { return ( diff --git a/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx b/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx new file mode 100644 index 00000000..c0f320fc --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/AskUiBubble.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; +import type { ToolPair } from '../tool-bubbles/ToolCallBubble'; +import { parseShowUiPayload } from './showUiPayload'; +import VendoredToolUi from '@toolui/VendoredToolUi'; +import { API_BASE, getAuthToken } from '@/shared/config'; + +interface AskUiBubbleProps { + pair: ToolPair; + sessionId: string; + isPending: boolean; + suppressReveal: boolean; +} + +function parseResultResponse(pair: ToolPair): Record | null { + const rc = pair.result?.content; + const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? String(rc.text) : ''; + if (!text.startsWith('{')) return null; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +/** An AskUI call: the live interactive component while the agent waits; its answered state after. */ +function AskUiBubble({ pair, sessionId, isPending, suppressReveal }: AskUiBubbleProps): React.ReactElement { + const payload = parseShowUiPayload(pair); + const [submitted, setSubmitted] = useState(false); + const answered = parseResultResponse(pair); + + const componentId = payload && payload.component === 'vendored' ? String(payload.props.id || '') : ''; + + const respond = useCallback( + (response: Record) => { + if (submitted) return; + setSubmitted(true); + void fetch(`${API_BASE}/ui-requests/respond`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken()}` }, + body: JSON.stringify({ session_id: sessionId, component_id: componentId, response }), + }).catch(() => setSubmitted(false)); + }, + [submitted, sessionId, componentId], + ); + + // Their embedded-actions contract: onAction(actionId, state) delivers the component's full state; + // approval-card uses onConfirm/onCancel instead. Inject a default Send action when none given. + const extraProps = useMemo(() => { + if (!payload || payload.component !== 'vendored') return {}; + const waiting = pair.result === null && !submitted; + if (payload.name === 'approval-card') { + return waiting + ? { + onConfirm: () => respond({ action: 'confirm', choice: 'approved' }), + onCancel: () => respond({ action: 'cancel', choice: 'denied' }), + } + : { choice: (answered?.choice as string) || undefined }; + } + if (waiting) { + const hasActions = Array.isArray((payload.props as { actions?: unknown[] }).actions) && (payload.props as { actions?: unknown[] }).actions!.length > 0; + return { + ...(hasActions ? {} : { actions: [{ id: 'submit', label: 'Send' }] }), + onAction: (actionId: string, state: unknown) => respond({ action: actionId, value: state ?? null }), + }; + } + return answered && 'value' in answered ? { choice: answered.value } : {}; + }, [payload, pair.result, submitted, respond, answered]); + + if (!payload || payload.component !== 'vendored' || !componentId) { + return ( + + ); + } + + return ( + + + {submitted && pair.result === null && ( + Sent to the agent... + )} + + ); +} + +export default AskUiBubble; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index 09537faa..07a1af82 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -70,6 +70,11 @@ export function isShowUiPair(pair: ToolPair): boolean { return /(^|__)ShowUI$/.test(tool); } +export function isAskUiPair(pair: ToolPair): boolean { + const tool = typeof pair.call.content === 'object' ? String(pair.call.content?.tool || '') : ''; + return /(^|__)AskUI$/.test(tool); +} + /** Latest ShowUI payload anywhere in a transcript; the collapsed card pins this artifact under its pill. */ export function extractLatestShowUi(messages: Array<{ role: string; content: any }>): ShowUiPayload | null { for (let i = messages.length - 1; i >= 0; i--) { diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index 09a33234..6f9fea0b 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -5,12 +5,14 @@ import { TOOL_UI_REGISTRY } from './registry'; interface VendoredToolUiProps { name: string; props: Record; + /** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */ + extraProps?: Record; } type Gate = 'pending' | 'ok' | 'bad'; /** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */ -function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElement | null { +function VendoredToolUi({ name, props, extraProps }: VendoredToolUiProps): React.ReactElement | null { const { mode } = useThemeMode(); const entry = TOOL_UI_REGISTRY[name]; const [gate, setGate] = useState('pending'); @@ -50,7 +52,7 @@ function VendoredToolUi({ name, props }: VendoredToolUiProps): React.ReactElemen return (
}> - +
); diff --git a/frontend/src/toolui/components/audio/README.md b/frontend/src/toolui/components/audio/README.md new file mode 100644 index 00000000..da6a00e6 --- /dev/null +++ b/frontend/src/toolui/components/audio/README.md @@ -0,0 +1,19 @@ +# Audio + +Implementation for the "audio" Tool UI surface. + +## Files + +- public exports: components/tool-ui/audio/index.ts +- serializable schema + parse helpers: components/tool-ui/audio/schema.ts + +## Companion assets + +- Docs page: app/docs/audio/content.mdx +- Preset payload: lib/presets/audio.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/audio/_adapter.tsx b/frontend/src/toolui/components/audio/_adapter.tsx new file mode 100644 index 00000000..ae01af74 --- /dev/null +++ b/frontend/src/toolui/components/audio/_adapter.tsx @@ -0,0 +1,8 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Slider } from "@toolui/ui/slider"; diff --git a/frontend/src/toolui/components/audio/audio.tsx b/frontend/src/toolui/components/audio/audio.tsx new file mode 100644 index 00000000..785c3c9f --- /dev/null +++ b/frontend/src/toolui/components/audio/audio.tsx @@ -0,0 +1,341 @@ +"use client"; + +import * as React from "react"; +import { Pause, Play } from "lucide-react"; +import { cn, Button, Slider } from "./_adapter"; + +import { AudioProvider, useAudio } from "./context"; +import type { SerializableAudio, AudioVariant } from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds)) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export interface AudioProps extends SerializableAudio { + variant?: AudioVariant; + className?: string; + onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void; +} + +export function Audio(props: AudioProps) { + return ( + + + + ); +} + +interface PlayerControls { + isPlaying: boolean; + currentTime: number; + duration: number; + onPlayPause: () => void; + onSeek: (value: number[]) => void; + onSeekStart: () => void; + onSeekEnd: () => void; +} + +interface FullPlayerProps { + artwork?: string; + title?: string; + description?: string; + controls: PlayerControls; +} + +function FullPlayer({ + artwork, + title, + description, + controls, +}: FullPlayerProps) { + return ( +
+ {artwork && ( +
+ +
+ )} +
+ {(title || description) && ( +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} +
+ )} +
+
+ +
+ {formatTime(controls.currentTime)} + {formatTime(controls.duration)} +
+
+ +
+
+
+ ); +} + +interface CompactPlayerProps { + artwork?: string; + title?: string; + description?: string; + controls: PlayerControls; +} + +function CompactPlayer({ + artwork, + title, + description, + controls, +}: CompactPlayerProps) { + const progress = + controls.duration > 0 + ? (controls.currentTime / controls.duration) * 100 + : 0; + + return ( +
+ {artwork && ( + <> + +
+ + )} + {artwork && ( +
+ +
+ )} +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} + {controls.duration > 0 && ( +
+
+
+
+ + {formatTime(controls.currentTime)} + +
+ )} +
+ +
+ ); +} + +function AudioInner(props: AudioProps) { + const { variant = "full", className, onMediaEvent, ...serializable } = props; + + const { + id, + src, + title, + description, + artwork, + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + + const { state, setState, setAudioElement } = useAudio(); + const audioRef = React.useRef(null); + const [currentTime, setCurrentTime] = React.useState(0); + const [duration, setDuration] = React.useState(0); + const [isSeeking, setIsSeeking] = React.useState(false); + + React.useEffect(() => { + setAudioElement(audioRef.current); + return () => setAudioElement(null); + }, [setAudioElement]); + + React.useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + if (state.playing && audio.paused) { + void audio.play().catch(() => undefined); + } else if (!state.playing && !audio.paused) { + audio.pause(); + } + }, [state.playing]); + + const handlePlayPause = () => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + void audio.play().catch(() => undefined); + } else { + audio.pause(); + } + }; + + const handleSeek = (value: number[]) => { + const audio = audioRef.current; + if (!audio) return; + const newTime = value[0]; + audio.currentTime = newTime; + setCurrentTime(newTime); + }; + + const handleSeekStart = () => { + setIsSeeking(true); + }; + + const handleSeekEnd = () => { + setIsSeeking(false); + }; + + const controls: PlayerControls = { + isPlaying: state.playing, + currentTime, + duration, + onPlayPause: handlePlayPause, + onSeek: handleSeek, + onSeekStart: handleSeekStart, + onSeekEnd: handleSeekEnd, + }; + + const isCompact = variant === "compact"; + + return ( +
+
+ {isCompact ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/src/toolui/components/audio/context.tsx b/frontend/src/toolui/components/audio/context.tsx new file mode 100644 index 00000000..18e37ef8 --- /dev/null +++ b/frontend/src/toolui/components/audio/context.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; + +export interface AudioPlaybackState { + playing: boolean; + muted: boolean; +} + +export interface AudioContextValue { + state: AudioPlaybackState; + setState: (patch: Partial) => void; + audioElement: HTMLAudioElement | null; + setAudioElement: (node: HTMLAudioElement | null) => void; +} + +const AudioContext = React.createContext(null); + +export function useAudio() { + const ctx = React.useContext(AudioContext); + if (!ctx) { + throw new Error("useAudio must be used within an "); + } + return ctx; +} + +export interface AudioProviderProps { + children: React.ReactNode; + defaultState?: Partial; +} + +export function AudioProvider({ children, defaultState }: AudioProviderProps) { + const [state, setStateInternal] = React.useState({ + playing: defaultState?.playing ?? false, + muted: defaultState?.muted ?? false, + }); + + const [audioElement, setAudioElement] = + React.useState(null); + + const setState = React.useCallback((patch: Partial) => { + setStateInternal((prev) => ({ ...prev, ...patch })); + }, []); + + const value = React.useMemo( + () => ({ state, setState, audioElement, setAudioElement }), + [state, setState, audioElement], + ); + + return ( + {children} + ); +} diff --git a/frontend/src/toolui/components/audio/index.ts b/frontend/src/toolui/components/audio/index.ts new file mode 100644 index 00000000..9b588b80 --- /dev/null +++ b/frontend/src/toolui/components/audio/index.ts @@ -0,0 +1,5 @@ +export { Audio } from "./audio"; +export type { AudioProps } from "./audio"; +export { AudioProvider, useAudio } from "./context"; +export type { AudioPlaybackState, AudioContextValue } from "./context"; +export type { SerializableAudio, Source, AudioVariant } from "./schema"; diff --git a/frontend/src/toolui/components/audio/schema.ts b/frontend/src/toolui/components/audio/schema.ts new file mode 100644 index 00000000..b4fdcfcf --- /dev/null +++ b/frontend/src/toolui/components/audio/schema.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const SourceSchema = z.object({ + label: z.string(), + iconUrl: z.url().optional(), + url: z.url().optional(), +}); + +export type Source = z.infer; + +export const SerializableAudioSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + assetId: z.string(), + src: z.url(), + title: z.string().optional(), + description: z.string().optional(), + artwork: z.url().optional(), + durationMs: z.number().int().positive().optional(), + fileSizeBytes: z.number().int().positive().optional(), + createdAt: z.string().datetime().optional(), + locale: z.string().optional(), + source: SourceSchema.optional(), +}); + +export type SerializableAudio = z.infer; + +const SerializableAudioSchemaContract = defineToolUiContract( + "Audio", + SerializableAudioSchema, +); + +export const parseSerializableAudio: (input: unknown) => SerializableAudio = + SerializableAudioSchemaContract.parse; + +export const safeParseSerializableAudio: ( + input: unknown, +) => SerializableAudio | null = SerializableAudioSchemaContract.safeParse; +export type AudioVariant = "full" | "compact"; diff --git a/frontend/src/toolui/components/chart/README.md b/frontend/src/toolui/components/chart/README.md new file mode 100644 index 00000000..eb421cd2 --- /dev/null +++ b/frontend/src/toolui/components/chart/README.md @@ -0,0 +1,19 @@ +# Chart + +Implementation for the "chart" Tool UI surface. + +## Files + +- public exports: components/tool-ui/chart/index.tsx +- serializable schema + parse helpers: components/tool-ui/chart/schema.ts + +## Companion assets + +- Docs page: app/docs/chart/content.mdx +- Preset payload: lib/presets/chart.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/chart/_adapter.tsx b/frontend/src/toolui/components/chart/_adapter.tsx new file mode 100644 index 00000000..52cd2e17 --- /dev/null +++ b/frontend/src/toolui/components/chart/_adapter.tsx @@ -0,0 +1,27 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Chart → shadcn/ui Chart (recharts wrapper) + * Card → shadcn/ui Card + */ + +export { cn } from "@toolui/lib/utils"; +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@toolui/ui/chart"; +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@toolui/ui/card"; diff --git a/frontend/src/toolui/components/chart/chart.tsx b/frontend/src/toolui/components/chart/chart.tsx new file mode 100644 index 00000000..97a7b44b --- /dev/null +++ b/frontend/src/toolui/components/chart/chart.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useMemo, useCallback, memo } from "react"; +import { + BarChart, + LineChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; + +import { + cn, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + type ChartConfig, +} from "./_adapter"; +import type { ChartProps } from "./schema"; + +const DEFAULT_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +export const Chart = memo(function Chart({ + id, + type, + title, + description, + data, + xKey, + series, + colors, + showLegend = false, + showGrid = true, + className, + onDataPointClick, +}: ChartProps) { + const palette = colors?.length ? colors : DEFAULT_COLORS; + + const seriesColors = useMemo( + () => + series.map( + (seriesItem, index) => + seriesItem.color ?? palette[index % palette.length], + ), + [series, palette], + ); + + const chartConfig: ChartConfig = useMemo( + () => + Object.fromEntries( + series.map((seriesItem, index) => [ + seriesItem.key, + { + label: seriesItem.label, + color: seriesColors[index], + }, + ]), + ), + [series, seriesColors], + ); + + const handleDataPointClick = useCallback( + ( + seriesKey: string, + seriesLabel: string, + payload: Record, + index: number, + ) => { + onDataPointClick?.({ + seriesKey, + seriesLabel, + xValue: payload[xKey], + yValue: payload[seriesKey], + index, + payload, + }); + }, + [onDataPointClick, xKey], + ); + + const ChartComponent = type === "bar" ? BarChart : LineChart; + + const chartContent = ( + + + {showGrid && } + + + } /> + {showLegend && } />} + + {type === "bar" && + series.map((s, i) => ( + + handleDataPointClick(s.key, s.label, data.payload, data.index) + } + cursor={onDataPointClick ? "pointer" : undefined} + /> + ))} + + {type === "line" && + series.map((s, i) => ( + ; index: number }, + ) => { + handleDataPointClick( + s.key, + s.label, + dotData.payload, + dotData.index, + ); + }) as unknown as React.MouseEventHandler, + }} + /> + ))} + + + ); + + return ( + + {(title || description) && ( + + {title && {title}} + {description && ( + + {description} + + )} + + )} + {chartContent} + + ); +}); diff --git a/frontend/src/toolui/components/chart/index.tsx b/frontend/src/toolui/components/chart/index.tsx new file mode 100644 index 00000000..aeebab8f --- /dev/null +++ b/frontend/src/toolui/components/chart/index.tsx @@ -0,0 +1,8 @@ +export { Chart } from "./chart"; +export { + type ChartProps, + type ChartSeries, + type ChartDataPoint, + type ChartClientProps, + type SerializableChart, +} from "./schema"; diff --git a/frontend/src/toolui/components/chart/schema.ts b/frontend/src/toolui/components/chart/schema.ts new file mode 100644 index 00000000..77a5b7f1 --- /dev/null +++ b/frontend/src/toolui/components/chart/schema.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const ChartSeriesSchema = z.object({ + key: z.string().min(1), + label: z.string().min(1), + color: z.string().optional(), +}); + +export type ChartSeries = z.infer; + +export const ChartPropsSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + type: z.enum(["bar", "line"]), + title: z.string().optional(), + description: z.string().optional(), + data: z.array(z.record(z.string(), z.unknown())).min(1), + xKey: z.string().min(1), + series: z.array(ChartSeriesSchema).min(1), + /** Color palette applied to series in order. Individual series.color takes precedence. */ + colors: z.array(z.string().min(1)).min(1).optional(), + showLegend: z.boolean().optional(), + showGrid: z.boolean().optional(), + }) + .superRefine((value, ctx) => { + const seenSeriesKeys = new Set(); + value.series.forEach((series, index) => { + if (seenSeriesKeys.has(series.key)) { + ctx.addIssue({ + code: "custom", + path: ["series", index, "key"], + message: `Duplicate series key "${series.key}".`, + }); + return; + } + seenSeriesKeys.add(series.key); + }); + + value.data.forEach((row, rowIndex) => { + if (!(value.xKey in row)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, value.xKey], + message: `Missing xKey "${value.xKey}" in data row.`, + }); + } else { + const xVal = row[value.xKey]; + const isValidX = typeof xVal === "string" || typeof xVal === "number"; + if (!isValidX) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, value.xKey], + message: `Expected "${value.xKey}" to be a string or number.`, + }); + } + } + + value.series.forEach((series) => { + if (!(series.key in row)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, series.key], + message: `Missing series key "${series.key}" in data row.`, + }); + return; + } + + const yVal = row[series.key]; + if (yVal === null) { + return; + } + if (typeof yVal !== "number" || !Number.isFinite(yVal)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, series.key], + message: `Expected "${series.key}" to be a finite number (or null).`, + }); + } + }); + }); + }); + +export type ChartDataPoint = { + seriesKey: string; + seriesLabel: string; + xValue: unknown; + yValue: unknown; + index: number; + payload: Record; +}; + +export type ChartClientProps = { + className?: string; + onDataPointClick?: (point: ChartDataPoint) => void; +}; + +export type ChartProps = z.infer & ChartClientProps; + +export const SerializableChartSchema = ChartPropsSchema; + +export type SerializableChart = z.infer; + +const SerializableChartSchemaContract = defineToolUiContract( + "Chart", + SerializableChartSchema, +); + +export const parseSerializableChart: (input: unknown) => SerializableChart = + SerializableChartSchemaContract.parse; + +export const safeParseSerializableChart: ( + input: unknown, +) => SerializableChart | null = SerializableChartSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/code-block/README.md b/frontend/src/toolui/components/code-block/README.md new file mode 100644 index 00000000..80e3f06a --- /dev/null +++ b/frontend/src/toolui/components/code-block/README.md @@ -0,0 +1,19 @@ +# Code Block + +Implementation for the "code-block" Tool UI surface. + +## Files + +- public exports: components/tool-ui/code-block/index.tsx +- serializable schema + parse helpers: components/tool-ui/code-block/schema.ts + +## Companion assets + +- Docs page: app/docs/code-block/content.mdx +- Preset payload: lib/presets/code-block.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/code-block/_adapter.tsx b/frontend/src/toolui/components/code-block/_adapter.tsx new file mode 100644 index 00000000..a929b219 --- /dev/null +++ b/frontend/src/toolui/components/code-block/_adapter.tsx @@ -0,0 +1,14 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Collapsible → shadcn/ui Collapsible + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible"; diff --git a/frontend/src/toolui/components/code-block/code-block.tsx b/frontend/src/toolui/components/code-block/code-block.tsx new file mode 100644 index 00000000..41885d30 --- /dev/null +++ b/frontend/src/toolui/components/code-block/code-block.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { + useState, + useCallback, + useEffect, + createContext, + useContext, + type ReactNode, +} from "react"; +import { + createHighlighter, + createJavaScriptRegexEngine, + type Highlighter, +} from "shiki"; +import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react"; +import pierreDarkTheme from "../shared/pierre-dark-theme.js"; +import pierreLightTheme from "../shared/pierre-light-theme.js"; +import type { CodeBlockLineNumbersMode, CodeBlockProps } from "./schema"; +import { useCopyToClipboard } from "../shared/use-copy-to-clipboard"; + +import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter"; + +const COPY_ID = "codeblock-code"; +const MAX_HTML_CACHE_ENTRIES = 64; + +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + let pending = highlighterPromise; + if (!pending) { + pending = createHighlighter({ + themes: [pierreDarkTheme as never, pierreLightTheme as never], + langs: [], + engine: createJavaScriptRegexEngine(), + }); + highlighterPromise = pending; + } + return pending; +} + +const htmlCache = new Map(); + +function getCacheKey( + code: string, + language: string, + theme: string, + lineNumbers: CodeBlockLineNumbersMode, + highlightLines?: number[], +): string { + return JSON.stringify({ + code, + language, + theme, + lineNumbers, + highlightLines: highlightLines ?? null, + }); +} + +function setCachedHtml(cacheKey: string, html: string): void { + if (htmlCache.has(cacheKey)) { + htmlCache.set(cacheKey, html); + return; + } + + if (htmlCache.size >= MAX_HTML_CACHE_ENTRIES) { + const oldestKey = htmlCache.keys().next().value; + if (typeof oldestKey === "string") { + htmlCache.delete(oldestKey); + } + } + + htmlCache.set(cacheKey, html); +} + +const LANGUAGE_DISPLAY_NAMES: Record = { + typescript: "TypeScript", + javascript: "JavaScript", + python: "Python", + tsx: "TSX", + jsx: "JSX", + json: "JSON", + bash: "Bash", + shell: "Shell", + css: "CSS", + html: "HTML", + markdown: "Markdown", + sql: "SQL", + yaml: "YAML", + go: "Go", + rust: "Rust", + text: "Plain Text", +}; + +function getLanguageDisplayName(lang: string): string { + return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase(); +} + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function getDocumentTheme(): "light" | "dark" | null { + if (typeof document === "undefined") return null; + const root = document.documentElement; + const dataTheme = root.getAttribute("data-theme")?.toLowerCase(); + if (dataTheme === "dark") return "dark"; + if (dataTheme === "light") return "light"; + if (root.classList.contains("dark")) return "dark"; + if (root.classList.contains("light")) return "light"; + return null; +} + +function useResolvedTheme(): "light" | "dark" { + const [theme, setTheme] = useState<"light" | "dark">(() => { + return getDocumentTheme() ?? getSystemTheme(); + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const update = () => setTheme(getDocumentTheme() ?? getSystemTheme()); + + const mql = window.matchMedia?.("(prefers-color-scheme: dark)"); + mql?.addEventListener("change", update); + + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }); + + return () => { + mql?.removeEventListener("change", update); + observer.disconnect(); + }; + }, []); + + return theme; +} + +export type CodeBlockRootProps = CodeBlockProps & { + children: ReactNode; + expanded?: boolean; + defaultExpanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}; + +type CodeBlockSharedState = { + id: string; + code: string; + language: string; + filename?: string; + highlightedHtml: string | null; + isCopied: boolean; + copyCode: () => void; + lineCount: number; + isCollapsed: boolean; + shouldCollapse: boolean; + toggleExpanded: () => void; +}; + +const CodeBlockContext = createContext(null); + +function useCodeBlock(): CodeBlockSharedState { + const context = useContext(CodeBlockContext); + if (!context) { + throw new Error( + "CodeBlock subcomponents must be used within .", + ); + } + return context; +} + +function CodeBlockRoot({ + id, + code, + language = "text", + lineNumbers = "visible", + filename, + highlightLines, + maxCollapsedLines, + className, + children, + expanded: expandedProp, + defaultExpanded = false, + onExpandedChange, +}: CodeBlockRootProps) { + const resolvedTheme = useResolvedTheme(); + const [expandedState, setExpandedState] = useState(defaultExpanded); + const { copiedId, copy } = useCopyToClipboard(); + const isCopied = copiedId === COPY_ID; + + const expanded = expandedProp ?? expandedState; + const setExpanded = useCallback( + (nextExpanded: boolean) => { + if (expandedProp === undefined) { + setExpandedState(nextExpanded); + } + onExpandedChange?.(nextExpanded); + }, + [expandedProp, onExpandedChange], + ); + + const theme = resolvedTheme === "dark" ? "pierre-dark" : "pierre-light"; + const cacheKey = getCacheKey( + code, + language, + theme, + lineNumbers, + highlightLines, + ); + + const [highlightedHtml, setHighlightedHtml] = useState( + () => htmlCache.get(cacheKey) ?? null, + ); + + useEffect(() => { + const cached = htmlCache.get(cacheKey); + if (cached) { + setHighlightedHtml(cached); + return; + } + + let cancelled = false; + const showLineNumbers = lineNumbers === "visible"; + + async function highlight() { + if (!code) { + if (!cancelled) setHighlightedHtml(""); + return; + } + + try { + const highlighter = await getHighlighter(); + const loadedLangs = highlighter.getLoadedLanguages(); + + if (!loadedLangs.includes(language)) { + await highlighter.loadLanguage( + language as Parameters[0], + ); + } + + const lineCount = code.split("\n").length; + const lineNumberWidth = `${String(lineCount).length + 0.5}ch`; + + const html = highlighter.codeToHtml(code, { + lang: language, + theme, + transformers: [ + { + line(node: any, line: number) { + node.properties["data-line"] = line; + if (highlightLines?.includes(line)) { + const highlightBg = + resolvedTheme === "dark" + ? "rgba(255,255,255,0.1)" + : "rgba(0,0,0,0.05)"; + node.properties.style = `background:${highlightBg};`; + } + if (showLineNumbers) { + node.children.unshift({ + type: "element", + tagName: "span", + properties: { + style: `display:inline-block;width:${lineNumberWidth};text-align:right;margin-right:1.5em;user-select:none;opacity:0.5;`, + "aria-hidden": "true", + }, + children: [{ type: "text", value: String(line) }], + }); + } + }, + }, + ], + }); + if (!cancelled) { + setCachedHtml(cacheKey, html); + setHighlightedHtml(html); + } + } catch { + const escaped = code + .replace(/&/g, "&") + .replace(//g, ">"); + if (!cancelled) { + setHighlightedHtml(`
${escaped}
`); + } + } + } + void highlight(); + return () => { + cancelled = true; + }; + }, [ + cacheKey, + code, + language, + lineNumbers, + theme, + highlightLines, + resolvedTheme, + ]); + + const lineCount = code.split("\n").length; + const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines; + const isCollapsed = shouldCollapse && !expanded; + + const copyCode = useCallback(() => { + void copy(code, COPY_ID); + }, [code, copy]); + + const toggleExpanded = useCallback(() => { + setExpanded(!expanded); + }, [expanded, setExpanded]); + + const state: CodeBlockSharedState = { + id, + code, + language, + filename, + highlightedHtml, + isCopied, + copyCode, + lineCount, + shouldCollapse, + isCollapsed, + toggleExpanded, + }; + + return ( + +
+
+ {children} +
+
+
+ ); +} + +export type CodeBlockSectionProps = { + className?: string; +}; + +function CodeBlockHeader({ className }: CodeBlockSectionProps) { + const { language, filename, isCopied, copyCode } = useCodeBlock(); + return ( +
+
+ + {getLanguageDisplayName(language)} + + {filename && ( + <> + + + {filename} + + + )} +
+ +
+ ); +} + +function CodeBlockContent({ className }: CodeBlockSectionProps) { + const { highlightedHtml, isCollapsed } = useCodeBlock(); + return ( +
+ {highlightedHtml && ( +
+ )} +
+ ); +} + +function CodeBlockCollapseToggle({ className }: CodeBlockSectionProps) { + const { shouldCollapse, isCollapsed, toggleExpanded, lineCount } = + useCodeBlock(); + + if (!shouldCollapse) return null; + + return ( + + + + ); +} + +export type CodeBlockComposedProps = Omit; + +function CodeBlockComposed(props: CodeBlockComposedProps) { + return ( + + + + + + ); +} + +type CodeBlockComponent = typeof CodeBlockComposed & { + Root: typeof CodeBlockRoot; + Header: typeof CodeBlockHeader; + Content: typeof CodeBlockContent; + CollapseToggle: typeof CodeBlockCollapseToggle; +}; + +export const CodeBlock = Object.assign(CodeBlockComposed, { + Root: CodeBlockRoot, + Header: CodeBlockHeader, + Content: CodeBlockContent, + CollapseToggle: CodeBlockCollapseToggle, +}) as CodeBlockComponent; diff --git a/frontend/src/toolui/components/code-block/index.tsx b/frontend/src/toolui/components/code-block/index.tsx new file mode 100644 index 00000000..9fd55b2f --- /dev/null +++ b/frontend/src/toolui/components/code-block/index.tsx @@ -0,0 +1,11 @@ +export { CodeBlock } from "./code-block"; +export type { + CodeBlockRootProps, + CodeBlockComposedProps, + CodeBlockSectionProps, +} from "./code-block"; +export type { + CodeBlockProps, + CodeBlockLineNumbersMode, + SerializableCodeBlock, +} from "./schema"; diff --git a/frontend/src/toolui/components/code-block/schema.ts b/frontend/src/toolui/components/code-block/schema.ts new file mode 100644 index 00000000..35c9b85b --- /dev/null +++ b/frontend/src/toolui/components/code-block/schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const CodeBlockPropsSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + code: z.string(), + language: z.string().trim().min(1).default("text"), + lineNumbers: z.enum(["visible", "hidden"]).default("visible"), + filename: z.string().optional(), + highlightLines: z.array(z.number().int().positive()).optional(), + maxCollapsedLines: z.number().min(1).optional(), + className: z.string().optional(), +}); + +export type CodeBlockProps = z.infer; +export type CodeBlockLineNumbersMode = CodeBlockProps["lineNumbers"]; + +export const SerializableCodeBlockSchema = CodeBlockPropsSchema.omit({ + className: true, +}); + +export type SerializableCodeBlock = z.infer; + +const SerializableCodeBlockSchemaContract = defineToolUiContract( + "CodeBlock", + SerializableCodeBlockSchema, +); + +export const parseSerializableCodeBlock: ( + input: unknown, +) => SerializableCodeBlock = SerializableCodeBlockSchemaContract.parse; + +export const safeParseSerializableCodeBlock: ( + input: unknown, +) => SerializableCodeBlock | null = + SerializableCodeBlockSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/code-diff/_adapter.tsx b/frontend/src/toolui/components/code-diff/_adapter.tsx new file mode 100644 index 00000000..f9bae08e --- /dev/null +++ b/frontend/src/toolui/components/code-diff/_adapter.tsx @@ -0,0 +1,14 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn -> Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button -> shadcn/ui Button + * Collapsible -> shadcn/ui Collapsible + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Collapsible, CollapsibleTrigger } from "@toolui/ui/collapsible"; diff --git a/frontend/src/toolui/components/code-diff/code-diff.tsx b/frontend/src/toolui/components/code-diff/code-diff.tsx new file mode 100644 index 00000000..a1d0e96a --- /dev/null +++ b/frontend/src/toolui/components/code-diff/code-diff.tsx @@ -0,0 +1,463 @@ +"use client"; + +import { + useState, + useCallback, + useEffect, + useMemo, + createContext, + useContext, + type ReactNode, +} from "react"; +import { + FileDiff as PierreFileDiff, + PatchDiff as PierrePatchDiff, +} from "@pierre/diffs/react"; +import { parseDiffFromFile, RegisteredCustomThemes } from "@pierre/diffs"; +import type { FileDiffMetadata, ThemesType } from "@pierre/diffs"; +import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react"; +import type { CodeDiffProps } from "./schema"; +import { useCopyToClipboard } from "../shared/use-copy-to-clipboard"; +import { Button, cn, Collapsible, CollapsibleTrigger } from "./_adapter"; + +/* + * Pierre's shared_highlighter registers custom themes with dynamic imports + * (`import("../themes/pierre-dark.js")`) that fail under Turbopack because the + * package `exports` field doesn't include those subpaths. We override the + * RegisteredCustomThemes map entries with loaders that point to local vendored + * theme files in `components/tool-ui/shared`, which Turbopack can resolve. + */ +RegisteredCustomThemes.set("pierre-dark", () => + import("../shared/pierre-dark-theme.js").then((m) => m.default as never), +); +RegisteredCustomThemes.set("pierre-light", () => + import("../shared/pierre-light-theme.js").then((m) => m.default as never), +); + +const COPY_ID = "codediff-code"; + +/* ── Theme detection (mirrors CodeBlock) ────────────────────────── */ + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function getDocumentTheme(): "light" | "dark" | null { + if (typeof document === "undefined") return null; + const root = document.documentElement; + const dataTheme = root.getAttribute("data-theme")?.toLowerCase(); + if (dataTheme === "dark") return "dark"; + if (dataTheme === "light") return "light"; + if (root.classList.contains("dark")) return "dark"; + if (root.classList.contains("light")) return "light"; + return null; +} + +function useResolvedTheme(): "light" | "dark" { + const [theme, setTheme] = useState<"light" | "dark">(() => { + return getDocumentTheme() ?? getSystemTheme(); + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const update = () => setTheme(getDocumentTheme() ?? getSystemTheme()); + + const mql = window.matchMedia?.("(prefers-color-scheme: dark)"); + mql?.addEventListener("change", update); + + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }); + + return () => { + mql?.removeEventListener("change", update); + observer.disconnect(); + }; + }, []); + + return theme; +} + +/* ── Language display names (mirrors CodeBlock) ─────────────────── */ + +const LANGUAGE_DISPLAY_NAMES: Record = { + typescript: "TypeScript", + javascript: "JavaScript", + python: "Python", + tsx: "TSX", + jsx: "JSX", + json: "JSON", + bash: "Bash", + shell: "Shell", + css: "CSS", + html: "HTML", + markdown: "Markdown", + sql: "SQL", + yaml: "YAML", + go: "Go", + rust: "Rust", + text: "Plain Text", +}; + +function getLanguageDisplayName(lang: string): string { + return LANGUAGE_DISPLAY_NAMES[lang.toLowerCase()] || lang.toUpperCase(); +} + +/* ── Shared context ─────────────────────────────────────────────── */ + +type CodeDiffSharedState = { + id: string; + isPatchMode: boolean; + language: string; + lineNumbers: "visible" | "hidden"; + filename?: string; + diffStyle: "unified" | "split"; + copyableCode: string; + isCopied: boolean; + copyCode: () => void; + isCollapsed: boolean; + shouldCollapse: boolean; + toggleExpanded: () => void; + resolvedTheme: "light" | "dark"; + pierreThemes: ThemesType; + fileDiffMetadata: FileDiffMetadata | null; + patch: string | null; + additions: number; + deletions: number; +}; + +const CodeDiffContext = createContext(null); + +function useCodeDiff(): CodeDiffSharedState { + const context = useContext(CodeDiffContext); + if (!context) { + throw new Error( + "CodeDiff subcomponents must be used within .", + ); + } + return context; +} + +/* ── Subcomponents ──────────────────────────────────────────────── */ + +export type CodeDiffRootProps = CodeDiffProps & { + children: ReactNode; + expanded?: boolean; + defaultExpanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}; + +function CodeDiffRoot({ + id, + oldCode, + newCode, + patch, + language = "text", + filename, + lineNumbers = "visible", + diffStyle = "unified", + maxCollapsedLines, + className, + children, + expanded: expandedProp, + defaultExpanded = false, + onExpandedChange, +}: CodeDiffRootProps) { + const resolvedTheme = useResolvedTheme(); + const [expandedState, setExpandedState] = useState(defaultExpanded); + const { copiedId, copy } = useCopyToClipboard(); + const isCopied = copiedId === COPY_ID; + + const expanded = expandedProp ?? expandedState; + const setExpanded = useCallback( + (nextExpanded: boolean) => { + if (expandedProp === undefined) { + setExpandedState(nextExpanded); + } + onExpandedChange?.(nextExpanded); + }, + [expandedProp, onExpandedChange], + ); + + const pierreThemes: ThemesType = { + dark: "pierre-dark", + light: "pierre-light", + }; + + // Auto-detect mode: if `patch` is provided, use patch mode; otherwise files mode + const isPatchMode = !!patch; + + const fileDiffMetadata = useMemo(() => { + if (isPatchMode) return null; + return parseDiffFromFile( + { + name: filename ?? "file", + contents: oldCode ?? "", + lang: language as never, + }, + { + name: filename ?? "file", + contents: newCode ?? "", + lang: language as never, + }, + ); + }, [isPatchMode, oldCode, newCode, filename, language]); + + const copyableCode = isPatchMode ? (patch ?? "") : (newCode ?? oldCode ?? ""); + + const lineCount = useMemo(() => { + if (isPatchMode) { + return (patch ?? "").split("\n").length; + } + if (fileDiffMetadata) { + return fileDiffMetadata.unifiedLineCount; + } + return 0; + }, [isPatchMode, patch, fileDiffMetadata]); + + const { additions, deletions } = useMemo(() => { + if (!isPatchMode && fileDiffMetadata) { + let add = 0; + let del = 0; + for (const hunk of fileDiffMetadata.hunks) { + add += hunk.additionLines; + del += hunk.deletionLines; + } + return { additions: add, deletions: del }; + } + if (isPatchMode && patch) { + let add = 0; + let del = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++ ")) add++; + else if (line.startsWith("-") && !line.startsWith("--- ")) del++; + } + return { additions: add, deletions: del }; + } + return { additions: 0, deletions: 0 }; + }, [isPatchMode, fileDiffMetadata, patch]); + + const shouldCollapse = !!maxCollapsedLines && lineCount > maxCollapsedLines; + const isCollapsed = shouldCollapse && !expanded; + + const copyCode = useCallback(() => { + void copy(copyableCode, COPY_ID); + }, [copyableCode, copy]); + + const toggleExpanded = useCallback(() => { + setExpanded(!expanded); + }, [expanded, setExpanded]); + + const state: CodeDiffSharedState = { + id, + isPatchMode, + language, + lineNumbers, + filename, + diffStyle, + copyableCode, + isCopied, + copyCode, + isCollapsed, + shouldCollapse, + toggleExpanded, + resolvedTheme, + pierreThemes, + fileDiffMetadata, + patch: isPatchMode ? (patch ?? null) : null, + additions, + deletions, + }; + + return ( + +
+
+ {children} +
+
+
+ ); +} + +export type CodeDiffSectionProps = { + className?: string; +}; + +function CodeDiffHeader({ className }: CodeDiffSectionProps) { + const { language, filename, isCopied, copyCode, additions, deletions } = + useCodeDiff(); + const hasChanges = additions > 0 || deletions > 0; + return ( +
+
+ + {getLanguageDisplayName(language)} + + {filename && ( + <> + + + {filename} + + + )} +
+ {hasChanges && ( + + {additions > 0 && ( + +{additions} + )} + {additions > 0 && deletions > 0 && " "} + {deletions > 0 && ( + -{deletions} + )} + + )} + +
+ ); +} + +function CodeDiffContent({ className }: CodeDiffSectionProps) { + const { + isPatchMode, + diffStyle, + lineNumbers, + isCollapsed, + resolvedTheme, + pierreThemes, + fileDiffMetadata, + patch, + } = useCodeDiff(); + + const disableLineNumbers = lineNumbers === "hidden"; + + return ( +
+ {!isPatchMode && fileDiffMetadata && ( + + )} + {isPatchMode && patch && ( + + )} +
+ ); +} + +function CodeDiffCollapseToggle({ className }: CodeDiffSectionProps) { + const { shouldCollapse, isCollapsed, toggleExpanded } = useCodeDiff(); + + if (!shouldCollapse) return null; + + return ( + + + + ); +} + +/* ── Composed preset (callable as a flat component) ─────────────── */ + +export type CodeDiffComposedProps = Omit; + +function CodeDiffComposed(props: CodeDiffComposedProps) { + return ( + + + + + + ); +} + +/* ── Compound export: CodeDiff is callable AND has subcomponents ── */ + +type CodeDiffComponent = typeof CodeDiffComposed & { + Root: typeof CodeDiffRoot; + Header: typeof CodeDiffHeader; + Content: typeof CodeDiffContent; + CollapseToggle: typeof CodeDiffCollapseToggle; +}; + +export const CodeDiff = Object.assign(CodeDiffComposed, { + Root: CodeDiffRoot, + Header: CodeDiffHeader, + Content: CodeDiffContent, + CollapseToggle: CodeDiffCollapseToggle, +}) as CodeDiffComponent; diff --git a/frontend/src/toolui/components/code-diff/index.tsx b/frontend/src/toolui/components/code-diff/index.tsx new file mode 100644 index 00000000..5c39834c --- /dev/null +++ b/frontend/src/toolui/components/code-diff/index.tsx @@ -0,0 +1,7 @@ +export { CodeDiff } from "./code-diff"; +export type { + CodeDiffRootProps, + CodeDiffComposedProps, + CodeDiffSectionProps, +} from "./code-diff"; +export type { CodeDiffProps, SerializableCodeDiff } from "./schema"; diff --git a/frontend/src/toolui/components/code-diff/schema.ts b/frontend/src/toolui/components/code-diff/schema.ts new file mode 100644 index 00000000..a480cded --- /dev/null +++ b/frontend/src/toolui/components/code-diff/schema.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +const CodeDiffPropsSchemaBase = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + oldCode: z.string().optional(), + newCode: z.string().optional(), + patch: z.string().optional(), + language: z.string().trim().min(1).default("text"), + filename: z.string().optional(), + lineNumbers: z.enum(["visible", "hidden"]).default("visible"), + diffStyle: z.enum(["unified", "split"]).default("unified"), + maxCollapsedLines: z.number().min(1).optional(), + className: z.string().optional(), +}); + +function validateCodeDiffInputMode( + data: { patch?: string; oldCode?: string; newCode?: string }, + ctx: z.RefinementCtx, +) { + const hasPatch = !!data.patch; + const hasFiles = !!data.oldCode || !!data.newCode; + + if (!hasPatch && !hasFiles) { + ctx.addIssue({ + code: "custom", + message: + "Provide either a patch string or at least one of oldCode/newCode", + }); + } + + if (hasPatch && hasFiles) { + ctx.addIssue({ + code: "custom", + message: + "Cannot mix patch mode with oldCode/newCode — use one or the other", + }); + } +} + +export const CodeDiffPropsSchema = CodeDiffPropsSchemaBase.superRefine( + validateCodeDiffInputMode, +); + +export type CodeDiffProps = z.infer; + +export const SerializableCodeDiffSchema = CodeDiffPropsSchemaBase.omit({ + className: true, +}).superRefine(validateCodeDiffInputMode); + +export type SerializableCodeDiff = z.infer; + +const SerializableCodeDiffSchemaContract = defineToolUiContract( + "CodeDiff", + SerializableCodeDiffSchema, +); + +export const parseSerializableCodeDiff: ( + input: unknown, +) => SerializableCodeDiff = SerializableCodeDiffSchemaContract.parse; + +export const safeParseSerializableCodeDiff: ( + input: unknown, +) => SerializableCodeDiff | null = SerializableCodeDiffSchemaContract.safeParse; diff --git a/frontend/src/toolui/components/geo-map/README.md b/frontend/src/toolui/components/geo-map/README.md new file mode 100644 index 00000000..ca7990b1 --- /dev/null +++ b/frontend/src/toolui/components/geo-map/README.md @@ -0,0 +1,24 @@ +# Geo Map + +Implementation for the "geo-map" Tool UI surface. + +## Files + +- public exports: components/tool-ui/geo-map/index.tsx +- serializable schema + parse helpers: components/tool-ui/geo-map/schema.ts +- public facade component: components/tool-ui/geo-map/geo-map.tsx +- internal Leaflet engine: components/tool-ui/geo-map/geo-map-engine.tsx +- colocated Leaflet shell theme styles: components/tool-ui/geo-map/geo-map-theme.module.css +- icon construction helpers: components/tool-ui/geo-map/geo-map-icons.ts +- popup/tooltip overlay renderer: components/tool-ui/geo-map/geo-map-overlays.tsx + +## Companion assets + +- Docs page: app/docs/geo-map/content.mdx +- Preset payload: lib/presets/geo-map.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/frontend/src/toolui/components/geo-map/_adapter.tsx b/frontend/src/toolui/components/geo-map/_adapter.tsx new file mode 100644 index 00000000..1d3dd30c --- /dev/null +++ b/frontend/src/toolui/components/geo-map/_adapter.tsx @@ -0,0 +1,23 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Leaflet → map primitives from react-leaflet + */ + +export { cn } from "@toolui/lib/utils"; +export { + CircleMarker, + MapContainer, + Marker, + Polyline, + Popup, + TileLayer, + Tooltip, + ZoomControl, + useMap, + useMapEvents, +} from "react-leaflet"; diff --git a/frontend/src/toolui/components/geo-map/geo-map-engine.tsx b/frontend/src/toolui/components/geo-map/geo-map-engine.tsx new file mode 100644 index 00000000..798beee2 --- /dev/null +++ b/frontend/src/toolui/components/geo-map/geo-map-engine.tsx @@ -0,0 +1,756 @@ +"use client"; + +import type { Map as LeafletMap } from "leaflet"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Supercluster from "supercluster"; +import { + CircleMarker, + MapContainer, + Marker, + Polyline, + TileLayer, + ZoomControl, + useMap, + useMapEvents, +} from "./_adapter"; +import { createClusterIcon, resolveMarkerIcon } from "./geo-map-icons"; +import { GeoMapOverlays } from "./geo-map-overlays"; +import type { + GeoMapClustering, + GeoMapFitTarget, + GeoMapMarker, + GeoMapRoute, + GeoMapViewport, +} from "./schema"; + +const TILE_ATTRIBUTION = + '© OpenStreetMap contributors © CARTO'; +const ROUTE_DEFAULT_COLOR = "var(--primary)"; +const ROUTE_DEFAULT_WEIGHT = 3; +const ROUTE_DEFAULT_OPACITY = 0.85; +const EMPTY_ROUTES: GeoMapRoute[] = []; + +const CLUSTER_RADIUS_DEFAULT = 60; +const CLUSTER_MAX_ZOOM_DEFAULT = 16; +const CLUSTER_MIN_POINTS_DEFAULT = 2; + +const DEFAULT_CENTER: [number, number] = [20, 0]; +export const DEFAULT_VIEW_ZOOM = 2; +const SINGLE_LOCATION_ZOOM = 13; +const DEFAULT_VIEWPORT_PADDING = 32; + +type LeafletRuntime = Pick< + typeof import("leaflet"), + "divIcon" | "latLngBounds" +>; + +export type GeoMapBbox = [ + west: number, + south: number, + east: number, + north: number, +]; +export type GeoMapLatLng = [lat: number, lng: number]; + +export type GeoMapClusterProperties = { + cluster?: boolean; + cluster_id?: number; + point_count?: number; + markerId?: string; +}; + +export type GeoMapClusterFeature = GeoJSON.Feature< + GeoJSON.Point, + GeoMapClusterProperties +>; + +type MarkerClusterPointProperties = GeoMapClusterProperties & { + markerId?: string; + marker?: GeoMapMarker; +}; + +type MapViewportState = { + bbox: GeoMapBbox; + zoom: number; +}; + +function roundCoordinate(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function normalizeViewportState(state: MapViewportState): MapViewportState { + return { + bbox: [ + roundCoordinate(state.bbox[0]), + roundCoordinate(state.bbox[1]), + roundCoordinate(state.bbox[2]), + roundCoordinate(state.bbox[3]), + ], + zoom: state.zoom, + }; +} + +function areViewportStatesEqual( + a: MapViewportState | null, + b: MapViewportState, +): boolean { + if (!a) { + return false; + } + + return ( + a.zoom === b.zoom && + a.bbox[0] === b.bbox[0] && + a.bbox[1] === b.bbox[1] && + a.bbox[2] === b.bbox[2] && + a.bbox[3] === b.bbox[3] + ); +} + +function serializeFitPoints(points: [number, number][]): string { + return points + .map(([lat, lng]) => `${roundCoordinate(lat)},${roundCoordinate(lng)}`) + .join("|"); +} + +function readViewportState(map: LeafletMap): MapViewportState { + const bounds = map.getBounds(); + return normalizeViewportState({ + bbox: [ + bounds.getWest(), + bounds.getSouth(), + bounds.getEast(), + bounds.getNorth(), + ], + zoom: Math.round(map.getZoom()), + }); +} + +export function collectFitPoints( + markers: GeoMapMarker[], + routes: GeoMapRoute[], + target: GeoMapFitTarget, +): GeoMapLatLng[] { + const markerPoints = + target === "markers" || target === "all" + ? markers.map((marker) => [marker.lat, marker.lng] as GeoMapLatLng) + : []; + + const routePoints = + target === "routes" || target === "all" + ? routes.flatMap((route) => + route.points.map((point) => [point.lat, point.lng] as GeoMapLatLng), + ) + : []; + + return [...markerPoints, ...routePoints]; +} + +export function resolveFitPointsWithFallback( + markers: GeoMapMarker[], + routes: GeoMapRoute[], + target: GeoMapFitTarget, +): GeoMapLatLng[] { + const selected = collectFitPoints(markers, routes, target); + if (selected.length > 0) { + return selected; + } + + if (target !== "markers") { + return collectFitPoints(markers, routes, "markers"); + } + + return []; +} + +export function splitDatelineBbox(bbox: GeoMapBbox): GeoMapBbox[] { + const [west, south, east, north] = bbox; + + if (west <= east) { + return [bbox]; + } + + return [ + [west, south, 180, north], + [-180, south, east, north], + ]; +} + +function getClusterFeatureKey(feature: GeoMapClusterFeature): string { + const properties = feature.properties ?? {}; + + if (properties.cluster && typeof properties.cluster_id === "number") { + return `cluster:${properties.cluster_id}`; + } + + if ( + typeof properties.markerId === "string" && + properties.markerId.length > 0 + ) { + return `marker:${properties.markerId}`; + } + + if (feature.id !== undefined && feature.id !== null) { + return `id:${String(feature.id)}`; + } + + const [lng, lat] = feature.geometry.coordinates; + return `point:${lat}:${lng}`; +} + +function dedupeClusterFeatures( + features: GeoMapClusterFeature[], +): GeoMapClusterFeature[] { + const seen = new Set(); + const deduped: GeoMapClusterFeature[] = []; + + features.forEach((feature) => { + const key = getClusterFeatureKey(feature); + if (seen.has(key)) { + return; + } + + seen.add(key); + deduped.push(feature); + }); + + return deduped; +} + +export function getClustersForDatelineAwareBbox( + bbox: GeoMapBbox, + zoom: number, + getClustersForBbox: ( + candidateBbox: GeoMapBbox, + zoom: number, + ) => GeoMapClusterFeature[], +): GeoMapClusterFeature[] { + const queried = splitDatelineBbox(bbox).flatMap((candidateBbox) => + getClustersForBbox(candidateBbox, zoom), + ); + + return dedupeClusterFeatures(queried); +} + +export function toSafeExpansionZoom( + zoom: number, + options?: { minZoom?: number; maxZoom?: number; fallback?: number }, +): number { + const minZoom = options?.minZoom ?? 1; + const maxZoom = options?.maxZoom ?? 22; + const fallback = options?.fallback ?? 2; + + if (!Number.isFinite(zoom)) { + return fallback; + } + + return Math.min(maxZoom, Math.max(minZoom, Math.round(zoom))); +} + +function resolveInitialView( + markers: GeoMapMarker[], + routes: GeoMapRoute[], + viewport: GeoMapViewport | undefined, +): { center: [number, number]; zoom: number } { + if (viewport?.mode === "center") { + return { + center: [viewport.center.lat, viewport.center.lng], + zoom: viewport.zoom, + }; + } + + const fitTarget = viewport?.target ?? "all"; + const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget); + + if (fitPoints.length === 1) { + return { + center: [fitPoints[0][0], fitPoints[0][1]], + zoom: viewport?.maxZoom + ? Math.min(SINGLE_LOCATION_ZOOM, viewport.maxZoom) + : SINGLE_LOCATION_ZOOM, + }; + } + + return { center: DEFAULT_CENTER, zoom: DEFAULT_VIEW_ZOOM }; +} + +function ViewportController({ + markers, + routes, + viewport, + leafletRuntime, +}: { + markers: GeoMapMarker[]; + routes: GeoMapRoute[]; + viewport: GeoMapViewport | undefined; + leafletRuntime: LeafletRuntime; +}) { + const map = useMap(); + const lastAppliedViewportRef = useRef(null); + + useEffect(() => { + lastAppliedViewportRef.current = null; + }, [map]); + + useEffect(() => { + if (viewport?.mode === "center") { + const viewportKey = `center:${roundCoordinate(viewport.center.lat)}:${roundCoordinate(viewport.center.lng)}:${viewport.zoom}`; + if (lastAppliedViewportRef.current === viewportKey) { + return; + } + + lastAppliedViewportRef.current = viewportKey; + map.setView([viewport.center.lat, viewport.center.lng], viewport.zoom); + return; + } + + const fitTarget = viewport?.target ?? "all"; + const fitPoints = resolveFitPointsWithFallback(markers, routes, fitTarget); + if (fitPoints.length === 0) { + return; + } + + const maxZoom = viewport?.maxZoom; + if (fitPoints.length === 1) { + const [lat, lng] = fitPoints[0]; + const zoom = maxZoom + ? Math.min(SINGLE_LOCATION_ZOOM, maxZoom) + : SINGLE_LOCATION_ZOOM; + const viewportKey = `fit-single:${roundCoordinate(lat)}:${roundCoordinate(lng)}:${zoom}`; + if (lastAppliedViewportRef.current === viewportKey) { + return; + } + + lastAppliedViewportRef.current = viewportKey; + map.setView([lat, lng], zoom); + return; + } + + const padding = viewport?.padding ?? DEFAULT_VIEWPORT_PADDING; + const viewportKey = `fit:${fitTarget}:${padding}:${maxZoom ?? "none"}:${serializeFitPoints(fitPoints)}`; + if (lastAppliedViewportRef.current === viewportKey) { + return; + } + + lastAppliedViewportRef.current = viewportKey; + const bounds = leafletRuntime.latLngBounds(fitPoints); + map.fitBounds(bounds, { + maxZoom, + padding: [padding, padding], + }); + }, [leafletRuntime, map, markers, routes, viewport]); + + return null; +} + +function MapObserver({ + onViewportChange, + onMapReady, +}: { + onViewportChange: (state: MapViewportState) => void; + onMapReady: (map: LeafletMap) => void; +}) { + const map = useMapEvents({ + moveend: () => { + onViewportChange(readViewportState(map)); + }, + zoomend: () => { + onViewportChange(readViewportState(map)); + }, + }); + + useEffect(() => { + onMapReady(map); + onViewportChange(readViewportState(map)); + }, [map, onMapReady, onViewportChange]); + + return null; +} + +function resolveMarkerAriaLabel(marker: GeoMapMarker): string { + if (marker.label && marker.description) { + return `${marker.label}. ${marker.description}`; + } + + return ( + marker.label ?? + marker.description ?? + `Marker at ${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}` + ); +} + +export const GeoMapEngine = memo(function GeoMapEngine({ + id, + markers, + routes, + clustering, + viewport, + showZoomControl, + tileUrl, + mapAriaLabel, + tooltipClassName, + popupClassName, + onMarkerClick, + onRouteClick, + onReadyChange, +}: { + id: string; + markers: GeoMapMarker[]; + routes?: GeoMapRoute[]; + clustering?: GeoMapClustering; + viewport?: GeoMapViewport; + showZoomControl: boolean; + tileUrl: string; + mapAriaLabel: string; + tooltipClassName?: string; + popupClassName?: string; + onMarkerClick?: (marker: GeoMapMarker) => void; + onRouteClick?: (route: GeoMapRoute) => void; + onReadyChange?: (isReady: boolean) => void; +}) { + const resolvedRoutes = routes ?? EMPTY_ROUTES; + const [leafletRuntime, setLeafletRuntime] = useState( + null, + ); + const [mapInstance, setMapInstance] = useState(null); + const [viewportState, setViewportState] = useState( + null, + ); + + const handleViewportChange = useCallback((nextState: MapViewportState) => { + const normalized = normalizeViewportState(nextState); + setViewportState((previousState) => + areViewportStatesEqual(previousState, normalized) + ? previousState + : normalized, + ); + }, []); + + useEffect(() => { + let isActive = true; + + void import("leaflet").then((module) => { + if (!isActive) { + return; + } + + setLeafletRuntime({ + divIcon: module.divIcon, + latLngBounds: module.latLngBounds, + }); + }); + + return () => { + isActive = false; + }; + }, []); + + const isReady = leafletRuntime !== null; + + useEffect(() => { + onReadyChange?.(isReady); + }, [isReady, onReadyChange]); + + useEffect(() => { + if (!mapInstance) { + return; + } + + const container = mapInstance.getContainer(); + container.setAttribute("role", "region"); + container.setAttribute("aria-label", mapAriaLabel); + }, [mapAriaLabel, mapInstance]); + + useEffect(() => { + if (!mapInstance) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + mapInstance.closePopup(); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [mapInstance]); + + const initialView = useMemo( + () => resolveInitialView(markers, resolvedRoutes, viewport), + [markers, resolvedRoutes, viewport], + ); + + const markerById = useMemo(() => { + const map = new Map(); + markers.forEach((marker, index) => { + map.set(marker.id ?? `marker-${index}`, marker); + }); + return map; + }, [markers]); + + const clusterConfig = useMemo( + () => ({ + enabled: clustering?.enabled === true, + radius: clustering?.radius ?? CLUSTER_RADIUS_DEFAULT, + maxZoom: clustering?.maxZoom ?? CLUSTER_MAX_ZOOM_DEFAULT, + minPoints: clustering?.minPoints ?? CLUSTER_MIN_POINTS_DEFAULT, + }), + [clustering], + ); + + const clusterIndex = useMemo(() => { + if (!clusterConfig.enabled) { + return null; + } + + const index = new Supercluster({ + radius: clusterConfig.radius, + maxZoom: clusterConfig.maxZoom, + minPoints: clusterConfig.minPoints, + }); + + const points = markers.map((marker, index) => { + const markerId = marker.id ?? `marker-${index}`; + return { + type: "Feature" as const, + id: markerId, + geometry: { + type: "Point" as const, + coordinates: [marker.lng, marker.lat] as [number, number], + }, + properties: { + markerId, + marker, + }, + }; + }); + + index.load(points); + return index; + }, [ + clusterConfig.enabled, + clusterConfig.maxZoom, + clusterConfig.minPoints, + clusterConfig.radius, + markers, + ]); + + const clusteredFeatures = useMemo(() => { + if (!clusterConfig.enabled || !clusterIndex || !viewportState) { + return [] as GeoMapClusterFeature[]; + } + + return getClustersForDatelineAwareBbox( + viewportState.bbox, + viewportState.zoom, + (bbox, zoom) => + clusterIndex.getClusters(bbox, zoom) as GeoMapClusterFeature[], + ); + }, [clusterConfig.enabled, clusterIndex, viewportState]); + + const renderMarker = useCallback( + ( + marker: GeoMapMarker, + markerKey: string, + markerPositionOverride?: [number, number], + ) => { + const markerPosition: [number, number] = markerPositionOverride ?? [ + marker.lat, + marker.lng, + ]; + const tooltipMode = marker.tooltip ?? "hover"; + const tooltipContent = marker.label ?? marker.description; + const icon = marker.icon; + const markerAriaLabel = resolveMarkerAriaLabel(marker); + + if (!leafletRuntime) { + return null; + } + + const leafletIcon = resolveMarkerIcon(icon, leafletRuntime); + if (leafletIcon) { + return ( + onMarkerClick?.(marker), + }} + > + + + ); + } + + const markerStroke = + icon?.type === "dot" + ? (icon.borderColor ?? "var(--border)") + : "var(--border)"; + const markerFill = + icon?.type === "dot" + ? (icon.color ?? "var(--primary)") + : "var(--primary)"; + const markerRadius = icon?.type === "dot" ? (icon.radius ?? 7) : 7; + + return ( + onMarkerClick?.(marker), + }} + > + + + ); + }, + [leafletRuntime, onMarkerClick, popupClassName, tooltipClassName], + ); + + if (!leafletRuntime) { + return null; + } + + return ( + + + {showZoomControl && } + + + + {resolvedRoutes.map((route, routeIndex) => { + const routeKey = route.id ?? `${id}-route-${routeIndex}`; + const positions = route.points.map((point) => [ + point.lat, + point.lng, + ]) as [number, number][]; + const tooltipMode = route.tooltip ?? "hover"; + const tooltipContent = route.label ?? route.description; + + return ( + onRouteClick?.(route), + }} + > + + + ); + })} + + {clusterConfig.enabled && clusterIndex && viewportState + ? clusteredFeatures.map((feature, index) => { + const [lng, lat] = feature.geometry.coordinates; + const properties = (feature.properties ?? + {}) as MarkerClusterPointProperties; + + if ( + properties.cluster && + typeof properties.cluster_id === "number" + ) { + const pointCount = properties.point_count ?? 0; + const clusterId = properties.cluster_id; + const clusterIcon = createClusterIcon(pointCount, leafletRuntime); + const clusterAriaLabel = `Cluster containing ${pointCount} locations`; + + return ( + { + if (!mapInstance) { + return; + } + + const expansionZoom = toSafeExpansionZoom( + clusterIndex.getClusterExpansionZoom(clusterId), + { + maxZoom: 22, + fallback: + (viewportState.zoom ?? DEFAULT_VIEW_ZOOM) + 2, + }, + ); + mapInstance.flyTo([lat, lng], expansionZoom); + }, + }} + /> + ); + } + + const marker = + properties.marker ?? + markerById.get(properties.markerId ?? `marker-${index}`); + if (!marker) { + return null; + } + + const markerKey = + marker.id ?? properties.markerId ?? `${id}-cluster-leaf-${index}`; + return renderMarker(marker, markerKey, [lat, lng]); + }) + : markers.map((marker, index) => + renderMarker(marker, marker.id ?? `${id}-marker-${index}`), + )} + + ); +}); diff --git a/frontend/src/toolui/components/geo-map/geo-map-icons.ts b/frontend/src/toolui/components/geo-map/geo-map-icons.ts new file mode 100644 index 00000000..1c6bfc97 --- /dev/null +++ b/frontend/src/toolui/components/geo-map/geo-map-icons.ts @@ -0,0 +1,131 @@ +import type { DivIcon } from "leaflet"; +import type { GeoMapMarker } from "./schema"; + +type LeafletIconRuntime = Pick; + +function isSafeHttpUrl(value: string | undefined): boolean { + if (!value) { + return false; + } + + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function createEmojiIcon( + icon: Extract, { type: "emoji" }>, + leafletRuntime: LeafletIconRuntime, +): DivIcon { + const size = icon.size ?? 24; + const background = icon.bgColor ?? "var(--card)"; + const border = icon.borderColor ?? "var(--border)"; + + return leafletRuntime.divIcon({ + className: "", + html: `${escapeHtml(icon.value)}`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -Math.round(size / 2)], + tooltipAnchor: [0, -Math.round(size / 2)], + }); +} + +function createImageIcon( + icon: Extract, { type: "image" }>, + leafletRuntime: LeafletIconRuntime, +): DivIcon { + const width = icon.width ?? 28; + const height = icon.height ?? 28; + const borderRadius = icon.borderRadius ?? Math.min(width, height) / 2; + const border = icon.borderColor ?? "var(--border)"; + + return leafletRuntime.divIcon({ + className: "", + html: ``, + iconSize: [width, height], + iconAnchor: [width / 2, height / 2], + popupAnchor: [0, -Math.round(height / 2)], + tooltipAnchor: [0, -Math.round(height / 2)], + }); +} + +export function createClusterIcon( + count: number, + leafletRuntime: LeafletIconRuntime, +): DivIcon { + const size = count >= 100 ? 42 : count >= 10 ? 38 : 34; + const background = "var(--primary)"; + const border = "var(--background)"; + + return leafletRuntime.divIcon({ + className: "", + html: `${count}`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -Math.round(size / 2)], + tooltipAnchor: [0, -Math.round(size / 2)], + }); +} + +export function resolveMarkerIcon( + icon: GeoMapMarker["icon"] | undefined, + leafletRuntime: LeafletIconRuntime, +): DivIcon | null { + if (icon?.type === "emoji") { + return createEmojiIcon(icon, leafletRuntime); + } + + if (icon?.type === "image" && isSafeHttpUrl(icon.url)) { + return createImageIcon(icon, leafletRuntime); + } + + return null; +} diff --git a/frontend/src/toolui/components/geo-map/geo-map-overlays.tsx b/frontend/src/toolui/components/geo-map/geo-map-overlays.tsx new file mode 100644 index 00000000..a9cbe105 --- /dev/null +++ b/frontend/src/toolui/components/geo-map/geo-map-overlays.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { Popup, Tooltip, cn } from "./_adapter"; + +function GeoMapPopupContent({ + label, + description, +}: { + label?: string; + description?: string; +}) { + return ( +
+ {label && ( +

+ {label} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ ); +} + +function GeoMapTooltipContent({ text }: { text: string }) { + return {text}; +} + +export function GeoMapOverlays({ + tooltipMode, + tooltipContent, + label, + description, + tooltipClassName, + popupClassName, +}: { + tooltipMode: "none" | "hover" | "always"; + tooltipContent?: string; + label?: string; + description?: string; + tooltipClassName?: string; + popupClassName?: string; +}) { + const hasPopup = Boolean(label || description); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const shouldRenderTooltip = + tooltipMode !== "none" && tooltipContent && (!hasPopup || !isPopupOpen); + const popupEventHandlers = useMemo( + () => ({ + add: () => setIsPopupOpen(true), + remove: () => setIsPopupOpen(false), + }), + [], + ); + + return ( + <> + {shouldRenderTooltip && ( + + + + )} + {hasPopup && ( + + + + )} + + ); +} diff --git a/frontend/src/toolui/components/geo-map/geo-map-theme.module.css b/frontend/src/toolui/components/geo-map/geo-map-theme.module.css new file mode 100644 index 00000000..833a09e4 --- /dev/null +++ b/frontend/src/toolui/components/geo-map/geo-map-theme.module.css @@ -0,0 +1,216 @@ +.root[data-slot="geo-map"] { + --geo-map-canvas-bg: var(--muted); + --geo-map-tooltip-bg: var(--foreground); + --geo-map-tooltip-fg: var(--background); + --geo-map-tooltip-shadow: 0 8px 20px + oklch(from var(--foreground) l c h / 0.18); + --geo-map-tooltip-radius: calc(var(--radius) - 2px); + --geo-map-tooltip-padding: 0.375rem 0.625rem; + --geo-map-tooltip-font-size: 0.75rem; + --geo-map-tooltip-font-weight: 500; + --geo-map-tooltip-line-height: 1.2; + --geo-map-popup-margin-bottom: 12px; + --geo-map-popup-border: var(--border); + --geo-map-popup-radius: calc(var(--radius) + 2px); + --geo-map-popup-bg: oklch(from var(--popover) l c h / 0.96); + --geo-map-popup-fg: var(--popover-foreground); + --geo-map-popup-shadow: 0 10px 30px oklch(from var(--foreground) l c h / 0.12); + --geo-map-popup-blur: 8px; + --geo-map-popup-content-padding: 0.625rem 0.75rem; + --geo-map-popup-max-width: min(80vw, 18rem); + --geo-map-popup-font-family: var( + --font-sans, + ui-sans-serif, + system-ui, + sans-serif + ); + --geo-map-zoom-bg: oklch(from var(--background) l c h / 0.78); + --geo-map-zoom-fg: var(--foreground); + --geo-map-zoom-border: var(--border); + --geo-map-zoom-hover-bg: oklch(from var(--accent) l c h / 0.82); + --geo-map-zoom-hover-fg: var(--accent-foreground); + --geo-map-zoom-disabled-bg: oklch(from var(--muted) l c h / 0.72); + --geo-map-zoom-disabled-fg: var(--muted-foreground); + --geo-map-zoom-shadow: 0 1px 2px oklch(from var(--foreground) l c h / 0.08); + --geo-map-zoom-focus-ring: var(--ring); + --geo-map-zoom-radius: 0.5rem; + --geo-map-zoom-size: 2.25rem; + --geo-map-zoom-font-size: 1.125rem; +} + +.root[data-slot="geo-map"] :global(.leaflet-container) { + background: var(--geo-map-canvas-bg); +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom) { + border: 1px solid var(--geo-map-zoom-border); + box-shadow: var(--geo-map-zoom-shadow); + background: var(--geo-map-zoom-bg); + backdrop-filter: blur(var(--geo-map-popup-blur)); + -webkit-backdrop-filter: blur(var(--geo-map-popup-blur)); +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom.leaflet-bar) { + border-radius: var(--geo-map-zoom-radius) !important; +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a) { + display: flex; + align-items: center; + justify-content: center; + width: var(--geo-map-zoom-size); + height: var(--geo-map-zoom-size); + line-height: 1; + text-indent: 0; + border: 0; + background: transparent; + color: var(--geo-map-zoom-fg); + font-size: var(--geo-map-zoom-font-size); + font-weight: 500; + box-shadow: none; + cursor: default; + transition: + background-color 150ms ease, + color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease, + opacity 150ms ease; + border-radius: 0 !important; +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a + a) { + border-top: 1px solid var(--geo-map-zoom-border); +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:first-child), +.root[data-slot="geo-map"] + :global(.leaflet-touch .leaflet-control-zoom a:first-child), +.root[data-slot="geo-map"] + :global(.leaflet-control-zoom .leaflet-control-zoom-in) { + border-radius: var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) 0 0 !important; +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:last-child), +.root[data-slot="geo-map"] + :global(.leaflet-touch .leaflet-control-zoom a:last-child), +.root[data-slot="geo-map"] + :global(.leaflet-control-zoom .leaflet-control-zoom-out) { + border-top: 0; + border-radius: 0 0 var(--geo-map-zoom-radius) var(--geo-map-zoom-radius) !important; +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:hover) { + background: var(--geo-map-zoom-hover-bg); + color: var(--geo-map-zoom-hover-fg); +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus), +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a:focus-visible) { + position: relative; + z-index: 1; + outline: 2px solid var(--geo-map-zoom-focus-ring); + outline-offset: 1px; +} + +.root[data-slot="geo-map"] :global(.leaflet-control-zoom a.leaflet-disabled), +.root[data-slot="geo-map"] + :global(.leaflet-control-zoom a.leaflet-disabled:hover) { + background: var(--geo-map-zoom-disabled-bg); + color: var(--geo-map-zoom-disabled-fg); + opacity: 0.55; +} + +.root[data-slot="geo-map"] :global(.leaflet-tooltip.geo-map-tooltip) { + border: 0; + border-radius: var(--geo-map-tooltip-radius); + background: var(--geo-map-tooltip-bg); + color: var(--geo-map-tooltip-fg); + box-shadow: var(--geo-map-tooltip-shadow); + font-size: var(--geo-map-tooltip-font-size); + font-weight: var(--geo-map-tooltip-font-weight); + line-height: var(--geo-map-tooltip-line-height); + padding: var(--geo-map-tooltip-padding); +} + +.root[data-slot="geo-map"] + :global(.leaflet-tooltip-top.geo-map-tooltip::before) { + border-top-color: var(--geo-map-tooltip-bg); +} + +.root[data-slot="geo-map"] + :global(.leaflet-tooltip-bottom.geo-map-tooltip::before) { + border-bottom-color: var(--geo-map-tooltip-bg); +} + +.root[data-slot="geo-map"] + :global(.leaflet-tooltip-left.geo-map-tooltip::before) { + border-left-color: var(--geo-map-tooltip-bg); +} + +.root[data-slot="geo-map"] + :global(.leaflet-tooltip-right.geo-map-tooltip::before) { + border-right-color: var(--geo-map-tooltip-bg); +} + +.root[data-slot="geo-map"] :global(.leaflet-popup.geo-map-popup) { + margin-bottom: var(--geo-map-popup-margin-bottom); +} + +.root[data-slot="geo-map"] + :global(.leaflet-popup.geo-map-popup .leaflet-popup-content-wrapper) { + border: 1px solid var(--geo-map-popup-border); + border-radius: var(--geo-map-popup-radius); + background: var(--geo-map-popup-bg); + color: var(--geo-map-popup-fg); + box-shadow: var(--geo-map-popup-shadow); + backdrop-filter: blur(var(--geo-map-popup-blur)); + -webkit-backdrop-filter: blur(var(--geo-map-popup-blur)); + padding: 0; +} + +.root[data-slot="geo-map"] + :global(.leaflet-popup.geo-map-popup .leaflet-popup-content) { + margin: 0; + min-width: 0; + width: max-content; + max-width: var(--geo-map-popup-max-width); + padding: var(--geo-map-popup-content-padding); + font-family: var(--geo-map-popup-font-family); +} + +.root[data-slot="geo-map"] + :global(.leaflet-popup.geo-map-popup .leaflet-popup-content p) { + margin: 0; +} + +.root[data-slot="geo-map"] + :global(.leaflet-popup.geo-map-popup .leaflet-popup-tip-container) { + display: none; +} + +.root[data-slot="geo-map"] + :global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button) { + color: var(--geo-map-popup-fg); + opacity: 0.75; + top: 0.25rem; + right: 0.25rem; + width: 1.5rem; + height: 1.5rem; + font-size: 1rem; + line-height: 1.5rem; + border-radius: calc(var(--radius) - 2px); +} + +.root[data-slot="geo-map"] + :global(.leaflet-popup.geo-map-popup .leaflet-popup-close-button:hover) { + opacity: 1; + background: oklch(from var(--muted) l c h / 0.65); +} + +.root[data-slot="geo-map"] + :global( + .leaflet-popup.geo-map-popup .leaflet-popup-close-button:focus-visible + ) { + outline: 2px solid var(--ring); + outline-offset: 1px; +} diff --git a/frontend/src/toolui/components/geo-map/geo-map.tsx b/frontend/src/toolui/components/geo-map/geo-map.tsx new file mode 100644 index 00000000..f56084ed --- /dev/null +++ b/frontend/src/toolui/components/geo-map/geo-map.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { memo, useEffect, useState } from "react"; +import { cn } from "./_adapter"; +import { GeoMapEngine } from "./geo-map-engine"; +import styles from "./geo-map-theme.module.css"; +import type { GeoMapProps, GeoMapStyle } from "./schema"; + +const LIGHT_TILE_URL = + "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"; +const DARK_TILE_URL = + "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; + +function getSystemTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function getDocumentTheme(): "light" | "dark" | null { + if (typeof document === "undefined") return null; + + const root = document.documentElement; + const dataTheme = root.getAttribute("data-theme")?.toLowerCase(); + if (dataTheme === "dark") return "dark"; + if (dataTheme === "light") return "light"; + if (root.classList.contains("dark")) return "dark"; + if (root.classList.contains("light")) return "light"; + + return null; +} + +function useInheritedTheme(): "light" | "dark" { + const [theme, setTheme] = useState<"light" | "dark">(() => { + return getDocumentTheme() ?? getSystemTheme(); + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + + const update = () => setTheme(getDocumentTheme() ?? getSystemTheme()); + + const mql = window.matchMedia?.("(prefers-color-scheme: dark)"); + mql?.addEventListener("change", update); + + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }); + + return () => { + mql?.removeEventListener("change", update); + observer.disconnect(); + }; + }, []); + + return theme; +} + +function resolveMapAriaLabel(title?: string, description?: string): string { + if (title && description) { + return `${title}. ${description}`; + } + + return title ?? description ?? "Geographic map"; +} + +export const GeoMap = memo(function GeoMap({ + id, + role: _role, + receipt: _receipt, + title, + description, + markers, + routes, + clustering, + viewport, + showZoomControl = true, + theme, + className, + style, + tooltipClassName, + popupClassName, + onMarkerClick, + onRouteClick, +}: GeoMapProps) { + const inheritedTheme = useInheritedTheme(); + const resolvedTheme = theme ?? inheritedTheme; + const [isMapReady, setIsMapReady] = useState(false); + const tileUrl = resolvedTheme === "dark" ? DARK_TILE_URL : LIGHT_TILE_URL; + const mapAriaLabel = resolveMapAriaLabel(title, description); + const resolvedRootStyle: GeoMapStyle = { + "--geo-map-canvas-bg": + resolvedTheme === "dark" ? "var(--background)" : "var(--muted)", + ...style, + }; + + return ( +
+
+ + + {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ )} + + {!isMapReady && ( +
+ Loading map... +
+ )} +
+
+ ); +}); diff --git a/frontend/src/toolui/components/geo-map/index.tsx b/frontend/src/toolui/components/geo-map/index.tsx new file mode 100644 index 00000000..09455bef --- /dev/null +++ b/frontend/src/toolui/components/geo-map/index.tsx @@ -0,0 +1,16 @@ +import "leaflet/dist/leaflet.css"; +import "./leaflet-overrides.css"; + +export { GeoMap } from "./geo-map"; +export { + type GeoMapClustering, + type GeoMapFitTarget, + type GeoMapMarker, + type GeoMapMarkerIcon, + type GeoMapStyle, + type GeoMapRoute, + type GeoMapViewport, + type GeoMapProps, + type GeoMapClientProps, + type SerializableGeoMap, +} from "./schema"; diff --git a/frontend/src/toolui/components/geo-map/leaflet-overrides.css b/frontend/src/toolui/components/geo-map/leaflet-overrides.css new file mode 100644 index 00000000..f8145c0d --- /dev/null +++ b/frontend/src/toolui/components/geo-map/leaflet-overrides.css @@ -0,0 +1,37 @@ +/* Leaflet overrides for theme integration */ + +.leaflet-container { + background: var(--muted); + /* Isolate Leaflet's high z-indices to prevent them from escaping the container */ + isolation: isolate; +} + +[data-theme="dark"] .leaflet-container { + background: hsl(240 0 10%); +} + +.leaflet-control-attribution { + background: oklch(from var(--background) l c h / 0.8) !important; + color: var(--muted-foreground); +} + +[data-theme="dark"] .leaflet-control-attribution { + background: hsl(240 10% 3.9% / 0.8) !important; + color: hsl(240 5% 64.9%); +} + +.leaflet-control-attribution a { + color: var(--muted-foreground); +} + +[data-theme="dark"] .leaflet-control-attribution a { + color: hsl(240 5% 64.9%); +} + +.leaflet-control-attribution a:hover { + color: var(--foreground); +} + +[data-theme="dark"] .leaflet-control-attribution a:hover { + color: hsl(0 0% 98%); +} diff --git a/frontend/src/toolui/components/geo-map/schema.ts b/frontend/src/toolui/components/geo-map/schema.ts new file mode 100644 index 00000000..b15ca37a --- /dev/null +++ b/frontend/src/toolui/components/geo-map/schema.ts @@ -0,0 +1,198 @@ +import { z } from "zod"; +import type { CSSProperties } from "react"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +const LatitudeSchema = z.number().finite().min(-90).max(90); +const LongitudeSchema = z.number().finite().min(-180).max(180); +const HttpUrlSchema = z + .string() + .url() + .refine((value) => /^https?:\/\//i.test(value), { + message: "Expected an http or https URL.", + }); + +const GeoMapMarkerIconDotSchema = z.object({ + type: z.literal("dot"), + color: z.string().optional(), + borderColor: z.string().optional(), + radius: z.number().min(3).max(16).optional(), +}); + +const GeoMapMarkerIconEmojiSchema = z.object({ + type: z.literal("emoji"), + value: z.string().min(1), + size: z.number().min(16).max(40).optional(), + bgColor: z.string().optional(), + borderColor: z.string().optional(), +}); + +const GeoMapMarkerIconImageSchema = z.object({ + type: z.literal("image"), + url: HttpUrlSchema, + width: z.number().min(16).max(64).optional(), + height: z.number().min(16).max(64).optional(), + borderRadius: z.number().min(0).max(999).optional(), + borderColor: z.string().optional(), +}); + +export const GeoMapMarkerIconSchema = z.union([ + GeoMapMarkerIconDotSchema, + GeoMapMarkerIconEmojiSchema, + GeoMapMarkerIconImageSchema, +]); + +export type GeoMapMarkerIcon = z.infer; + +export const GeoMapMarkerSchema = z.object({ + id: z.string().min(1).optional(), + lat: LatitudeSchema, + lng: LongitudeSchema, + label: z.string().optional(), + description: z.string().optional(), + tooltip: z.enum(["none", "hover", "always"]).optional(), + icon: GeoMapMarkerIconSchema.optional(), +}); + +export type GeoMapMarker = z.infer; + +export const GeoMapRoutePointSchema = z.object({ + lat: LatitudeSchema, + lng: LongitudeSchema, +}); + +export const GeoMapRouteSchema = z.object({ + id: z.string().min(1).optional(), + points: z.array(GeoMapRoutePointSchema).min(2), + label: z.string().optional(), + description: z.string().optional(), + tooltip: z.enum(["none", "hover", "always"]).optional(), + color: z.string().optional(), + weight: z.number().min(1).max(12).optional(), + opacity: z.number().min(0).max(1).optional(), + dashArray: z.string().optional(), +}); + +export type GeoMapRoute = z.infer; + +export const GeoMapClusteringSchema = z.object({ + enabled: z.boolean().optional(), + radius: z.number().min(20).max(120).optional(), + maxZoom: z.number().min(1).max(22).optional(), + minPoints: z.number().min(2).max(20).optional(), +}); + +export type GeoMapClustering = z.infer; + +export const GeoMapFitTargetSchema = z.enum(["markers", "routes", "all"]); +export type GeoMapFitTarget = z.infer; + +const GeoMapFitViewportSchema = z.object({ + mode: z.literal("fit"), + padding: z.number().nonnegative().optional(), + maxZoom: z.number().min(1).max(22).optional(), + target: GeoMapFitTargetSchema.optional(), +}); + +const GeoMapCenterViewportSchema = z.object({ + mode: z.literal("center"), + center: z.object({ + lat: LatitudeSchema, + lng: LongitudeSchema, + }), + zoom: z.number().min(1).max(22), +}); + +export const GeoMapViewportSchema = z.union([ + GeoMapFitViewportSchema, + GeoMapCenterViewportSchema, +]); + +export type GeoMapViewport = z.infer; + +export const GeoMapPropsSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + title: z.string().optional(), + description: z.string().optional(), + markers: z.array(GeoMapMarkerSchema).min(1), + routes: z.array(GeoMapRouteSchema).optional(), + clustering: GeoMapClusteringSchema.optional(), + viewport: GeoMapViewportSchema.optional(), + showZoomControl: z.boolean().optional(), + theme: z.enum(["light", "dark"]).optional(), + }) + .superRefine((value, ctx) => { + const seenMarkerIds = new Set(); + + value.markers.forEach((marker, index) => { + if (!marker.id) { + return; + } + + if (seenMarkerIds.has(marker.id)) { + ctx.addIssue({ + code: "custom", + path: ["markers", index, "id"], + message: `Duplicate marker id "${marker.id}".`, + }); + return; + } + + seenMarkerIds.add(marker.id); + }); + + const seenRouteIds = new Set(); + value.routes?.forEach((route, index) => { + if (!route.id) { + return; + } + + if (seenRouteIds.has(route.id)) { + ctx.addIssue({ + code: "custom", + path: ["routes", index, "id"], + message: `Duplicate route id "${route.id}".`, + }); + return; + } + + seenRouteIds.add(route.id); + }); + }); + +export type GeoMapStyle = CSSProperties & + Partial>; + +export type GeoMapClientProps = { + className?: string; + style?: GeoMapStyle; + tooltipClassName?: string; + popupClassName?: string; + onMarkerClick?: (marker: GeoMapMarker) => void; + onRouteClick?: (route: GeoMapRoute) => void; +}; + +export type GeoMapProps = z.infer & GeoMapClientProps; + +export const SerializableGeoMapSchema = GeoMapPropsSchema; + +export type SerializableGeoMap = z.infer; + +const SerializableGeoMapSchemaContract = defineToolUiContract( + "GeoMap", + SerializableGeoMapSchema, +); + +export const parseSerializableGeoMap: (input: unknown) => SerializableGeoMap = + SerializableGeoMapSchemaContract.parse; + +export const safeParseSerializableGeoMap: ( + input: unknown, +) => SerializableGeoMap | null = SerializableGeoMapSchemaContract.safeParse; diff --git a/frontend/src/toolui/registry.tsx b/frontend/src/toolui/registry.tsx index 34d620ae..06791380 100644 --- a/frontend/src/toolui/registry.tsx +++ b/frontend/src/toolui/registry.tsx @@ -11,6 +11,26 @@ export interface ToolUiEntry { /* Every entry lazy-loads both the component and its zod contract so the chat bundle only pays for components a transcript actually uses. Names mirror upstream tool-ui component slugs. */ export const TOOL_UI_REGISTRY: Record = { + 'audio': { + Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))), + loadSchema: () => import('./components/audio/schema').then((m) => m.SerializableAudioSchema), + }, + 'chart': { + Component: lazy(() => import('./components/chart').then((m) => ({ default: m.Chart }))), + loadSchema: () => import('./components/chart/schema').then((m) => m.SerializableChartSchema), + }, + 'code-block': { + Component: lazy(() => import('./components/code-block').then((m) => ({ default: m.CodeBlock }))), + loadSchema: () => import('./components/code-block/schema').then((m) => m.SerializableCodeBlockSchema), + }, + 'code-diff': { + Component: lazy(() => import('./components/code-diff').then((m) => ({ default: m.CodeDiff }))), + loadSchema: () => import('./components/code-diff/schema').then((m) => m.SerializableCodeDiffSchema), + }, + 'geo-map': { + Component: lazy(() => import('./components/geo-map').then((m) => ({ default: m.GeoMap }))), + loadSchema: () => import('./components/geo-map/schema').then((m) => m.SerializableGeoMapSchema), + }, 'approval-card': { Component: lazy(() => import('./components/approval-card').then((m) => ({ default: m.ApprovalCard }))), loadSchema: () => import('./components/approval-card/schema').then((m) => m.SerializableApprovalCardSchema), diff --git a/frontend/src/toolui/ui/chart.tsx b/frontend/src/toolui/ui/chart.tsx new file mode 100644 index 00000000..07fd5b33 --- /dev/null +++ b/frontend/src/toolui/ui/chart.tsx @@ -0,0 +1,357 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@toolui/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +