mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
Merge branch 'main' into feat/configurable-model-call-limit
This commit is contained in:
commit
c7b9ca5a61
13 changed files with 435 additions and 13 deletions
|
|
@ -181,7 +181,8 @@ All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers
|
|||
|
||||
- `email_send_failed` — send returned an error or threw (`components/email-view.tsx`)
|
||||
- `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`)
|
||||
- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks
|
||||
- `bg_agent_run_failed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event', error: string }` **(core)** — emitted when a background-agent run fails; `error` contains the normalized failure message
|
||||
- `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — emitted when a background-agent run succeeds; together these events give a failure *rate* across all trigger sources, not just manual clicks
|
||||
|
||||
**Misc**:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, autoUpdater, nativeImage, BrowserWindow } from "electron";
|
||||
import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron";
|
||||
import { capture } from "@x/core/dist/analytics/posthog.js";
|
||||
import type { ipc } from "@x/shared";
|
||||
|
||||
|
|
@ -90,6 +90,12 @@ export function initUpdater(): void {
|
|||
releaseNotes: releaseNotes || undefined,
|
||||
});
|
||||
showReadyBadge();
|
||||
// Squirrel.Windows never carries notes, and Squirrel.Mac's copy is a
|
||||
// snapshot from download time — stale when the release body is edited
|
||||
// after publish (update.electronjs.org caching widens that window).
|
||||
// Refresh from the GitHub API; on failure the snapshot (or the card's
|
||||
// static fallback line) stands.
|
||||
void backfillReleaseNotes(releaseName || undefined);
|
||||
});
|
||||
autoUpdater.on("error", (err) => {
|
||||
setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt });
|
||||
|
|
@ -110,6 +116,30 @@ export function initUpdater(): void {
|
|||
setInterval(checkForUpdates, CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the staged update's release notes with the current GitHub release
|
||||
* body. releaseName is "0.7.7" from Squirrel.Windows and "v0.7.7" from
|
||||
* Squirrel.Mac — normalize to the tag form.
|
||||
*/
|
||||
async function backfillReleaseNotes(releaseName: string | undefined): Promise<void> {
|
||||
if (!releaseName) return;
|
||||
try {
|
||||
const tag = `v${releaseName.replace(/^v/, "")}`;
|
||||
const res = await net.fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||
headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const { body } = (await res.json()) as { body?: string };
|
||||
const notes = body?.trim();
|
||||
// Re-check the state: the fetch raced user actions (quitAndInstall).
|
||||
if (notes && status.state === "ready" && status.newVersion === releaseName) {
|
||||
setStatus({ ...status, releaseNotes: notes });
|
||||
}
|
||||
} catch {
|
||||
// Offline or rate-limited — the Squirrel snapshot / fallback line stands.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "Check for updates". Only meaningful when idle or errored;
|
||||
* checking/downloading are already in flight and ready is already staged.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { isValidElement, type JSX } from 'react'
|
||||
import { FilePathCard } from './file-path-card'
|
||||
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
||||
import { ChartRenderer } from '@/components/chart-renderer'
|
||||
|
||||
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||
const { children, ...rest } = props
|
||||
|
|
@ -31,6 +32,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
|||
return <MermaidRenderer source={text} />
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof childProps.className === 'string' &&
|
||||
childProps.className.includes('language-chart')
|
||||
) {
|
||||
const text = typeof childProps.children === 'string'
|
||||
? childProps.children.trim()
|
||||
: ''
|
||||
if (text) {
|
||||
return <ChartRenderer source={text} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Passthrough for all other code blocks - return children directly
|
||||
|
|
|
|||
52
apps/x/apps/renderer/src/components/chart-renderer.test.ts
Normal file
52
apps/x/apps/renderer/src/components/chart-renderer.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseChartSource } from './chart-renderer'
|
||||
|
||||
const valid = {
|
||||
chart: 'line',
|
||||
x: 'day',
|
||||
y: 'pct',
|
||||
data: [{ day: 'Mon', pct: 0.1 }],
|
||||
}
|
||||
|
||||
describe('parseChartSource', () => {
|
||||
it('parses a valid config', () => {
|
||||
const { config, invalid } = parseChartSource(JSON.stringify(valid))
|
||||
expect(invalid).toBe(false)
|
||||
expect(config?.chart).toBe('line')
|
||||
})
|
||||
|
||||
it('treats incomplete JSON as streaming, not invalid', () => {
|
||||
const partial = JSON.stringify(valid).slice(0, 25)
|
||||
expect(parseChartSource(partial)).toEqual({ config: null, invalid: false })
|
||||
})
|
||||
|
||||
it('flags complete-but-schema-invalid JSON as invalid', () => {
|
||||
const { config, invalid } = parseChartSource(
|
||||
JSON.stringify({ chart: 'line', data: [] }),
|
||||
)
|
||||
expect(config).toBeNull()
|
||||
expect(invalid).toBe(true)
|
||||
})
|
||||
|
||||
it('maps the label/value pie aliases to x/y', () => {
|
||||
// Seen live: models emit pie configs with label/value field names.
|
||||
const { config, invalid } = parseChartSource(
|
||||
JSON.stringify({
|
||||
chart: 'pie',
|
||||
label: 'index',
|
||||
value: 'drop',
|
||||
data: [{ index: 'Nasdaq', drop: 2.4 }],
|
||||
}),
|
||||
)
|
||||
expect(invalid).toBe(false)
|
||||
expect(config?.x).toBe('index')
|
||||
expect(config?.y).toBe('drop')
|
||||
})
|
||||
|
||||
it('accepts a y array for multi-series charts', () => {
|
||||
const { config } = parseChartSource(
|
||||
JSON.stringify({ ...valid, y: ['a', 'b'] }),
|
||||
)
|
||||
expect(config?.y).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
178
apps/x/apps/renderer/src/components/chart-renderer.tsx
Normal file
178
apps/x/apps/renderer/src/components/chart-renderer.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useMemo } from 'react'
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import {
|
||||
LineChart, Line,
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts'
|
||||
|
||||
// Categorical palettes validated for CVD separation and surface contrast on
|
||||
// each mode's chart surface (dataviz six-checks validator). Slots are
|
||||
// assigned to series in fixed order — the dark column is the same hues
|
||||
// re-stepped for the dark surface, not a different palette.
|
||||
const SERIES_COLORS_LIGHT = ['#2a78d6', '#008300', '#e87ba4', '#eda100', '#1baf7a', '#eb6834']
|
||||
const SERIES_COLORS_DARK = ['#3987e5', '#008300', '#d55181', '#c98500', '#199e70', '#d95926']
|
||||
|
||||
interface ChartRendererProps {
|
||||
/** Raw contents of a ```chart fence: ChartBlockSchema JSON. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a chart fence body. Distinguishes the three states the renderer
|
||||
* shows: `streaming` (JSON incomplete — the model is still writing),
|
||||
* `invalid` (complete JSON that fails the schema), and a parsed config.
|
||||
* Models occasionally reach for pie-flavored field names despite the
|
||||
* skill's schema; the predictable label/value aliases map to x/y.
|
||||
*/
|
||||
export function parseChartSource(
|
||||
source: string,
|
||||
): { config: blocks.ChartBlock | null; invalid: boolean } {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(source)
|
||||
} catch {
|
||||
return { config: null, invalid: false }
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label
|
||||
if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value
|
||||
}
|
||||
const result = blocks.ChartBlockSchema.safeParse(parsed)
|
||||
return result.success
|
||||
? { config: result.data, invalid: false }
|
||||
: { config: null, invalid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline chart for chat messages: renders a ```chart fenced block (same
|
||||
* ChartBlockSchema the notes chart block uses) via recharts. Invalid or
|
||||
* still-streaming JSON renders as a quiet placeholder rather than an error —
|
||||
* the fence body arrives token by token while the model writes it.
|
||||
*/
|
||||
export function ChartRenderer({ source }: ChartRendererProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const colors = resolvedTheme === 'dark' ? SERIES_COLORS_DARK : SERIES_COLORS_LIGHT
|
||||
const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0'
|
||||
const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e'
|
||||
|
||||
const { config, invalid } = useMemo(() => parseChartSource(source), [source])
|
||||
|
||||
// Complete JSON that fails the schema is a real error — say so instead of
|
||||
// showing the streaming placeholder forever.
|
||||
if (invalid || (config && (!config.data || config.data.length === 0))) {
|
||||
return (
|
||||
<div className="my-2 flex h-24 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{invalid ? 'Chart config invalid — ask me to redraw it' : 'Chart has no data'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="my-2 flex h-24 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<BarChart3 className="size-3.5" />
|
||||
Preparing chart…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = config.data ?? []
|
||||
const series = blocks.chartSeries(config)
|
||||
const axisProps = {
|
||||
stroke: textColor,
|
||||
tick: { fill: textColor, fontSize: 11 },
|
||||
tickLine: false,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-md border border-border bg-card p-3">
|
||||
{config.title && (
|
||||
<div className="mb-2 text-sm font-medium text-foreground">{config.title}</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
{config.chart === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
|
||||
<XAxis dataKey={config.x} {...axisProps} />
|
||||
<YAxis {...axisProps} width={44} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
|
||||
border: `1px solid ${gridStroke}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((key, i) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[i % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2.5 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : config.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
|
||||
<XAxis dataKey={config.x} {...axisProps} />
|
||||
<YAxis {...axisProps} width={44} />
|
||||
<Tooltip
|
||||
cursor={{ fill: resolvedTheme === 'dark' ? '#ffffff14' : '#00000009' }}
|
||||
contentStyle={{
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
|
||||
border: `1px solid ${gridStroke}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
|
||||
{series.map((key, i) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={colors[i % colors.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
|
||||
border: `1px solid ${gridStroke}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={series[0]}
|
||||
nameKey={config.x}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={90}
|
||||
stroke={resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb'}
|
||||
strokeWidth={2}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={colors[i % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -55,6 +55,11 @@ export function UpdateCard() {
|
|||
if (!visible || status?.state !== "ready") return null
|
||||
|
||||
const releaseUrl = version ? `${RELEASES_URL}/tag/v${version}` : `${RELEASES_URL}/latest`
|
||||
// Release bodies usually open with their own "What's new" heading, which
|
||||
// would duplicate the card's label above the box — drop it.
|
||||
const releaseNotes = status.releaseNotes
|
||||
?.replace(/^\s*#{1,6}[ \t]*what[’']?s new[ \t]*\r?\n+/i, "")
|
||||
.trim()
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -81,10 +86,13 @@ export function UpdateCard() {
|
|||
</p>
|
||||
<div className="mt-3">
|
||||
<h5 className="text-xs font-semibold">What's new</h5>
|
||||
{status.releaseNotes ? (
|
||||
<div className="mt-1.5 max-h-56 overflow-y-auto">
|
||||
{releaseNotes ? (
|
||||
// A bordered, fixed-height scroll box (not a bare max-h clip): the
|
||||
// frame and inset scrollbar signal there is more content below the
|
||||
// fold on every platform.
|
||||
<div className="mt-1.5 max-h-56 overflow-y-auto overscroll-contain rounded-md border border-border/60 bg-muted/30 p-2.5">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-xs [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{status.releaseNotes}
|
||||
{releaseNotes}
|
||||
</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
if (error) return <div className="chart-block-error-msg">{error}</div>
|
||||
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
||||
|
||||
const series = blocks.chartSeries(config!)
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{config!.chart === 'line' ? (
|
||||
|
|
@ -73,7 +74,9 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
|
||||
{series.map((key, index) => (
|
||||
<Line key={key} type="monotone" dataKey={key} stroke={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</LineChart>
|
||||
) : config!.chart === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
|
|
@ -82,13 +85,15 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
|||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey={config!.y} fill="#8884d8" />
|
||||
{series.map((key, index) => (
|
||||
<Bar key={key} dataKey={key} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
<Pie data={data} dataKey={series[0]} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}i
|
|||
|
||||
const runningTasks = new Set<string>();
|
||||
|
||||
type RunAnalyticsOutcome =
|
||||
| { event: 'bg_agent_run_completed'; properties: { trigger: BackgroundTaskTriggerType } }
|
||||
| { event: 'bg_agent_run_failed'; properties: { trigger: BackgroundTaskTriggerType; error: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -114,6 +118,8 @@ export async function runBackgroundTask(
|
|||
}
|
||||
runningTasks.add(slug);
|
||||
|
||||
let analyticsOutcome: RunAnalyticsOutcome | undefined;
|
||||
|
||||
try {
|
||||
const task = await fetchTask(slug);
|
||||
if (!task) {
|
||||
|
|
@ -211,7 +217,6 @@ export async function runBackgroundTask(
|
|||
});
|
||||
|
||||
log.log(`${slug} — done summary="${truncate(summary)}"`);
|
||||
capture('bg_agent_run_completed', { trigger });
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
type: 'background_task_agent_complete',
|
||||
|
|
@ -220,10 +225,16 @@ export async function runBackgroundTask(
|
|||
...(summary ? { summary } : {}),
|
||||
});
|
||||
|
||||
analyticsOutcome = { event: 'bg_agent_run_completed', properties: { trigger } };
|
||||
return { slug, runId, summary };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
analyticsOutcome = {
|
||||
event: 'bg_agent_run_failed',
|
||||
properties: { trigger, error: msg },
|
||||
};
|
||||
|
||||
// Failure — only record the error. `lastRunAt` and `lastRunSummary`
|
||||
// are deliberately untouched so the user keeps seeing the last good
|
||||
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
|
||||
|
|
@ -235,7 +246,6 @@ export async function runBackgroundTask(
|
|||
}
|
||||
|
||||
log.log(`${slug} — failed: ${truncate(msg)}`);
|
||||
capture('bg_agent_run_failed', { trigger });
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
type: 'background_task_agent_complete',
|
||||
|
|
@ -246,7 +256,22 @@ export async function runBackgroundTask(
|
|||
|
||||
return { slug, runId, summary: null, error: msg };
|
||||
}
|
||||
} catch (err) {
|
||||
// Preserve the original throw behavior for setup/infrastructure errors,
|
||||
// but still settle analytics for the attempted run. If the agent had
|
||||
// already failed, keep that original failure reason.
|
||||
analyticsOutcome ??= {
|
||||
event: 'bg_agent_run_failed',
|
||||
properties: {
|
||||
trigger,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
throw err;
|
||||
} finally {
|
||||
if (analyticsOutcome) {
|
||||
capture(analyticsOutcome.event, analyticsOutcome.properties);
|
||||
}
|
||||
runningTasks.delete(slug);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
export const skill = String.raw`
|
||||
# Charts
|
||||
|
||||
Load this skill when the user asks for a chart, graph, plot, trend, comparison, or "visualize" — or when data you've gathered (prices over time, counts per category, proportions) would land better as a picture than a table.
|
||||
|
||||
## How it works
|
||||
|
||||
Emit a fenced code block with language \`chart\` anywhere in your reply. The app renders it as an interactive chart inline (tooltips, legend, light/dark theming are handled for you — never pick colors yourself). Everything outside the fence is normal markdown.
|
||||
|
||||
\`\`\`\`
|
||||
\`\`\`chart
|
||||
{ ...JSON config... }
|
||||
\`\`\`
|
||||
\`\`\`\`
|
||||
|
||||
## Config schema
|
||||
|
||||
The ONLY fields are \`chart\`, \`data\`, \`x\`, \`y\`, \`title\` — nothing else exists (no \`label\`, \`value\`, \`series\`, \`color\`, etc.). Unknown fields are ignored; a missing \`x\` or \`y\` breaks the chart.
|
||||
|
||||
- **\`chart\`** (required): \`"line"\` | \`"bar"\` | \`"pie"\`
|
||||
- **\`data\`** (required): array of flat objects — the rows to plot. Put REAL values you gathered this turn here; never invent numbers.
|
||||
- **\`x\`** (required): key of the label/category field in each row. For pie: the slice-name key.
|
||||
- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart. For pie: the slice-value key.
|
||||
- **\`title\`** (optional): short heading shown above the chart
|
||||
|
||||
**Data must be wide-format**: one row per x value, one key per series. Never long/tidy format (a row per series-point with a series-name column) — multiple series means multiple keys in the SAME row:
|
||||
|
||||
WRONG: \`[{ "day": "Mon", "index": "S&P", "pct": 0.1 }, { "day": "Mon", "index": "Dow", "pct": 0.2 }]\`
|
||||
RIGHT: \`[{ "day": "Mon", "S&P": 0.1, "Dow": 0.2 }]\` with \`"y": ["S&P", "Dow"]\`
|
||||
|
||||
## Picking the form
|
||||
|
||||
- **line** — change over time (prices, counts by date). X is the time field.
|
||||
- **bar** — compare magnitudes across categories (issues per label, revenue per region).
|
||||
- **pie** — proportions of a whole; only with ≤ 6 slices, otherwise use a bar.
|
||||
- Two measures with very different scales (e.g. a $600 stock vs a $5 one): do NOT mix them on one chart — either normalize to % change and say so in the title, or emit two chart blocks.
|
||||
|
||||
## Examples
|
||||
|
||||
Multi-series line (comparison over time, normalized):
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "line",
|
||||
"title": "5-Day % Change",
|
||||
"x": "date",
|
||||
"y": ["AAPL", "NVDA", "SPY"],
|
||||
"data": [
|
||||
{ "date": "Jul 15", "AAPL": 0, "NVDA": 0, "SPY": 0 },
|
||||
{ "date": "Jul 16", "AAPL": -0.4, "NVDA": 1.2, "SPY": 0.1 },
|
||||
{ "date": "Jul 17", "AAPL": -1.1, "NVDA": 0.8, "SPY": -0.3 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Single-series bar:
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "bar",
|
||||
"title": "Open issues by area",
|
||||
"x": "area",
|
||||
"y": "count",
|
||||
"data": [
|
||||
{ "area": "sync", "count": 14 },
|
||||
{ "area": "editor", "count": 9 },
|
||||
{ "area": "billing", "count": 3 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Pie (x names the slice, y is its value — same keys as every other chart):
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "pie",
|
||||
"title": "Time spent by project",
|
||||
"x": "project",
|
||||
"y": "hours",
|
||||
"data": [
|
||||
{ "project": "Alpha", "hours": 14 },
|
||||
{ "project": "Beta", "hours": 6 },
|
||||
{ "project": "Internal", "hours": 3 }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Rules
|
||||
|
||||
- Data must come from what you actually fetched or computed this turn — never fabricate points to make a chart possible. Too little real data? Say so instead of charting.
|
||||
- Keep it readable: ≤ ~30 rows per chart, ≤ 6 series. Round values sensibly.
|
||||
- Numbers must be JSON numbers, not strings ("3.1", "5%" won't plot).
|
||||
- Follow the chart with one sentence of takeaway — what the reader should see in it.
|
||||
- One chart per distinct question; don't emit several variants of the same data.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -224,7 +224,7 @@ Renders a chart from inline data.
|
|||
- \`data\` (optional): Array of objects with the data points
|
||||
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
|
||||
- \`x\` (required): Key name for the x-axis / label field
|
||||
- \`y\` (required): Key name for the y-axis / value field
|
||||
- \`y\` (required): Key name for the y-axis / value field — or an array of key names to plot several series on one chart (each row then carries one key per series)
|
||||
|
||||
### Table Block
|
||||
Renders a styled table from structured data.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import backgroundTaskSkill from "./background-task/skill.js";
|
|||
import notifyUserSkill from "./notify-user/skill.js";
|
||||
import appsSkill from "./apps/skill.js";
|
||||
import slackSkill from "./slack/skill.js";
|
||||
import chartsSkill from "./charts/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/runtime/assembly/skills";
|
||||
|
|
@ -77,6 +78,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
|
||||
content: meetingPrepSkill,
|
||||
},
|
||||
{
|
||||
id: "charts",
|
||||
title: "Charts",
|
||||
summary: "Render interactive charts (line, bar, pie) inline in the chat reply. Use when the user asks for a chart/graph/visualization or when gathered data is clearer as a picture.",
|
||||
content: chartsSkill,
|
||||
},
|
||||
{
|
||||
id: "organize-files",
|
||||
title: "Organize Files",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function parentCreated(
|
|||
config: {
|
||||
autoPermission: true,
|
||||
humanAvailable: false,
|
||||
maxModelCalls: 20,
|
||||
maxModelCalls: 50,
|
||||
},
|
||||
} as z.infer<typeof TurnEvent>,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -47,11 +47,18 @@ export const ChartBlockSchema = z.object({
|
|||
data: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
source: z.string().optional(),
|
||||
x: z.string(),
|
||||
y: z.string(),
|
||||
// One series (string) or several (array of data keys). Pie ignores all
|
||||
// but the first.
|
||||
y: z.union([z.string(), z.array(z.string()).min(1)]),
|
||||
});
|
||||
|
||||
export type ChartBlock = z.infer<typeof ChartBlockSchema>;
|
||||
|
||||
/** The y series list regardless of which form the block used. */
|
||||
export function chartSeries(block: ChartBlock): string[] {
|
||||
return Array.isArray(block.y) ? block.y : [block.y];
|
||||
}
|
||||
|
||||
export const TableBlockSchema = z.object({
|
||||
columns: z.array(z.string()),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue