From b4e02cdfecfb1d8076cd7312260eb4dd9e48b32e Mon Sep 17 00:00:00 2001 From: Tristan Croiset Date: Wed, 15 Jul 2026 20:12:25 +0200 Subject: [PATCH 1/4] feat: add display_map tool for point maps on OpenStreetMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new display_map agent tool that renders geographic point data from execute_sql results on an interactive MapLibre GL map, using OpenFreeMap (OSM-based vector tiles) as the default basemap, overridable via VITE_MAP_STYLE_URL. - Shared zod schema with query_id reference, lat/lng column keys and optional popup label/tooltip keys; map_type enum ready for a future heatmap mode - Point building/validation lives in @nao/shared (map-points.ts): column keys resolve exact-first then case-insensitively (resolveColumnName, with resolveDataKey moved to shared on top of it), rows with null or blank coordinates are rejected before Number() coercion so no fabricated (0,0) points are plotted, latitudes beyond the Web Mercator limit (85.051129°) are dropped instead of collapsing onto the map edge, and keys resolving to the same column are rejected instead of silently plotting lat=lng - The tool resolves the query_id through getQueryResult (current run, then persisted chat history) and fails with an actionable error when the query is missing, a column does not resolve, or no row carries plottable coordinates; on success it reports plotted and dropped row counts so the model can notice partial data and fix its SQL - Frontend component mirrors display_chart: resolves rows from the sibling execute_sql message part; MapLibre loads in a lazy chunk so the main bundle is unaffected; kept out of collapsed tool groups like charts; rendering is capped at 5000 points with a "showing first N" note - fitBounds wraps across the antimeridian (computeMapBounds), so points at 179°E/179°W fit a 2° span instead of a world-scale view - Popups are built with textContent only so query values cannot inject HTML - Slack, Teams and Telegram cannot render an interactive map, so a completed display_map degrades to a "View interactive map in nao" link card; automations, the MCP ask_nao sub-agent and WhatsApp exclude the tool (shared WEB_CHAT_ONLY_TOOLS list, getTools excludeBuiltinTools option) and the system prompt only advertises the tool when the run's tool set includes it Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + apps/backend/src/agents/tools/display-map.ts | 80 ++++++++ apps/backend/src/agents/tools/index.ts | 28 ++- .../src/components/ai/system-prompt.tsx | 11 + .../components/tool-outputs/display-map.tsx | 20 ++ .../src/components/tool-outputs/index.ts | 1 + .../src/handlers/automation.handler.ts | 3 +- apps/backend/src/mcp/tools/sub-agent.ts | 7 +- apps/backend/src/services/agent.ts | 8 + apps/backend/src/services/slack.ts | 36 +++- apps/backend/src/services/teams.ts | 35 +++- apps/backend/src/services/telegram.ts | 30 ++- apps/backend/src/services/whatsapp.ts | 10 +- apps/backend/src/types/messaging-provider.ts | 2 +- apps/backend/src/utils/messaging-provider.ts | 18 +- apps/backend/tests/agent-tools.test.ts | 37 ++++ apps/backend/tests/display-map-tool.test.ts | 123 ++++++++++++ apps/backend/tests/system-prompt.test.ts | 12 ++ apps/frontend/package.json | 1 + .../components/tool-calls/display-chart.tsx | 15 +- .../tool-calls/display-map-view.tsx | 190 ++++++++++++++++++ .../src/components/tool-calls/display-map.tsx | 108 ++++++++++ .../src/components/tool-calls/index.tsx | 2 + apps/frontend/src/hooks/use-source-query.tsx | 58 ++++++ apps/frontend/src/lib/ai.ts | 3 +- apps/frontend/src/lib/charts.utils.test.ts | 3 +- apps/frontend/src/lib/charts.utils.ts | 15 +- apps/shared/src/index.ts | 1 + apps/shared/src/map-points.ts | 122 +++++++++++ apps/shared/src/tools/display-map.ts | 36 ++++ apps/shared/src/tools/index.ts | 1 + apps/shared/tests/map-points.test.ts | 157 +++++++++++++++ 32 files changed, 1126 insertions(+), 48 deletions(-) create mode 100644 apps/backend/src/agents/tools/display-map.ts create mode 100644 apps/backend/src/components/tool-outputs/display-map.tsx create mode 100644 apps/backend/tests/agent-tools.test.ts create mode 100644 apps/backend/tests/display-map-tool.test.ts create mode 100644 apps/frontend/src/components/tool-calls/display-map-view.tsx create mode 100644 apps/frontend/src/components/tool-calls/display-map.tsx create mode 100644 apps/frontend/src/hooks/use-source-query.tsx create mode 100644 apps/shared/src/map-points.ts create mode 100644 apps/shared/src/tools/display-map.ts create mode 100644 apps/shared/tests/map-points.test.ts diff --git a/.gitignore b/.gitignore index 05cf265a2..59c143d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ apps/backend/projects/ *.code-workspace .cursor/ +*.bun-build diff --git a/apps/backend/src/agents/tools/display-map.ts b/apps/backend/src/agents/tools/display-map.ts new file mode 100644 index 000000000..2ff2fea84 --- /dev/null +++ b/apps/backend/src/agents/tools/display-map.ts @@ -0,0 +1,80 @@ +import { buildMapPoints, MAX_MAP_POINTS, resolveColumnName } from '@nao/shared'; +import { displayMap } from '@nao/shared/tools'; + +import { DisplayMapOutput, renderToModelOutput } from '../../components/tool-outputs'; +import { getQueryResult } from '../../services/query-result.service'; +import { createTool } from '../../utils/tools'; + +export default createTool({ + description: + 'Display an interactive map of geographic data from a previous `execute_sql` tool call. The query result must contain latitude and longitude columns in decimal degrees.', + inputSchema: displayMap.InputSchema, + outputSchema: displayMap.OutputSchema, + + execute: async (input, context) => { + const queryResult = await getQueryResult(context, input.query_id); + if (!queryResult) { + return { + _version: '1', + success: false, + error: `Query result "${input.query_id}" not found. Run \`execute_sql\` first and use the id from its output.`, + }; + } + + const latitudeColumn = resolveColumnName(queryResult.columns, input.latitude_key); + const longitudeColumn = resolveColumnName(queryResult.columns, input.longitude_key); + const missingColumn = [latitudeColumn, longitudeColumn].find((column) => !queryResult.columns.includes(column)); + if (missingColumn) { + return { + _version: '1', + success: false, + error: `Column "${missingColumn}" not found in the query result. Available columns: ${queryResult.columns.join(', ')}.`, + }; + } + if (latitudeColumn === longitudeColumn) { + return { + _version: '1', + success: false, + error: 'latitude_key and longitude_key must reference different columns.', + }; + } + + const points = buildMapPoints(queryResult.data, { + ...input, + latitude_key: latitudeColumn, + longitude_key: longitudeColumn, + }); + if (points.length === 0) { + return { + _version: '1', + success: false, + error: 'The query result contains no rows with valid decimal-degree coordinates, so there is nothing to plot.', + }; + } + + const warnings: string[] = []; + const missingPopupKeys = [input.label_key, ...(input.tooltip_keys ?? [])] + .filter((key): key is string => !!key) + .filter((key) => !queryResult.columns.includes(resolveColumnName(queryResult.columns, key))); + if (missingPopupKeys.length > 0) { + warnings.push( + `Popup column(s) ${missingPopupKeys.map((key) => `"${key}"`).join(', ')} not found in the query result, so popups will not show them. Available columns: ${queryResult.columns.join(', ')}.`, + ); + } + if (points.length > MAX_MAP_POINTS) { + warnings.push( + `The map only renders the first ${MAX_MAP_POINTS} points — mention this to the user, or aggregate in SQL to stay under the limit.`, + ); + } + + return { + _version: '1', + success: true, + point_count: points.length, + dropped_row_count: queryResult.data.length - points.length, + ...(warnings.length > 0 && { warning: warnings.join(' ') }), + }; + }, + + toModelOutput: ({ output }) => renderToModelOutput(DisplayMapOutput({ output }), output), +}); diff --git a/apps/backend/src/agents/tools/index.ts b/apps/backend/src/agents/tools/index.ts index 737037d1a..68ecfaaac 100644 --- a/apps/backend/src/agents/tools/index.ts +++ b/apps/backend/src/agents/tools/index.ts @@ -7,6 +7,7 @@ import { mcpService } from '../../services/mcp'; import { AgentSettings } from '../../types/agent-settings'; import clarification from './clarification'; import displayChart from './display-chart'; +import displayMap from './display-map'; import executePython from './execute-python'; import executeSandboxedCode from './execute-sandboxed-code'; import executeSql from './execute-sql'; @@ -19,10 +20,19 @@ import search from './search'; import story from './story'; import suggestFollowUps from './suggest-follow-ups'; +/** + * Tools whose output only the web chat can render — excluded from automations, MCP sub-agent and + * WhatsApp runs. Slack, Teams and Telegram keep them and degrade to an "open in nao" link card. + * TODO: WhatsApp has no block UI for a link card yet — post a plain-text chat link instead of + * excluding the tool. + */ +export const WEB_CHAT_ONLY_TOOLS = ['display_map']; + export const tools = { story, clarification, display_chart: displayChart, + display_map: displayMap, ...(executePython && { execute_python: executePython }), ...(executeSandboxedCode && { execute_sandboxed_code: executeSandboxedCode }), execute_sql: executeSql, @@ -49,6 +59,12 @@ export const getTools = ( * only discover context, not query the warehouse or render charts. */ builtinToolAllowlist?: string[]; + /** + * Drops these built-in tools from the returned set. Used by runs whose + * surface cannot render a tool's output (e.g. `display_map` outside the + * web chat: automations, MCP sub-agent, WhatsApp). + */ + excludeBuiltinTools?: string[]; } = {}, ) => { const configuredServers = new Set(mcpService.getConfiguredServerNames()); @@ -79,10 +95,18 @@ export const getTools = ( ...extraTools, }; + let result = allTools; if (options.builtinToolAllowlist) { const allowed = new Set([...options.builtinToolAllowlist, ...Object.keys(extraTools ?? {})]); - return Object.fromEntries(Object.entries(allTools).filter(([name]) => allowed.has(name))) as typeof allTools; + result = Object.fromEntries(Object.entries(result).filter(([name]) => allowed.has(name))) as typeof allTools; + } + if (options.excludeBuiltinTools) { + const excluded = new Set(options.excludeBuiltinTools); + const extraToolNames = new Set(Object.keys(extraTools ?? {})); + result = Object.fromEntries( + Object.entries(result).filter(([name]) => !excluded.has(name) || extraToolNames.has(name)), + ) as typeof allTools; } - return allTools; + return result; }; diff --git a/apps/backend/src/components/ai/system-prompt.tsx b/apps/backend/src/components/ai/system-prompt.tsx index 737466432..579ea6d14 100644 --- a/apps/backend/src/components/ai/system-prompt.tsx +++ b/apps/backend/src/components/ai/system-prompt.tsx @@ -22,6 +22,8 @@ type SystemPromptProps = { mcpServers?: string[]; timezone?: string; testMode?: boolean; + /** Names of the tools in the run's tool set — rules for surface-dependent tools (e.g. display_map) are only emitted when the tool is present. Omit to include every rule. */ + toolNames?: string[]; }; export const MEMORY_TOKEN_LIMIT = 1000; @@ -34,7 +36,9 @@ export function SystemPrompt({ mcpServers = [], timezone, testMode, + toolNames, }: SystemPromptProps) { + const hasTool = (name: string) => !toolNames || toolNames.includes(name); const visibleMemories = getMemoriesInTokenRange(memories, MEMORY_TOKEN_LIMIT); const dialectToolCallRules = getDialectToolCallRules(connections); const dialectSqlQueryRules = getDialectSqlQueryRules(connections); @@ -97,6 +101,13 @@ export function SystemPrompt({ Chart Rules + {hasTool('display_map') && ( + + Use display_map instead of display_chart for spatial point data: query results with latitude and + longitude columns (store locations, delivery points, events). Coordinates must be in decimal + degrees — select or convert them in SQL if needed. + + )} For display_chart x_axis_type: use "date" only when x-axis values are parseable by JavaScript Date (e.g. YYYY-MM-DD). Use "category" for quarter labels (quarter_ending), fiscal periods (FY25-Q1), or diff --git a/apps/backend/src/components/tool-outputs/display-map.tsx b/apps/backend/src/components/tool-outputs/display-map.tsx new file mode 100644 index 000000000..f0e5b318d --- /dev/null +++ b/apps/backend/src/components/tool-outputs/display-map.tsx @@ -0,0 +1,20 @@ +import type { displayMap } from '@nao/shared/tools'; + +import { Block } from '../../lib/markdown'; + +export function DisplayMapOutput({ output }: { output: displayMap.Output }) { + if (output.error) { + return Could not display the map: {output.error}; + } + const points = output.point_count != null ? ` with ${output.point_count} point(s)` : ''; + const dropped = output.dropped_row_count + ? ` ${output.dropped_row_count} row(s) were dropped for missing or invalid coordinates — adjust the SQL if they should appear.` + : ''; + const warning = output.warning ? ` Warning: ${output.warning}` : ''; + return ( + + Map displayed successfully{points}.{dropped} + {warning} + + ); +} diff --git a/apps/backend/src/components/tool-outputs/index.ts b/apps/backend/src/components/tool-outputs/index.ts index 30550df3c..7f94c6b5f 100644 --- a/apps/backend/src/components/tool-outputs/index.ts +++ b/apps/backend/src/components/tool-outputs/index.ts @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { renderToMarkdown } from '../../lib/markdown/render-to-markdown'; export { DisplayChartOutput } from './display-chart'; +export { DisplayMapOutput } from './display-map'; export { ExecuteSqlOutput } from './execute-sql'; export { GrepOutput } from './grep'; export { ListOutput } from './list'; diff --git a/apps/backend/src/handlers/automation.handler.ts b/apps/backend/src/handlers/automation.handler.ts index 458f5531f..3f5515840 100644 --- a/apps/backend/src/handlers/automation.handler.ts +++ b/apps/backend/src/handlers/automation.handler.ts @@ -1,7 +1,7 @@ import type { InferUIMessageChunk } from 'ai'; import { getToolName, isToolUIPart, readUIMessageStream } from 'ai'; -import { getTools } from '../agents/tools'; +import { getTools, WEB_CHAT_ONLY_TOOLS } from '../agents/tools'; import { renderAutomationRunPrompt } from '../components/ai/automation-run-prompt'; import type { DBAutomationRun, DBScheduledJob } from '../db/abstractSchema'; import type { AutomationWithSchedule } from '../queries/automation.queries'; @@ -142,6 +142,7 @@ async function finishAutomationRun(automation: AutomationWithSchedule, run: DBAu mcpEnabled: automation.mcpEnabled, mcpServers: automation.mcpServers, excludeFollowUps: true, + excludeBuiltinTools: WEB_CHAT_ONLY_TOOLS, }, ), }, diff --git a/apps/backend/src/mcp/tools/sub-agent.ts b/apps/backend/src/mcp/tools/sub-agent.ts index 6c8752938..dce033f3a 100644 --- a/apps/backend/src/mcp/tools/sub-agent.ts +++ b/apps/backend/src/mcp/tools/sub-agent.ts @@ -2,9 +2,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { type InferUIMessageChunk, readUIMessageStream } from 'ai'; import { z } from 'zod'; +import { WEB_CHAT_ONLY_TOOLS } from '../../agents/tools'; import * as chatQueries from '../../queries/chat.queries'; import * as storyQueries from '../../queries/story.queries'; -import { agentService } from '../../services/agent'; +import { agentService, defaultAgentToolsExcluding } from '../../services/agent'; import { mcpService } from '../../services/mcp'; import { skillService } from '../../services/skill'; import type { UIMessage, UIMessagePart } from '../../types/chat'; @@ -129,7 +130,9 @@ export function registerSubAgentTools(server: McpServer, ctx: McpContext): void const { chat, uiMessages } = await buildChatContext(ctx.projectId, ctx.userId, question, chatId); const naoChatUrl = chatUrl(chat.id); - const agent = await agentService.create(chat); + const agent = await agentService.create(chat, undefined, { + tools: defaultAgentToolsExcluding(WEB_CHAT_ONLY_TOOLS), + }); askNaoRuns.start(chat.id); const runPromise = runAskNaoInBackground(agent, uiMessages, chat.id, naoChatUrl); diff --git a/apps/backend/src/services/agent.ts b/apps/backend/src/services/agent.ts index d84718e51..0898136bc 100644 --- a/apps/backend/src/services/agent.ts +++ b/apps/backend/src/services/agent.ts @@ -114,6 +114,12 @@ export type AgentToolsResolver = (context: AgentToolsContext) => AgentTools | Pr export const defaultAgentTools: AgentToolsResolver = ({ chat, agentSettings, webTools }) => getTools(agentSettings, webTools ?? {}, { testMode: chat.testMode }); +/** Default tool set minus the given built-ins — for runs whose surface cannot render them. */ +export const defaultAgentToolsExcluding = + (excludeBuiltinTools: string[]): AgentToolsResolver => + ({ chat, agentSettings, webTools }) => + getTools(agentSettings, webTools ?? {}, { testMode: chat.testMode, excludeBuiltinTools }); + /** * Admin-mode tool set: the same `execute_sql` tool the chat already uses (it * runs against nao's own app database when `ToolContext.adminMode` is set), @@ -129,6 +135,7 @@ export const adminAgentTools: AgentToolsResolver = ({ chat, agentSettings }) => 'execute_sql', 'read_query_result', 'display_chart', + 'display_map', 'suggest_follow_ups', 'story', 'clarification', @@ -589,6 +596,7 @@ class AgentManager { mcpServers, timezone, testMode: this.chat.testMode, + toolNames: Object.keys(this._agentTools), }), ); const renderedPrompt = provider diff --git a/apps/backend/src/services/slack.ts b/apps/backend/src/services/slack.ts index 519e24be6..2201566a1 100644 --- a/apps/backend/src/services/slack.ts +++ b/apps/backend/src/services/slack.ts @@ -30,6 +30,7 @@ import { createFeedbackModal, createImageBlock, createLiveToolCall, + createMapLinkCard, createStopButtonCard, createSummaryToolCalls, createTextBlock, @@ -665,7 +666,7 @@ class ProjectSlackBot { ctx: ConversationContext, ): Promise { const state: StreamState = { - renderedChartIds: new Set(), + renderedToolCallIds: new Set(), sqlOutputs: new Map(), lastUpdateAt: Date.now(), toolGroup: new Map(), @@ -691,6 +692,8 @@ class ProjectSlackBot { this._handleSqlPart(part, state); } else if (part.type === 'tool-display_chart') { await this._handleChartPart(part, state, ctx); + } else if (part.type === 'tool-display_map') { + await this._handleMapPart(part, state, ctx); } } @@ -725,7 +728,7 @@ class ProjectSlackBot { state: StreamState, ctx: ConversationContext, ): Promise { - if (part.state !== 'output-available' || state.renderedChartIds.has(part.toolCallId)) { + if (part.state !== 'output-available' || state.renderedToolCallIds.has(part.toolCallId)) { return; } if (part.input.chart_type === 'table') { @@ -743,7 +746,7 @@ class ProjectSlackBot { dateFormat: displaySettings.dateFormat, }); const chartId = await chartImageQueries.saveChart(part.toolCallId, png.toString('base64')); - state.renderedChartIds.add(part.toolCallId); + state.renderedToolCallIds.add(part.toolCallId); if (this._config.transportMode === 'socket') { await this._uploadChartImageFile(png, sqlOutput.name, ctx); @@ -763,6 +766,33 @@ class ProjectSlackBot { } } + private async _handleMapPart( + part: Extract, + state: StreamState, + ctx: ConversationContext, + ): Promise { + if ( + part.state !== 'output-available' || + !part.output.success || + state.renderedToolCallIds.has(part.toolCallId) + ) { + return; + } + state.renderedToolCallIds.add(part.toolCallId); + try { + const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); + ctx.textBlockIndex = -1; + ctx.textBlockCount = 0; + ctx.blocks.push(...createMapLinkCard(part.input.title, chatUrl)); + await ctx.convMessage?.edit(Card({ children: ctx.blocks })); + } catch (error) { + logger.error(`Map link card failed: ${String(error)}`, { + source: 'system', + context: { chatId: ctx.chatId, toolCallId: part.toolCallId }, + }); + } + } + private async _uploadChartImageFile(png: Buffer, name: string | null, ctx: ConversationContext): Promise { const [, channelId, threadTs] = ctx.thread.id.split(':'); const filename = name ? `${name.toLowerCase().replace(/\s+/g, '_')}.png` : 'chart.png'; diff --git a/apps/backend/src/services/teams.ts b/apps/backend/src/services/teams.ts index 0ee884c92..bdb03804b 100644 --- a/apps/backend/src/services/teams.ts +++ b/apps/backend/src/services/teams.ts @@ -23,6 +23,7 @@ import { createCompletionCard, createImageBlock, createLiveToolCall, + createMapLinkCard, createStopButtonCard, createSummaryToolCalls, createTextBlock, @@ -291,7 +292,7 @@ class TeamsService { ctx: ConversationContext, ): Promise { const state: StreamState = { - renderedChartIds: new Set(), + renderedToolCallIds: new Set(), sqlOutputs: new Map(), lastUpdateAt: Date.now(), toolGroup: new Map(), @@ -319,6 +320,8 @@ class TeamsService { this._handleSqlPart(part, state); } else if (part.type === 'tool-display_chart') { await this._handleChartPart(part, state, ctx); + } else if (part.type === 'tool-display_map') { + await this._handleMapPart(part, state, ctx); } lastMessage = uiMessage; } @@ -354,7 +357,7 @@ class TeamsService { state: StreamState, ctx: ConversationContext, ): Promise { - if (part.state !== 'output-available' || state.renderedChartIds.has(part.toolCallId)) { + if (part.state !== 'output-available' || state.renderedToolCallIds.has(part.toolCallId)) { return; } if (part.input.chart_type === 'table') { @@ -371,7 +374,7 @@ class TeamsService { data: sqlOutput.rows, dateFormat: displaySettings.dateFormat, }); - state.renderedChartIds.add(part.toolCallId); + state.renderedToolCallIds.add(part.toolCallId); const chartId = await chartImageQueries.saveChart(part.toolCallId, png.toString('base64')); const imageUrl = new URL(`c/${ctx.chatId}/${chartId}.png`, this._redirectUrl).toString(); @@ -386,6 +389,32 @@ class TeamsService { } } + private async _handleMapPart( + part: Extract, + state: StreamState, + ctx: ConversationContext, + ): Promise { + if ( + part.state !== 'output-available' || + !part.output.success || + state.renderedToolCallIds.has(part.toolCallId) + ) { + return; + } + state.renderedToolCallIds.add(part.toolCallId); + try { + const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); + ctx.textBlockIndex = -1; + ctx.blocks.push(...createMapLinkCard(part.input.title, chatUrl)); + await ctx.convMessage?.edit(Card({ children: ctx.blocks })); + } catch (error) { + logger.error(`Map link card failed: ${String(error)}`, { + source: 'system', + context: { chatId: ctx.chatId, toolCallId: part.toolCallId }, + }); + } + } + private async _handleCollapsibleToolPart( part: Extract, state: StreamState, diff --git a/apps/backend/src/services/telegram.ts b/apps/backend/src/services/telegram.ts index 7ac32b66c..a444a9443 100644 --- a/apps/backend/src/services/telegram.ts +++ b/apps/backend/src/services/telegram.ts @@ -19,6 +19,7 @@ import { createPlainTextBlock, createSummaryToolCalls, createTelegramCompletionCard, + createTelegramMapLinkCard, createTelegramStopButtonCard, EXCLUDED_TOOLS, formatMessagingError, @@ -288,7 +289,7 @@ class TelegramService { ctx: ConversationContext, ): Promise { const state: StreamState = { - renderedChartIds: new Set(), + renderedToolCallIds: new Set(), sqlOutputs: new Map(), lastUpdateAt: Date.now(), toolGroup: new Map(), @@ -316,6 +317,8 @@ class TelegramService { this._handleSqlPart(part, state); } else if (part.type === 'tool-display_chart') { await this._handleChartPart(part, state, ctx); + } else if (part.type === 'tool-display_map') { + await this._handleMapPart(part, state, ctx); } lastMessage = uiMessage; } @@ -367,7 +370,7 @@ class TelegramService { state: StreamState, ctx: ConversationContext, ): Promise { - if (part.state !== 'output-available' || state.renderedChartIds.has(part.toolCallId)) { + if (part.state !== 'output-available' || state.renderedToolCallIds.has(part.toolCallId)) { return; } if (part.input.chart_type === 'table') { @@ -384,7 +387,7 @@ class TelegramService { data: sqlOutput.rows, dateFormat: displaySettings.dateFormat, }); - state.renderedChartIds.add(part.toolCallId); + state.renderedToolCallIds.add(part.toolCallId); ctx.textBlockIndex = -1; await ctx.thread.post({ @@ -400,6 +403,27 @@ class TelegramService { } } + private async _handleMapPart( + part: Extract, + state: StreamState, + ctx: ConversationContext, + ): Promise { + if ( + part.state !== 'output-available' || + !part.output.success || + state.renderedToolCallIds.has(part.toolCallId) + ) { + return; + } + state.renderedToolCallIds.add(part.toolCallId); + const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); + ctx.textBlockIndex = -1; + ctx.blocks.push(...createTelegramMapLinkCard(part.input.title, chatUrl)); + if (ctx.convMessage) { + await this._safeEdit(ctx.convMessage, Card({ children: ctx.blocks })); + } + } + private async _handleCollapsibleToolPart( part: Extract, state: StreamState, diff --git a/apps/backend/src/services/whatsapp.ts b/apps/backend/src/services/whatsapp.ts index 148f8f2da..8ac5ee3f7 100644 --- a/apps/backend/src/services/whatsapp.ts +++ b/apps/backend/src/services/whatsapp.ts @@ -6,6 +6,7 @@ import type { LlmSelectedModel } from '@nao/shared/types'; import { InferUIMessageChunk, readUIMessageStream } from 'ai'; import { Attachment, Chat, Message, Thread } from 'chat'; +import { WEB_CHAT_ONLY_TOOLS } from '../agents/tools'; import { generateChartImage } from '../components/generate-chart'; import { env } from '../env'; import * as chartImageQueries from '../queries/chart-image'; @@ -21,7 +22,7 @@ import { createChatTitle } from '../utils/ai'; import { buildImageUrl } from '../utils/image'; import { logger } from '../utils/logger'; import { EXCLUDED_TOOLS, formatMessagingError } from '../utils/messaging-provider'; -import { agentService } from './agent'; +import { agentService, defaultAgentToolsExcluding } from './agent'; import { posthog, PostHogEvent } from './posthog'; import * as transcribeService from './transcribe.service'; @@ -463,6 +464,7 @@ class WhatsappService { const agent = await agentService.create( { ...chat, userId: ctx.user!.id, projectId: this._projectId }, this._modelSelection, + { tools: defaultAgentToolsExcluding(WEB_CHAT_ONLY_TOOLS) }, ); ctx.modelId = agent.getModelId(); return agent.stream(chat.messages, { provider: 'whatsapp', timezone: ctx.timezone, chatUrl }); @@ -473,7 +475,7 @@ class WhatsappService { ctx: ConversationContext, ): Promise<{ finalText: string; chartUrls: string[] }> { const state: StreamState = { - renderedChartIds: new Set(), + renderedToolCallIds: new Set(), sqlOutputs: new Map(), lastUpdateAt: Date.now(), toolGroup: new Map(), @@ -515,7 +517,7 @@ class WhatsappService { state: StreamState, ctx: ConversationContext, ): Promise { - if (part.state !== 'output-available' || state.renderedChartIds.has(part.toolCallId)) { + if (part.state !== 'output-available' || state.renderedToolCallIds.has(part.toolCallId)) { return null; } if (part.input.chart_type === 'table') { @@ -533,7 +535,7 @@ class WhatsappService { dateFormat: displaySettings.dateFormat, }); const chartId = await chartImageQueries.saveChart(part.toolCallId, png.toString('base64')); - state.renderedChartIds.add(part.toolCallId); + state.renderedToolCallIds.add(part.toolCallId); return new URL(`c/${ctx.chatId}/${chartId}.png`, this._redirectUrl).toString(); } catch (error) { logger.error(`Chart image generation failed: ${String(error)}`, { diff --git a/apps/backend/src/types/messaging-provider.ts b/apps/backend/src/types/messaging-provider.ts index 0f77d5426..e241e9c28 100644 --- a/apps/backend/src/types/messaging-provider.ts +++ b/apps/backend/src/types/messaging-provider.ts @@ -28,7 +28,7 @@ export type ToolCallEntry = { }; export type StreamState = { - renderedChartIds: Set; + renderedToolCallIds: Set; sqlOutputs: Map; lastUpdateAt: number; toolGroup: Map; diff --git a/apps/backend/src/utils/messaging-provider.ts b/apps/backend/src/utils/messaging-provider.ts index 2d2a3ac86..6bb7e5e86 100644 --- a/apps/backend/src/utils/messaging-provider.ts +++ b/apps/backend/src/utils/messaging-provider.ts @@ -6,7 +6,12 @@ import { Actions, Button, Card, CardText, Image, LinkButton, Table } from 'chat' import { ToolCallEntry } from '../types/messaging-provider'; import { BudgetExceededError } from './error'; -export const EXCLUDED_TOOLS = ['tool-suggest_follow_ups', 'tool-display_chart', 'tool-clarification']; +export const EXCLUDED_TOOLS = [ + 'tool-suggest_follow_ups', + 'tool-display_chart', + 'tool-display_map', + 'tool-clarification', +]; export const createLiveToolCall = (toolGroup: Map): CardChild => { const parts = [...countToolsByNoun(toolGroup).entries()].map( @@ -139,6 +144,17 @@ export const createImageBlock = (url: string): CardChild => { return Image({ url, alt: 'image' }); }; +/** Interactive maps cannot be rendered by messaging providers, so they degrade to a link to the nao chat. */ +export const createMapLinkCard = (title: string, chatUrl: string): CardChild[] => [ + CardText(`🗺️ **${title}**`), + Actions([LinkButton({ url: chatUrl, label: 'View interactive map in nao' })]), +]; + +export const createTelegramMapLinkCard = (title: string, chatUrl: string): CardChild[] => [ + createPlainTextBlock(`🗺️ ${title}`), + Actions([LinkButton({ url: chatUrl, label: 'View interactive map in nao' })]), +]; + export const createPlainTextBlock = (text: string): CardChild => { return CardText(stripMarkdown(text)); }; diff --git a/apps/backend/tests/agent-tools.test.ts b/apps/backend/tests/agent-tools.test.ts new file mode 100644 index 000000000..a01610a70 --- /dev/null +++ b/apps/backend/tests/agent-tools.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/db/db', () => ({ db: {} })); + +import { getTools } from '../src/agents/tools'; + +describe('getTools excludeBuiltinTools', () => { + it('includes display_map by default', () => { + expect(getTools(null)).toHaveProperty('display_map'); + }); + + it('drops excluded tools and keeps the rest', () => { + const tools = getTools(null, undefined, { excludeBuiltinTools: ['display_map'] }); + expect(tools).not.toHaveProperty('display_map'); + expect(tools).toHaveProperty('display_chart'); + expect(tools).toHaveProperty('execute_sql'); + }); + + it('applies the exclusion after the allowlist', () => { + const tools = getTools(null, undefined, { + builtinToolAllowlist: ['execute_sql', 'display_map'], + excludeBuiltinTools: ['display_map'], + }); + expect(Object.keys(tools)).toEqual(['execute_sql']); + }); + + it('keeps extra tools that are not excluded', () => { + const extra = { my_tool: {} }; + const tools = getTools(null, extra, { excludeBuiltinTools: ['display_map'] }); + expect(tools).toHaveProperty('my_tool'); + }); + + it('never drops extra tools, even when their name is in the exclusion list', () => { + const tools = getTools(null, { display_map: {} }, { excludeBuiltinTools: ['display_map'] }); + expect(tools).toHaveProperty('display_map'); + }); +}); diff --git a/apps/backend/tests/display-map-tool.test.ts b/apps/backend/tests/display-map-tool.test.ts new file mode 100644 index 000000000..8bdb41136 --- /dev/null +++ b/apps/backend/tests/display-map-tool.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/db/db', () => ({ db: {} })); +vi.mock('../src/queries/chat.queries', () => ({ + getQueryResultByQueryId: vi.fn(async () => null), +})); + +import displayMapTool from '../src/agents/tools/display-map'; +import * as chatQueries from '../src/queries/chat.queries'; +import type { QueryResult, ToolContext } from '../src/types/tools'; + +const input = { + query_id: 'q1', + map_type: 'points' as const, + latitude_key: 'lat', + longitude_key: 'lng', + title: 'Test map', +}; + +const rows = [{ lat: 48.85, lng: 2.35 }]; + +function execute(overrides: Partial, queryResults = new Map()) { + const context = { queryResults, chatId: 'chat1' } as unknown as ToolContext; + return displayMapTool.execute!({ ...input, ...overrides }, { + experimental_context: context, + } as Parameters>[1]); +} + +describe('display_map execute', () => { + it('rejects identical latitude and longitude keys', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'lng'], data: rows }]]); + const output = await execute({ longitude_key: 'lat' }, queryResults); + expect(output).toMatchObject({ success: false }); + expect((output as { error?: string }).error).toContain('different columns'); + }); + + it('rejects a query id that is neither in the run context nor in the chat history', async () => { + const output = await execute({}); + expect(output).toMatchObject({ success: false }); + expect((output as { error?: string }).error).toContain('q1'); + }); + + it('resolves query results from the persisted chat history', async () => { + vi.mocked(chatQueries.getQueryResultByQueryId).mockResolvedValueOnce({ + columns: ['lat', 'lng'], + data: rows, + }); + const output = await execute({}); + expect(output).toMatchObject({ success: true }); + }); + + it('succeeds when the keys match the query columns case-insensitively', async () => { + const queryResults = new Map([['q1', { columns: ['LAT', 'LNG'], data: [{ LAT: 48.85, LNG: 2.35 }] }]]); + const output = await execute({}, queryResults); + expect(output).toMatchObject({ success: true }); + }); + + it('rejects a key missing from the query columns and lists the available ones', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'city'], data: rows }]]); + const output = await execute({}, queryResults); + expect(output).toMatchObject({ success: false }); + expect((output as { error?: string }).error).toContain('lng'); + expect((output as { error?: string }).error).toContain('lat, city'); + }); + + it('rejects case variants that resolve to the same column', async () => { + const queryResults = new Map([['q1', { columns: ['LAT'], data: [{ LAT: 48.85 }] }]]); + const output = await execute({ latitude_key: 'lat', longitude_key: 'LAT' }, queryResults); + expect(output).toMatchObject({ success: false }); + }); + + it('accepts genuinely distinct case-variant columns via exact matches', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'LAT'], data: [{ lat: 48.85, LAT: 2.35 }] }]]); + const output = await execute({ latitude_key: 'lat', longitude_key: 'LAT' }, queryResults); + expect(output).toMatchObject({ success: true }); + }); + + it('reports plotted and dropped counts on success', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'lng'], data: [...rows, { lat: null, lng: null }] }]]); + const output = await execute({}, queryResults); + expect(output).toMatchObject({ success: true, point_count: 1, dropped_row_count: 1 }); + }); + + it('warns about popup keys missing from the query columns without failing', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'lng', 'city'], data: rows }]]); + const output = await execute({ label_key: 'cty', tooltip_keys: ['city', 'missing'] }, queryResults); + expect(output).toMatchObject({ success: true }); + expect((output as { warning?: string }).warning).toContain('"cty"'); + expect((output as { warning?: string }).warning).toContain('"missing"'); + expect((output as { warning?: string }).warning).not.toContain('"city"'); + }); + + it('warns when the result exceeds the rendered point cap', async () => { + const manyRows = Array.from({ length: 5001 }, (_, i) => ({ + lat: 40 + (i % 100) / 100, + lng: 2 + (i % 100) / 100, + })); + const queryResults = new Map([['q1', { columns: ['lat', 'lng'], data: manyRows }]]); + const output = await execute({}, queryResults); + expect(output).toMatchObject({ success: true, point_count: 5001 }); + expect((output as { warning?: string }).warning).toContain('first 5000 points'); + }); + + it('omits the warning when popup keys resolve', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'lng', 'CITY'], data: rows }]]); + const output = await execute({ label_key: 'city' }, queryResults); + expect(output).toMatchObject({ success: true }); + expect((output as { warning?: string }).warning).toBeUndefined(); + }); + + it('rejects a query result with no plottable coordinates', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'lng'], data: [{ lat: null, lng: null }] }]]); + const output = await execute({}, queryResults); + expect(output).toMatchObject({ success: false }); + expect((output as { error?: string }).error).toContain('no rows with valid'); + }); + + it('rejects an empty query result', async () => { + const queryResults = new Map([['q1', { columns: ['lat', 'lng'], data: [] }]]); + const output = await execute({}, queryResults); + expect(output).toMatchObject({ success: false }); + }); +}); diff --git a/apps/backend/tests/system-prompt.test.ts b/apps/backend/tests/system-prompt.test.ts index 09305571c..b8262b8df 100644 --- a/apps/backend/tests/system-prompt.test.ts +++ b/apps/backend/tests/system-prompt.test.ts @@ -76,3 +76,15 @@ describe('SystemPrompt timezone rendering', () => { expect(markdown).toContain('Tuesday, March 10, 2026 (UTC)'); }); }); + +describe('SystemPrompt display_map rules', () => { + it('includes the display_map rule by default', () => { + expect(renderToMarkdown(SystemPrompt({}))).toContain('display_map'); + }); + + it('omits the display_map rule when the run excludes the tool', () => { + expect(renderToMarkdown(SystemPrompt({ toolNames: ['execute_sql', 'display_chart'] }))).not.toContain( + 'display_map', + ); + }); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 77495843e..aabdaa782 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -55,6 +55,7 @@ "jszip": "^3.10.1", "katex": "^0.16.45", "lucide-react": "^0.562.0", + "maplibre-gl": "^5.24.0", "posthog-js": "^1.336.4", "prompt-mentions": "^0.0.37", "proxy-from-env": "^1.1.0", diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 7e778b949..d5daea5da 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -1,4 +1,4 @@ -import { buildChart, bucketPieData, buildStoryChartBlock, labelize } from '@nao/shared'; +import { buildChart, bucketPieData, buildStoryChartBlock, labelize, resolveDataKey } from '@nao/shared'; import { appendBlockToStoryCode } from '@nao/shared/story-tabs'; import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -28,7 +28,6 @@ import { SqlResultDisplay } from './sql-result-display'; import { ToolCallWrapper } from './tool-call-wrapper'; import type { ToolCallComponentProps } from '.'; import type { ChartConfig } from '../ui/chart'; -import type { executeSql } from '@nao/shared/tools'; import type { UIMessage } from '@nao/backend/chat'; import type { DateRange } from '@/lib/charts.utils'; import type { DataExportFormat } from '@/components/export-data-menu'; @@ -37,7 +36,6 @@ import { findStoryIds } from '@/lib/story.utils'; import { DATE_RANGE_OPTIONS, filterByDateRange, - resolveDataKey, resolvePieTooltipLabel, sortByDateKey, toKey, @@ -49,8 +47,8 @@ import { useSidePanel } from '@/contexts/side-panel'; import { useToolCallContext } from '@/contexts/tool-call'; import { StoryViewer } from '@/components/side-panel/story-viewer'; import { cn } from '@/lib/utils'; -import { findLatestExecuteSqlInMessages } from '@/lib/execute-sql-messages'; import { ExportDataMenu } from '@/components/export-data-menu'; +import { useSourceQuery } from '@/hooks/use-source-query'; const Colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']; const EMPTY_MESSAGES: UIMessage[] = []; @@ -104,14 +102,7 @@ export const DisplayChartToolCall = ({ const [isEditOpen, setIsEditOpen] = useState(false); const isEditable = Boolean(agent && !agent.isReadonly && !agent.isRunning); - const sourceQuery = useMemo<{ input?: executeSql.Input; output: executeSql.Output } | null>(() => { - if (!chartConfig?.query_id) { - return null; - } - return findLatestExecuteSqlInMessages(messages, chartConfig.query_id); - }, [messages, chartConfig?.query_id]); - - const sourceData = sourceQuery?.output ?? null; + const { sourceQuery, sourceData } = useSourceQuery(chartConfig?.query_id); const sqlQuery = sourceQuery?.input?.sql_query; const handleDownloadPng = async () => { diff --git a/apps/frontend/src/components/tool-calls/display-map-view.tsx b/apps/frontend/src/components/tool-calls/display-map-view.tsx new file mode 100644 index 000000000..36bc51ec1 --- /dev/null +++ b/apps/frontend/src/components/tool-calls/display-map-view.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from 'react'; +import maplibregl from 'maplibre-gl'; +import { computeMapBounds, labelize } from '@nao/shared'; +import type { MapPoint } from '@nao/shared'; +import type { displayMap } from '@nao/shared/tools'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +const MAP_STYLE_URL = import.meta.env.VITE_MAP_STYLE_URL || 'https://tiles.openfreemap.org/styles/positron'; +const POINTS_SOURCE_ID = 'query-points'; +const POINTS_LAYER_ID = 'query-points-circles'; + +interface MapViewProps { + points: MapPoint[]; + config: displayMap.Input; +} + +export default function MapView({ points, config }: MapViewProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const pointsRef = useRef(points); + const configRef = useRef(config); + const [initFailed, setInitFailed] = useState(false); + pointsRef.current = points; + configRef.current = config; + + useEffect(() => { + if (!containerRef.current) { + return; + } + + let map: maplibregl.Map; + try { + map = new maplibregl.Map({ + container: containerRef.current, + style: MAP_STYLE_URL, + cooperativeGestures: true, + attributionControl: { compact: true }, + }); + } catch { + setInitFailed(true); + return; + } + mapRef.current = map; + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); + + map.on('load', () => { + map.addSource(POINTS_SOURCE_ID, { type: 'geojson', data: toGeoJsonPoints(pointsRef.current) }); + map.addLayer({ + id: POINTS_LAYER_ID, + type: 'circle', + source: POINTS_SOURCE_ID, + paint: { + 'circle-radius': 5, + 'circle-color': resolveCssColor('--chart-1', '#2f6f8f'), + 'circle-opacity': 0.9, + 'circle-stroke-width': 2, + 'circle-stroke-color': resolveCssColor('--background', '#ffffff'), + }, + }); + fitToPoints(map, pointsRef.current); + }); + + map.on('click', POINTS_LAYER_ID, (event) => { + const index = event.features?.[0]?.properties?.index; + const point = typeof index === 'number' ? pointsRef.current[index] : undefined; + if (!point) { + return; + } + const content = buildPopupContent(point, configRef.current); + if (!content) { + return; + } + new maplibregl.Popup({ closeButton: false, maxWidth: '280px' }) + .setLngLat([point.longitude, point.latitude]) + .setDOMContent(content) + .addTo(map); + }); + map.on('mouseenter', POINTS_LAYER_ID, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', POINTS_LAYER_ID, () => { + map.getCanvas().style.cursor = ''; + }); + + return () => { + mapRef.current = null; + map.remove(); + }; + }, []); + + useEffect(() => { + const map = mapRef.current; + const source = map?.getSource(POINTS_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + if (!map || !source) { + return; + } + source.setData(toGeoJsonPoints(points)); + fitToPoints(map, points); + }, [points]); + + if (initFailed) { + return ( +
+ Interactive maps are not supported in this browser (WebGL unavailable). +
+ ); + } + + return
; +} + +function toGeoJsonPoints(points: MapPoint[]) { + return { + type: 'FeatureCollection' as const, + features: points.map((point, index) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [point.longitude, point.latitude] }, + properties: { index }, + })), + }; +} + +function fitToPoints(map: maplibregl.Map, points: MapPoint[]) { + const bounds = computeMapBounds(points); + if (!bounds) { + return; + } + map.fitBounds( + [ + [bounds.west, bounds.south], + [bounds.east, bounds.north], + ], + { padding: 48, maxZoom: 14, duration: 0 }, + ); +} + +/** Builds popup DOM with `textContent` only, so untrusted query values can never inject HTML. */ +function buildPopupContent(point: MapPoint, config: displayMap.Input): HTMLElement | null { + const rows: HTMLElement[] = []; + + if (config.label_key && point.row[config.label_key] != null) { + const title = document.createElement('div'); + title.className = 'text-sm font-medium text-neutral-900'; + title.textContent = String(point.row[config.label_key]); + rows.push(title); + } + + for (const key of config.tooltip_keys ?? []) { + if (key === config.label_key || point.row[key] == null) { + continue; + } + const row = document.createElement('div'); + row.className = 'flex justify-between gap-3 text-xs'; + const label = document.createElement('span'); + label.className = 'text-neutral-500'; + label.textContent = labelize(key); + const value = document.createElement('span'); + value.className = 'text-neutral-900 font-medium'; + value.textContent = String(point.row[key]); + row.append(label, value); + rows.push(row); + } + + if (rows.length === 0) { + return null; + } + + const container = document.createElement('div'); + container.className = 'flex flex-col gap-1 font-sans'; + container.append(...rows); + return container; +} + +/** + * Resolves a CSS variable to a color MapLibre can parse (oklch tokens are not supported natively). + * TODO: colors and the basemap style are snapshotted on mount — a light/dark toggle does not + * restyle the map until remount. + */ +function resolveCssColor(variableName: string, fallback: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); + const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true }); + if (!value || !context) { + return fallback; + } + context.fillStyle = fallback; + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + return `rgba(${r}, ${g}, ${b}, ${a / 255})`; +} diff --git a/apps/frontend/src/components/tool-calls/display-map.tsx b/apps/frontend/src/components/tool-calls/display-map.tsx new file mode 100644 index 000000000..c8d628707 --- /dev/null +++ b/apps/frontend/src/components/tool-calls/display-map.tsx @@ -0,0 +1,108 @@ +import { lazy, Suspense, useMemo } from 'react'; +import { Code } from 'lucide-react'; +import { buildMapPoints, MAX_MAP_POINTS, resolveMapConfig } from '@nao/shared'; +import { Skeleton } from '../ui/skeleton'; +import { TextShimmer } from '../ui/text-shimmer'; +import { Button } from '../ui/button'; +import { ToolCallWrapper } from './tool-call-wrapper'; +import type { ToolCallComponentProps } from '.'; +import type { displayMap } from '@nao/shared/tools'; +import type { MapPoint } from '@nao/shared'; +import { useSourceQuery } from '@/hooks/use-source-query'; + +const MapView = lazy(() => import('./display-map-view')); + +export const DisplayMapToolCall = ({ toolPart: { state, input, output } }: ToolCallComponentProps<'display_map'>) => { + const config = state !== 'input-streaming' ? input : undefined; + const { sourceQuery, sourceData, handleViewQuery } = useSourceQuery(config?.query_id); + + const resolvedConfig = useMemo(() => { + if (!config || !sourceData?.data) { + return config; + } + return resolveMapConfig(sourceData.data, config); + }, [config, sourceData?.data]); + + const points = useMemo(() => { + if (!sourceData?.data || !resolvedConfig) { + return []; + } + return buildMapPoints(sourceData.data, resolvedConfig); + }, [sourceData?.data, resolvedConfig]); + + const visiblePoints = useMemo(() => { + return points.slice(0, MAX_MAP_POINTS); + }, [points]); + + if (output && output.error) { + return ( + +
{output.error}
+
+ ); + } + + if (!config) { + return ( +
+ + + + +
+ ); + } + + if (!sourceData) { + return ( +
+ Could not display the map because the data is missing. +
+ ); + } + + const mapConfig = resolvedConfig ?? config; + + if (mapConfig.latitude_key === mapConfig.longitude_key) { + return ( +
+ Could not display the map because the latitude and longitude keys resolve to the same column. +
+ ); + } + + if (points.length === 0) { + return ( +
+ Could not display the map because no rows contain valid coordinates. +
+ ); + } + + return ( +
+
+ {config.title} + {sourceQuery?.input && ( + + )} +
+ }> + + + {points.length > MAX_MAP_POINTS && ( + + Showing the first {MAX_MAP_POINTS.toLocaleString()} of {points.length.toLocaleString()} points. + + )} +
+ ); +}; diff --git a/apps/frontend/src/components/tool-calls/index.tsx b/apps/frontend/src/components/tool-calls/index.tsx index 6fd5be5f5..879d070cc 100644 --- a/apps/frontend/src/components/tool-calls/index.tsx +++ b/apps/frontend/src/components/tool-calls/index.tsx @@ -3,6 +3,7 @@ import { StoryToolCall } from './story'; import { ClarificationToolCall } from './clarification'; import { DefaultToolCall } from './default'; import { DisplayChartToolCall } from './display-chart'; +import { DisplayMapToolCall } from './display-map'; import { ExecutePythonToolCall } from './execute-python'; import { ExecuteSandboxedCodeToolCall } from './execute-sandboxed-code'; import { ExecuteSqlToolCall } from './execute-sql'; @@ -31,6 +32,7 @@ const toolComponents: Partial<{ story: StoryToolCall, clarification: ClarificationToolCall, display_chart: DisplayChartToolCall, + display_map: DisplayMapToolCall, execute_python: ExecutePythonToolCall, execute_sandboxed_code: ExecuteSandboxedCodeToolCall, execute_sql: ExecuteSqlToolCall, diff --git a/apps/frontend/src/hooks/use-source-query.tsx b/apps/frontend/src/hooks/use-source-query.tsx new file mode 100644 index 000000000..0fabcb874 --- /dev/null +++ b/apps/frontend/src/hooks/use-source-query.tsx @@ -0,0 +1,58 @@ +import { useCallback, useMemo, useRef } from 'react'; +import type { executeSql } from '@nao/shared/tools'; +import type { UIMessage } from '@nao/backend/chat'; +import { useOptionalAgentContext } from '@/contexts/agent.provider'; +import { useSidePanel } from '@/contexts/side-panel'; +import { SidePanelContent } from '@/components/side-panel/sql-editor'; +import { findLatestExecuteSqlInMessages } from '@/lib/execute-sql-messages'; + +const EMPTY_MESSAGES: UIMessage[] = []; + +export type SourceQuery = { input?: executeSql.Input; output: executeSql.Output }; + +/** + * Finds the `execute_sql` tool call a visualization references. The hit is cached in a ref — + * tool outputs are immutable once emitted — so streaming re-renders skip the message scan + * and keep a stable object identity. + */ +export function useSourceQuery(queryId: string | undefined): { + sourceQuery: SourceQuery | null; + sourceData: executeSql.Output | null; + handleViewQuery: () => void; +} { + const agent = useOptionalAgentContext(); + const messages = agent?.messages ?? EMPTY_MESSAGES; + const { open: openSidePanel } = useSidePanel(); + const foundRef = useRef<{ queryId: string; result: SourceQuery } | null>(null); + + const sourceQuery = useMemo(() => { + if (!queryId) { + return null; + } + if (foundRef.current?.queryId === queryId) { + return foundRef.current.result; + } + for (const message of messages) { + for (const part of message.parts) { + if (part.type === 'tool-execute_sql' && part.output && part.output.id === queryId) { + const result = { input: part.input, output: part.output }; + foundRef.current = { queryId, result }; + return result; + } + } + } + if (!queryId) { + return null; + } + return findLatestExecuteSqlInMessages(messages, queryId); + }, [messages, queryId]); + + const handleViewQuery = useCallback(() => { + if (!sourceQuery?.input || !sourceQuery.output) { + return; + } + openSidePanel(); + }, [openSidePanel, sourceQuery]); + + return { sourceQuery, sourceData: sourceQuery?.output ?? null, handleViewQuery }; +} diff --git a/apps/frontend/src/lib/ai.ts b/apps/frontend/src/lib/ai.ts index 2df9de7c5..369ab3572 100644 --- a/apps/frontend/src/lib/ai.ts +++ b/apps/frontend/src/lib/ai.ts @@ -73,13 +73,14 @@ export const checkIsAgentRunning = (agent: Pick, 'stat /** Tools that should NOT be collapsed (important UI elements), per density setting. */ const NON_COLLAPSIBLE_TOOLS_BY_DENSITY: Record = { - compact: ['story', 'display_chart', 'suggest_follow_ups', 'clarification'], + compact: ['story', 'display_chart', 'display_map', 'suggest_follow_ups', 'clarification'], detailed: [ 'story', 'execute_sql', 'query_app_db', 'record_recommendation', 'display_chart', + 'display_map', 'suggest_follow_ups', 'clarification', 'execute_python', diff --git a/apps/frontend/src/lib/charts.utils.test.ts b/apps/frontend/src/lib/charts.utils.test.ts index cac8c5037..3158734d7 100644 --- a/apps/frontend/src/lib/charts.utils.test.ts +++ b/apps/frontend/src/lib/charts.utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { alignChartDataToBaselineX, resolveDataKey, resolvePieTooltipLabel } from './charts.utils'; +import { resolveDataKey } from '@nao/shared'; +import { alignChartDataToBaselineX, resolvePieTooltipLabel } from './charts.utils'; describe('resolveDataKey', () => { it('returns an empty string when the key is undefined (kpi cards have no x-axis)', () => { diff --git a/apps/frontend/src/lib/charts.utils.ts b/apps/frontend/src/lib/charts.utils.ts index c59d30d5c..18d70a642 100644 --- a/apps/frontend/src/lib/charts.utils.ts +++ b/apps/frontend/src/lib/charts.utils.ts @@ -1,3 +1,4 @@ +import { resolveDataKey } from '@nao/shared'; import { getToolName, isToolUIPart } from './ai'; import { hashValue } from './hash'; import type { UIMessage } from '@nao/backend/chat'; @@ -101,20 +102,6 @@ export function resolvePieTooltipLabel(payload?: readonly { name?: unknown }[]): return name == null ? '' : String(name); } -/** Resolves a config key to the matching key in the data, ignoring case. Falls back to the original key. */ -export function resolveDataKey(data: Record[], key: string | undefined): string { - if (key === undefined) { - return ''; - } - const row = data[0]; - if (!row || key in row) { - return key; - } - const lower = key.toLowerCase(); - const match = Object.keys(row).find((dataKey) => dataKey.toLowerCase() === lower); - return match ?? key; -} - /** * Keeps the baseline x-axis categories when filtered SQL drops rows, zero-filling * series values for missing categories so bars/lines animate in place instead of diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 85e17bb77..08d15d42d 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './chart-domain'; export * from './citation'; export * from './date'; export * from './execute-sql-parts'; +export * from './map-points'; export * from './mcp'; export * from './mcp-embed'; export * from './mentions'; diff --git a/apps/shared/src/map-points.ts b/apps/shared/src/map-points.ts new file mode 100644 index 000000000..9d1d30a6f --- /dev/null +++ b/apps/shared/src/map-points.ts @@ -0,0 +1,122 @@ +import type * as displayMap from './tools/display-map'; + +export interface MapPoint { + latitude: number; + longitude: number; + row: Record; +} + +export interface MapBounds { + west: number; + south: number; + east: number; + north: number; +} + +/** Web Mercator clamps latitudes beyond this, so points above it would all collapse onto the map edge. */ +export const MERCATOR_MAX_LATITUDE = 85.051129; + +/** The map UI renders at most this many points; the tool warns the model when a result exceeds it. */ +export const MAX_MAP_POINTS = 5000; + +/** Resolves a key to the matching column name, preferring an exact match before case-insensitive lookup. Falls back to the original key. */ +export function resolveColumnName(columns: string[], key: string): string { + if (columns.includes(key)) { + return key; + } + const lower = key.toLowerCase(); + const match = columns.find((column) => column.toLowerCase() === lower); + return match ?? key; +} + +/** Resolves a config key to the matching key in the data, ignoring case. Falls back to the original key. */ +export function resolveDataKey(data: Record[], key: string | undefined): string { + if (key === undefined) { + return ''; + } + const row = data[0]; + if (!row) { + return key; + } + return resolveColumnName(Object.keys(row), key); +} + +/** Resolves the configured column keys against the actual query result columns, ignoring case. */ +export function resolveMapConfig(rows: Record[], config: displayMap.Input): displayMap.Input { + return { + ...config, + latitude_key: resolveDataKey(rows, config.latitude_key), + longitude_key: resolveDataKey(rows, config.longitude_key), + label_key: config.label_key && resolveDataKey(rows, config.label_key), + tooltip_keys: config.tooltip_keys?.map((key) => resolveDataKey(rows, key)), + }; +} + +export function buildMapPoints(rows: Record[], config: displayMap.Input): MapPoint[] { + return rows + .map((row) => ({ + latitude: parseCoordinate(row[config.latitude_key]), + longitude: parseCoordinate(row[config.longitude_key]), + row, + })) + .filter( + (point): point is MapPoint => + point.latitude !== null && + point.longitude !== null && + Math.abs(point.latitude) <= MERCATOR_MAX_LATITUDE && + Math.abs(point.longitude) <= 180, + ); +} + +/** + * Computes the smallest bounds covering the points. Longitudes wrap at the + * antimeridian: for points at 179° and -179° this returns [179, 181] (a 2° + * span, which map libraries accept) instead of the naive [-179, 179]. + */ +export function computeMapBounds(points: MapPoint[]): MapBounds | null { + if (points.length === 0) { + return null; + } + + let south = Infinity; + let north = -Infinity; + for (const point of points) { + south = Math.min(south, point.latitude); + north = Math.max(north, point.latitude); + } + + const longitudes = [...new Set(points.map((point) => point.longitude))].sort((a, b) => a - b); + if (longitudes.length === 1) { + return { west: longitudes[0], south, east: longitudes[0], north }; + } + + let largestGap = longitudes[0] + 360 - longitudes[longitudes.length - 1]; + let largestGapIndex = longitudes.length - 1; + for (let i = 0; i < longitudes.length - 1; i++) { + const gap = longitudes[i + 1] - longitudes[i]; + if (gap > largestGap) { + largestGap = gap; + largestGapIndex = i; + } + } + + const crossesAntimeridian = largestGapIndex !== longitudes.length - 1; + const west = longitudes[(largestGapIndex + 1) % longitudes.length]; + const east = longitudes[largestGapIndex] + (crossesAntimeridian ? 360 : 0); + return { west, south, east, north }; +} + +/** + * Accepts only numbers and decimal-number strings. A blanket `Number()` coercion would turn + * null/blank into 0 (fabricated points at (0,0)), booleans into 0/1 and dates into timestamps. + */ +function parseCoordinate(value: unknown): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} diff --git a/apps/shared/src/tools/display-map.ts b/apps/shared/src/tools/display-map.ts new file mode 100644 index 000000000..57b7416f5 --- /dev/null +++ b/apps/shared/src/tools/display-map.ts @@ -0,0 +1,36 @@ +import z from 'zod/v3'; + +export const MapTypeEnum = z.enum(['points']); + +export const InputSchema = z.object({ + query_id: z.string().describe("The id of a previous `execute_sql` tool call's output to get data from."), + map_type: MapTypeEnum.describe('Type of map visualization to display.'), + latitude_key: z.string().describe('Column name containing the latitude in WGS84 decimal degrees.'), + longitude_key: z.string().describe('Column name containing the longitude in WGS84 decimal degrees.'), + label_key: z + .string() + .optional() + .describe('Column name used as the title of the popup shown when a point is clicked.'), + tooltip_keys: z + .array(z.string()) + .optional() + .describe('Additional column names to display as label/value rows in the point popup.'), + title: z + .string() + .describe( + 'A concise and descriptive title of what the map shows. Do not include the type of map in the title or other map configurations.', + ), +}); + +export const OutputSchema = z.object({ + _version: z.literal('1').optional(), + success: z.boolean(), + error: z.string().optional(), + warning: z.string().optional(), + point_count: z.number().optional(), + dropped_row_count: z.number().optional(), +}); + +export type MapType = z.infer; +export type Input = z.infer; +export type Output = z.infer; diff --git a/apps/shared/src/tools/index.ts b/apps/shared/src/tools/index.ts index 2209340ea..1c859a5ba 100644 --- a/apps/shared/src/tools/index.ts +++ b/apps/shared/src/tools/index.ts @@ -1,5 +1,6 @@ export * as clarification from './clarification'; export * as displayChart from './display-chart'; +export * as displayMap from './display-map'; export * as executePython from './execute-python'; export * as executeSandboxedCode from './execute-sandboxed-code'; export * as executeSql from './execute-sql'; diff --git a/apps/shared/tests/map-points.test.ts b/apps/shared/tests/map-points.test.ts new file mode 100644 index 000000000..99961c472 --- /dev/null +++ b/apps/shared/tests/map-points.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMapPoints, computeMapBounds, resolveColumnName, resolveMapConfig } from '../src/map-points'; +import type * as displayMap from '../src/tools/display-map'; + +const config: displayMap.Input = { + query_id: 'q1', + map_type: 'points', + latitude_key: 'lat', + longitude_key: 'lng', + title: 'Test map', +}; + +describe('buildMapPoints', () => { + it('builds points from numeric coordinates', () => { + const rows = [{ lat: 48.85, lng: 2.35 }]; + expect(buildMapPoints(rows, config)).toEqual([{ latitude: 48.85, longitude: 2.35, row: rows[0] }]); + }); + + it('parses string coordinates', () => { + const points = buildMapPoints([{ lat: '48.85', lng: '2.35' }], config); + expect(points).toEqual([expect.objectContaining({ latitude: 48.85, longitude: 2.35 })]); + }); + + it('drops rows with null, empty or blank coordinates instead of coercing them to 0', () => { + const rows = [ + { lat: null, lng: 2.35 }, + { lat: 48.85, lng: undefined }, + { lat: '', lng: 2.35 }, + { lat: 48.85, lng: ' ' }, + ]; + expect(buildMapPoints(rows, config)).toEqual([]); + }); + + it('keeps legitimate zero coordinates', () => { + const points = buildMapPoints([{ lat: 0, lng: 0 }], config); + expect(points).toEqual([expect.objectContaining({ latitude: 0, longitude: 0 })]); + }); + + it('drops non-numeric coordinates', () => { + expect(buildMapPoints([{ lat: 'Paris', lng: 2.35 }], config)).toEqual([]); + }); + + it('drops coercible non-numeric types instead of plotting them', () => { + const rows = [ + { lat: true, lng: false }, + { lat: new Date('2026-01-01'), lng: 2.35 }, + { lat: [48.85], lng: 2.35 }, + ]; + expect(buildMapPoints(rows, config)).toEqual([]); + }); + + it('drops out-of-range coordinates', () => { + const rows = [ + { lat: 91, lng: 2.35 }, + { lat: -90.5, lng: 2.35 }, + { lat: 48.85, lng: 180.1 }, + { lat: 48.85, lng: -181 }, + ]; + expect(buildMapPoints(rows, config)).toEqual([]); + }); + + it('drops latitudes beyond the Web Mercator limit, which would collapse onto the map edge', () => { + const rows = [ + { lat: 86, lng: 2.35 }, + { lat: -90, lng: 2.35 }, + ]; + expect(buildMapPoints(rows, config)).toEqual([]); + }); + + it('keeps boundary coordinates', () => { + const rows = [{ lat: 85.051129, lng: -180 }]; + expect(buildMapPoints(rows, config)).toHaveLength(1); + }); +}); + +describe('resolveColumnName', () => { + it('prefers an exact match over a case-insensitive one', () => { + expect(resolveColumnName(['Lat', 'lat'], 'lat')).toBe('lat'); + expect(resolveColumnName(['lat', 'LAT'], 'LAT')).toBe('LAT'); + }); + + it('falls back to a case-insensitive match, then to the original key', () => { + expect(resolveColumnName(['LAT'], 'lat')).toBe('LAT'); + expect(resolveColumnName(['city'], 'lat')).toBe('lat'); + }); +}); + +describe('resolveMapConfig', () => { + it('resolves configured keys against uppercase result columns', () => { + const rows = [{ LAT: 48.85, LNG: 2.35, CITY: 'Paris', COUNT: 3 }]; + const resolved = resolveMapConfig(rows, { + ...config, + label_key: 'city', + tooltip_keys: ['count'], + }); + expect(resolved.latitude_key).toBe('LAT'); + expect(resolved.longitude_key).toBe('LNG'); + expect(resolved.label_key).toBe('CITY'); + expect(resolved.tooltip_keys).toEqual(['COUNT']); + }); + + it('keeps keys unchanged when they match or when no column matches', () => { + const rows = [{ lat: 1, lng: 2 }]; + const resolved = resolveMapConfig(rows, { ...config, label_key: 'missing' }); + expect(resolved.latitude_key).toBe('lat'); + expect(resolved.label_key).toBe('missing'); + }); + + it('resolves case variants of the same column to one key, so callers can detect the collision', () => { + const rows = [{ LATITUDE: 48.85, city: 'Paris' }]; + const resolved = resolveMapConfig(rows, { + ...config, + latitude_key: 'latitude', + longitude_key: 'LATITUDE', + }); + expect(resolved.latitude_key).toBe('LATITUDE'); + expect(resolved.longitude_key).toBe('LATITUDE'); + }); + + it('resolves case-insensitive keys end to end with buildMapPoints', () => { + const rows = [{ LATITUDE: 48.85, LONGITUDE: 2.35 }]; + const resolved = resolveMapConfig(rows, { + ...config, + latitude_key: 'latitude', + longitude_key: 'longitude', + }); + expect(buildMapPoints(rows, resolved)).toHaveLength(1); + }); +}); + +describe('computeMapBounds', () => { + const point = (latitude: number, longitude: number) => ({ latitude, longitude, row: {} }); + + it('returns null for no points', () => { + expect(computeMapBounds([])).toBeNull(); + }); + + it('returns a degenerate box for a single point', () => { + expect(computeMapBounds([point(48.85, 2.35)])).toEqual({ west: 2.35, south: 48.85, east: 2.35, north: 48.85 }); + }); + + it('returns plain min/max bounds for points on one side of the antimeridian', () => { + const bounds = computeMapBounds([point(48.85, 2.35), point(52.5, 13.4), point(40.4, -3.7)]); + expect(bounds).toEqual({ west: -3.7, south: 40.4, east: 13.4, north: 52.5 }); + }); + + it('wraps across the antimeridian instead of spanning the whole world', () => { + const bounds = computeMapBounds([point(-17, 179), point(-18, -179)]); + expect(bounds).toEqual({ west: 179, south: -18, east: 181, north: -17 }); + }); + + it('keeps the wrapped interval minimal with several points near the antimeridian', () => { + const bounds = computeMapBounds([point(60, 170), point(62, 179), point(61, -170)]); + expect(bounds).toEqual({ west: 170, south: 60, east: 190, north: 62 }); + }); +}); From d410b385fc3da5f9d2e44d86da19f473faa19f3a Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Thu, 23 Jul 2026 15:07:17 +0200 Subject: [PATCH 2/4] add theme handler + include display_map tool in whatsapp --- .gitignore | 1 - apps/backend/src/agents/tools/index.ts | 29 +++-- apps/backend/src/services/whatsapp.ts | 39 ++++-- apps/backend/src/trpc/project.routes.ts | 2 + apps/backend/src/types/agent-settings.ts | 1 + apps/backend/src/utils/messaging-provider.ts | 4 + apps/backend/tests/agent-tools.test.ts | 37 ------ .../src/components/settings-search-index.ts | 10 ++ .../src/components/settings/experimental.tsx | 22 ++++ .../tool-calls/display-map-view.tsx | 98 ++++++++++----- .../src/components/tool-calls/display-map.tsx | 115 ++++++++++++++---- apps/frontend/src/hooks/use-source-query.tsx | 15 +-- apps/frontend/src/styles.css | 65 ++++++++++ 13 files changed, 318 insertions(+), 120 deletions(-) delete mode 100644 apps/backend/tests/agent-tools.test.ts diff --git a/.gitignore b/.gitignore index 59c143d5e..05cf265a2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,3 @@ apps/backend/projects/ *.code-workspace .cursor/ -*.bun-build diff --git a/apps/backend/src/agents/tools/index.ts b/apps/backend/src/agents/tools/index.ts index 68ecfaaac..6562963ae 100644 --- a/apps/backend/src/agents/tools/index.ts +++ b/apps/backend/src/agents/tools/index.ts @@ -21,10 +21,9 @@ import story from './story'; import suggestFollowUps from './suggest-follow-ups'; /** - * Tools whose output only the web chat can render — excluded from automations, MCP sub-agent and - * WhatsApp runs. Slack, Teams and Telegram keep them and degrade to an "open in nao" link card. - * TODO: WhatsApp has no block UI for a link card yet — post a plain-text chat link instead of - * excluding the tool. + * Tools whose output only the web chat can render — excluded from automations and the MCP sub-agent. + * Messaging providers keep them and degrade to an "open in nao" link (a card on Slack/Teams/Telegram, + * a plain-text link on WhatsApp). */ export const WEB_CHAT_ONLY_TOOLS = ['display_map']; @@ -82,6 +81,7 @@ export const getTools = ( execute_sandboxed_code, clarification: clarificationTool, suggest_follow_ups, + display_map: displayMapTool, ...rest } = tools; const baseTools = options.excludeFollowUps ? rest : { ...rest, suggest_follow_ups }; @@ -92,21 +92,30 @@ export const getTools = ( ...mcpTools, ...(agentSettings?.experimental?.pythonSandboxing && execute_python && { execute_python }), ...(agentSettings?.experimental?.sandboxes && execute_sandboxed_code && { execute_sandboxed_code }), + ...(agentSettings?.experimental?.displayMap && { display_map: displayMapTool }), ...extraTools, }; let result = allTools; if (options.builtinToolAllowlist) { - const allowed = new Set([...options.builtinToolAllowlist, ...Object.keys(extraTools ?? {})]); - result = Object.fromEntries(Object.entries(result).filter(([name]) => allowed.has(name))) as typeof allTools; + const allowed = new Set(options.builtinToolAllowlist); + result = keepTools(result, extraTools, (name) => allowed.has(name)); } if (options.excludeBuiltinTools) { const excluded = new Set(options.excludeBuiltinTools); - const extraToolNames = new Set(Object.keys(extraTools ?? {})); - result = Object.fromEntries( - Object.entries(result).filter(([name]) => !excluded.has(name) || extraToolNames.has(name)), - ) as typeof allTools; + result = keepTools(result, extraTools, (name) => !excluded.has(name)); } return result; }; + +const keepTools = >( + toolset: T, + extraTools: Record | undefined, + shouldKeep: (name: string) => boolean, +): T => { + const extraToolNames = new Set(Object.keys(extraTools ?? {})); + return Object.fromEntries( + Object.entries(toolset).filter(([name]) => shouldKeep(name) || extraToolNames.has(name)), + ) as T; +}; diff --git a/apps/backend/src/services/whatsapp.ts b/apps/backend/src/services/whatsapp.ts index 8ac5ee3f7..5af9dfeec 100644 --- a/apps/backend/src/services/whatsapp.ts +++ b/apps/backend/src/services/whatsapp.ts @@ -6,7 +6,6 @@ import type { LlmSelectedModel } from '@nao/shared/types'; import { InferUIMessageChunk, readUIMessageStream } from 'ai'; import { Attachment, Chat, Message, Thread } from 'chat'; -import { WEB_CHAT_ONLY_TOOLS } from '../agents/tools'; import { generateChartImage } from '../components/generate-chart'; import { env } from '../env'; import * as chartImageQueries from '../queries/chart-image'; @@ -21,8 +20,8 @@ import { ConversationContext, StreamState, ToolCallEntry } from '../types/messag import { createChatTitle } from '../utils/ai'; import { buildImageUrl } from '../utils/image'; import { logger } from '../utils/logger'; -import { EXCLUDED_TOOLS, formatMessagingError } from '../utils/messaging-provider'; -import { agentService, defaultAgentToolsExcluding } from './agent'; +import { createWhatsappMapLink, EXCLUDED_TOOLS, formatMessagingError } from '../utils/messaging-provider'; +import { agentService } from './agent'; import { posthog, PostHogEvent } from './posthog'; import * as transcribeService from './transcribe.service'; @@ -436,7 +435,7 @@ class WhatsappService { const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); const stream = await this._createAgentStream(chat, ctx, chatUrl); - const { finalText, chartUrls } = await this._readStreamAndUpdateMessage(stream, ctx); + const { finalText, chartUrls, mapLinks } = await this._readStreamAndUpdateMessage(stream, ctx); if (finalText) { await ctx.thread.post(finalText); @@ -446,6 +445,10 @@ class WhatsappService { await this._sendWhatsAppImage(ctx.thread.id, url); } + for (const link of mapLinks) { + await ctx.thread.post(link); + } + posthog.capture(ctx.user!.id, PostHogEvent.MessageSent, { project_id: this._projectId, chat_id: ctx.chatId, @@ -464,7 +467,6 @@ class WhatsappService { const agent = await agentService.create( { ...chat, userId: ctx.user!.id, projectId: this._projectId }, this._modelSelection, - { tools: defaultAgentToolsExcluding(WEB_CHAT_ONLY_TOOLS) }, ); ctx.modelId = agent.getModelId(); return agent.stream(chat.messages, { provider: 'whatsapp', timezone: ctx.timezone, chatUrl }); @@ -473,7 +475,7 @@ class WhatsappService { private async _readStreamAndUpdateMessage( stream: ReadableStream>, ctx: ConversationContext, - ): Promise<{ finalText: string; chartUrls: string[] }> { + ): Promise<{ finalText: string; chartUrls: string[]; mapLinks: string[] }> { const state: StreamState = { renderedToolCallIds: new Set(), sqlOutputs: new Map(), @@ -483,6 +485,7 @@ class WhatsappService { }; const chartUrls: string[] = []; + const mapLinks: string[] = []; let lastMessage: UIMessage | null = null; for await (const uiMessage of readUIMessageStream({ stream })) { @@ -501,6 +504,11 @@ class WhatsappService { if (url) { chartUrls.push(url); } + } else if (part.type === 'tool-display_map') { + const link = this._handleMapPart(part, state, ctx); + if (link) { + mapLinks.push(link); + } } } @@ -509,7 +517,24 @@ class WhatsappService { .map((p) => p.text.replace(CITATION_TAG_REGEX, '')) .join('\n\n'); - return { finalText, chartUrls }; + return { finalText, chartUrls, mapLinks }; + } + + private _handleMapPart( + part: Extract, + state: StreamState, + ctx: ConversationContext, + ): string | null { + if ( + part.state !== 'output-available' || + !part.output.success || + state.renderedToolCallIds.has(part.toolCallId) + ) { + return null; + } + state.renderedToolCallIds.add(part.toolCallId); + const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); + return createWhatsappMapLink(part.input.title, chatUrl); } private async _handleChartPart( diff --git a/apps/backend/src/trpc/project.routes.ts b/apps/backend/src/trpc/project.routes.ts index efbeabaa4..f0f555291 100644 --- a/apps/backend/src/trpc/project.routes.ts +++ b/apps/backend/src/trpc/project.routes.ts @@ -746,6 +746,7 @@ export const projectRoutes = { .object({ pythonSandboxing: z.boolean().optional(), sandboxes: z.boolean().optional(), + displayMap: z.boolean().optional(), }) .optional(), transcribe: z @@ -793,6 +794,7 @@ export const projectRoutes = { sql_dangerously_write_perm_enabled: merged.sql?.dangerouslyWritePermEnabled, python_execution_max_duration_secs: merged.pythonExecution?.maxDurationSecs, python_sandboxing_enabled: merged.experimental?.pythonSandboxing, + display_map_enabled: merged.experimental?.displayMap, memory_enabled: merged.memoryEnabled, web_search_enabled: merged.webSearch?.enabled, web_search_mode: merged.webSearch?.mode, diff --git a/apps/backend/src/types/agent-settings.ts b/apps/backend/src/types/agent-settings.ts index dd4b061e6..48669352c 100644 --- a/apps/backend/src/types/agent-settings.ts +++ b/apps/backend/src/types/agent-settings.ts @@ -5,6 +5,7 @@ export interface AgentSettings { experimental?: { pythonSandboxing?: boolean; sandboxes?: boolean; + displayMap?: boolean; }; transcribe?: { enabled?: boolean; diff --git a/apps/backend/src/utils/messaging-provider.ts b/apps/backend/src/utils/messaging-provider.ts index 6bb7e5e86..26a0f68df 100644 --- a/apps/backend/src/utils/messaging-provider.ts +++ b/apps/backend/src/utils/messaging-provider.ts @@ -155,6 +155,10 @@ export const createTelegramMapLinkCard = (title: string, chatUrl: string): CardC Actions([LinkButton({ url: chatUrl, label: 'View interactive map in nao' })]), ]; +/** WhatsApp has no interactive card UI, so a map degrades to a plain-text link to the nao chat. */ +export const createWhatsappMapLink = (title: string, chatUrl: string): string => + `🗺️ ${title}\nView interactive map in nao: ${chatUrl}`; + export const createPlainTextBlock = (text: string): CardChild => { return CardText(stripMarkdown(text)); }; diff --git a/apps/backend/tests/agent-tools.test.ts b/apps/backend/tests/agent-tools.test.ts deleted file mode 100644 index a01610a70..000000000 --- a/apps/backend/tests/agent-tools.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('../src/db/db', () => ({ db: {} })); - -import { getTools } from '../src/agents/tools'; - -describe('getTools excludeBuiltinTools', () => { - it('includes display_map by default', () => { - expect(getTools(null)).toHaveProperty('display_map'); - }); - - it('drops excluded tools and keeps the rest', () => { - const tools = getTools(null, undefined, { excludeBuiltinTools: ['display_map'] }); - expect(tools).not.toHaveProperty('display_map'); - expect(tools).toHaveProperty('display_chart'); - expect(tools).toHaveProperty('execute_sql'); - }); - - it('applies the exclusion after the allowlist', () => { - const tools = getTools(null, undefined, { - builtinToolAllowlist: ['execute_sql', 'display_map'], - excludeBuiltinTools: ['display_map'], - }); - expect(Object.keys(tools)).toEqual(['execute_sql']); - }); - - it('keeps extra tools that are not excluded', () => { - const extra = { my_tool: {} }; - const tools = getTools(null, extra, { excludeBuiltinTools: ['display_map'] }); - expect(tools).toHaveProperty('my_tool'); - }); - - it('never drops extra tools, even when their name is in the exclusion list', () => { - const tools = getTools(null, { display_map: {} }, { excludeBuiltinTools: ['display_map'] }); - expect(tools).toHaveProperty('display_map'); - }); -}); diff --git a/apps/frontend/src/components/settings-search-index.ts b/apps/frontend/src/components/settings-search-index.ts index 759f766a3..5f9cfac3d 100644 --- a/apps/frontend/src/components/settings-search-index.ts +++ b/apps/frontend/src/components/settings-search-index.ts @@ -267,6 +267,16 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [ keywords: ['boxlite', 'code execution'], adminOnly: true, }, + { + page: '/settings/project/agent', + pageLabel: 'Agent', + section: 'Experimental', + title: 'Display map', + description: + 'Allow the agent to render query results with latitude and longitude columns on an interactive map.', + keywords: ['map', 'geo', 'location', 'latitude', 'longitude', 'coordinates', 'spatial'], + adminOnly: true, + }, { page: '/settings/project/agent', pageLabel: 'Agent', diff --git a/apps/frontend/src/components/settings/experimental.tsx b/apps/frontend/src/components/settings/experimental.tsx index a596c92a3..73066eead 100644 --- a/apps/frontend/src/components/settings/experimental.tsx +++ b/apps/frontend/src/components/settings/experimental.tsx @@ -39,6 +39,7 @@ export function SettingsExperimental({ isAdmin }: SettingsExperimentalProps) { const [pythonExecutionDurationInput, setPythonExecutionDurationInput] = useState( String(pythonExecutionDurationSecs), ); + const displayMapEnabled = agentSettings.data?.experimental?.displayMap ?? false; useEffect(() => { setPythonExecutionDurationInput(String(pythonExecutionDurationSecs)); @@ -82,6 +83,14 @@ export function SettingsExperimental({ isAdmin }: SettingsExperimentalProps) { const pythonExecutionDurationError = getPythonExecutionDurationError(pythonExecutionDurationInput); + const handleDisplayMapChange = (enabled: boolean) => { + updateAgentSettings.mutate({ + experimental: { + displayMap: enabled, + }, + }); + }; + return ( } /> + + } + /> (null); const pointsRef = useRef(points); const configRef = useRef(config); + const markerColorRef = useRef('#522bff'); + const styleUrlRef = useRef(''); const [initFailed, setInitFailed] = useState(false); + const isDark = useIsDark(); pointsRef.current = points; configRef.current = config; @@ -28,11 +32,14 @@ export default function MapView({ points, config }: MapViewProps) { return; } + const styleUrl = resolveStyleUrl(document.documentElement.classList.contains('dark')); + styleUrlRef.current = styleUrl; + let map: maplibregl.Map; try { map = new maplibregl.Map({ container: containerRef.current, - style: MAP_STYLE_URL, + style: styleUrl, cooperativeGestures: true, attributionControl: { compact: true }, }); @@ -43,7 +50,8 @@ export default function MapView({ points, config }: MapViewProps) { mapRef.current = map; map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); - map.on('load', () => { + map.on('style.load', () => { + markerColorRef.current = resolveCssColor('--primary', '#522bff'); map.addSource(POINTS_SOURCE_ID, { type: 'geojson', data: toGeoJsonPoints(pointsRef.current) }); map.addLayer({ id: POINTS_LAYER_ID, @@ -51,7 +59,7 @@ export default function MapView({ points, config }: MapViewProps) { source: POINTS_SOURCE_ID, paint: { 'circle-radius': 5, - 'circle-color': resolveCssColor('--chart-1', '#2f6f8f'), + 'circle-color': markerColorRef.current, 'circle-opacity': 0.9, 'circle-stroke-width': 2, 'circle-stroke-color': resolveCssColor('--background', '#ffffff'), @@ -60,26 +68,29 @@ export default function MapView({ points, config }: MapViewProps) { fitToPoints(map, pointsRef.current); }); - map.on('click', POINTS_LAYER_ID, (event) => { + const tooltip = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + className: 'map-tooltip', + maxWidth: '280px', + offset: 12, + }); + + map.on('mousemove', POINTS_LAYER_ID, (event) => { const index = event.features?.[0]?.properties?.index; const point = typeof index === 'number' ? pointsRef.current[index] : undefined; - if (!point) { - return; - } - const content = buildPopupContent(point, configRef.current); - if (!content) { + const content = point ? buildTooltipContent(point, configRef.current, markerColorRef.current) : null; + if (!point || !content) { + tooltip.remove(); + map.getCanvas().style.cursor = ''; return; } - new maplibregl.Popup({ closeButton: false, maxWidth: '280px' }) - .setLngLat([point.longitude, point.latitude]) - .setDOMContent(content) - .addTo(map); - }); - map.on('mouseenter', POINTS_LAYER_ID, () => { map.getCanvas().style.cursor = 'pointer'; + tooltip.setLngLat([point.longitude, point.latitude]).setDOMContent(content).addTo(map); }); map.on('mouseleave', POINTS_LAYER_ID, () => { map.getCanvas().style.cursor = ''; + tooltip.remove(); }); return () => { @@ -88,6 +99,16 @@ export default function MapView({ points, config }: MapViewProps) { }; }, []); + useEffect(() => { + const map = mapRef.current; + const styleUrl = resolveStyleUrl(isDark); + if (!map || styleUrl === styleUrlRef.current) { + return; + } + styleUrlRef.current = styleUrl; + map.setStyle(styleUrl); + }, [isDark]); + useEffect(() => { const map = mapRef.current; const source = map?.getSource(POINTS_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; @@ -106,7 +127,24 @@ export default function MapView({ points, config }: MapViewProps) { ); } - return
; + return
; +} + +function useIsDark() { + const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains('dark')); + + useEffect(() => { + const root = document.documentElement; + const observer = new MutationObserver(() => setIsDark(root.classList.contains('dark'))); + observer.observe(root, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); + + return isDark; +} + +function resolveStyleUrl(isDark: boolean): string { + return isDark ? MAP_STYLE_DARK : MAP_STYLE_LIGHT; } function toGeoJsonPoints(points: MapPoint[]) { @@ -134,14 +172,18 @@ function fitToPoints(map: maplibregl.Map, points: MapPoint[]) { ); } -/** Builds popup DOM with `textContent` only, so untrusted query values can never inject HTML. */ -function buildPopupContent(point: MapPoint, config: displayMap.Input): HTMLElement | null { +function buildTooltipContent(point: MapPoint, config: displayMap.Input, markerColor: string): HTMLElement | null { const rows: HTMLElement[] = []; if (config.label_key && point.row[config.label_key] != null) { const title = document.createElement('div'); - title.className = 'text-sm font-medium text-neutral-900'; - title.textContent = String(point.row[config.label_key]); + title.className = 'flex items-center gap-2 font-medium text-foreground'; + const dot = document.createElement('span'); + dot.className = 'h-2.5 w-2.5 shrink-0 rounded-[2px]'; + dot.style.backgroundColor = markerColor; + const text = document.createElement('span'); + text.textContent = String(point.row[config.label_key]); + title.append(dot, text); rows.push(title); } @@ -150,12 +192,12 @@ function buildPopupContent(point: MapPoint, config: displayMap.Input): HTMLEleme continue; } const row = document.createElement('div'); - row.className = 'flex justify-between gap-3 text-xs'; + row.className = 'flex items-center justify-between gap-4 leading-none'; const label = document.createElement('span'); - label.className = 'text-neutral-500'; + label.className = 'text-muted-foreground'; label.textContent = labelize(key); const value = document.createElement('span'); - value.className = 'text-neutral-900 font-medium'; + value.className = 'text-foreground font-mono font-medium tabular-nums'; value.textContent = String(point.row[key]); row.append(label, value); rows.push(row); @@ -166,16 +208,12 @@ function buildPopupContent(point: MapPoint, config: displayMap.Input): HTMLEleme } const container = document.createElement('div'); - container.className = 'flex flex-col gap-1 font-sans'; + container.className = + 'border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl font-sans'; container.append(...rows); return container; } -/** - * Resolves a CSS variable to a color MapLibre can parse (oklch tokens are not supported natively). - * TODO: colors and the basemap style are snapshotted on mount — a light/dark toggle does not - * restyle the map until remount. - */ function resolveCssColor(variableName: string, fallback: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true }); diff --git a/apps/frontend/src/components/tool-calls/display-map.tsx b/apps/frontend/src/components/tool-calls/display-map.tsx index c8d628707..01c21e79f 100644 --- a/apps/frontend/src/components/tool-calls/display-map.tsx +++ b/apps/frontend/src/components/tool-calls/display-map.tsx @@ -1,20 +1,28 @@ -import { lazy, Suspense, useMemo } from 'react'; -import { Code } from 'lucide-react'; +import { lazy, Suspense, useMemo, useState } from 'react'; +import { Code, Map as MapIcon, Table as TableIcon } from 'lucide-react'; import { buildMapPoints, MAX_MAP_POINTS, resolveMapConfig } from '@nao/shared'; import { Skeleton } from '../ui/skeleton'; import { TextShimmer } from '../ui/text-shimmer'; import { Button } from '../ui/button'; +import GraphLoaderAnimated from '../icons/graph-loader-animated'; +import { TableDisplay } from './display-table'; +import { SqlQueryDisplay } from './sql-query-display'; import { ToolCallWrapper } from './tool-call-wrapper'; import type { ToolCallComponentProps } from '.'; +import type { ReactNode } from 'react'; import type { displayMap } from '@nao/shared/tools'; import type { MapPoint } from '@nao/shared'; +import { cn } from '@/lib/utils'; import { useSourceQuery } from '@/hooks/use-source-query'; const MapView = lazy(() => import('./display-map-view')); +type MapViewMode = 'map' | 'table' | 'query'; + export const DisplayMapToolCall = ({ toolPart: { state, input, output } }: ToolCallComponentProps<'display_map'>) => { const config = state !== 'input-streaming' ? input : undefined; - const { sourceQuery, sourceData, handleViewQuery } = useSourceQuery(config?.query_id); + const { sourceQuery, sourceData } = useSourceQuery(config?.query_id); + const [viewMode, setViewMode] = useState('map'); const resolvedConfig = useMemo(() => { if (!config || !sourceData?.data) { @@ -46,7 +54,8 @@ export const DisplayMapToolCall = ({ toolPart: { state, input, output } }: ToolC return (
- + +
@@ -80,29 +89,91 @@ export const DisplayMapToolCall = ({ toolPart: { state, input, output } }: ToolC } return ( -
-
- {config.title} - {sourceQuery?.input && ( - +
+
+
+ {config.title} +
+ } + title='View map' + isActive={viewMode === 'map'} + onClick={() => setViewMode('map')} + /> + } + title='View data table' + isActive={viewMode === 'table'} + onClick={() => setViewMode('table')} + /> + {sourceQuery?.input && ( + } + title='View SQL query' + isActive={viewMode === 'query'} + onClick={() => setViewMode('query')} + /> + )} +
+
+ + {viewMode === 'map' && ( +
+ }> + + +
+ )} + + {viewMode === 'table' && ( + []} + columns={sourceData.columns} + tableContainerClassName='max-h-[28rem]' + maxRowsBeforePagination={10} + compactFooter + humanizeColumnLabels + /> + )} + + {viewMode === 'query' && sourceQuery?.input && ( +
+ +
)}
- }> - - - {points.length > MAX_MAP_POINTS && ( - + + {viewMode === 'map' && points.length > MAX_MAP_POINTS && ( + Showing the first {MAX_MAP_POINTS.toLocaleString()} of {points.length.toLocaleString()} points. )}
); }; + +interface ViewToggleButtonProps { + icon: ReactNode; + title: string; + isActive: boolean; + onClick: () => void; +} + +function ViewToggleButton({ icon, title, isActive, onClick }: ViewToggleButtonProps) { + return ( + + ); +} diff --git a/apps/frontend/src/hooks/use-source-query.tsx b/apps/frontend/src/hooks/use-source-query.tsx index 0fabcb874..af3ca74d0 100644 --- a/apps/frontend/src/hooks/use-source-query.tsx +++ b/apps/frontend/src/hooks/use-source-query.tsx @@ -1,9 +1,7 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useMemo, useRef } from 'react'; import type { executeSql } from '@nao/shared/tools'; import type { UIMessage } from '@nao/backend/chat'; import { useOptionalAgentContext } from '@/contexts/agent.provider'; -import { useSidePanel } from '@/contexts/side-panel'; -import { SidePanelContent } from '@/components/side-panel/sql-editor'; import { findLatestExecuteSqlInMessages } from '@/lib/execute-sql-messages'; const EMPTY_MESSAGES: UIMessage[] = []; @@ -18,11 +16,9 @@ export type SourceQuery = { input?: executeSql.Input; output: executeSql.Output export function useSourceQuery(queryId: string | undefined): { sourceQuery: SourceQuery | null; sourceData: executeSql.Output | null; - handleViewQuery: () => void; } { const agent = useOptionalAgentContext(); const messages = agent?.messages ?? EMPTY_MESSAGES; - const { open: openSidePanel } = useSidePanel(); const foundRef = useRef<{ queryId: string; result: SourceQuery } | null>(null); const sourceQuery = useMemo(() => { @@ -47,12 +43,5 @@ export function useSourceQuery(queryId: string | undefined): { return findLatestExecuteSqlInMessages(messages, queryId); }, [messages, queryId]); - const handleViewQuery = useCallback(() => { - if (!sourceQuery?.input || !sourceQuery.output) { - return; - } - openSidePanel(); - }, [openSidePanel, sourceQuery]); - - return { sourceQuery, sourceData: sourceQuery?.output ?? null, handleViewQuery }; + return { sourceQuery, sourceData: sourceQuery?.output ?? null }; } diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index fed403d03..ff7a73d49 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -734,6 +734,71 @@ code.dollar:before { opacity: 0.9; } +/* MapLibre hover tooltip: strip default popup chrome so the inner box matches the chart tooltip */ +.maplibregl-popup.map-tooltip { + pointer-events: none; +} + +.maplibregl-popup.map-tooltip .maplibregl-popup-content { + padding: 0; + background: transparent; + box-shadow: none; + border-radius: 0; +} + +.maplibregl-popup.map-tooltip .maplibregl-popup-tip { + display: none; +} + +/* Round the zoom buttons to match the control group so hover/active fills stay inside the corners */ +.maplibregl-ctrl-group button:first-child { + border-radius: 4px 4px 0 0; +} + +.maplibregl-ctrl-group button:last-child { + border-radius: 0 0 4px 4px; +} + +.maplibregl-ctrl-group button:only-child { + border-radius: inherit; +} + +/* MapLibre controls (zoom +/- and attribution) follow the app light/dark theme */ +.dark .maplibregl-ctrl-group { + background: var(--background); +} + +.dark .maplibregl-ctrl-group button + button { + border-top-color: var(--border); +} + +.dark .maplibregl-ctrl-group button:not(:disabled):hover, +.dark .maplibregl-ctrl-group button:not(:disabled):active { + background-color: var(--accent); +} + +.dark .maplibregl-ctrl button .maplibregl-ctrl-icon, +.dark .maplibregl-ctrl-attrib.maplibregl-compact:after { + filter: invert(1); +} + +.dark .maplibregl-ctrl.maplibregl-ctrl-attrib { + background-color: color-mix(in srgb, var(--background) 50%, transparent); +} + +.dark .maplibregl-ctrl-attrib.maplibregl-compact { + background-color: var(--background); + color: var(--foreground); +} + +.dark .maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button { + background-color: var(--accent); +} + +.dark .maplibregl-ctrl-attrib a { + color: var(--foreground); +} + @keyframes chat-input-border-draw { from { stroke-dashoffset: 100; From 79567328c1483c28a744daca5e554661ccaf94d2 Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Thu, 23 Jul 2026 15:54:16 +0200 Subject: [PATCH 3/4] fix from cubic review --- apps/backend/src/services/agent.ts | 1 - apps/backend/src/services/telegram.ts | 16 ++++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/services/agent.ts b/apps/backend/src/services/agent.ts index 0898136bc..4bee67cf8 100644 --- a/apps/backend/src/services/agent.ts +++ b/apps/backend/src/services/agent.ts @@ -135,7 +135,6 @@ export const adminAgentTools: AgentToolsResolver = ({ chat, agentSettings }) => 'execute_sql', 'read_query_result', 'display_chart', - 'display_map', 'suggest_follow_ups', 'story', 'clarification', diff --git a/apps/backend/src/services/telegram.ts b/apps/backend/src/services/telegram.ts index a444a9443..77f20dc81 100644 --- a/apps/backend/src/services/telegram.ts +++ b/apps/backend/src/services/telegram.ts @@ -415,12 +415,16 @@ class TelegramService { ) { return; } - state.renderedToolCallIds.add(part.toolCallId); - const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); - ctx.textBlockIndex = -1; - ctx.blocks.push(...createTelegramMapLinkCard(part.input.title, chatUrl)); - if (ctx.convMessage) { - await this._safeEdit(ctx.convMessage, Card({ children: ctx.blocks })); + try { + state.renderedToolCallIds.add(part.toolCallId); + const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString(); + ctx.textBlockIndex = -1; + ctx.blocks.push(...createTelegramMapLinkCard(part.input.title, chatUrl)); + if (ctx.convMessage) { + await this._safeEdit(ctx.convMessage, Card({ children: ctx.blocks })); + } + } catch (error) { + console.error('Error rendering map link card:', error); } } From 4742e364d89e97d06bf535bc40b488811191a0dc Mon Sep 17 00:00:00 2001 From: socallmebertille Date: Thu, 23 Jul 2026 15:58:41 +0200 Subject: [PATCH 4/4] chore: sync package-lock with maplibre-gl dependency --- package-lock.json | 363 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 298 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0615b374d..c63bf4c80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -291,6 +292,7 @@ "jszip": "^3.10.1", "katex": "^0.16.45", "lucide-react": "^0.562.0", + "maplibre-gl": "^5.24.0", "posthog-js": "^1.336.4", "prompt-mentions": "^0.0.37", "proxy-from-env": "^1.1.0", @@ -1998,6 +2000,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2279,6 +2282,7 @@ "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.3.tgz", "integrity": "sha512-HefGR2SNfAi2RhT6XvSYViH4a0xoCGGL10bSDiv6sQGrmY6ulEQEV1X4nebTHeG0P6jdBmXAoEW3k37nhpk99w==", "license": "MIT", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", @@ -2411,6 +2415,7 @@ "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", "license": "MIT", + "peer": true, "dependencies": { "@noble/hashes": "^2.0.1" } @@ -2418,7 +2423,8 @@ "node_modules/@better-fetch/fetch": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", - "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==", + "peer": true }, "node_modules/@boxlite-ai/boxlite": { "version": "0.3.0", @@ -2478,6 +2484,12 @@ "node": ">=18.0.0" } }, + "node_modules/@boxlite-ai/boxlite/node_modules/@boxlite-ai/boxlite-darwin-x64": { + "optional": true + }, + "node_modules/@boxlite-ai/boxlite/node_modules/@boxlite-ai/boxlite-linux-arm64-gnu": { + "optional": true + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -2682,6 +2694,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -2730,6 +2743,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2757,6 +2771,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2906,6 +2921,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -2916,6 +2932,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2953,7 +2970,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -2970,7 +2986,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -2987,7 +3002,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -3004,7 +3018,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -3021,7 +3034,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -3038,7 +3050,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -3055,7 +3066,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -3072,7 +3082,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3089,7 +3098,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3106,7 +3114,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3123,7 +3130,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3140,7 +3146,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3157,7 +3162,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3174,7 +3178,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3191,7 +3194,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3208,7 +3210,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -3225,7 +3226,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -3242,7 +3242,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -3259,7 +3258,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=12" } @@ -3276,7 +3274,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -3293,7 +3290,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -3310,7 +3306,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -3377,7 +3372,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -3394,7 +3388,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -3411,7 +3404,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -3428,7 +3420,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -3445,7 +3436,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -3462,7 +3452,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -3479,7 +3468,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3496,7 +3484,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3513,7 +3500,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3530,7 +3516,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3547,7 +3532,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3564,7 +3548,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3581,7 +3564,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3598,7 +3580,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3615,7 +3596,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3632,7 +3612,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -3665,7 +3644,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3682,7 +3660,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3699,7 +3676,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3716,7 +3692,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3733,7 +3708,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -3750,7 +3724,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -3767,7 +3740,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3784,7 +3756,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3801,7 +3772,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -4342,6 +4312,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -4573,6 +4544,119 @@ "node": ">=8" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", @@ -4745,6 +4829,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -4897,6 +4982,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4930,6 +5016,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4964,7 +5051,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.220.0.tgz", "integrity": "sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", @@ -4984,7 +5070,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, @@ -4997,7 +5082,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -5013,7 +5097,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.220.0.tgz", "integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" @@ -5030,7 +5113,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.220.0.tgz", "integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", @@ -5051,7 +5133,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.220.0.tgz", "integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", @@ -5070,7 +5151,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz", "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" @@ -5253,6 +5333,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -7306,6 +7387,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.0.tgz", "integrity": "sha512-rFtRx68ZOtFO6aQwEfMaje3dh/x6BOr0kAauIiTimUw+A6RVbx5QVLbYHQXL+hlxondnm8dbNV6lsgzShg3yKQ==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -9800,6 +9882,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz", "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.99.0" }, @@ -9816,6 +9899,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.21.tgz", "integrity": "sha512-slnitYiHHmU52eMWtW8JbV9EMT5q6mRMbTA5yepBmJAnj5WZDrDRsLY/TuUrdD97A4W7/25tEQRoqc1G2X0oCw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", @@ -9904,6 +9988,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.15.tgz", "integrity": "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", @@ -10112,6 +10197,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -10159,6 +10245,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -10436,6 +10523,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz", "integrity": "sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -10570,6 +10658,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz", "integrity": "sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -10601,6 +10690,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -10631,6 +10721,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.22.3.tgz", "integrity": "sha512-6MNr6z0PxwfJFs+BKhHcvPNvY+UV1PXgqzTiTM4Z9guml84iVZxv7ZOCSj1dFYTr3Bf1MiOs4hT1yvBFlTfIaQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", @@ -10724,6 +10815,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "bin": { "intent": "bin/intent.js" }, @@ -10740,6 +10832,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "bin": { "intent": "bin/intent.js" }, @@ -11278,6 +11371,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -11287,6 +11381,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -11390,6 +11485,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -11552,6 +11648,7 @@ "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", @@ -12098,6 +12195,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12129,6 +12227,7 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.159.tgz", "integrity": "sha512-S18ozG7Dkm3Ud1tzOtAK5acczD4vygfml80RkpM9VWMFpvAFwAKSHaGYkATvPQHIE+VpD1tJY9zcTXLZ/zR5cw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.96", "@ai-sdk/provider": "3.0.8", @@ -12637,6 +12736,7 @@ "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.3.tgz", "integrity": "sha512-jMsoSYQyO8nNRuLEoCP+OUShLyeIGU8ioPYqra0IteLjnS3WNjHj21YE/COSJ/V/f0H5SInZiF+uXcEEHREDMQ==", "license": "MIT", + "peer": true, "dependencies": { "@better-auth/core": "1.6.3", "@better-auth/drizzle-adapter": "1.6.3", @@ -12742,6 +12842,7 @@ "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", "license": "MIT", + "peer": true, "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", @@ -12940,6 +13041,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -13006,6 +13108,7 @@ "integrity": "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -13295,6 +13398,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -13784,13 +13888,15 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cytoscape": { "version": "3.33.2", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -14200,6 +14306,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -14655,7 +14762,8 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "8.0.4", @@ -14735,7 +14843,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -14798,6 +14905,7 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -14945,6 +15053,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -15194,6 +15308,7 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -15241,7 +15356,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -15300,6 +15414,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -16803,6 +16918,12 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -17357,6 +17478,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -18252,7 +18374,6 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -18278,6 +18399,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -18287,6 +18409,7 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -18325,6 +18448,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -18457,7 +18581,6 @@ "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.1.1", "fast-uri": "^3.0.5", @@ -18490,6 +18613,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -18643,6 +18772,12 @@ "node": ">= 12" } }, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -18672,6 +18807,7 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.16.tgz", "integrity": "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" } @@ -18730,7 +18866,6 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -19373,6 +19508,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -20556,7 +20725,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -20567,7 +20735,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -20587,6 +20754,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -20628,6 +20801,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -21624,6 +21798,18 @@ "node": ">= 14.16" } }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -21635,6 +21821,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -21910,6 +22097,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -22065,6 +22253,12 @@ "cross-spawn": "^7.0.6" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/preact": { "version": "10.29.1", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", @@ -22343,6 +22537,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -22372,6 +22567,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -22420,6 +22616,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -22450,6 +22647,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -22580,6 +22783,12 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -22772,6 +22981,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -22828,6 +23038,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -23405,6 +23616,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -23814,6 +24034,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -24156,6 +24377,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz", "integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -24905,6 +25127,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -25671,6 +25899,7 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -26264,6 +26493,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -26489,6 +26719,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -26722,6 +26953,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -27283,6 +27515,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -28041,7 +28274,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -28163,6 +28395,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }