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:
@@ -5,6 +5,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import {
|
||||
Archive,
|
||||
ArrowRight,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
useSettings as useProviderSettings,
|
||||
} from "@crema/llm-providers-ui"
|
||||
import { TypingIndicator } from "@crema/chat-ui"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
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 { useArcadiaClient } from "@crema/arcadia-client"
|
||||
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
|
||||
import type { DocHit } from "~/lib/docs-search"
|
||||
import {
|
||||
AgentAvatar,
|
||||
ToolCallCard,
|
||||
@@ -96,6 +99,176 @@ function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
|
||||
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"
|
||||
// Separate key for the live conversation that survives navigation. The
|
||||
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
|
||||
@@ -727,12 +900,21 @@ function ChatSurface({
|
||||
return lines.join("\n")
|
||||
}, [messages, activeAgent])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const copyMarkdown = useCallback(async () => {
|
||||
if (messages.length === 0) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildTranscript())
|
||||
} catch {}
|
||||
}, [buildTranscript, messages.length])
|
||||
toast.success("Copied as Markdown", {
|
||||
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(() => {
|
||||
if (messages.length === 0) return
|
||||
@@ -742,26 +924,30 @@ function ChatSurface({
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
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()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [buildTranscript, messages.length])
|
||||
toast.success("Exported transcript", { description: filename })
|
||||
}, [buildTranscript, messages.length, toast])
|
||||
|
||||
const saveToLibrary = useCallback(() => {
|
||||
if (messages.length === 0) return
|
||||
const md = buildTranscript()
|
||||
const title =
|
||||
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
|
||||
"AI conversation"
|
||||
addLibraryItem({
|
||||
kind: "conversation",
|
||||
title:
|
||||
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
|
||||
"AI conversation",
|
||||
title,
|
||||
content: md,
|
||||
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
|
||||
agentName: activeAgent?.name,
|
||||
agentRole: activeAgent?.role,
|
||||
messageCount: messages.length,
|
||||
})
|
||||
}, [buildTranscript, messages, activeAgent])
|
||||
toast.success("Saved to Library", { description: title })
|
||||
}, [buildTranscript, messages, activeAgent, toast])
|
||||
|
||||
const regenerateLast = useCallback(() => {
|
||||
if (isStreaming) return
|
||||
@@ -963,18 +1149,17 @@ function ChatSurface({
|
||||
{/* Empty state — flight-recorder card with staggered reveal */}
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<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)]">
|
||||
<span>arcadia // operator console</span>
|
||||
<span>session {sessionLabel}</span>
|
||||
</div>
|
||||
<div className="console-empty-line h-px bg-[var(--console-rule-soft)]" />
|
||||
<h1 className="console-empty-line console-empty-headline">
|
||||
ATLAS<span className="text-[var(--console-amber)]">.</span>
|
||||
<br />
|
||||
ATLAS<span className="text-[var(--console-amber)]">.</span>{" "}
|
||||
<em>standing by</em>
|
||||
</h1>
|
||||
<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
|
||||
confirmation. Tab ⇥ for command palette.
|
||||
</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>
|
||||
|
||||
@@ -1018,6 +1216,11 @@ function ChatSurface({
|
||||
messageAgents.get(i)?.name ?? activeAgent?.name ?? "Atlas"
|
||||
}
|
||||
timestamp={clockLabel}
|
||||
sources={
|
||||
m.role === "assistant"
|
||||
? extractDocSources(messages, i)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{calls.length > 0 && (
|
||||
<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)
|
||||
}
|
||||
|
||||
/** 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({
|
||||
role,
|
||||
content,
|
||||
@@ -1277,6 +1535,7 @@ function MessageRow({
|
||||
turnNum,
|
||||
agentName,
|
||||
timestamp,
|
||||
sources,
|
||||
}: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
@@ -1284,6 +1543,7 @@ function MessageRow({
|
||||
turnNum?: number
|
||||
agentName?: string
|
||||
timestamp?: string
|
||||
sources?: DocHit[]
|
||||
}) {
|
||||
// Operator turn — monospace, sodium-amber prompt, no bubble. The whole
|
||||
// row hangs from a left gutter showing the turn number.
|
||||
@@ -1327,6 +1587,7 @@ function MessageRow({
|
||||
<div className="console-agent-prose">
|
||||
<MessageBody content={content} toolCalls={toolCalls} />
|
||||
</div>
|
||||
{sources && sources.length > 0 && <SourcesFooter sources={sources} />}
|
||||
<div className="console-sig mt-2 flex items-center gap-2">
|
||||
<span className="console-sig-name">
|
||||
{agentName?.toLowerCase() ?? "atlas"}»
|
||||
@@ -1711,8 +1972,19 @@ function CommandsMenu({
|
||||
hasCompactSnapshot: 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 (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
@@ -1731,7 +2003,7 @@ function CommandsMenu({
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ToolTile
|
||||
data-action="ai-cmd-regenerate"
|
||||
onClick={onRegenerate}
|
||||
onClick={close(onRegenerate)}
|
||||
disabled={isStreaming || !hasUserMessage}
|
||||
icon={<RotateCcw className="size-4" />}
|
||||
label="Regenerate"
|
||||
@@ -1739,7 +2011,7 @@ function CommandsMenu({
|
||||
/>
|
||||
<ToolTile
|
||||
data-action="ai-cmd-continue"
|
||||
onClick={onContinue}
|
||||
onClick={close(onContinue)}
|
||||
disabled={isStreaming || !hasMessages}
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
label="Continue"
|
||||
@@ -1747,7 +2019,7 @@ function CommandsMenu({
|
||||
/>
|
||||
<ToolTile
|
||||
data-action="ai-cmd-compact"
|
||||
onClick={onCompact}
|
||||
onClick={close(onCompact)}
|
||||
disabled={isCompacting || isStreaming || !hasMessages}
|
||||
icon={
|
||||
isCompacting ? (
|
||||
@@ -1761,7 +2033,7 @@ function CommandsMenu({
|
||||
/>
|
||||
<ToolTile
|
||||
data-action="ai-cmd-restore-compact"
|
||||
onClick={onRestoreCompact}
|
||||
onClick={close(onRestoreCompact)}
|
||||
disabled={!hasCompactSnapshot}
|
||||
icon={<Undo2 className="size-4" />}
|
||||
label="Restore"
|
||||
@@ -1777,7 +2049,7 @@ function CommandsMenu({
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ToolTile
|
||||
data-action="ai-cmd-copy-md"
|
||||
onClick={onCopyMarkdown}
|
||||
onClick={close(onCopyMarkdown)}
|
||||
disabled={!hasMessages}
|
||||
icon={<Copy className="size-4" />}
|
||||
label="Copy MD"
|
||||
@@ -1785,7 +2057,7 @@ function CommandsMenu({
|
||||
/>
|
||||
<ToolTile
|
||||
data-action="ai-cmd-export-md"
|
||||
onClick={onExportMarkdown}
|
||||
onClick={close(onExportMarkdown)}
|
||||
disabled={!hasMessages}
|
||||
icon={<Download className="size-4" />}
|
||||
label="Export MD"
|
||||
@@ -1793,7 +2065,7 @@ function CommandsMenu({
|
||||
/>
|
||||
<ToolTile
|
||||
data-action="ai-cmd-save-library"
|
||||
onClick={onSaveToLibrary}
|
||||
onClick={close(onSaveToLibrary)}
|
||||
disabled={!hasMessages}
|
||||
icon={<BookmarkPlus className="size-4" />}
|
||||
label="Save to Library"
|
||||
@@ -1801,7 +2073,7 @@ function CommandsMenu({
|
||||
/>
|
||||
<ToolTile
|
||||
data-action="ai-cmd-show-prompt"
|
||||
onClick={onShowPrompt}
|
||||
onClick={close(onShowPrompt)}
|
||||
icon={<FileText className="size-4" />}
|
||||
label="Show prompt"
|
||||
title="Preview the system prompt"
|
||||
@@ -1814,7 +2086,7 @@ function CommandsMenu({
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ToolTile
|
||||
data-action="ai-cmd-retry-probe"
|
||||
onClick={onRetryProbe}
|
||||
onClick={close(onRetryProbe)}
|
||||
icon={<RefreshCw className="size-4" />}
|
||||
label="Reconnect"
|
||||
title="Probe the LLM endpoint again"
|
||||
@@ -1826,7 +2098,7 @@ function CommandsMenu({
|
||||
<div className="my-2 h-px bg-border" />
|
||||
<ToolTile
|
||||
data-action="ai-cmd-clear"
|
||||
onClick={onClear}
|
||||
onClick={close(onClear)}
|
||||
disabled={!hasMessages}
|
||||
icon={<Trash2 className="size-4" />}
|
||||
label="Clear conversation"
|
||||
@@ -1851,7 +2123,8 @@ function SystemPromptDialog({
|
||||
await navigator.clipboard.writeText(prompt)
|
||||
} catch {}
|
||||
}
|
||||
return (
|
||||
if (typeof document === "undefined") return null
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -1886,7 +2159,8 @@ function SystemPromptDialog({
|
||||
{prompt}
|
||||
</pre>
|
||||
</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;
|
||||
// this catalog tells the model what exists elsewhere so it can plan
|
||||
// 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.
|
||||
|
||||
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 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.",
|
||||
RICH_OUTPUT_PREFACE,
|
||||
ARCADIA_KNOWLEDGE,
|
||||
persona,
|
||||
ctx,
|
||||
|
||||
Reference in New Issue
Block a user