ai: rich-output blocks via lazy-fetched typed-fence protocol
Assistant replies can now emit typed fenced blocks that render as @crema/*-ui components inline at their position in the reply. - message-body.tsx: segmented rendering — alternating prose chunks and block dispatch (was: all blocks appended at end). Renderers for kpi, table, chart-bar/-line/-donut/-spark, code, diff, flowchart, orgchart, steps, checklist, welcome, hint, plus the legacy card kinds. - block-schemas.ts: single source of truth — BLOCK_INDEX (one-line purpose per kind, always in prompt) + SCHEMAS (full JSON shape + example, fetched on demand). - admin-tools.ts: new get_block_schema(kind) tool the model calls once per kind per thread to fetch the exact schema. Keeps the always-on prompt small (~110 tokens vs ~400 inline). - assistant.tsx: replaces the inline schema dump with the generated thin index. - ai.tsx: empty-state preview button injects a synthetic assistant message exercising every block, for renderer/theme smoke-testing. - console.css + ai.tsx: shrink ATLAS headline so it doesn't slip under the composer with the added preview button. - tsconfig.json + app.css: wire lib-data-ui, lib-code-ui, lib-diagram-ui, lib-onboarding-ui as siblings. Adding a new block kind = add the lib paths, add a renderer case, add a schema entry. No prompt edits required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,11 @@
|
|||||||
@source "../../lib-chart-ui/src";
|
@source "../../lib-chart-ui/src";
|
||||||
@source "../../lib-map-ui/src";
|
@source "../../lib-map-ui/src";
|
||||||
@source "../../lib-status-ui/src";
|
@source "../../lib-status-ui/src";
|
||||||
|
@source "../../lib-data-ui/src";
|
||||||
|
@source "../../lib-code-ui/src";
|
||||||
|
@source "../../lib-diagram-ui/src";
|
||||||
|
@source "../../lib-onboarding-ui/src";
|
||||||
|
@source "../../lib-notification-ui/src";
|
||||||
/* CREMA:SOURCES */
|
/* CREMA:SOURCES */
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
// Renders an assistant message: GFM markdown for prose, custom ```card```
|
// Renders an assistant message: GFM markdown for prose, plus typed fenced
|
||||||
// blocks rendered as rich UI (status pills, tenant cards, KPIs), pills for
|
// code blocks rendered as rich UI from `@crema/*-ui` libs (charts, tables,
|
||||||
// command-bus action blocks, and tool-result cards (role: "tool").
|
// KPIs, code, diffs, status pills, callouts), pills for command-bus action
|
||||||
|
// blocks, and tool-result cards (role: "tool").
|
||||||
|
//
|
||||||
|
// Typed blocks recognized (each is a fenced ```<kind>\n<json>\n``` block):
|
||||||
|
// action — command-bus DSL (handled by extractActionBlocks; replaced
|
||||||
|
// with a "Ran N actions" pill)
|
||||||
|
// card — { kind: "pill" | "stat" | "callout", ... } (legacy)
|
||||||
|
// chart-spark — { values: number[], stroke?, fill? }
|
||||||
|
// chart-bar — { data: [{ label, value, color? }] }
|
||||||
|
// chart-line — { series: [{ x, y }] }
|
||||||
|
// chart-donut — { data: [{ label, value, color? }] }
|
||||||
|
// table — { columns: [{ id, header, accessor? }], rows: [...] }
|
||||||
|
// kpi — { items: [{ label, value, unit? }] }
|
||||||
|
// code — { code, language?, title?, lineNumbers? }
|
||||||
|
// diff — { oldCode, newCode, language?, title? }
|
||||||
|
|
||||||
import { type ReactNode, useMemo } from "react"
|
import { type ReactNode, useMemo } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
@@ -9,9 +23,33 @@ import { Sparkles, Wrench } from "lucide-react"
|
|||||||
|
|
||||||
import { extractActionBlocks } from "@crema/action-bus"
|
import { extractActionBlocks } from "@crema/action-bus"
|
||||||
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
import { stripToolCallTags, type ToolCall } from "@crema/llm-ui"
|
||||||
|
import {
|
||||||
|
Sparkline,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
Donut,
|
||||||
|
type ChartDatum,
|
||||||
|
type SeriesPoint,
|
||||||
|
} from "@crema/chart-ui"
|
||||||
|
import { DataTable, type Column } from "@crema/table-ui"
|
||||||
|
import { KPIRow } from "@crema/data-ui"
|
||||||
|
import { CodeBlock, DiffViewer } from "@crema/code-ui"
|
||||||
|
import { FlowChart, OrgChart } from "@crema/diagram-ui"
|
||||||
|
import { StepTrail, type AgentStep } from "@crema/agent-ui"
|
||||||
|
import {
|
||||||
|
OnboardingChecklist,
|
||||||
|
WelcomeCard,
|
||||||
|
HintCard,
|
||||||
|
type ChecklistTask,
|
||||||
|
type OnboardingTone,
|
||||||
|
} from "@crema/onboarding-ui"
|
||||||
|
|
||||||
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
|
||||||
const CARD_BLOCK_RE = /```card\s*\n([\s\S]*?)```/g
|
|
||||||
|
// Captures the kind tag and the body of every fenced block we render as UI.
|
||||||
|
// Kept alongside the markdown ones — react-markdown ignores anything we strip.
|
||||||
|
const TYPED_BLOCK_RE =
|
||||||
|
/```(card|chart-spark|chart-bar|chart-line|chart-donut|table|kpi|code|diff|flowchart|orgchart|steps|checklist|welcome|hint)\s*\n([\s\S]*?)```/g
|
||||||
|
|
||||||
export type MessageBodyProps = {
|
export type MessageBodyProps = {
|
||||||
content: string
|
content: string
|
||||||
@@ -19,33 +57,231 @@ export type MessageBodyProps = {
|
|||||||
toolCalls?: ToolCall[]
|
toolCalls?: ToolCall[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Segment =
|
||||||
|
| { type: "prose"; text: string }
|
||||||
|
| { type: "block"; kind: string; spec: unknown; raw: string }
|
||||||
|
|
||||||
|
function parseSegments(content: string): Segment[] {
|
||||||
|
const segments: Segment[] = []
|
||||||
|
TYPED_BLOCK_RE.lastIndex = 0
|
||||||
|
let lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = TYPED_BLOCK_RE.exec(content)) !== null) {
|
||||||
|
const [raw, kind, body] = match
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
segments.push({ type: "prose", text: content.slice(lastIndex, match.index) })
|
||||||
|
}
|
||||||
|
let spec: unknown = null
|
||||||
|
try {
|
||||||
|
spec = JSON.parse(body.trim())
|
||||||
|
} catch {
|
||||||
|
// malformed → emit the raw fence as prose so the user sees the model output
|
||||||
|
segments.push({ type: "prose", text: raw })
|
||||||
|
lastIndex = match.index + raw.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments.push({ type: "block", kind, spec, raw })
|
||||||
|
lastIndex = match.index + raw.length
|
||||||
|
}
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
segments.push({ type: "prose", text: content.slice(lastIndex) })
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBlock(kind: string, spec: any, key: number): ReactNode {
|
||||||
|
switch (kind) {
|
||||||
|
case "card":
|
||||||
|
return <CardBlock key={key} spec={spec} />
|
||||||
|
case "chart-spark":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-2 inline-block text-primary">
|
||||||
|
<Sparkline
|
||||||
|
values={spec.values ?? []}
|
||||||
|
width={spec.width ?? 240}
|
||||||
|
height={spec.height ?? 48}
|
||||||
|
stroke={spec.stroke ?? "currentColor"}
|
||||||
|
fill={spec.fill}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "chart-bar":
|
||||||
|
return (
|
||||||
|
<ChartFrame key={key} title={spec.title}>
|
||||||
|
<div className="text-primary">
|
||||||
|
<BarChart data={(spec.data ?? []) as ChartDatum[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||||
|
</div>
|
||||||
|
<ChartLegend data={spec.data} />
|
||||||
|
</ChartFrame>
|
||||||
|
)
|
||||||
|
case "chart-line":
|
||||||
|
return (
|
||||||
|
<ChartFrame key={key} title={spec.title}>
|
||||||
|
<div className="text-primary">
|
||||||
|
<LineChart series={(spec.series ?? []) as SeriesPoint[]} width={spec.width ?? 360} height={spec.height ?? 180} />
|
||||||
|
</div>
|
||||||
|
</ChartFrame>
|
||||||
|
)
|
||||||
|
case "chart-donut":
|
||||||
|
return (
|
||||||
|
<ChartFrame key={key} title={spec.title}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-primary">
|
||||||
|
<Donut data={(spec.data ?? []) as ChartDatum[]} size={spec.size ?? 140} thickness={spec.thickness ?? 20} />
|
||||||
|
</div>
|
||||||
|
<ChartLegend data={spec.data} />
|
||||||
|
</div>
|
||||||
|
</ChartFrame>
|
||||||
|
)
|
||||||
|
case "table":
|
||||||
|
return <TableBlock key={key} spec={spec} />
|
||||||
|
case "kpi":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<KPIRow items={spec.items ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<CodeBlock
|
||||||
|
code={spec.code ?? ""}
|
||||||
|
language={spec.language}
|
||||||
|
title={spec.title}
|
||||||
|
showLineNumbers={spec.lineNumbers ?? false}
|
||||||
|
highlightLines={spec.highlightLines}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "diff":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<DiffViewer
|
||||||
|
oldCode={spec.oldCode ?? ""}
|
||||||
|
newCode={spec.newCode ?? ""}
|
||||||
|
language={spec.language}
|
||||||
|
title={spec.title}
|
||||||
|
mode={spec.mode ?? "unified"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "flowchart":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||||
|
<FlowChart nodes={spec.nodes ?? []} edges={spec.edges ?? []} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "orgchart":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3 overflow-x-auto rounded-lg border bg-card/50 p-3">
|
||||||
|
<OrgChart data={spec.data} horizontal={spec.horizontal} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "steps":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3 rounded-lg border bg-card/50 p-3">
|
||||||
|
<StepTrail steps={(spec.steps ?? []) as AgentStep[]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "checklist":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<OnboardingChecklist
|
||||||
|
title={spec.title}
|
||||||
|
description={spec.description}
|
||||||
|
tasks={(spec.tasks ?? []) as ChecklistTask[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "welcome":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<WelcomeCard
|
||||||
|
title={spec.title}
|
||||||
|
description={spec.description}
|
||||||
|
badge={spec.badge}
|
||||||
|
primaryAction={spec.primaryAction}
|
||||||
|
secondaryAction={spec.secondaryAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "hint":
|
||||||
|
return (
|
||||||
|
<div key={key} className="my-3">
|
||||||
|
<HintCard
|
||||||
|
title={spec.title}
|
||||||
|
tone={(spec.tone ?? "info") as OnboardingTone}
|
||||||
|
action={spec.action}
|
||||||
|
>
|
||||||
|
{spec.body ?? ""}
|
||||||
|
</HintCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<pre key={key} className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||||
|
{JSON.stringify(spec, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartFrame({ title, children }: { title?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 rounded-lg border bg-card/50 p-3">
|
||||||
|
{title && <div className="mb-2 text-xs font-medium text-muted-foreground">{title}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartLegend({ data }: { data?: ChartDatum[] }) {
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{data.map((d) => (
|
||||||
|
<li key={d.label} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block size-2 rounded-sm"
|
||||||
|
style={{ background: d.color ?? "currentColor" }}
|
||||||
|
/>
|
||||||
|
<span>{d.label}</span>
|
||||||
|
<span className="tabular-nums text-foreground/70">{d.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBlock({ spec }: { spec: any }) {
|
||||||
|
const rows: Record<string, unknown>[] = Array.isArray(spec.rows) ? spec.rows : []
|
||||||
|
const columns: Column<Record<string, unknown>>[] = (spec.columns ?? []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
header: c.header ?? c.id,
|
||||||
|
accessor: c.accessor ?? c.id,
|
||||||
|
sortable: c.sortable ?? true,
|
||||||
|
align: c.align,
|
||||||
|
}))
|
||||||
|
const idKey = spec.idKey ?? columns[0]?.id ?? "id"
|
||||||
|
return (
|
||||||
|
<div className="my-3 overflow-x-auto rounded-lg border bg-card/50">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
getRowId={(r) => String(r[idKey] ?? Math.random())}
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type CardSpec =
|
type CardSpec =
|
||||||
| { kind: "pill"; status: string; label?: string }
|
| { kind: "pill"; status: string; label?: string }
|
||||||
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
| { kind: "stat"; label: string; value: string | number; tone?: string }
|
||||||
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
| { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||||
| { kind: string; [k: string]: unknown }
|
| { kind: string; [k: string]: unknown }
|
||||||
|
|
||||||
function parseCardBlocks(content: string): { blocks: CardSpec[]; stripped: string } {
|
function CardBlock({ spec }: { spec: CardSpec }) {
|
||||||
const blocks: CardSpec[] = []
|
|
||||||
CARD_BLOCK_RE.lastIndex = 0
|
|
||||||
const stripped = content.replace(CARD_BLOCK_RE, (_, body: string) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body.trim()) as CardSpec
|
|
||||||
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
|
|
||||||
blocks.push(parsed)
|
|
||||||
return "" // strip from prose
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// malformed — leave the original block in the prose so the user can see
|
|
||||||
// what the model tried to emit.
|
|
||||||
return _
|
|
||||||
}
|
|
||||||
return _
|
|
||||||
})
|
|
||||||
return { blocks, stripped }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCard(spec: CardSpec): ReactNode {
|
|
||||||
switch (spec.kind) {
|
switch (spec.kind) {
|
||||||
case "pill": {
|
case "pill": {
|
||||||
const s = spec as { kind: "pill"; status: string; label?: string }
|
const s = spec as { kind: "pill"; status: string; label?: string }
|
||||||
@@ -58,9 +294,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
|
? "border-rose-500/40 bg-rose-500/15 text-rose-700 dark:text-rose-300"
|
||||||
: "border-border bg-muted text-muted-foreground"
|
: "border-border bg-muted text-muted-foreground"
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`my-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}
|
|
||||||
>
|
|
||||||
{s.label ?? s.status}
|
{s.label ?? s.status}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -68,19 +302,14 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
case "stat": {
|
case "stat": {
|
||||||
const s = spec as { kind: "stat"; label: string; value: string | number }
|
const s = spec as { kind: "stat"; label: string; value: string | number }
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
<span className="my-1 inline-flex items-baseline gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">{s.label}</span>
|
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||||
<span className="font-semibold tabular-nums">{s.value}</span>
|
<span className="font-semibold tabular-nums">{s.value}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "callout": {
|
case "callout": {
|
||||||
const s = spec as {
|
const s = spec as { kind: "callout"; title?: string; tone?: "info" | "warning" | "danger" | "success"; body?: string }
|
||||||
kind: "callout"
|
|
||||||
title?: string
|
|
||||||
tone?: "info" | "warning" | "danger" | "success"
|
|
||||||
body?: string
|
|
||||||
}
|
|
||||||
const tone = s.tone ?? "info"
|
const tone = s.tone ?? "info"
|
||||||
const palette: Record<string, string> = {
|
const palette: Record<string, string> = {
|
||||||
info: "border-sky-500/40 bg-sky-500/10",
|
info: "border-sky-500/40 bg-sky-500/10",
|
||||||
@@ -89,7 +318,7 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
success: "border-emerald-500/40 bg-emerald-500/10",
|
success: "border-emerald-500/40 bg-emerald-500/10",
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
<div className={`my-2 rounded-md border px-3 py-2 text-sm ${palette[tone]}`}>
|
||||||
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
|
{s.title && <div className="mb-1 font-medium">{s.title}</div>}
|
||||||
{s.body && <div className="text-muted-foreground">{s.body}</div>}
|
{s.body && <div className="text-muted-foreground">{s.body}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -97,24 +326,67 @@ function renderCard(spec: CardSpec): ReactNode {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<pre className="rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
<pre className="my-2 rounded-md border border-border/60 bg-muted/40 p-2 text-[11px] font-mono text-muted-foreground">
|
||||||
{JSON.stringify(spec, null, 2)}
|
{JSON.stringify(spec, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROSE_COMPONENTS = {
|
||||||
|
p: ({ children }: any) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
||||||
|
code: ({ children, className }: any) => {
|
||||||
|
const isBlock = className?.startsWith("language-")
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
||||||
|
<code className="font-mono">{children}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||||
|
},
|
||||||
|
ul: ({ children }: any) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
||||||
|
ol: ({ children }: any) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
||||||
|
li: ({ children }: any) => <li className="my-0.5">{children}</li>,
|
||||||
|
a: ({ children, href }: any) => (
|
||||||
|
<a href={href} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
table: ({ children }: any) => (
|
||||||
|
<div className="my-2 overflow-x-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }: any) => <thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>,
|
||||||
|
th: ({ children }: any) => <th className="px-3 py-2 text-left font-medium">{children}</th>,
|
||||||
|
td: ({ children }: any) => <td className="border-t px-3 py-2">{children}</td>,
|
||||||
|
input: ({ checked, type, ...rest }: any) =>
|
||||||
|
type === "checkbox" ? (
|
||||||
|
<input type="checkbox" checked={!!checked} readOnly {...rest} className="mr-1.5 align-middle" />
|
||||||
|
) : (
|
||||||
|
<input type={type} {...rest} />
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProseChunk({ text }: { text: string }) {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
return (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={PROSE_COMPONENTS}>
|
||||||
|
{trimmed}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
|
export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) {
|
||||||
const { prose, actionCount, cardBlocks } = useMemo(() => {
|
const { segments, actionCount } = useMemo(() => {
|
||||||
const blocks = extractActionBlocks(content)
|
const blocks = extractActionBlocks(content)
|
||||||
const cleaned = stripToolCallTags(content)
|
const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "")
|
||||||
.replace(ACTION_BLOCK_RE, "")
|
|
||||||
.trim()
|
|
||||||
const { blocks: cardBlocks, stripped } = parseCardBlocks(cleaned)
|
|
||||||
return {
|
return {
|
||||||
prose: stripped.trim(),
|
segments: parseSegments(cleaned),
|
||||||
actionCount: blocks.length,
|
actionCount: blocks.length,
|
||||||
cardBlocks,
|
|
||||||
}
|
}
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
@@ -134,76 +406,8 @@ export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
{prose && (
|
{segments.map((seg, i) =>
|
||||||
<ReactMarkdown
|
seg.type === "prose" ? <ProseChunk key={i} text={seg.text} /> : renderBlock(seg.kind, seg.spec, i),
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
|
||||||
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
|
|
||||||
code: ({ children, className }) => {
|
|
||||||
const isBlock = className?.startsWith("language-")
|
|
||||||
if (isBlock) {
|
|
||||||
return (
|
|
||||||
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
|
|
||||||
<code className="font-mono">{children}</code>
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
|
|
||||||
ol: ({ children }) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
|
|
||||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
|
||||||
a: ({ children, href }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="text-primary underline underline-offset-2"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
table: ({ children }) => (
|
|
||||||
<div className="my-2 overflow-x-auto rounded-md border">
|
|
||||||
<table className="w-full text-sm">{children}</table>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
thead: ({ children }) => (
|
|
||||||
<thead className="bg-muted/50 text-xs text-muted-foreground">{children}</thead>
|
|
||||||
),
|
|
||||||
th: ({ children }) => (
|
|
||||||
<th className="px-3 py-2 text-left font-medium">{children}</th>
|
|
||||||
),
|
|
||||||
td: ({ children }) => <td className="border-t px-3 py-2">{children}</td>,
|
|
||||||
input: ({ checked, type, ...rest }) =>
|
|
||||||
type === "checkbox" ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!checked}
|
|
||||||
readOnly
|
|
||||||
{...rest}
|
|
||||||
className="mr-1.5 align-middle"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input type={type} {...rest} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{prose}
|
|
||||||
</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
{cardBlocks.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
{cardBlocks.map((spec, i) => (
|
|
||||||
<span key={i} className={spec.kind === "callout" ? "block w-full" : ""}>
|
|
||||||
{renderCard(spec)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{actionCount > 0 && (
|
{actionCount > 0 && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
suspendTenant,
|
suspendTenant,
|
||||||
type Tenant,
|
type Tenant,
|
||||||
} from "~/lib/arcadia/tenants"
|
} from "~/lib/arcadia/tenants"
|
||||||
|
import { searchDocs } from "~/lib/docs-search"
|
||||||
|
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
|
||||||
|
|
||||||
export type ToolCall = {
|
export type ToolCall = {
|
||||||
name: string
|
name: string
|
||||||
@@ -221,6 +223,71 @@ const TOOLS: ToolDef[] = [
|
|||||||
return summarize(updated)
|
return summarize(updated)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "search_docs",
|
||||||
|
description:
|
||||||
|
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Max passages to return. Default 5, cap 10.",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args) => {
|
||||||
|
const query = typeof args.query === "string" ? args.query.trim() : ""
|
||||||
|
if (!query) throw new Error("search_docs requires a non-empty { query }")
|
||||||
|
const limit = Math.min(
|
||||||
|
10,
|
||||||
|
Math.max(1, typeof args.limit === "number" ? args.limit : 5),
|
||||||
|
)
|
||||||
|
const hits = await searchDocs(query, limit)
|
||||||
|
return { query, count: hits.length, hits }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_block_schema",
|
||||||
|
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
|
||||||
|
BLOCK_INDEX,
|
||||||
|
)
|
||||||
|
.map(([k, v]) => `${k} (${v})`)
|
||||||
|
.join(", ")}.`,
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
kind: {
|
||||||
|
type: "string",
|
||||||
|
description: "The block kind to fetch the schema for.",
|
||||||
|
enum: Object.keys(BLOCK_INDEX),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["kind"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
isWrite: false,
|
||||||
|
run: async (args) => {
|
||||||
|
const kind = typeof args.kind === "string" ? args.kind : ""
|
||||||
|
const schema = getBlockSchema(kind)
|
||||||
|
if (!schema) {
|
||||||
|
return {
|
||||||
|
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind, schema }
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface AuditEntry {
|
interface AuditEntry {
|
||||||
|
|||||||
247
app/lib/block-schemas.ts
Normal file
247
app/lib/block-schemas.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// Lazy-fetched schemas for the typed fenced blocks the assistant can emit.
|
||||||
|
// The system prompt only ships a thin index (kind → one-line purpose). Full
|
||||||
|
// JSON schemas + examples live here and are pulled on demand via the
|
||||||
|
// `get_block_schema` tool. Keeps the always-on prompt small and lets new
|
||||||
|
// blocks be added by editing this file alone — no prompt edits required.
|
||||||
|
//
|
||||||
|
// Renderer is in app/components/assistant/message-body.tsx — keep these in
|
||||||
|
// sync (kinds, field names) when adding or changing blocks.
|
||||||
|
|
||||||
|
export type BlockKind =
|
||||||
|
| "kpi"
|
||||||
|
| "table"
|
||||||
|
| "chart-bar"
|
||||||
|
| "chart-line"
|
||||||
|
| "chart-donut"
|
||||||
|
| "chart-spark"
|
||||||
|
| "code"
|
||||||
|
| "diff"
|
||||||
|
| "card"
|
||||||
|
| "flowchart"
|
||||||
|
| "orgchart"
|
||||||
|
| "steps"
|
||||||
|
| "checklist"
|
||||||
|
| "welcome"
|
||||||
|
| "hint"
|
||||||
|
|
||||||
|
export const BLOCK_INDEX: Record<BlockKind, string> = {
|
||||||
|
kpi: "Headline numbers row (2–6 metrics).",
|
||||||
|
table: "Tabular data (≥3 rows or ≥3 columns).",
|
||||||
|
"chart-bar": "Compare ≤8 categories.",
|
||||||
|
"chart-line": "Ordered series / trend over time.",
|
||||||
|
"chart-donut": "Part-to-whole, ≤5 slices.",
|
||||||
|
"chart-spark": "Inline trend, no axes.",
|
||||||
|
code: "Syntax-highlighted snippet (SQL, JSON, YAML, etc).",
|
||||||
|
diff: "Before/after comparison.",
|
||||||
|
card: "Inline pill, stat chip, or callout banner.",
|
||||||
|
flowchart: "Process / decision flow with shaped nodes (start/end/process/decision/io).",
|
||||||
|
orgchart: "Tree of nested entities (org structure, dependency tree, taxonomy).",
|
||||||
|
steps: "Multi-step plan with statuses (queued/running/done/error/skipped).",
|
||||||
|
checklist: "Onboarding checklist with completable tasks (links/CTAs allowed).",
|
||||||
|
welcome: "Hero welcome card with title, description, primary/secondary CTA.",
|
||||||
|
hint: "Tip / lightbulb card with tone (info/success/warning/neutral/primary).",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMAS: Record<BlockKind, string> = {
|
||||||
|
kpi: `\`\`\`kpi
|
||||||
|
{ "items": [
|
||||||
|
{ "label": "Tenants", "value": 42 },
|
||||||
|
{ "label": "Active users", "value": 318, "unit": "/day" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: items[]: { label: string, value: string|number, unit?: string }.
|
||||||
|
Use 2–6 items. Don't repeat the numbers in prose.`,
|
||||||
|
|
||||||
|
table: `\`\`\`table
|
||||||
|
{ "columns": [
|
||||||
|
{ "id": "slug", "header": "Tenant" },
|
||||||
|
{ "id": "users", "header": "Users", "align": "right" },
|
||||||
|
{ "id": "status", "header": "Status" }
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
{ "slug": "acme", "users": 42, "status": "active" },
|
||||||
|
{ "slug": "globex", "users": 18, "status": "suspended" }
|
||||||
|
],
|
||||||
|
"idKey": "slug" }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- columns[]: { id: string, header?: string, align?: "left"|"center"|"right", sortable?: boolean }
|
||||||
|
- rows[]: object keyed by column id.
|
||||||
|
- idKey?: string — column whose value is the row id (defaults to first column).
|
||||||
|
Use for ≥3 rows OR ≥3 columns. Smaller lists → markdown table.`,
|
||||||
|
|
||||||
|
"chart-bar": `\`\`\`chart-bar
|
||||||
|
{ "title": "Users by tenant",
|
||||||
|
"data": [
|
||||||
|
{ "label": "acme", "value": 42 },
|
||||||
|
{ "label": "globex", "value": 18 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||||
|
≤8 categories. For more, use a table.`,
|
||||||
|
|
||||||
|
"chart-line": `\`\`\`chart-line
|
||||||
|
{ "title": "Signups over time",
|
||||||
|
"series": [
|
||||||
|
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: title?: string, series[]: { x: number, y: number }.
|
||||||
|
Use for ordered numeric series ≥3 points. x is treated as a numeric axis.`,
|
||||||
|
|
||||||
|
"chart-donut": `\`\`\`chart-donut
|
||||||
|
{ "title": "Status breakdown",
|
||||||
|
"data": [
|
||||||
|
{ "label": "active", "value": 38 },
|
||||||
|
{ "label": "suspended", "value": 4 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: title?: string, data[]: { label: string, value: number, color?: string }.
|
||||||
|
≤5 slices. Skip if one slice would be >90%.`,
|
||||||
|
|
||||||
|
"chart-spark": `\`\`\`chart-spark
|
||||||
|
{ "values": [3, 5, 4, 8, 12, 9, 14] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: values: number[], width?, height?, stroke?, fill?.
|
||||||
|
Use inline next to a single number to show its recent trend.`,
|
||||||
|
|
||||||
|
code: `\`\`\`code
|
||||||
|
{ "code": "SELECT count(*) FROM tenants WHERE status='active';",
|
||||||
|
"language": "sql",
|
||||||
|
"title": "Active tenant count",
|
||||||
|
"lineNumbers": false,
|
||||||
|
"highlightLines": [] }
|
||||||
|
\`\`\`
|
||||||
|
Fields: code: string, language?: string, title?: string, lineNumbers?: boolean, highlightLines?: number[].
|
||||||
|
Languages with syntax: js/ts/tsx, python, rust, go, html, css, sql, json, yaml.
|
||||||
|
Prefer this over plain markdown fences when the snippet matters (queries the user might copy, configs, etc.).`,
|
||||||
|
|
||||||
|
diff: `\`\`\`diff
|
||||||
|
{ "oldCode": "max_users: 100\\n",
|
||||||
|
"newCode": "max_users: 250\\n",
|
||||||
|
"language": "yaml",
|
||||||
|
"title": "Tenant quota change",
|
||||||
|
"mode": "unified" }
|
||||||
|
\`\`\`
|
||||||
|
Fields: oldCode: string, newCode: string, language?: string, title?: string, mode?: "unified"|"split".
|
||||||
|
Use for showing exactly what changed in a config, query, or file.`,
|
||||||
|
|
||||||
|
card: `\`\`\`card
|
||||||
|
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "This action is destructive." }
|
||||||
|
\`\`\`
|
||||||
|
Three sub-kinds:
|
||||||
|
- pill: { "kind": "pill", "status": "active"|"suspended"|"deactivated"|other, "label"?: string } — small status badge.
|
||||||
|
- stat: { "kind": "stat", "label": string, "value": string|number } — inline metric chip.
|
||||||
|
- callout: { "kind": "callout", "tone": "info"|"warning"|"danger"|"success", "title"?: string, "body"?: string } — banner.
|
||||||
|
Use sparingly. For multiple metrics use \`kpi\` instead of multiple \`stat\` cards.`,
|
||||||
|
|
||||||
|
flowchart: `\`\`\`flowchart
|
||||||
|
{ "nodes": [
|
||||||
|
{ "id": "a", "type": "start", "label": "Receive request", "x": 60, "y": 20 },
|
||||||
|
{ "id": "b", "type": "process", "label": "Validate token", "x": 60, "y": 100 },
|
||||||
|
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 60, "y": 180 },
|
||||||
|
{ "id": "d", "type": "process", "label": "Process", "x": 220, "y": 180 },
|
||||||
|
{ "id": "e", "type": "end", "label": "Reject", "x": 60, "y": 280 }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "a", "to": "b" },
|
||||||
|
{ "from": "b", "to": "c" },
|
||||||
|
{ "from": "c", "to": "d", "label": "yes" },
|
||||||
|
{ "from": "c", "to": "e", "label": "no" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- nodes[]: { id: string, type: "start"|"end"|"process"|"decision"|"io", label: string, x: number, y: number } — coordinates in pixels (canvas auto-sizes).
|
||||||
|
- edges[]: { from: nodeId, to: nodeId, label?: string }.
|
||||||
|
Use for control flow, workflows, request lifecycles. Keep ≤12 nodes; lay out top-to-bottom or left-to-right with ~80–120px spacing.`,
|
||||||
|
|
||||||
|
orgchart: `\`\`\`orgchart
|
||||||
|
{ "data": {
|
||||||
|
"id": "root", "name": "Platform", "title": "Tenant",
|
||||||
|
"children": [
|
||||||
|
{ "id": "a", "name": "Auth", "title": "Service",
|
||||||
|
"children": [
|
||||||
|
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||||||
|
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||||||
|
] },
|
||||||
|
{ "id": "b", "name": "Billing", "title": "Service" }
|
||||||
|
] },
|
||||||
|
"horizontal": false }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- data: OrgNode = { id: string, name: string, title?: string, avatar?: string (url), children?: OrgNode[] }
|
||||||
|
- horizontal?: boolean — left-to-right vs top-to-bottom (default).
|
||||||
|
Use for nested hierarchies (org charts, dependency trees, taxonomies). Skip for flat lists.`,
|
||||||
|
|
||||||
|
steps: `\`\`\`steps
|
||||||
|
{ "steps": [
|
||||||
|
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||||||
|
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||||||
|
{ "id": "3", "title": "Build report", "status": "queued" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- steps[]: { id: string, title: string, status: "queued"|"planning"|"running"|"waiting"|"done"|"error"|"skipped", detail?: string, substeps?: same-shape[] }
|
||||||
|
Use for: showing a multi-step plan you're about to execute, or a post-hoc trail of what you did. Skip for single-step actions.`,
|
||||||
|
|
||||||
|
checklist: `\`\`\`checklist
|
||||||
|
{ "title": "Get started",
|
||||||
|
"description": "Finish setting up your tenant.",
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||||||
|
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||||||
|
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- title?: string, description?: string
|
||||||
|
- tasks[]: { id: string, title: string, description?: string, completed?: boolean, optional?: boolean, estimate?: string, href?: string }
|
||||||
|
Use for: actionable setup lists with progress. Each task with an href becomes a click-through link. Toggling is read-only in chat (can't persist completion across turns).`,
|
||||||
|
|
||||||
|
welcome: `\`\`\`welcome
|
||||||
|
{ "title": "Welcome to Arcadia Admin",
|
||||||
|
"description": "Manage tenants, users, and platform settings from one place.",
|
||||||
|
"badge": "v2",
|
||||||
|
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||||||
|
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- title: string (required), description?: string, badge?: string
|
||||||
|
- primaryAction?, secondaryAction?: { label: string, href?: string }
|
||||||
|
Use sparingly — once at the top of a thread that's introducing a feature/product, never as a recurring response.`,
|
||||||
|
|
||||||
|
hint: `\`\`\`hint
|
||||||
|
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||||||
|
\`\`\`
|
||||||
|
Fields:
|
||||||
|
- title?: string, body: string
|
||||||
|
- tone?: "info"|"success"|"warning"|"neutral"|"primary" (default "info")
|
||||||
|
- action?: { label: string, href?: string }
|
||||||
|
Use for: discoverability tips, gotchas, "did you know". One per reply.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_KINDS = Object.keys(SCHEMAS) as BlockKind[]
|
||||||
|
|
||||||
|
export function isBlockKind(kind: string): kind is BlockKind {
|
||||||
|
return (ALL_KINDS as string[]).includes(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockSchema(kind: string): string | null {
|
||||||
|
if (!isBlockKind(kind)) return null
|
||||||
|
return SCHEMAS[kind]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin index suitable for the always-on system prompt. */
|
||||||
|
export function blockIndexForPrompt(): string {
|
||||||
|
const lines = ALL_KINDS.map((k) => ` ${k} — ${BLOCK_INDEX[k]}`)
|
||||||
|
return [
|
||||||
|
"Rich output: when a UI primitive will communicate better than prose, emit a typed fenced ```<kind>\\n<json>\\n``` block. The chat renderer turns it into a @crema/*-ui component inline at that position.",
|
||||||
|
"",
|
||||||
|
"Available kinds:",
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
"Before emitting a block for the FIRST time in a thread, call get_block_schema(kind) to fetch the exact JSON shape and field rules. Once you've seen a schema in this conversation, reuse it from memory.",
|
||||||
|
"Always lead with one short sentence of prose, then the block. Don't repeat block data in prose.",
|
||||||
|
"JSON must be valid (double quotes, no trailing commas). If unsure of the schema, fetch it.",
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
useSettings as useProviderSettings,
|
useSettings as useProviderSettings,
|
||||||
} from "@crema/llm-providers-ui"
|
} from "@crema/llm-providers-ui"
|
||||||
import { TypingIndicator } from "@crema/chat-ui"
|
import { TypingIndicator } from "@crema/chat-ui"
|
||||||
|
import { useToast } from "@crema/notification-ui"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
import { MessageBody } from "~/components/assistant/message-body"
|
import { MessageBody } from "~/components/assistant/message-body"
|
||||||
@@ -67,6 +69,7 @@ import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
|||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||||
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
|
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
|
||||||
|
import type { DocHit } from "~/lib/docs-search"
|
||||||
import {
|
import {
|
||||||
AgentAvatar,
|
AgentAvatar,
|
||||||
ToolCallCard,
|
ToolCallCard,
|
||||||
@@ -96,6 +99,176 @@ function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
|
|||||||
return <div className="px-1">{rich}</div>
|
return <div className="px-1">{rich}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synthetic assistant message that exercises every typed rich-output block.
|
||||||
|
// Wired to the "preview rich-output blocks" button in the empty state — used
|
||||||
|
// to eyeball renderer + theme without driving a live model. Safe to delete
|
||||||
|
// once Phase 2 has been validated end-to-end.
|
||||||
|
const BLOCK_SAMPLES_CONTENT = `Here's one example of every rich-output block, in roughly the order a model would emit them.
|
||||||
|
|
||||||
|
A **kpi** strip for headline numbers:
|
||||||
|
|
||||||
|
\`\`\`kpi
|
||||||
|
{ "items": [
|
||||||
|
{ "label": "Tenants", "value": 42 },
|
||||||
|
{ "label": "Active users", "value": 318, "unit": "/day" },
|
||||||
|
{ "label": "Suspended", "value": 4 },
|
||||||
|
{ "label": "Storage", "value": "1.2", "unit": "TB" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **table** for tabular data:
|
||||||
|
|
||||||
|
\`\`\`table
|
||||||
|
{ "columns": [
|
||||||
|
{ "id": "slug", "header": "Tenant" },
|
||||||
|
{ "id": "users", "header": "Users", "align": "right" },
|
||||||
|
{ "id": "status", "header": "Status" }
|
||||||
|
],
|
||||||
|
"rows": [
|
||||||
|
{ "slug": "acme", "users": 42, "status": "active" },
|
||||||
|
{ "slug": "globex", "users": 18, "status": "suspended" },
|
||||||
|
{ "slug": "initech", "users": 73, "status": "active" }
|
||||||
|
],
|
||||||
|
"idKey": "slug" }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **chart-bar** for category comparison and a **chart-line** for a trend:
|
||||||
|
|
||||||
|
\`\`\`chart-bar
|
||||||
|
{ "title": "Users by tenant",
|
||||||
|
"data": [
|
||||||
|
{ "label": "acme", "value": 42 },
|
||||||
|
{ "label": "globex", "value": 18 },
|
||||||
|
{ "label": "initech", "value": 73 },
|
||||||
|
{ "label": "umbrella", "value": 11 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`chart-line
|
||||||
|
{ "title": "Signups over time",
|
||||||
|
"series": [
|
||||||
|
{ "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 },
|
||||||
|
{ "x": 4, "y": 31 }, { "x": 5, "y": 28 }, { "x": 6, "y": 42 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **chart-donut** for part-to-whole and a **chart-spark** inline:
|
||||||
|
|
||||||
|
\`\`\`chart-donut
|
||||||
|
{ "title": "Status breakdown",
|
||||||
|
"data": [
|
||||||
|
{ "label": "active", "value": 38 },
|
||||||
|
{ "label": "suspended", "value": 4 },
|
||||||
|
{ "label": "deactivated", "value": 2 }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`chart-spark
|
||||||
|
{ "values": [3, 5, 4, 8, 12, 9, 14, 11, 18, 16, 22] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **code** block and a **diff**:
|
||||||
|
|
||||||
|
\`\`\`code
|
||||||
|
{ "code": "SELECT slug, count(*) AS users\\nFROM tenants t\\nJOIN users u ON u.tenant_id = t.id\\nWHERE t.status = 'active'\\nGROUP BY slug\\nORDER BY users DESC;",
|
||||||
|
"language": "sql",
|
||||||
|
"title": "Active tenants by user count",
|
||||||
|
"lineNumbers": true }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`diff
|
||||||
|
{ "oldCode": "max_users: 100\\nplan: free\\n",
|
||||||
|
"newCode": "max_users: 250\\nplan: pro\\n",
|
||||||
|
"language": "yaml",
|
||||||
|
"title": "Tenant quota change",
|
||||||
|
"mode": "unified" }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **flowchart** for control flow and an **orgchart** for hierarchy:
|
||||||
|
|
||||||
|
\`\`\`flowchart
|
||||||
|
{ "nodes": [
|
||||||
|
{ "id": "a", "type": "start", "label": "Receive request", "x": 80, "y": 20 },
|
||||||
|
{ "id": "b", "type": "process", "label": "Validate token", "x": 80, "y": 110 },
|
||||||
|
{ "id": "c", "type": "decision", "label": "Token valid?", "x": 80, "y": 200 },
|
||||||
|
{ "id": "d", "type": "process", "label": "Process", "x": 260, "y": 200 },
|
||||||
|
{ "id": "e", "type": "end", "label": "Reject (401)", "x": 80, "y": 310 }
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "a", "to": "b" },
|
||||||
|
{ "from": "b", "to": "c" },
|
||||||
|
{ "from": "c", "to": "d", "label": "yes" },
|
||||||
|
{ "from": "c", "to": "e", "label": "no" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`orgchart
|
||||||
|
{ "data": {
|
||||||
|
"id": "root", "name": "Platform", "title": "Tenant",
|
||||||
|
"children": [
|
||||||
|
{ "id": "a", "name": "Auth", "title": "Service",
|
||||||
|
"children": [
|
||||||
|
{ "id": "a1", "name": "Sessions", "title": "Module" },
|
||||||
|
{ "id": "a2", "name": "MFA", "title": "Module" }
|
||||||
|
] },
|
||||||
|
{ "id": "b", "name": "Billing", "title": "Service",
|
||||||
|
"children": [
|
||||||
|
{ "id": "b1", "name": "Invoices", "title": "Module" }
|
||||||
|
] }
|
||||||
|
] } }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **steps** trail for a multi-step plan:
|
||||||
|
|
||||||
|
\`\`\`steps
|
||||||
|
{ "steps": [
|
||||||
|
{ "id": "1", "title": "List tenants", "status": "done", "detail": "Found 42 tenants" },
|
||||||
|
{ "id": "2", "title": "Filter suspended", "status": "running" },
|
||||||
|
{ "id": "3", "title": "Build report", "status": "queued" },
|
||||||
|
{ "id": "4", "title": "Email summary", "status": "queued" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A **welcome** hero, a **checklist**, and a **hint**:
|
||||||
|
|
||||||
|
\`\`\`welcome
|
||||||
|
{ "title": "Welcome to Arcadia Admin",
|
||||||
|
"description": "Manage tenants, users, and platform settings from one place.",
|
||||||
|
"badge": "v2",
|
||||||
|
"primaryAction": { "label": "Create your first tenant", "href": "/tenants" },
|
||||||
|
"secondaryAction": { "label": "Read the docs", "href": "/library" } }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`checklist
|
||||||
|
{ "title": "Get started",
|
||||||
|
"description": "Finish setting up your tenant.",
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "1", "title": "Invite your team", "description": "Add at least one admin.", "completed": true, "estimate": "2 min" },
|
||||||
|
{ "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets", "estimate": "5 min" },
|
||||||
|
{ "id": "3", "title": "Set up SSO", "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
|
||||||
|
] }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`hint
|
||||||
|
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
And the legacy **card** kinds — pill, stat, callout:
|
||||||
|
|
||||||
|
\`\`\`card
|
||||||
|
{ "kind": "pill", "status": "active", "label": "active" }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`card
|
||||||
|
{ "kind": "stat", "label": "MRR", "value": "$12.4k" }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`card
|
||||||
|
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "Suspending a tenant blocks all of its users immediately." }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Clear the conversation to dismiss the preview.`
|
||||||
|
|
||||||
const SNAPSHOT_KEY = "crema.ai.snapshot"
|
const SNAPSHOT_KEY = "crema.ai.snapshot"
|
||||||
// Separate key for the live conversation that survives navigation. The
|
// Separate key for the live conversation that survives navigation. The
|
||||||
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
|
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
|
||||||
@@ -727,12 +900,21 @@ function ChatSurface({
|
|||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}, [messages, activeAgent])
|
}, [messages, activeAgent])
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const copyMarkdown = useCallback(async () => {
|
const copyMarkdown = useCallback(async () => {
|
||||||
if (messages.length === 0) return
|
if (messages.length === 0) return
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(buildTranscript())
|
await navigator.clipboard.writeText(buildTranscript())
|
||||||
} catch {}
|
toast.success("Copied as Markdown", {
|
||||||
}, [buildTranscript, messages.length])
|
description: `${messages.length} message${messages.length === 1 ? "" : "s"} on the clipboard.`,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error("Couldn't copy", {
|
||||||
|
description: "Clipboard access was blocked.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [buildTranscript, messages.length, toast])
|
||||||
|
|
||||||
const exportMarkdown = useCallback(() => {
|
const exportMarkdown = useCallback(() => {
|
||||||
if (messages.length === 0) return
|
if (messages.length === 0) return
|
||||||
@@ -742,26 +924,30 @@ function ChatSurface({
|
|||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
|
||||||
a.download = `ai-${stamp}.md`
|
const filename = `ai-${stamp}.md`
|
||||||
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [buildTranscript, messages.length])
|
toast.success("Exported transcript", { description: filename })
|
||||||
|
}, [buildTranscript, messages.length, toast])
|
||||||
|
|
||||||
const saveToLibrary = useCallback(() => {
|
const saveToLibrary = useCallback(() => {
|
||||||
if (messages.length === 0) return
|
if (messages.length === 0) return
|
||||||
const md = buildTranscript()
|
const md = buildTranscript()
|
||||||
|
const title =
|
||||||
|
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
|
||||||
|
"AI conversation"
|
||||||
addLibraryItem({
|
addLibraryItem({
|
||||||
kind: "conversation",
|
kind: "conversation",
|
||||||
title:
|
title,
|
||||||
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
|
|
||||||
"AI conversation",
|
|
||||||
content: md,
|
content: md,
|
||||||
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
|
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
|
||||||
agentName: activeAgent?.name,
|
agentName: activeAgent?.name,
|
||||||
agentRole: activeAgent?.role,
|
agentRole: activeAgent?.role,
|
||||||
messageCount: messages.length,
|
messageCount: messages.length,
|
||||||
})
|
})
|
||||||
}, [buildTranscript, messages, activeAgent])
|
toast.success("Saved to Library", { description: title })
|
||||||
|
}, [buildTranscript, messages, activeAgent, toast])
|
||||||
|
|
||||||
const regenerateLast = useCallback(() => {
|
const regenerateLast = useCallback(() => {
|
||||||
if (isStreaming) return
|
if (isStreaming) return
|
||||||
@@ -963,18 +1149,17 @@ function ChatSurface({
|
|||||||
{/* Empty state — flight-recorder card with staggered reveal */}
|
{/* Empty state — flight-recorder card with staggered reveal */}
|
||||||
<div
|
<div
|
||||||
aria-hidden={!isEmpty}
|
aria-hidden={!isEmpty}
|
||||||
className="pointer-events-none absolute inset-x-0 top-[14%] px-8 transition-opacity duration-300"
|
className="pointer-events-none absolute inset-x-0 top-[10%] px-8 transition-opacity duration-300"
|
||||||
style={{ opacity: isEmpty ? 1 : 0 }}
|
style={{ opacity: isEmpty ? 1 : 0 }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
||||||
<div className="console-empty-line console-mono flex items-center justify-between text-[10.5px] tracking-[0.18em] uppercase text-[var(--console-muted)]">
|
<div className="console-empty-line console-mono flex items-center justify-between text-[10.5px] tracking-[0.18em] uppercase text-[var(--console-muted)]">
|
||||||
<span>arcadia // operator console</span>
|
<span>arcadia // operator console</span>
|
||||||
<span>session {sessionLabel}</span>
|
<span>session {sessionLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="console-empty-line h-px bg-[var(--console-rule-soft)]" />
|
<div className="console-empty-line h-px bg-[var(--console-rule-soft)]" />
|
||||||
<h1 className="console-empty-line console-empty-headline">
|
<h1 className="console-empty-line console-empty-headline">
|
||||||
ATLAS<span className="text-[var(--console-amber)]">.</span>
|
ATLAS<span className="text-[var(--console-amber)]">.</span>{" "}
|
||||||
<br />
|
|
||||||
<em>standing by</em>
|
<em>standing by</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="console-empty-line console-mono max-w-[58ch] text-[13.5px] leading-[1.7] text-[var(--console-text-2)]">
|
<p className="console-empty-line console-mono max-w-[58ch] text-[13.5px] leading-[1.7] text-[var(--console-text-2)]">
|
||||||
@@ -982,6 +1167,19 @@ function ChatSurface({
|
|||||||
Issue an instruction. Read tools run automatically. Writes pause for
|
Issue an instruction. Read tools run automatically. Writes pause for
|
||||||
confirmation. Tab ⇥ for command palette.
|
confirmation. Tab ⇥ for command palette.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="console-empty-line pointer-events-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setMessages([
|
||||||
|
{ role: "assistant", content: BLOCK_SAMPLES_CONTENT } as LLMMessage,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
className="console-mono inline-flex items-center gap-1.5 rounded-md border border-[var(--console-rule-soft)] bg-transparent px-2.5 py-1 text-[10.5px] uppercase tracking-[0.18em] text-[var(--console-muted)] transition-colors hover:border-[var(--console-amber)] hover:text-[var(--console-amber)]"
|
||||||
|
>
|
||||||
|
› preview rich-output blocks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1018,6 +1216,11 @@ function ChatSurface({
|
|||||||
messageAgents.get(i)?.name ?? activeAgent?.name ?? "Atlas"
|
messageAgents.get(i)?.name ?? activeAgent?.name ?? "Atlas"
|
||||||
}
|
}
|
||||||
timestamp={clockLabel}
|
timestamp={clockLabel}
|
||||||
|
sources={
|
||||||
|
m.role === "assistant"
|
||||||
|
? extractDocSources(messages, i)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{calls.length > 0 && (
|
{calls.length > 0 && (
|
||||||
<div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
|
<div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
|
||||||
@@ -1270,6 +1473,61 @@ function truncateModel(m: string): string {
|
|||||||
return m.slice(0, 10) + "…" + m.slice(-9)
|
return m.slice(0, 10) + "…" + m.slice(-9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Walk forward from an assistant message and collect doc-search hits from
|
||||||
|
* any matching `tool` result messages. Deduped by sourcePath so a chunk and
|
||||||
|
* its sibling chunk in the same file collapse to one citation. */
|
||||||
|
function extractDocSources(
|
||||||
|
messages: LLMMessage[],
|
||||||
|
assistantIdx: number,
|
||||||
|
): DocHit[] {
|
||||||
|
const msg = messages[assistantIdx]
|
||||||
|
if (msg?.role !== "assistant" || !msg.toolCalls?.length) return []
|
||||||
|
const docCallIds = new Set(
|
||||||
|
msg.toolCalls.filter((tc) => tc.name === "search_docs").map((tc) => tc.id),
|
||||||
|
)
|
||||||
|
if (docCallIds.size === 0) return []
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: DocHit[] = []
|
||||||
|
for (let i = assistantIdx + 1; i < messages.length; i++) {
|
||||||
|
const m = messages[i]
|
||||||
|
if (m.role === "assistant") break // hit the next turn
|
||||||
|
if (m.role !== "tool" || !m.toolCallId || !docCallIds.has(m.toolCallId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m.content) as { hits?: DocHit[] }
|
||||||
|
for (const h of parsed.hits ?? []) {
|
||||||
|
if (seen.has(h.sourcePath + "#" + h.id)) continue
|
||||||
|
seen.add(h.sourcePath + "#" + h.id)
|
||||||
|
out.push(h)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Tool errors come back as { error } — no hits to surface.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourcesFooter({ sources }: { sources: DocHit[] }) {
|
||||||
|
if (sources.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="console-mono mt-3 flex flex-wrap items-center gap-1.5 text-[10.5px] tracking-[0.08em] text-[var(--console-muted)]">
|
||||||
|
<span className="uppercase text-[var(--console-muted-2)]">sources</span>
|
||||||
|
<span className="text-[var(--console-muted-2)]">›</span>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.id}
|
||||||
|
title={`${s.sourcePath}\n\n${s.excerpt}`}
|
||||||
|
className="inline-flex max-w-[28ch] items-center gap-1 truncate rounded border border-[var(--console-rule-soft)] bg-[var(--console-deck)] px-1.5 py-0.5 text-[var(--console-text-2)]"
|
||||||
|
>
|
||||||
|
<span className="truncate">{s.title}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MessageRow({
|
function MessageRow({
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
@@ -1277,6 +1535,7 @@ function MessageRow({
|
|||||||
turnNum,
|
turnNum,
|
||||||
agentName,
|
agentName,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
sources,
|
||||||
}: {
|
}: {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
@@ -1284,6 +1543,7 @@ function MessageRow({
|
|||||||
turnNum?: number
|
turnNum?: number
|
||||||
agentName?: string
|
agentName?: string
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
|
sources?: DocHit[]
|
||||||
}) {
|
}) {
|
||||||
// Operator turn — monospace, sodium-amber prompt, no bubble. The whole
|
// Operator turn — monospace, sodium-amber prompt, no bubble. The whole
|
||||||
// row hangs from a left gutter showing the turn number.
|
// row hangs from a left gutter showing the turn number.
|
||||||
@@ -1327,6 +1587,7 @@ function MessageRow({
|
|||||||
<div className="console-agent-prose">
|
<div className="console-agent-prose">
|
||||||
<MessageBody content={content} toolCalls={toolCalls} />
|
<MessageBody content={content} toolCalls={toolCalls} />
|
||||||
</div>
|
</div>
|
||||||
|
{sources && sources.length > 0 && <SourcesFooter sources={sources} />}
|
||||||
<div className="console-sig mt-2 flex items-center gap-2">
|
<div className="console-sig mt-2 flex items-center gap-2">
|
||||||
<span className="console-sig-name">
|
<span className="console-sig-name">
|
||||||
{agentName?.toLowerCase() ?? "atlas"}»
|
{agentName?.toLowerCase() ?? "atlas"}»
|
||||||
@@ -1711,8 +1972,19 @@ function CommandsMenu({
|
|||||||
hasCompactSnapshot: boolean
|
hasCompactSnapshot: boolean
|
||||||
isMock: boolean
|
isMock: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
// Close the popover after a tile is clicked, so the menu acknowledges the
|
||||||
|
// action visually even when the action itself produces no obvious change
|
||||||
|
// (Copy MD, Export MD, etc — those also fire a toast at the call site).
|
||||||
|
const close = useCallback(
|
||||||
|
(fn: () => void) => () => {
|
||||||
|
fn()
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
@@ -1731,7 +2003,7 @@ function CommandsMenu({
|
|||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-regenerate"
|
data-action="ai-cmd-regenerate"
|
||||||
onClick={onRegenerate}
|
onClick={close(onRegenerate)}
|
||||||
disabled={isStreaming || !hasUserMessage}
|
disabled={isStreaming || !hasUserMessage}
|
||||||
icon={<RotateCcw className="size-4" />}
|
icon={<RotateCcw className="size-4" />}
|
||||||
label="Regenerate"
|
label="Regenerate"
|
||||||
@@ -1739,7 +2011,7 @@ function CommandsMenu({
|
|||||||
/>
|
/>
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-continue"
|
data-action="ai-cmd-continue"
|
||||||
onClick={onContinue}
|
onClick={close(onContinue)}
|
||||||
disabled={isStreaming || !hasMessages}
|
disabled={isStreaming || !hasMessages}
|
||||||
icon={<ArrowRight className="size-4" />}
|
icon={<ArrowRight className="size-4" />}
|
||||||
label="Continue"
|
label="Continue"
|
||||||
@@ -1747,7 +2019,7 @@ function CommandsMenu({
|
|||||||
/>
|
/>
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-compact"
|
data-action="ai-cmd-compact"
|
||||||
onClick={onCompact}
|
onClick={close(onCompact)}
|
||||||
disabled={isCompacting || isStreaming || !hasMessages}
|
disabled={isCompacting || isStreaming || !hasMessages}
|
||||||
icon={
|
icon={
|
||||||
isCompacting ? (
|
isCompacting ? (
|
||||||
@@ -1761,7 +2033,7 @@ function CommandsMenu({
|
|||||||
/>
|
/>
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-restore-compact"
|
data-action="ai-cmd-restore-compact"
|
||||||
onClick={onRestoreCompact}
|
onClick={close(onRestoreCompact)}
|
||||||
disabled={!hasCompactSnapshot}
|
disabled={!hasCompactSnapshot}
|
||||||
icon={<Undo2 className="size-4" />}
|
icon={<Undo2 className="size-4" />}
|
||||||
label="Restore"
|
label="Restore"
|
||||||
@@ -1777,7 +2049,7 @@ function CommandsMenu({
|
|||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-copy-md"
|
data-action="ai-cmd-copy-md"
|
||||||
onClick={onCopyMarkdown}
|
onClick={close(onCopyMarkdown)}
|
||||||
disabled={!hasMessages}
|
disabled={!hasMessages}
|
||||||
icon={<Copy className="size-4" />}
|
icon={<Copy className="size-4" />}
|
||||||
label="Copy MD"
|
label="Copy MD"
|
||||||
@@ -1785,7 +2057,7 @@ function CommandsMenu({
|
|||||||
/>
|
/>
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-export-md"
|
data-action="ai-cmd-export-md"
|
||||||
onClick={onExportMarkdown}
|
onClick={close(onExportMarkdown)}
|
||||||
disabled={!hasMessages}
|
disabled={!hasMessages}
|
||||||
icon={<Download className="size-4" />}
|
icon={<Download className="size-4" />}
|
||||||
label="Export MD"
|
label="Export MD"
|
||||||
@@ -1793,7 +2065,7 @@ function CommandsMenu({
|
|||||||
/>
|
/>
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-save-library"
|
data-action="ai-cmd-save-library"
|
||||||
onClick={onSaveToLibrary}
|
onClick={close(onSaveToLibrary)}
|
||||||
disabled={!hasMessages}
|
disabled={!hasMessages}
|
||||||
icon={<BookmarkPlus className="size-4" />}
|
icon={<BookmarkPlus className="size-4" />}
|
||||||
label="Save to Library"
|
label="Save to Library"
|
||||||
@@ -1801,7 +2073,7 @@ function CommandsMenu({
|
|||||||
/>
|
/>
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-show-prompt"
|
data-action="ai-cmd-show-prompt"
|
||||||
onClick={onShowPrompt}
|
onClick={close(onShowPrompt)}
|
||||||
icon={<FileText className="size-4" />}
|
icon={<FileText className="size-4" />}
|
||||||
label="Show prompt"
|
label="Show prompt"
|
||||||
title="Preview the system prompt"
|
title="Preview the system prompt"
|
||||||
@@ -1814,7 +2086,7 @@ function CommandsMenu({
|
|||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-retry-probe"
|
data-action="ai-cmd-retry-probe"
|
||||||
onClick={onRetryProbe}
|
onClick={close(onRetryProbe)}
|
||||||
icon={<RefreshCw className="size-4" />}
|
icon={<RefreshCw className="size-4" />}
|
||||||
label="Reconnect"
|
label="Reconnect"
|
||||||
title="Probe the LLM endpoint again"
|
title="Probe the LLM endpoint again"
|
||||||
@@ -1826,7 +2098,7 @@ function CommandsMenu({
|
|||||||
<div className="my-2 h-px bg-border" />
|
<div className="my-2 h-px bg-border" />
|
||||||
<ToolTile
|
<ToolTile
|
||||||
data-action="ai-cmd-clear"
|
data-action="ai-cmd-clear"
|
||||||
onClick={onClear}
|
onClick={close(onClear)}
|
||||||
disabled={!hasMessages}
|
disabled={!hasMessages}
|
||||||
icon={<Trash2 className="size-4" />}
|
icon={<Trash2 className="size-4" />}
|
||||||
label="Clear conversation"
|
label="Clear conversation"
|
||||||
@@ -1851,7 +2123,8 @@ function SystemPromptDialog({
|
|||||||
await navigator.clipboard.writeText(prompt)
|
await navigator.clipboard.writeText(prompt)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
return (
|
if (typeof document === "undefined") return null
|
||||||
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -1886,7 +2159,8 @@ function SystemPromptDialog({
|
|||||||
{prompt}
|
{prompt}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ const PROBE_TIMEOUT_MS = 3000
|
|||||||
// "Available actions" in the system prompt only lists what's on screen NOW;
|
// "Available actions" in the system prompt only lists what's on screen NOW;
|
||||||
// this catalog tells the model what exists elsewhere so it can plan
|
// this catalog tells the model what exists elsewhere so it can plan
|
||||||
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
// multi-step flows (navigate → wait_for → fill → click) in a single block.
|
||||||
|
// Rich-output protocol: typed fenced blocks the chat renderer turns into UI
|
||||||
|
// from @crema/*-ui. The system prompt only carries a thin INDEX (kind →
|
||||||
|
// one-line purpose) — full schemas live in app/lib/block-schemas.ts and are
|
||||||
|
// fetched on demand via the get_block_schema tool. Adding a new block kind
|
||||||
|
// = edit block-schemas.ts + the renderer; no prompt edit required.
|
||||||
|
import { blockIndexForPrompt } from "~/lib/block-schemas"
|
||||||
|
const RICH_OUTPUT_PREFACE = blockIndexForPrompt()
|
||||||
|
|
||||||
|
|
||||||
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces.
|
||||||
|
|
||||||
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves.
|
||||||
@@ -104,6 +113,7 @@ function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean):
|
|||||||
const ctx = formatAdminContextForPrompt()
|
const ctx = formatAdminContextForPrompt()
|
||||||
const parts = [
|
const parts = [
|
||||||
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
|
"You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
|
||||||
|
RICH_OUTPUT_PREFACE,
|
||||||
ARCADIA_KNOWLEDGE,
|
ARCADIA_KNOWLEDGE,
|
||||||
persona,
|
persona,
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -187,10 +187,10 @@
|
|||||||
/* Empty-state oversize text — letter-spacing tracking is the whole point */
|
/* Empty-state oversize text — letter-spacing tracking is the whole point */
|
||||||
[data-theme="console"] .console-empty-headline {
|
[data-theme="console"] .console-empty-headline {
|
||||||
font-family: var(--console-font-mono);
|
font-family: var(--console-font-mono);
|
||||||
font-size: clamp(2.25rem, 5.5vw, 4.5rem);
|
font-size: clamp(1.5rem, 3.2vw, 2.5rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
line-height: 0.95;
|
line-height: 1;
|
||||||
color: var(--console-text);
|
color: var(--console-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,10 +325,18 @@
|
|||||||
color: var(--console-muted-2);
|
color: var(--console-muted-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header strip — session card */
|
/* Header strip — session card. Solid background so messages scrolling past
|
||||||
|
* don't bleed through the sticky bar. */
|
||||||
[data-theme="console"] .console-header {
|
[data-theme="console"] .console-header {
|
||||||
border-bottom: 1px solid var(--console-rule-soft);
|
border-bottom: 1px solid var(--console-rule-soft);
|
||||||
background: linear-gradient(to bottom, oklch(0.16 0.02 240 / 0.6), transparent);
|
background: var(--console-ink);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||||
|
backdrop-filter: blur(12px) saturate(140%);
|
||||||
|
}
|
||||||
|
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||||
|
[data-theme="console"] .console-header {
|
||||||
|
background: oklch(0.13 0.02 240 / 0.82);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
[data-theme="console"] .console-session-id {
|
[data-theme="console"] .console-session-id {
|
||||||
font-family: var(--console-font-mono);
|
font-family: var(--console-font-mono);
|
||||||
|
|||||||
@@ -54,6 +54,14 @@
|
|||||||
"@crema/map-ui/*": ["../lib-map-ui/src/*"],
|
"@crema/map-ui/*": ["../lib-map-ui/src/*"],
|
||||||
"@crema/status-ui": ["../lib-status-ui/src/index.tsx"],
|
"@crema/status-ui": ["../lib-status-ui/src/index.tsx"],
|
||||||
"@crema/status-ui/*": ["../lib-status-ui/src/*"],
|
"@crema/status-ui/*": ["../lib-status-ui/src/*"],
|
||||||
|
"@crema/data-ui": ["../lib-data-ui/src/index.tsx"],
|
||||||
|
"@crema/data-ui/*": ["../lib-data-ui/src/*"],
|
||||||
|
"@crema/code-ui": ["../lib-code-ui/src/index.tsx"],
|
||||||
|
"@crema/code-ui/*": ["../lib-code-ui/src/*"],
|
||||||
|
"@crema/diagram-ui": ["../lib-diagram-ui/src/index.tsx"],
|
||||||
|
"@crema/diagram-ui/*": ["../lib-diagram-ui/src/*"],
|
||||||
|
"@crema/onboarding-ui": ["../lib-onboarding-ui/src/index.tsx"],
|
||||||
|
"@crema/onboarding-ui/*": ["../lib-onboarding-ui/src/*"],
|
||||||
"// CREMA:PATHS": [""],
|
"// CREMA:PATHS": [""],
|
||||||
"react": ["./node_modules/@types/react"],
|
"react": ["./node_modules/@types/react"],
|
||||||
"react/*": ["./node_modules/@types/react/*"],
|
"react/*": ["./node_modules/@types/react/*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user