diff --git a/apps/backend/src/queries/chart-image.ts b/apps/backend/src/queries/chart-image.ts index 5b2488295..23b14f0bd 100644 --- a/apps/backend/src/queries/chart-image.ts +++ b/apps/backend/src/queries/chart-image.ts @@ -66,3 +66,23 @@ export const saveChart = async (toolCallId: string, data: string): Promise => { + const [row] = await db + .select({ projectId: s.chat.projectId, userId: s.chat.userId }) + .from(s.messagePart) + .innerJoin(s.chatMessage, eq(s.messagePart.messageId, s.chatMessage.id)) + .innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id)) + .where(eq(s.messagePart.toolCallId, toolCallId)) + .execute(); + return row ?? null; +}; + +/** Persists an updated `display_chart` config for the given tool call. */ +export const updateChartConfig = async (toolCallId: string, config: displayChart.Input): Promise => { + await db.update(s.messagePart).set({ toolInput: config }).where(eq(s.messagePart.toolCallId, toolCallId)).execute(); + + // Invalidate any cached PNG so externally-served chart images refresh. + await db.delete(s.message_part_chart_image).where(eq(s.message_part_chart_image.toolCallId, toolCallId)).execute(); +}; diff --git a/apps/backend/src/trpc/chart.routes.ts b/apps/backend/src/trpc/chart.routes.ts index 0fafce801..9b0bb9ba1 100644 --- a/apps/backend/src/trpc/chart.routes.ts +++ b/apps/backend/src/trpc/chart.routes.ts @@ -1,8 +1,15 @@ +import { displayChart } from '@nao/shared/tools'; +import { TRPCError } from '@trpc/server'; import { z } from 'zod/v4'; import { generateChartImage } from '../components/generate-chart'; -import { getChartConfigByToolCallId, getChartDataByQueryId } from '../queries/chart-image'; -import { projectProtectedProcedure } from './trpc'; +import { + getChartConfigByToolCallId, + getChartDataByQueryId, + getChartOwnerInfo, + updateChartConfig, +} from '../queries/chart-image'; +import { projectProtectedProcedure, protectedProcedure } from './trpc'; export const chartRoutes = { download: projectProtectedProcedure @@ -17,4 +24,29 @@ export const chartRoutes = { const png = generateChartImage({ config, data }); return png.toString('base64'); }), + + updateConfig: protectedProcedure + .input( + z.object({ + toolCallId: z.string(), + config: z.custom((value) => displayChart.InputSchema.safeParse(value).success, { + message: 'Invalid chart config', + }), + }), + ) + .mutation(async ({ input, ctx }) => { + const owner = await getChartOwnerInfo(input.toolCallId); + if (!owner) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Chart not found.' }); + } + if (owner.userId !== ctx.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not authorized to edit this chart.', + }); + } + + await updateChartConfig(input.toolCallId, input.config); + return { success: true as const }; + }), }; diff --git a/apps/frontend/src/components/side-panel/story-chart-embed.tsx b/apps/frontend/src/components/side-panel/story-chart-embed.tsx index 840b1fd09..75da8ded0 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -1,8 +1,12 @@ -import { memo, useMemo } from 'react'; +import { memo, useMemo, useState } from 'react'; +import { Pencil } from 'lucide-react'; import type { UIMessage } from '@nao/backend/chat'; import type { displayChart } from '@nao/shared/tools'; +import { Button } from '@/components/ui/button'; import { useOptionalAgentContext } from '@/contexts/agent.provider'; +import { useStoryChartEdit } from '@/contexts/story-chart-edit'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; +import { ChartConfigEditDialog } from '@/components/tool-calls/display-chart-edit-dialog'; import { sortByDateKey } from '@/lib/charts.utils'; interface ChartBlock { @@ -12,6 +16,7 @@ interface ChartBlock { xAxisType: string | null; series: Array<{ data_key: string; color: string; label?: string }>; title: string; + rawTag?: string; } export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: ChartBlock }) { @@ -59,7 +64,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart: const xAxisType = chart.xAxisType === 'number' ? 'number' : ('category' as const); return ( -
+ -
+ ); }); + +interface StoryChartEmbedShellProps { + chart: ChartBlock; + availableColumns: string[]; + children: React.ReactNode; +} + +/** + * Wraps a rendered chart with an "Edit chart" button when the surrounding story + * context provides a save handler. + */ +export function StoryChartEmbedShell({ chart, availableColumns, children }: StoryChartEmbedShellProps) { + const edit = useStoryChartEdit(); + const [isEditOpen, setIsEditOpen] = useState(false); + const canEdit = Boolean(edit && chart.rawTag); + + const config = useMemo( + () => ({ + query_id: chart.queryId, + chart_type: chart.chartType as displayChart.ChartType, + x_axis_key: chart.xAxisKey, + x_axis_type: (chart.xAxisType || null) as displayChart.XAxisType | null, + series: chart.series.map((s) => ({ + data_key: s.data_key, + color: s.color || undefined, + label: s.label, + })), + title: chart.title, + }), + [chart], + ); + + return ( +
+ {canEdit && ( + + )} + {children} + {canEdit && edit && chart.rawTag && ( + edit.saveChart(chart.rawTag!, next)} + description='Tweak the chart parameters. Changes are saved to the story as a new version.' + /> + )} +
+ ); +} diff --git a/apps/frontend/src/components/side-panel/story-editor.tsx b/apps/frontend/src/components/side-panel/story-editor.tsx index 9f1435263..721b89a7f 100644 --- a/apps/frontend/src/components/side-panel/story-editor.tsx +++ b/apps/frontend/src/components/side-panel/story-editor.tsx @@ -68,7 +68,8 @@ function ChartBlockView({ node }: ReactNodeViewProps) { if (!attrMatch) { return null; } - return parseChartBlock(attrMatch[1]); + const parsed = parseChartBlock(attrMatch[1]); + return parsed ? { ...parsed, rawTag } : null; }, [rawTag]); if (!chart) { diff --git a/apps/frontend/src/components/side-panel/story-viewer.tsx b/apps/frontend/src/components/side-panel/story-viewer.tsx index 1078a2ccc..a50b9804a 100644 --- a/apps/frontend/src/components/side-panel/story-viewer.tsx +++ b/apps/frontend/src/components/side-panel/story-viewer.tsx @@ -20,6 +20,7 @@ import { useStoryViewerViewMode } from './hooks/use-story-viewer-view-mode'; import type { Editor as TiptapEditor } from '@tiptap/react'; import { useSidePanel } from '@/contexts/side-panel'; import { ReadonlyAgentMessagesProvider, useOptionalAgentContext } from '@/contexts/agent.provider'; +import { StoryChartEditProvider } from '@/contexts/story-chart-edit'; import { Spinner } from '@/components/ui/spinner'; import { chatActivityStore } from '@/stores/chat-activity'; import { trpc } from '@/main'; @@ -169,18 +170,27 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: {Boolean(archivedAt) && }
- {viewMode === 'preview' ? ( - - ) : viewMode === 'edit' ? ( - - ) : ( - + {renderWithEditProvider( + !isReadonlyMode && isViewingLatest && !archivedAt && !isAgentRunning && viewMode !== 'edit', + { + chatId, + storySlug: resolvedStorySlug, + storyTitle, + storyCode, + }, + viewMode === 'preview' ? ( + + ) : viewMode === 'edit' ? ( + + ) : ( + + ), )}
@@ -210,3 +220,23 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: return {content}; } + +function renderWithEditProvider( + enabled: boolean, + params: { chatId: string; storySlug: string; storyTitle: string; storyCode: string }, + children: React.ReactNode, +) { + if (!enabled) { + return children; + } + return ( + + {children} + + ); +} diff --git a/apps/frontend/src/components/story-embeds.tsx b/apps/frontend/src/components/story-embeds.tsx index e9573063e..3a7ca83d6 100644 --- a/apps/frontend/src/components/story-embeds.tsx +++ b/apps/frontend/src/components/story-embeds.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import type { ParsedChartBlock, ParsedTableBlock } from '@nao/shared/story-segments'; import type { displayChart } from '@nao/shared/tools'; +import { StoryChartEmbedShell } from '@/components/side-panel/story-chart-embed'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; import { TableDisplay } from '@/components/tool-calls/display-table'; @@ -54,9 +55,11 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ }) { const noCacheFetch = useLiveQueryData(chart.queryId, liveQuery); - const resolvedData = liveQuery - ? (noCacheFetch.data as { data: Record[]; columns: string[] } | undefined)?.data - : queryData?.[chart.queryId]?.data; + const resolved = liveQuery + ? (noCacheFetch.data as { data: Record[]; columns: string[] } | undefined) + : queryData?.[chart.queryId]; + const resolvedData = resolved?.data; + const resolvedColumns = resolved?.columns ?? []; if (liveQuery && noCacheFetch.isLoading) { return ; @@ -71,7 +74,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ } return ( -
+ -
+ ); }); diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx new file mode 100644 index 000000000..3d849ca3c --- /dev/null +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -0,0 +1,351 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Trash2 } from 'lucide-react'; +import { displayChart } from '@nao/shared/tools'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import type { UIMessage, UIToolPart } from '@nao/backend/chat'; +import { useAgentContext } from '@/contexts/agent.provider'; +import { trpc } from '@/main'; + +const CHART_TYPE_OPTIONS: { value: displayChart.ChartType; label: string }[] = [ + { value: 'bar', label: 'Bar' }, + { value: 'stacked_bar', label: 'Stacked bar' }, + { value: 'line', label: 'Line' }, + { value: 'area', label: 'Area' }, + { value: 'stacked_area', label: 'Stacked area' }, + { value: 'pie', label: 'Pie' }, + { value: 'kpi_card', label: 'KPI card' }, + { value: 'scatter', label: 'Scatter' }, + { value: 'radar', label: 'Radar' }, +]; + +const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto'; label: string }[] = [ + { value: 'auto', label: 'Auto' }, + { value: 'category', label: 'Category' }, + { value: 'date', label: 'Date' }, + { value: 'number', label: 'Number' }, +]; + +interface ChartConfigEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + config: displayChart.Input; + availableColumns: string[]; + onSave: (next: displayChart.Input) => Promise; + isSaving?: boolean; + description?: string; +} + +/** Presentational edit dialog for `display_chart` configuration. */ +export function ChartConfigEditDialog({ + open, + onOpenChange, + config, + availableColumns, + onSave, + isSaving = false, + description = 'Tweak the chart parameters.', +}: ChartConfigEditDialogProps) { + const [draft, setDraft] = useState(config); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setDraft(config); + setError(null); + } + }, [open, config]); + + const xAxisOptions = useMemo(() => { + if (availableColumns.length === 0) { + return [config.x_axis_key]; + } + return availableColumns; + }, [availableColumns, config.x_axis_key]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const parsed = displayChart.InputSchema.safeParse(draft); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? 'Invalid chart configuration.'); + return; + } + + try { + await onSave(parsed.data); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update chart.'); + } + }; + + const updateSeriesAt = (index: number, patch: Partial) => { + setDraft((prev) => ({ + ...prev, + series: prev.series.map((s, i) => (i === index ? { ...s, ...patch } : s)), + })); + }; + + const removeSeriesAt = (index: number) => { + setDraft((prev) => ({ + ...prev, + series: prev.series.length <= 1 ? prev.series : prev.series.filter((_, i) => i !== index), + })); + }; + + const addSeries = () => { + const used = new Set(draft.series.map((s) => s.data_key)); + const fallback = + availableColumns.find((c) => c !== draft.x_axis_key && !used.has(c)) ?? availableColumns[0] ?? ''; + setDraft((prev) => ({ + ...prev, + series: [...prev.series, { data_key: fallback }], + })); + }; + + return ( + + + + Edit chart + {description} + + +
+
+ + setDraft((prev) => ({ ...prev, title: e.target.value }))} + placeholder='Chart title' + /> +
+ +
+
+ Chart type + +
+ +
+ X-axis type + +
+
+ +
+ X-axis column + setDraft((prev) => ({ ...prev, x_axis_key: value }))} + /> +
+ +
+
+ Series + +
+
+ {draft.series.map((series, index) => ( +
+ 0 ? availableColumns : [series.data_key]} + onChange={(value) => updateSeriesAt(index, { data_key: value })} + /> + updateSeriesAt(index, { label: e.target.value || undefined })} + placeholder='Label (optional)' + className='h-9 text-sm' + /> + updateSeriesAt(index, { color: e.target.value })} + className='h-9 w-9 cursor-pointer rounded-md border border-input bg-transparent p-0.5' + /> + +
+ ))} +
+
+ + {error &&

{error}

} + + + + + +
+
+
+ ); +} + +interface DisplayChartEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + toolCallId: string; + config: displayChart.Input; + availableColumns: string[]; +} + +/** Edit dialog bound to a `tool-display_chart` message part: persists through `chart.updateConfig`. */ +export function DisplayChartEditDialog({ + open, + onOpenChange, + toolCallId, + config, + availableColumns, +}: DisplayChartEditDialogProps) { + const queryClient = useQueryClient(); + const { messages, setMessages } = useAgentContext(); + + const updateMutation = useMutation( + trpc.chart.updateConfig.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [['chat', 'get']] }); + }, + }), + ); + + const handleSave = async (next: displayChart.Input) => { + const previousMessages = messages; + setMessages(applyChartConfigToMessages(previousMessages, toolCallId, next)); + try { + await updateMutation.mutateAsync({ toolCallId, config: next }); + } catch (err) { + setMessages(previousMessages); + throw err; + } + }; + + return ( + + ); +} + +interface ColumnSelectProps { + value: string; + columns: string[]; + onChange: (value: string) => void; +} + +function ColumnSelect({ value, columns, onChange }: ColumnSelectProps) { + const items = columns.includes(value) ? columns : [value, ...columns]; + return ( + + ); +} + +const HEX_RE = /^#[0-9a-fA-F]{6}$/; +function normalizeHexColor(color?: string): string { + if (color && HEX_RE.test(color)) { + return color; + } + return '#104e64'; +} + +function applyChartConfigToMessages( + messages: UIMessage[], + toolCallId: string, + config: displayChart.Input, +): UIMessage[] { + return messages.map((message) => { + let changed = false; + const parts = message.parts.map((part) => { + if (part.type !== 'tool-display_chart') { + return part; + } + const toolPart = part as UIToolPart<'display_chart'>; + if (toolPart.toolCallId !== toolCallId) { + return part; + } + changed = true; + return { ...toolPart, input: config } as typeof part; + }); + return changed ? { ...message, parts } : message; + }); +} diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 71543f2fb..88190acbc 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useMemo, useState } from 'react'; import { buildChart, labelize } from '@nao/shared'; -import { Download, FilePlus } from 'lucide-react'; +import { buildChartTag } from '@nao/shared/story-segments'; +import { Download, FilePlus, Pencil } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useOptionalAgentContext } from '../../contexts/agent.provider'; import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent } from '../ui/chart'; @@ -9,6 +10,7 @@ import { Skeleton } from '../ui/skeleton'; import { Button } from '../ui/button'; import { ToolCallWrapper } from './tool-call-wrapper'; import { ChartRangeSelector } from './display-chart-range-selector'; +import { DisplayChartEditDialog } from './display-chart-edit-dialog'; import type { ToolCallComponentProps } from '.'; import type { ChartConfig } from '../ui/chart'; import type { displayChart } from '@nao/shared/tools'; @@ -24,9 +26,6 @@ import { trpc } from '@/main'; const Colors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']; const EMPTY_MESSAGES: UIMessage[] = []; -const escapeDoubleQuotedAttr = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -const escapeSingleQuotedAttr = (value: string) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - export const DisplayChartToolCall = ({ toolPart: { state, input, output, toolCallId }, }: ToolCallComponentProps<'display_chart'>) => { @@ -55,6 +54,8 @@ export const DisplayChartToolCall = ({ ); const [isDownloading, setIsDownloading] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const isEditable = Boolean(agent && !agent.isReadonly && !agent.isRunning); const handleDownload = async () => { if (!config) { @@ -165,8 +166,7 @@ export const DisplayChartToolCall = ({ return; } - const seriesJson = JSON.stringify(config.series); - const chartBlock = ``; + const chartBlock = buildChartTag(config); const newCode = latest.code.trimEnd() + '\n\n' + chartBlock; addToStoryMutation.mutate({ @@ -208,6 +208,16 @@ export const DisplayChartToolCall = ({ onRangeSelected={(range) => setDataRange(range)} /> )} + {isEditable && ( + + )} {config.chart_type != 'kpi_card' && (