Files
arcadia-admin/app/routes/ai.tsx
jules fe93f2766c Wire AI assistant to arcadia: domain primer, tool calling, admin context
Make /ai and /assistant operate as the platform admin's assistant
against arcadia-app's API:

- Add `arcadia-knowledge.ts` — domain primer (multi-tenant Phoenix
  backend, tenant lifecycle, platform_admins identity, etc.) baked into
  every system prompt.
- Add `admin-tools.ts` — curated tool registry exposing `list_tenants`
  and `get_tenant`, callable via OpenAI-native function calling. Tools
  hit arcadia through `useArcadiaClient()` and inherit the operator's
  JWT + tenant header. `runLLMToolCalls()` returns `tool` role messages
  ready to push back into history.
- Add `admin-context.ts` — runtime registry pages publish to so the
  assistant can answer factual questions about live UI state without
  scraping the DOM. Tenants page registers its summary on mount.
- Replace generic Vibespace personas (Atlas/Forge/Inkwell/Pilot/Cursor)
  with arcadia-flavoured ones: Operator, Auditor, Triage, Analyst,
  UI Operator. Auto-migrate stored agents from the legacy set.
- /assistant: build admin preface (role + primer + persona + ctx) and
  pass it as the `useChat` system at construction. Pass `tools` on every
  `send()`. Auto-loop reads `toolCalls` off the streaming assistant
  message and uses `continueChat()` to push tool results.
- /ai: same wiring (this is the canonical admin chat surface; the user
  prefers its look).
- MessageBody renders tool-result cards (role: "tool") and a "Called X"
  pill on assistant messages with toolCalls. Strips Qwen-style
  `<tool_call>` XML from prose when the tags were converted to
  structured calls.
- Extend ThreadMessage with the `tool` role + tool-call metadata so
  conversations round-trip through localStorage.
- Tenants page: row actions get `data-action="tenant-<slug>-{suspend,
  activate,deactivate}"` (via lib-table-ui's new dataAction prop);
  registers tenant summary into admin-context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:08:47 +10:00

1216 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import {
Archive,
ArrowRight,
BookmarkPlus,
ChevronDown,
Command as CommandIcon,
Copy,
Download,
FileText,
Loader2,
Mic,
MicOff,
Plus,
RefreshCw,
RotateCcw,
Square,
Trash2,
Undo2,
X,
} from "lucide-react"
import {
LLMProvider,
MockLLM,
OpenAICompatibleAdapter,
listModels,
useChat,
useCompletion,
type LLMAdapter,
} from "@crema/llm-ui"
import { TypingIndicator } from "@crema/chat-ui"
import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { useLLMSettings } from "~/lib/llm-settings"
import {
loadActiveAgentId,
saveActiveAgentId,
useAgents,
type Agent,
} from "~/lib/agents"
import { addLibraryItem } from "~/lib/library"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client"
import type { ToolCall } from "@crema/llm-ui"
import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import { formatAdminContextForPrompt } from "~/lib/admin-context"
const SNAPSHOT_KEY = "crema.ai.snapshot"
type StoredMessage = { role: "user" | "assistant"; content: string }
function loadAISnapshot(): StoredMessage[] | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(SNAPSHOT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed as StoredMessage[]
} catch {}
return null
}
function saveAISnapshot(msgs: StoredMessage[]) {
if (typeof window === "undefined") return
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(msgs))
}
function clearAISnapshot() {
if (typeof window === "undefined") return
localStorage.removeItem(SNAPSHOT_KEY)
}
export const meta = () => pageTitle("AI")
const MODEL_KEY = "crema.ai.model"
const PROBE_TIMEOUT_MS = 3000
type Status =
| { kind: "probing" }
| { kind: "live"; models: string[] }
| { kind: "mock"; reason: string }
const mockAdapter = new MockLLM({
label: "Mock",
delayMs: 18,
fallback:
"I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.",
responses: [],
})
function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal) {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error("timeout")), ms)
signal.addEventListener("abort", () => {
clearTimeout(t)
reject(new DOMException("Aborted", "AbortError"))
})
p.then(
(v) => {
clearTimeout(t)
resolve(v)
},
(e) => {
clearTimeout(t)
reject(e)
},
)
})
}
export default function AIRoute() {
const settings = useLLMSettings()
const agents = useAgents()
const [status, setStatus] = useState<Status>({ kind: "probing" })
const [model, setModel] = useState<string>(() => {
if (typeof window === "undefined") return ""
return localStorage.getItem(MODEL_KEY) ?? ""
})
const [activeAgentId, setActiveAgentIdState] = useState<string>(() =>
loadActiveAgentId(),
)
const setActiveAgentId = useCallback((id: string) => {
saveActiveAgentId(id)
setActiveAgentIdState(id)
}, [])
const activeAgent =
agents.find((a) => a.id === activeAgentId) ?? agents[0]
const probe = useCallback(() => {
const ac = new AbortController()
setStatus({ kind: "probing" })
withTimeout(
listModels({ baseURL: settings.baseURL, signal: ac.signal }),
PROBE_TIMEOUT_MS,
ac.signal,
)
.then((rows) => {
const ids = rows.map((m) => m.id)
if (ids.length === 0) {
setStatus({ kind: "mock", reason: "endpoint returned no models" })
return
}
setStatus({ kind: "live", models: ids })
setModel((cur) => (cur && ids.includes(cur) ? cur : ids[0]))
})
.catch(() => {
setStatus({ kind: "mock", reason: "endpoint unreachable" })
})
return () => ac.abort()
}, [settings.baseURL])
useEffect(() => probe(), [probe])
useEffect(() => {
if (model) localStorage.setItem(MODEL_KEY, model)
}, [model])
const adapter: LLMAdapter = useMemo(() => {
if (status.kind === "live") {
return new OpenAICompatibleAdapter({
baseURL: settings.baseURL,
apiKey: settings.apiKey || "lm-studio",
})
}
return mockAdapter
}, [status.kind, settings.baseURL, settings.apiKey])
const activeModel =
status.kind === "live" ? model || status.models[0] : "mock"
const availableModels = status.kind === "live" ? status.models : ["mock"]
return (
<AppShell title="AI">
<LLMProvider adapter={adapter} model={activeModel}>
<ChatSurface
models={availableModels}
model={activeModel}
onModelChange={setModel}
agents={agents}
activeAgent={activeAgent}
onAgentChange={setActiveAgentId}
isMock={status.kind === "mock"}
onRetryProbe={probe}
/>
</LLMProvider>
</AppShell>
)
}
function ChatSurface({
models,
model,
onModelChange,
agents,
activeAgent,
onAgentChange,
isMock,
onRetryProbe,
}: {
models: string[]
model: string
onModelChange: (m: string) => void
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
isMock: boolean
onRetryProbe: () => void
}) {
const persona = activeAgent
? `Active persona: ${activeAgent.name}${activeAgent.role}\n${activeAgent.prompt}`
: ""
const systemPrompt = [
"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.",
ARCADIA_KNOWLEDGE,
persona,
formatAdminContextForPrompt(),
]
.filter(Boolean)
.join("\n\n")
const arcadia = useArcadiaClient()
const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({
system: systemPrompt,
})
// Auto tool-loop using native function calls.
const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming)
const MAX_TOOL_ITERATIONS = 3
useEffect(() => {
const justFinished = prevStreamingRef.current && !isStreaming
prevStreamingRef.current = isStreaming
if (!justFinished) return
const lastIdx = messages.length - 1
if (lastIdx < 0) return
const last = messages[lastIdx]
if (last.role !== "assistant") return
if (processedTurnRef.current === lastIdx) return
processedTurnRef.current = lastIdx
const calls = last.toolCalls ?? []
if (calls.length === 0) {
toolIterationsRef.current = 0
return
}
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return
toolIterationsRef.current += 1
void (async () => {
const { toolMessages } = await runLLMToolCalls(calls, { arcadia })
void continueChat(toolMessages, {
system: systemPrompt,
tools: getOpenAITools(),
})
})()
}, [messages, isStreaming, arcadia, continueChat, systemPrompt])
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
const [input, setInput] = useState("")
const [showPromptOpen, setShowPromptOpen] = useState(false)
const [hasCompactSnapshot, setHasCompactSnapshot] = useState(
() => !!loadAISnapshot(),
)
const hasAssistantReply = messages.some((m) => m.role === "assistant")
const buildTranscript = useCallback(() => {
const lines: string[] = [
`# Conversation`,
"",
activeAgent
? `**Persona:** ${activeAgent.name}${activeAgent.role}`
: "",
`**Date:** ${new Date().toISOString()}`,
"",
].filter(Boolean)
for (const m of messages) {
lines.push(`### ${m.role === "user" ? "User" : "Assistant"}`)
lines.push("")
lines.push(m.content.trim())
lines.push("")
}
return lines.join("\n")
}, [messages, activeAgent])
const copyMarkdown = useCallback(async () => {
if (messages.length === 0) return
try {
await navigator.clipboard.writeText(buildTranscript())
} catch {}
}, [buildTranscript, messages.length])
const exportMarkdown = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
a.download = `ai-${stamp}.md`
a.click()
URL.revokeObjectURL(url)
}, [buildTranscript, messages.length])
const saveToLibrary = useCallback(() => {
if (messages.length === 0) return
const md = buildTranscript()
addLibraryItem({
kind: "conversation",
title:
messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
"AI conversation",
content: md,
tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
agentName: activeAgent?.name,
agentRole: activeAgent?.role,
messageCount: messages.length,
})
}, [buildTranscript, messages, activeAgent])
const regenerateLast = useCallback(() => {
if (isStreaming) return
let lastUserIdx = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") {
lastUserIdx = i
break
}
}
if (lastUserIdx === -1) return
const text = messages[lastUserIdx].content
setMessages(messages.slice(0, lastUserIdx))
// Defer so the state flush completes before send() reads `messages`.
setTimeout(() => void send(text, { tools: getOpenAITools() }), 0)
}, [messages, setMessages, send, isStreaming])
const continueLast = useCallback(() => {
if (isStreaming || messages.length === 0) return
void send("Please continue your previous reply.", { tools: getOpenAITools() })
}, [isStreaming, messages.length, send])
const compactConversation = useCallback(async () => {
if (compacting || isStreaming || messages.length < 2) return
const transcript = messages
.map(
(m) =>
`${m.role === "user" ? "User" : "Assistant"}: ${m.content.trim()}`,
)
.join("\n\n")
const summarySystem =
"You compress conversations. Output a tight 12 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings."
try {
const summary = await completeOneShot(
[
{ role: "system", content: summarySystem },
{
role: "user",
content: `Summarize this conversation:\n\n${transcript}`,
},
],
{ maxTokens: 800 },
)
// Snapshot first so Restore can undo.
saveAISnapshot(messages as StoredMessage[])
setHasCompactSnapshot(true)
setMessages([
{
role: "assistant",
content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`,
},
])
} catch {}
}, [compacting, isStreaming, messages, completeOneShot, setMessages])
const restoreCompact = useCallback(() => {
const snap = loadAISnapshot()
if (!snap) return
setMessages(snap)
clearAISnapshot()
setHasCompactSnapshot(false)
}, [setMessages])
const endRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLDivElement | null>(null)
const lastContent = messages.at(-1)?.content ?? ""
// Track the composer's actual height so the auto-scroll sentinel can
// keep the latest text ~24px above its top edge regardless of how many
// lines the textarea has grown to.
const [composerHeight, setComposerHeight] = useState(160)
useEffect(() => {
const el = composerRef.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(([entry]) => {
if (entry) setComposerHeight(entry.contentRect.height)
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// Auto-stick to the bottom only when the user is already near it. If they
// scroll up to read earlier turns mid-stream, don't yank them back down.
// The ref dodges React render cycles so scroll events feel instant.
const stickRef = useRef(true)
useEffect(() => {
const onScroll = () => {
const distFromBottom =
document.documentElement.scrollHeight -
(window.scrollY + window.innerHeight)
stickRef.current = distFromBottom < 120
}
window.addEventListener("scroll", onScroll, { passive: true })
onScroll()
return () => window.removeEventListener("scroll", onScroll)
}, [])
useEffect(() => {
if (!stickRef.current) return
endRef.current?.scrollIntoView({ block: "end" })
}, [messages.length, lastContent, isStreaming])
const submit = useCallback(() => {
const text = input.trim()
if (!text || isStreaming) return
setInput("")
stickRef.current = true
void send(text, { tools: getOpenAITools() })
}, [input, isStreaming, send])
const isEmpty = messages.length === 0
return (
<div className="relative -mb-6 flex h-full min-h-0 flex-col">
{/* Greeting — only when empty, fades out as composer slides down */}
<div
aria-hidden={!isEmpty}
className="pointer-events-none absolute inset-x-0 top-[28%] -translate-y-1/2 px-6 text-center transition-opacity duration-300"
style={{ opacity: isEmpty ? 1 : 0 }}
>
<h1
className="text-4xl font-semibold tracking-tight text-foreground"
style={{ fontFamily: "var(--font-heading)" }}
>
How can I help you today?
</h1>
</div>
{/* Messages — rendered when there are any. In empty state a flex-grow
* spacer takes its place so the sticky-bottom composer lands at the
* actual bottom of the surface (otherwise it'd sit at the top with
* nothing above it, and the lift transform would push it off-screen). */}
{isEmpty ? (
<div className="flex-1" aria-hidden="true" />
) : (
<div className="flex-1 px-4 py-6 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
{messages
.filter((m) => m.role !== "system")
.map((m, i) => (
<MessageRow
key={i}
role={m.role as "user" | "assistant" | "tool"}
content={m.content}
toolCalls={m.toolCalls}
/>
))}
{isStreaming && messages.at(-1)?.role !== "assistant" && (
<div className="self-start">
<TypingIndicator />
</div>
)}
<div
ref={endRef}
aria-hidden="true"
style={{ scrollMarginBottom: `${composerHeight + 24}px` }}
/>
</div>
</div>
)}
{/* Composer — single persistent mount; transform lifts it to viewport
* center when empty, then springs to sticky-bottom on the first message. */}
<div
ref={composerRef}
className="sticky bottom-0 z-20 px-4 pb-3 pt-3 sm:px-6"
style={{
transform: isEmpty
? "translateY(calc(-50dvh + 50% + 4rem))"
: "translateY(0)",
}}
>
<div className="mx-auto w-full max-w-3xl">
<Composer
value={input}
onChange={setInput}
onSubmit={submit}
onAbort={abort}
isStreaming={isStreaming}
models={models}
model={model}
onModelChange={onModelChange}
agents={agents}
activeAgent={activeAgent}
onAgentChange={onAgentChange}
onRegenerate={regenerateLast}
onContinue={continueLast}
onCompact={() => void compactConversation()}
onRestoreCompact={restoreCompact}
onCopyMarkdown={() => void copyMarkdown()}
onExportMarkdown={exportMarkdown}
onSaveToLibrary={saveToLibrary}
onShowPrompt={() => setShowPromptOpen(true)}
onRetryProbe={onRetryProbe}
onClear={reset}
hasMessages={messages.length > 0}
hasUserMessage={messages.some((m) => m.role === "user")}
hasCompactSnapshot={hasCompactSnapshot}
isMock={isMock}
isCompacting={compacting}
placeholder={isEmpty ? "Ask anything…" : "Reply…"}
/>
{showPromptOpen && (
<SystemPromptDialog
prompt={systemPrompt}
onClose={() => setShowPromptOpen(false)}
/>
)}
</div>
</div>
</div>
)
}
function MessageRow({
role,
content,
toolCalls,
}: {
role: "user" | "assistant" | "tool"
content: string
toolCalls?: ToolCall[]
}) {
if (role === "tool") {
return (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} isToolResult />
</div>
)
}
if (role === "user") {
return (
<div className="self-end">
<div
className="max-w-[80ch] rounded-3xl px-5 py-3 shadow-sm"
style={{
background: "oklch(0.55 0.22 295)",
color: "oklch(1 0 0)",
}}
>
<div className="whitespace-pre-wrap leading-relaxed">{content}</div>
</div>
</div>
)
}
return (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} toolCalls={toolCalls} />
</div>
)
}
function Composer({
value,
onChange,
onSubmit,
onAbort,
isStreaming,
models,
model,
onModelChange,
agents,
activeAgent,
onAgentChange,
onRegenerate,
onContinue,
onCompact,
onRestoreCompact,
onCopyMarkdown,
onExportMarkdown,
onSaveToLibrary,
onShowPrompt,
onRetryProbe,
onClear,
hasMessages,
hasUserMessage,
hasCompactSnapshot,
isMock,
isCompacting,
placeholder,
}: {
value: string
onChange: (v: string) => void
onSubmit: () => void
onAbort: () => void
isStreaming: boolean
models: string[]
model: string
onModelChange: (m: string) => void
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
onRegenerate: () => void
onContinue: () => void
onCompact: () => void
onRestoreCompact: () => void
onCopyMarkdown: () => void
onExportMarkdown: () => void
onSaveToLibrary: () => void
onShowPrompt: () => void
onRetryProbe: () => void
onClear: () => void
hasMessages: boolean
hasUserMessage: boolean
hasCompactSnapshot: boolean
isMock: boolean
isCompacting: boolean
placeholder: string
}) {
const taRef = useRef<HTMLTextAreaElement | null>(null)
// Auto-grow the textarea.
useEffect(() => {
const el = taRef.current
if (!el) return
el.style.height = "auto"
el.style.height = Math.min(el.scrollHeight, 240) + "px"
}, [value])
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}
return (
<div
className="rounded-3xl border shadow-sm transition-shadow focus-within:shadow-md"
style={{
background: "var(--card)",
borderColor: "var(--border)",
backdropFilter: "blur(32px) saturate(180%)",
}}
>
<div className="flex flex-col gap-3 px-5 pt-4 pb-3">
<textarea
ref={taRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKey}
placeholder={placeholder}
rows={2}
data-action="ai-composer-input"
className="min-h-[3.5rem] w-full resize-none bg-transparent text-base leading-relaxed outline-none placeholder:text-muted-foreground"
style={{ fontFamily: "var(--font-sans)" }}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<button
type="button"
data-action="ai-attach"
aria-label="Attach"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Plus className="size-5" />
</button>
<CommandsMenu
onRegenerate={onRegenerate}
onContinue={onContinue}
onCompact={onCompact}
onRestoreCompact={onRestoreCompact}
onCopyMarkdown={onCopyMarkdown}
onExportMarkdown={onExportMarkdown}
onSaveToLibrary={onSaveToLibrary}
onShowPrompt={onShowPrompt}
onRetryProbe={onRetryProbe}
onClear={onClear}
isStreaming={isStreaming}
isCompacting={isCompacting}
hasMessages={hasMessages}
hasUserMessage={hasUserMessage}
hasCompactSnapshot={hasCompactSnapshot}
isMock={isMock}
/>
</div>
<div className="flex items-center gap-1">
<AgentChip
agents={agents}
activeAgent={activeAgent}
onAgentChange={onAgentChange}
/>
<ModelSelector
models={models}
model={model}
onModelChange={onModelChange}
/>
<VoiceInputButton
onTranscript={(t) => onChange((value ? value + " " : "") + t)}
/>
{isStreaming ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onAbort}
aria-label="Stop"
data-action="ai-stop"
className="rounded-full"
>
<Square className="size-4" />
</Button>
) : null}
</div>
</div>
</div>
</div>
)
}
function ModelSelector({
models,
model,
onModelChange,
}: {
models: string[]
model: string
onModelChange: (m: string) => void
}) {
const label = prettyModelName(model)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-action="ai-model"
className="inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<span>{label}</span>
<ChevronDown className="size-4 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{models.map((m) => (
<DropdownMenuItem
key={m}
onClick={() => onModelChange(m)}
className={m === model ? "font-medium" : ""}
>
{prettyModelName(m)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function AgentChip({
agents,
activeAgent,
onAgentChange,
}: {
agents: Agent[]
activeAgent: Agent | undefined
onAgentChange: (id: string) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
data-action="ai-agent"
className="inline-flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={
activeAgent
? `${activeAgent.name}${activeAgent.role}`
: "Pick a persona"
}
>
<Avatar className="size-5">
<AvatarFallback
style={{
background: agentTint(activeAgent?.id ?? ""),
color: "var(--primary-foreground)",
}}
className="text-[10px] font-semibold"
>
{agentInitials(activeAgent?.name)}
</AvatarFallback>
</Avatar>
<span className="font-medium">
{activeAgent?.name ?? "Agent"}
</span>
<ChevronDown className="size-4 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="max-h-80 w-72 overflow-y-auto"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Switch persona
</div>
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => onAgentChange(a.id)}
data-state={activeAgent?.id === a.id ? "checked" : undefined}
data-action={`ai-agent-${a.id}`}
className="flex items-center gap-2.5"
>
<Avatar className="size-7">
<AvatarFallback
style={{
background: agentTint(a.id),
color: "var(--primary-foreground)",
}}
className="text-[11px] font-semibold"
>
{agentInitials(a.name)}
</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{a.name}</span>
<span className="truncate text-xs text-muted-foreground">
{a.role}
</span>
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function agentInitials(name: string | undefined): string {
if (!name) return "?"
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}
function agentTint(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0
}
const hue = ((hash % 360) + 360) % 360
return `oklch(0.55 0.18 ${hue})`
}
function CommandsMenu({
onRegenerate,
onContinue,
onCompact,
onRestoreCompact,
onCopyMarkdown,
onExportMarkdown,
onSaveToLibrary,
onShowPrompt,
onRetryProbe,
onClear,
isStreaming,
isCompacting,
hasMessages,
hasUserMessage,
hasCompactSnapshot,
isMock,
}: {
onRegenerate: () => void
onContinue: () => void
onCompact: () => void
onRestoreCompact: () => void
onCopyMarkdown: () => void
onExportMarkdown: () => void
onSaveToLibrary: () => void
onShowPrompt: () => void
onRetryProbe: () => void
onClear: () => void
isStreaming: boolean
isCompacting: boolean
hasMessages: boolean
hasUserMessage: boolean
hasCompactSnapshot: boolean
isMock: boolean
}) {
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
data-action="ai-commands"
aria-label="Commands"
title="Commands"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<CommandIcon className="size-5" />
</button>
}
/>
<PopoverContent align="start" side="top" className="w-80 p-2">
<SectionLabel>Conversation</SectionLabel>
<div className="grid grid-cols-2 gap-1">
<ToolTile
data-action="ai-cmd-regenerate"
onClick={onRegenerate}
disabled={isStreaming || !hasUserMessage}
icon={<RotateCcw className="size-4" />}
label="Regenerate"
title="Re-run the most recent prompt"
/>
<ToolTile
data-action="ai-cmd-continue"
onClick={onContinue}
disabled={isStreaming || !hasMessages}
icon={<ArrowRight className="size-4" />}
label="Continue"
title="Ask the model to keep going"
/>
<ToolTile
data-action="ai-cmd-compact"
onClick={onCompact}
disabled={isCompacting || isStreaming || !hasMessages}
icon={
isCompacting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Archive className="size-4" />
)
}
label="Compact"
title="Summarize older turns to free context"
/>
<ToolTile
data-action="ai-cmd-restore-compact"
onClick={onRestoreCompact}
disabled={!hasCompactSnapshot}
icon={<Undo2 className="size-4" />}
label="Restore"
title={
hasCompactSnapshot
? "Undo the most recent compact"
: "No snapshot available"
}
/>
</div>
<SectionLabel>Share</SectionLabel>
<div className="grid grid-cols-2 gap-1">
<ToolTile
data-action="ai-cmd-copy-md"
onClick={onCopyMarkdown}
disabled={!hasMessages}
icon={<Copy className="size-4" />}
label="Copy MD"
title="Copy conversation as Markdown"
/>
<ToolTile
data-action="ai-cmd-export-md"
onClick={onExportMarkdown}
disabled={!hasMessages}
icon={<Download className="size-4" />}
label="Export MD"
title="Download a .md file"
/>
<ToolTile
data-action="ai-cmd-save-library"
onClick={onSaveToLibrary}
disabled={!hasMessages}
icon={<BookmarkPlus className="size-4" />}
label="Save to Library"
title="Snapshot this conversation"
/>
<ToolTile
data-action="ai-cmd-show-prompt"
onClick={onShowPrompt}
icon={<FileText className="size-4" />}
label="Show prompt"
title="Preview the system prompt"
/>
</div>
{isMock && (
<>
<SectionLabel>Connection</SectionLabel>
<div className="grid grid-cols-2 gap-1">
<ToolTile
data-action="ai-cmd-retry-probe"
onClick={onRetryProbe}
icon={<RefreshCw className="size-4" />}
label="Reconnect"
title="Probe the LLM endpoint again"
/>
</div>
</>
)}
<div className="my-2 h-px bg-border" />
<ToolTile
data-action="ai-cmd-clear"
onClick={onClear}
disabled={!hasMessages}
icon={<Trash2 className="size-4" />}
label="Clear conversation"
title="Wipe history and start fresh"
destructive
fullWidth
/>
</PopoverContent>
</Popover>
)
}
function SystemPromptDialog({
prompt,
onClose,
}: {
prompt: string
onClose: () => void
}) {
const copy = async () => {
try {
await navigator.clipboard.writeText(prompt)
} catch {}
}
return (
<div
role="dialog"
aria-modal="true"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm"
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border bg-card shadow-lg"
>
<div className="flex items-center gap-2 border-b px-4 py-3">
<FileText className="size-4 text-muted-foreground" />
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold">System prompt</span>
<span className="text-xs text-muted-foreground">
Base prompt + active persona
</span>
</div>
<Button variant="ghost" size="sm" onClick={copy}>
<Copy className="size-3.5" /> Copy
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
{prompt}
</pre>
</div>
</div>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="mt-2 mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{children}
</div>
)
}
function ToolTile({
icon,
label,
title,
onClick,
disabled,
destructive,
fullWidth,
...rest
}: {
icon: React.ReactNode
label: string
title: string
onClick: () => void
disabled?: boolean
destructive?: boolean
fullWidth?: boolean
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "title">) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={
"flex items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 " +
(fullWidth ? "w-full " : "") +
(destructive
? "text-destructive hover:bg-destructive/10"
: "hover:bg-accent hover:text-accent-foreground")
}
{...rest}
>
<span className="shrink-0">{icon}</span>
<span className="truncate font-medium">{label}</span>
</button>
)
}
function prettyModelName(id: string): string {
if (!id) return "model"
// Trim known prefixes / paths so the chip stays compact.
const last = id.split(/[\\/]/).pop() ?? id
return last.length > 28 ? last.slice(0, 26) + "…" : last
}
type SpeechRecognitionLike = {
lang: string
interimResults: boolean
continuous: boolean
onresult: (e: { results: { [k: number]: { [k: number]: { transcript: string } } } }) => void
onerror: () => void
onend: () => void
start: () => void
stop: () => void
}
function getSpeechRecognition(): (new () => SpeechRecognitionLike) | null {
if (typeof window === "undefined") return null
const w = window as unknown as {
SpeechRecognition?: new () => SpeechRecognitionLike
webkitSpeechRecognition?: new () => SpeechRecognitionLike
}
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
}
function VoiceInputButton({
onTranscript,
}: {
onTranscript: (text: string) => void
}) {
const Ctor = getSpeechRecognition()
const [listening, setListening] = useState(false)
const recRef = useRef<SpeechRecognitionLike | null>(null)
if (!Ctor) return null
const start = () => {
try {
const rec = new Ctor()
rec.lang = navigator.language || "en-US"
rec.interimResults = false
rec.continuous = false
rec.onresult = (e) => {
const text = e.results[0]?.[0]?.transcript ?? ""
if (text.trim()) onTranscript(text.trim())
}
rec.onerror = () => setListening(false)
rec.onend = () => setListening(false)
recRef.current = rec
rec.start()
setListening(true)
} catch {
setListening(false)
}
}
const stop = () => {
recRef.current?.stop()
setListening(false)
}
return (
<button
type="button"
data-action="ai-voice"
onClick={() => (listening ? stop() : start())}
aria-label={listening ? "Stop listening" : "Voice input"}
className={
"inline-flex size-9 items-center justify-center rounded-full transition-colors " +
(listening
? "bg-destructive/10 text-destructive animate-pulse"
: "text-muted-foreground hover:bg-accent hover:text-foreground")
}
>
{listening ? <MicOff className="size-5" /> : <Mic className="size-5" />}
</button>
)
}