Files
arcadia-admin/app/lib/threads.ts
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

229 lines
6.1 KiB
TypeScript

// Conversation threads — multiple named chats, each with its own history,
// active agent, and pinned message indices. Persisted in localStorage.
import { useEffect, useSyncExternalStore } from "react"
export type ThreadMessage = {
role: "user" | "assistant" | "tool"
content: string
/** Persona that authored this assistant message (omitted for user msgs). */
agentId?: string
/** Native tool calls attached to an assistant message. */
toolCalls?: { id: string; name: string; arguments: string }[]
/** Tool role only — id of the matching assistant tool_call. */
toolCallId?: string
/** Tool role only — function name. */
name?: string
}
export type Thread = {
id: string
title: string
agentId: string
messages: ThreadMessage[]
pinned: number[] // indices into messages[]
createdAt: number
updatedAt: number
}
const THREADS_KEY = "crema.assistant.threads"
const ACTIVE_KEY = "crema.assistant.activeThreadId"
const SNAPSHOT_KEY_PREFIX = "crema.assistant.thread.snapshot."
const CHANGE_EVENT = "crema:threads-change"
const MAX_BYTES = 800_000
export function newThreadId(): string {
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function isThread(v: unknown): v is Thread {
if (!v || typeof v !== "object") return false
const t = v as Thread
return (
typeof t.id === "string" &&
typeof t.title === "string" &&
typeof t.agentId === "string" &&
Array.isArray(t.messages) &&
Array.isArray(t.pinned) &&
typeof t.createdAt === "number" &&
typeof t.updatedAt === "number"
)
}
function readFromStorage(): Thread[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(THREADS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isThread)
} catch {
return []
}
}
function writeToStorage(threads: Thread[]) {
if (typeof window === "undefined") return
let serialized = JSON.stringify(threads)
// Trim oldest threads if quota gets tight.
let trimmed = threads
while (serialized.length > MAX_BYTES && trimmed.length > 1) {
trimmed = trimmed.slice(0, -1)
serialized = JSON.stringify(trimmed)
}
try {
localStorage.setItem(THREADS_KEY, serialized)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota — bail */
}
}
export function loadThreads(): Thread[] {
return readFromStorage()
}
export function saveThreads(threads: Thread[]) {
writeToStorage(threads)
}
export function loadActiveThreadId(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(ACTIVE_KEY)
}
export function saveActiveThreadId(id: string | null) {
if (typeof window === "undefined") return
if (id) localStorage.setItem(ACTIVE_KEY, id)
else localStorage.removeItem(ACTIVE_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
let cached: Thread[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === THREADS_KEY || e.key === ACTIVE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Thread[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Thread[] {
return []
}
export function useThreads(): Thread[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function ensureThread(
threads: Thread[],
fallbackAgentId: string,
): { threads: Thread[]; activeId: string } {
const stored = loadActiveThreadId()
if (stored && threads.some((t) => t.id === stored))
return { threads, activeId: stored }
if (threads.length > 0) {
saveActiveThreadId(threads[0].id)
return { threads, activeId: threads[0].id }
}
const id = newThreadId()
const now = Date.now()
const fresh: Thread = {
id,
title: "New conversation",
agentId: fallbackAgentId,
messages: [],
pinned: [],
createdAt: now,
updatedAt: now,
}
saveActiveThreadId(id)
saveThreads([fresh, ...threads])
return { threads: [fresh, ...threads], activeId: id }
}
export function updateThread(id: string, patch: Partial<Thread>) {
const threads = readFromStorage()
const next = threads.map((t) =>
t.id === id ? { ...t, ...patch, updatedAt: Date.now() } : t,
)
writeToStorage(next)
}
export function createThread(agentId: string, title = "New conversation"): Thread {
const threads = readFromStorage()
const id = newThreadId()
const now = Date.now()
const fresh: Thread = {
id,
title,
agentId,
messages: [],
pinned: [],
createdAt: now,
updatedAt: now,
}
writeToStorage([fresh, ...threads])
saveActiveThreadId(id)
return fresh
}
export function deleteThread(id: string) {
const threads = readFromStorage()
const next = threads.filter((t) => t.id !== id)
writeToStorage(next)
if (loadActiveThreadId() === id) {
saveActiveThreadId(next[0]?.id ?? null)
}
}
export function snapshotThread(id: string) {
const threads = readFromStorage()
const t = threads.find((x) => x.id === id)
if (!t) return
try {
localStorage.setItem(SNAPSHOT_KEY_PREFIX + id, JSON.stringify(t))
} catch {
/* quota */
}
}
export function loadThreadSnapshot(id: string): Thread | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(SNAPSHOT_KEY_PREFIX + id)
if (!raw) return null
const parsed = JSON.parse(raw)
return isThread(parsed) ? parsed : null
} catch {
return null
}
}
export function clearThreadSnapshot(id: string) {
if (typeof window === "undefined") return
localStorage.removeItem(SNAPSHOT_KEY_PREFIX + id)
}
export function deriveTitleFromFirstMessage(text: string): string {
const trimmed = text.trim().split(/\s+/).slice(0, 8).join(" ")
return trimmed.length > 60 ? trimmed.slice(0, 57) + "…" : trimmed || "New conversation"
}