Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/backend/src/queries/chart-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,23 @@ export const saveChart = async (toolCallId: string, data: string): Promise<strin
);
return row.id;
};

/** Returns the project owner of the chat that contains the given chart tool call. */
export const getChartOwnerInfo = async (toolCallId: string): Promise<{ projectId: string; userId: string } | null> => {
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<void> => {
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();
Comment on lines +84 to +87
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Wrap the config update and chart-image cache invalidation in a single DB transaction to avoid partial writes when one statement fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/queries/chart-image.ts, line 84:

<comment>Wrap the config update and chart-image cache invalidation in a single DB transaction to avoid partial writes when one statement fails.</comment>

<file context>
@@ -66,3 +66,23 @@ export const saveChart = async (toolCallId: string, data: string): Promise<strin
+
+/** Persists an updated `display_chart` config for the given tool call. */
+export const updateChartConfig = async (toolCallId: string, config: displayChart.Input): Promise<void> => {
+	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.
</file context>
Suggested change
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();
await db.transaction(async (tx) => {
await tx.update(s.messagePart).set({ toolInput: config }).where(eq(s.messagePart.toolCallId, toolCallId)).execute();
// Invalidate any cached PNG so externally-served chart images refresh.
await tx.delete(s.message_part_chart_image).where(eq(s.message_part_chart_image.toolCallId, toolCallId)).execute();
});

};
36 changes: 34 additions & 2 deletions apps/backend/src/trpc/chart.routes.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<displayChart.Input>((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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Validate that toolCallId belongs to a display_chart tool call before writing, otherwise this mutation can overwrite non-chart tool inputs for owned chats.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/trpc/chart.routes.ts, line 49:

<comment>Validate that `toolCallId` belongs to a `display_chart` tool call before writing, otherwise this mutation can overwrite non-chart tool inputs for owned chats.</comment>

<file context>
@@ -17,4 +24,29 @@ export const chartRoutes = {
+				});
+			}
+
+			await updateChartConfig(input.toolCallId, input.config);
+			return { success: true as const };
+		}),
</file context>

return { success: true as const };
}),
};
71 changes: 68 additions & 3 deletions apps/frontend/src/components/side-panel/story-chart-embed.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 }) {
Expand Down Expand Up @@ -59,7 +64,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart:
const xAxisType = chart.xAxisType === 'number' ? 'number' : ('category' as const);

return (
<div className={`my-2 ${chart.chartType != 'kpi_card' ? 'aspect-3/2' : ''} `}>
<StoryChartEmbedShell chart={chart} availableColumns={sourceData.columns ?? []}>
<ChartDisplay
data={data}
chartType={chart.chartType as displayChart.ChartType}
Expand All @@ -68,6 +73,66 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({ chart }: { chart:
series={chart.series}
title={chart.title}
/>
</div>
</StoryChartEmbedShell>
);
});

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<displayChart.Input>(
() => ({
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 (
<div className={`my-2 relative ${chart.chartType != 'kpi_card' ? 'aspect-3/2' : ''}`}>
{canEdit && (
<Button
variant='ghost-muted'
size='icon-xs'
onClick={() => setIsEditOpen(true)}
title='Edit chart'
className='absolute top-1 right-1 z-10 bg-background/80 backdrop-blur hover:bg-accent'
>
<Pencil className='size-3.5' />
</Button>
)}
{children}
{canEdit && edit && chart.rawTag && (
<ChartConfigEditDialog
open={isEditOpen}
onOpenChange={setIsEditOpen}
config={config}
availableColumns={availableColumns}
isSaving={edit.isSaving}
onSave={(next) => edit.saveChart(chart.rawTag!, next)}
description='Tweak the chart parameters. Changes are saved to the story as a new version.'
/>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion apps/frontend/src/components/side-panel/story-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
54 changes: 42 additions & 12 deletions apps/frontend/src/components/side-panel/story-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -169,18 +170,27 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }:
{Boolean(archivedAt) && <ArchivedBanner chatId={chatId} storySlug={resolvedStorySlug} />}

<div ref={scrollContainerRef} className='flex-1 min-h-0 overflow-auto'>
{viewMode === 'preview' ? (
<StoryPreview
code={storyCode}
cacheSchedule={cacheSchedule}
queryData={queryData ?? null}
chatId={chatId}
versionKey={`${currentVersionNumber}-${cachedAt ?? ''}`}
/>
) : viewMode === 'edit' ? (
<StoryEditor code={storyCode} editorRef={tiptapEditorRef} onSave={handleSave} />
) : (
<StoryCodeView code={storyCode} />
{renderWithEditProvider(
!isReadonlyMode && isViewingLatest && !archivedAt && !isAgentRunning && viewMode !== 'edit',
{
chatId,
storySlug: resolvedStorySlug,
storyTitle,
storyCode,
},
viewMode === 'preview' ? (
<StoryPreview
code={storyCode}
cacheSchedule={cacheSchedule}
queryData={queryData ?? null}
chatId={chatId}
versionKey={`${currentVersionNumber}-${cachedAt ?? ''}`}
/>
) : viewMode === 'edit' ? (
<StoryEditor code={storyCode} editorRef={tiptapEditorRef} onSave={handleSave} />
) : (
<StoryCodeView code={storyCode} />
),
)}
</div>

Expand Down Expand Up @@ -210,3 +220,23 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }:

return <ReadonlyAgentMessagesProvider messages={chatMessages}>{content}</ReadonlyAgentMessagesProvider>;
}

function renderWithEditProvider(
enabled: boolean,
params: { chatId: string; storySlug: string; storyTitle: string; storyCode: string },
children: React.ReactNode,
) {
if (!enabled) {
return children;
}
return (
<StoryChartEditProvider
chatId={params.chatId}
storySlug={params.storySlug}
storyTitle={params.storyTitle}
storyCode={params.storyCode}
>
{children}
</StoryChartEditProvider>
);
}
13 changes: 8 additions & 5 deletions apps/frontend/src/components/story-embeds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -54,9 +55,11 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
}) {
const noCacheFetch = useLiveQueryData(chart.queryId, liveQuery);

const resolvedData = liveQuery
? (noCacheFetch.data as { data: Record<string, unknown>[]; columns: string[] } | undefined)?.data
: queryData?.[chart.queryId]?.data;
const resolved = liveQuery
? (noCacheFetch.data as { data: Record<string, unknown>[]; columns: string[] } | undefined)
: queryData?.[chart.queryId];
const resolvedData = resolved?.data;
const resolvedColumns = resolved?.columns ?? [];

if (liveQuery && noCacheFetch.isLoading) {
return <EmbedLoading />;
Expand All @@ -71,7 +74,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
}

return (
<div className={`my-2 ${chart.chartType !== 'kpi_card' ? 'aspect-3/2' : ''}`}>
<StoryChartEmbedShell chart={chart} availableColumns={resolvedColumns}>
<ChartDisplay
data={resolvedData}
chartType={chart.chartType as displayChart.ChartType}
Expand All @@ -80,7 +83,7 @@ export const StoryChartEmbed = memo(function StoryChartEmbed({
series={chart.series}
title={chart.title}
/>
</div>
</StoryChartEmbedShell>
);
});

Expand Down
Loading
Loading