Skip to content
Draft
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
6 changes: 5 additions & 1 deletion apps/backend/src/agents/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,13 @@ export const getTools = (

if ('story' in result) {
const mapsEnabled = 'display_map' in result;
const storyPluginsEnabled = agentSettings?.storyPlugins?.enabled === true;
result = {
...result,
story: { ...result.story, description: buildStoryToolDescription({ mapsEnabled }) },
story: {
...result.story,
description: buildStoryToolDescription({ mapsEnabled, storyPluginsEnabled }),
},
};
}

Expand Down
16 changes: 14 additions & 2 deletions apps/backend/src/agents/tools/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,18 @@ const STORY_FILTER_DESCRIPTION = [
'When adding filters to existing charts, prefer execute_sql with query_id set to the existing query so chart/table tags keep the same query_id.',
].join(' ');

export function buildStoryToolDescription({ mapsEnabled = false }: { mapsEnabled?: boolean } = {}) {
const STORY_PLUGIN_DESCRIPTION = [
'Plugins are embedded via <plugin title="...">...</plugin> blocks.',
'Use a plugin only when built-in chart, table, map, or markdown blocks cannot express the desired interactive component.',
'Plugin code must be a self-contained vanilla JavaScript ES module with export default function render(element) that renders into the provided DOM element.',
'Do not use imports or make network calls to external services.',
`Example: <plugin title="Counter">export default function render(element) { let count = 0; element.innerHTML = '<button>Count: 0</button>'; const button = element.querySelector('button'); button.onclick = () => { count += 1; button.textContent = 'Count: ' + count; }; }</plugin>.`,
].join(' ');

export function buildStoryToolDescription({
mapsEnabled = false,
storyPluginsEnabled = false,
}: { mapsEnabled?: boolean; storyPluginsEnabled?: boolean } = {}) {
return [
'Create or modify a nao Story — an interactive document combining markdown text and chart visualizations.',
'Use "create" to initialize a new story, "update" to search-and-replace within it (producing a new version),',
Expand All @@ -33,8 +44,9 @@ export function buildStoryToolDescription({ mapsEnabled = false }: { mapsEnabled
...(mapsEnabled
? ['Maps are embedded via <map query_id="..." map_type="points|scatter_bubble|choropleth" title="..." />.']
: []),
...(storyPluginsEnabled ? [STORY_PLUGIN_DESCRIPTION] : []),
...(env.BETA_STORY_FILTERS_ENABLED ? [STORY_FILTER_DESCRIPTION] : []),
`Use <grid>...</grid> to place 2–4 charts/tables${mapsEnabled ? '/maps' : ''} side by side; its direct <chart>/<table>${mapsEnabled ? '/<map>' : ''} blocks are the columns.`,
`Use <grid>...</grid> to place 2–4 charts/tables${mapsEnabled ? '/maps' : ''}${storyPluginsEnabled ? '/plugins' : ''} side by side; its direct <chart>/<table>${mapsEnabled ? '/<map>' : ''}${storyPluginsEnabled ? '/<plugin>' : ''} blocks are the columns.`,
'For unequal columns add widths="w1,w2,..." to the <grid> — one positive integer per column giving its relative width (e.g. widths="2,1" makes the first column twice as wide as the second). The number of values must equal the number of columns; omit widths for equal columns. Choose widths that fit the content, e.g. a wide time-series next to a narrow KPI or pie.',
'Use consecutive <tab title="...">...</tab> blocks to organize a story into top-level tabs.',
'Default to a single flowing story. Use tabs only when the user asks for tabs, or when the content splits into clearly distinct sections that are better separated than stacked (e.g. overview vs. detail, one topic/department/metric per tab). Avoid tabs for a short or single-topic story. Always follow the user\'s explicit request (e.g. "a tab per chart" means one chart per tab). When using tabs, the entire story must consist of <tab title="...">...</tab> blocks — no content outside a tab.',
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/components/ai/live-story-refresh-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export function LiveStoryRefreshPrompt({
<List>
<ListItem>Preserve every heading exactly as written.</ListItem>
<ListItem>
Preserve every {'<chart ... />'}, {'<table ... />'}, {'<grid ...>'}, {'</grid>'}, {'<tab ...>'}, and{' '}
{'</tab>'} tag exactly as written and in the same order.
Preserve every {'<chart ... />'}, {'<table ... />'}, {'<grid ...>'}, {'</grid>'},{' '}
{'<plugin ...>...</plugin>'}, {'<tab ...>'}, and {'</tab>'} tag exactly as written and in the same
order.
</ListItem>
<ListItem>
Do not add, remove, or reorder structural blocks. Try to keep the formatting as close as possible to
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/queries/project.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export const updateAgentSettings = async (projectId: string, settings: AgentSett
...current.webSearch,
...settings.webSearch,
},
storyPlugins: {
...current.storyPlugins,
...settings.storyPlugins,
},
pythonExecution: {
...current.pythonExecution,
...settings.pythonExecution,
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/trpc/project.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,11 @@ export const projectRoutes = {
mode: z.enum(['provider']).optional(),
})
.optional(),
storyPlugins: z
.object({
enabled: z.boolean().optional(),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
Expand All @@ -863,6 +868,7 @@ export const projectRoutes = {
sql: { ...existing.sql, ...input.sql },
pythonExecution: { ...existing.pythonExecution, ...input.pythonExecution },
webSearch: { ...existing.webSearch, ...input.webSearch },
storyPlugins: { ...existing.storyPlugins, ...input.storyPlugins },
};
posthog.capture(ctx.user.id, PostHogEvent.ProjectAgentSettingsUpdated, {
project_id: ctx.project.id,
Expand All @@ -876,6 +882,7 @@ export const projectRoutes = {
memory_enabled: merged.memoryEnabled,
web_search_enabled: merged.webSearch?.enabled,
web_search_mode: merged.webSearch?.mode,
story_plugins_enabled: merged.storyPlugins?.enabled,
});
return projectQueries.updateAgentSettings(ctx.project.id, merged);
}),
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/types/agent-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ export interface AgentSettings {
enabled?: boolean;
mode?: WebSearchMode;
};
storyPlugins?: {
enabled?: boolean;
};
}
2 changes: 2 additions & 0 deletions apps/backend/src/utils/story-html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ function StorySegment({ segment, queryData }: { segment: Segment; queryData: Que
return <MapBlock map={segment.map} queryData={queryData} />;
case 'filter':
return null;
case 'plugin':
return null;
case 'grid':
return <GridBlock segment={segment} queryData={queryData} />;
}
Expand Down
30 changes: 30 additions & 0 deletions apps/backend/tests/story-tool-description.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest';

vi.mock('../src/db/db', () => ({ db: {} }));

import { getTools } from '../src/agents/tools';
import { buildStoryToolDescription } from '../src/agents/tools/story';
import type { AgentSettings } from '../src/types/agent-settings';

describe('story plugin tool description', () => {
it('does not mention plugins when story plugins are disabled', () => {
expect(buildStoryToolDescription().toLowerCase()).not.toContain('plugin');
expect(storyDescription({ storyPlugins: { enabled: false } }).toLowerCase()).not.toContain('plugin');
});

it('documents the plugin contract and restrictions when enabled', () => {
const description = storyDescription({ storyPlugins: { enabled: true } });

expect(description).toContain('<plugin title="...">...</plugin>');
expect(description).toContain('export default function render(element)');
expect(description).toContain('self-contained vanilla JavaScript');
expect(description).toContain('Do not use imports or make network calls');
expect(description).toContain('only when built-in chart, table, map, or markdown blocks cannot express');
expect(description).toContain('<plugin title="Counter">');
});
});

function storyDescription(agentSettings: AgentSettings): string {
const storyTool = getTools(agentSettings, undefined, { testMode: true }).story as { description?: string };
return storyTool.description ?? '';
}
8 changes: 8 additions & 0 deletions apps/frontend/src/components/settings-search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,14 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [
keywords: ['display map', 'choropleth', 'points', 'scatter', 'bubble', 'geospatial'],
adminOnly: true,
},
{
page: '/settings/project/agent',
pageLabel: 'Agent',
title: 'Vibe coded plugins',
description: 'Allow the agent to generate custom interactive plugin blocks inside stories.',
keywords: ['plugin', 'vibe', 'story', 'custom component'],
adminOnly: true,
},
{
page: '/settings/project/agent',
pageLabel: 'Agent',
Expand Down
51 changes: 51 additions & 0 deletions apps/frontend/src/components/settings/story-plugins.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { SettingsCard } from '@/components/ui/settings-card';
import { SettingsControlRow } from '@/components/ui/settings-toggle-row';
import { Switch } from '@/components/ui/switch';
import { trpc } from '@/main';

interface SettingsStoryPluginsProps {
isAdmin: boolean;
}

export function SettingsStoryPlugins({ isAdmin }: SettingsStoryPluginsProps) {
const queryClient = useQueryClient();
const agentSettings = useQuery(trpc.project.getAgentSettings.queryOptions());

const updateAgentSettings = useMutation(
trpc.project.updateAgentSettings.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.project.getAgentSettings.queryOptions().queryKey,
});
},
}),
);

const storyPluginsEnabled = agentSettings.data?.storyPlugins?.enabled ?? false;

const handleStoryPluginsChange = (enabled: boolean) => {
updateAgentSettings.mutate({ storyPlugins: { enabled } });
};

return (
<SettingsCard
title='Vibe coded plugins'
description='Allow the agent to generate custom interactive plugin blocks inside stories.'
>
<SettingsControlRow
id='story-plugins'
label='Enable story plugins'
description='Allows self-contained JavaScript plugins generated by the agent to run in stories.'
control={
<Switch
id='story-plugins'
checked={storyPluginsEnabled}
onCheckedChange={handleStoryPluginsChange}
disabled={!isAdmin || updateAgentSettings.isPending}
/>
}
/>
</SettingsCard>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,8 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
hoveredNode != null &&
(hoveredNode.type.name === 'gridBlock' ||
hoveredNode.type.name === 'chartBlock' ||
hoveredNode.type.name === 'tableBlock')
hoveredNode.type.name === 'tableBlock' ||
hoveredNode.type.name === 'pluginBlock')
) {
event.preventDefault();
return;
Expand Down Expand Up @@ -490,7 +491,10 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams
const node = editor.state.doc.nodeAt(pos);
if (
node != null &&
(node.type.name === 'gridBlock' || node.type.name === 'chartBlock' || node.type.name === 'tableBlock')
(node.type.name === 'gridBlock' ||
node.type.name === 'chartBlock' ||
node.type.name === 'tableBlock' ||
node.type.name === 'pluginBlock')
) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export function useStoryBlockDrag({ node, editor, getPos }: Pick<ReactNodeViewPr

event.dataTransfer.effectAllowed = 'move';
dragContext.sourceRef.current = {
markup: node.attrs.rawTag as string,
markup: getStoryBlockMarkup(node),
origin: { kind: 'block', pos },
};
dragContext.setDragging(true);
},
[dragContext, editor, getPos, node.attrs.rawTag],
[dragContext, editor, getPos, node],
);

const handleDragEnd = useCallback(
Expand Down Expand Up @@ -134,7 +134,7 @@ export function StoryBlockDropZones({ node, editor, getPos }: Pick<ReactNodeView
}

const state = editor.state;
const targetMarkup = node.attrs.rawTag as string;
const targetMarkup = getStoryBlockMarkup(node);
const leftMarkup = side === 'left' ? source.markup : targetMarkup;
const rightMarkup = side === 'left' ? targetMarkup : source.markup;
const gridNode = createBlockNode(state.schema, groupBlocksIntoGrid(leftMarkup, rightMarkup));
Expand Down Expand Up @@ -186,3 +186,11 @@ export function StoryBlockDropZones({ node, editor, getPos }: Pick<ReactNodeView
})
);
}

function getStoryBlockMarkup(node: ReactNodeViewProps['node']): string {
const rawContent = node.attrs.rawContent;
if (typeof rawContent === 'string' && rawContent) {
return rawContent;
}
return typeof node.attrs.rawTag === 'string' ? node.attrs.rawTag : '';
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BlockSelection } from './story-block-selection';
import { ChartBlock } from './story-editor-chart-block';
import { GridBlock } from './story-editor-grid-block';
import { MapBlock } from './story-editor-map-block';
import { PluginBlock } from './story-editor-plugin-block';
import { TableBlock } from './story-editor-table-block';
import type { Editor as CoreEditor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
Expand Down Expand Up @@ -90,6 +91,7 @@ export const EDITOR_EXTENSIONS = [
ChartBlock,
TableBlock,
MapBlock,
PluginBlock,
GridBlock,
BlockSelection,
];
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { StoryTableEmbed } from './story-table-embed';
import { blockSelectionPluginKey, selectColumnFromHandle } from './story-block-selection';
import { BlockSelectionContext } from './story-block-selection-context';
import { StoryBlockDragContext } from './story-editor-drag-context';
import { StoryPluginPlaceholder } from './story-editor-plugin-block';
import { decodeFromAttr } from './story-editor-utils';
import { useStoryEditorGridBlock } from './hooks/use-story-editor-grid-block';
import type { Segment } from '@nao/shared/story-segments';
Expand Down Expand Up @@ -67,6 +68,8 @@ function renderColumnContent(
<StoryMapEmbed map={segment.map} dragHandle={dragHandle} />
</EditorStoryMapEditProvider>
);
case 'plugin':
return <StoryPluginPlaceholder plugin={segment.plugin} dragHandle={dragHandle} />;
case 'grid':
return (
<div className='flex flex-col gap-4'>
Expand Down Expand Up @@ -132,10 +135,11 @@ function GridBlockView(props: ReactNodeViewProps) {
>
{segments.map((segment, i) => {
// Markdown columns cannot be dragged out (createBlockNode would
// turn their markdown into literal text), so only chart/table
// columns get a move handle.
// turn their markdown into literal text), so only supported story
// block columns get a move handle.
const columnGrip =
segments.length >= 2 && (segment.type === 'chart' || segment.type === 'table') ? (
segments.length >= 2 &&
(segment.type === 'chart' || segment.type === 'table' || segment.type === 'plugin') ? (
<button
type='button'
aria-label={`Move column ${i + 1}`}
Expand Down
Loading
Loading