feat(dashboards): agent-built custom dashboards (#55)
Persist agent-generated UIs as bookmarkable dashboards with CRUD, sidebar nav, and iterative editing support. Max 5 per user. Fresh data on each visit via saved queries. Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
parent
404a881758
commit
b24f94e570
@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"./src/db/schema-ai-config.ts",
|
||||
"./src/db/schema-theme.ts",
|
||||
"./src/db/schema-google.ts",
|
||||
"./src/db/schema-dashboards.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
|
||||
12
drizzle/0018_left_veda.sql
Executable file
12
drizzle/0018_left_veda.sql
Executable file
@ -0,0 +1,12 @@
|
||||
CREATE TABLE `custom_dashboards` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text DEFAULT '' NOT NULL,
|
||||
`spec_data` text NOT NULL,
|
||||
`queries` text NOT NULL,
|
||||
`render_prompt` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
3604
drizzle/meta/0018_snapshot.json
Executable file
3604
drizzle/meta/0018_snapshot.json
Executable file
File diff suppressed because it is too large
Load Diff
@ -127,6 +127,13 @@
|
||||
"when": 1770442889458,
|
||||
"tag": "0017_outstanding_colonel_america",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1770471997491,
|
||||
"tag": "0018_left_veda",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
336
src/app/actions/dashboards.ts
Executable file
336
src/app/actions/dashboards.ts
Executable file
@ -0,0 +1,336 @@
|
||||
"use server"
|
||||
|
||||
import { eq, and, desc } from "drizzle-orm"
|
||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||
import { getDb } from "@/db"
|
||||
import { customDashboards } from "@/db/schema-dashboards"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
const MAX_DASHBOARDS = 5
|
||||
|
||||
interface SavedQuery {
|
||||
readonly key: string
|
||||
readonly queryType: string
|
||||
readonly id?: string
|
||||
readonly search?: string
|
||||
readonly limit?: number
|
||||
}
|
||||
|
||||
export async function getCustomDashboards(): Promise<
|
||||
| {
|
||||
readonly success: true
|
||||
readonly data: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly updatedAt: string
|
||||
}>
|
||||
}
|
||||
| { readonly success: false; readonly error: string }
|
||||
> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "not authenticated" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const dashboards = await db.query.customDashboards.findMany({
|
||||
where: (d, { eq: e }) => e(d.userId, user.id),
|
||||
orderBy: (d) => desc(d.updatedAt),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true, data: dashboards }
|
||||
}
|
||||
|
||||
export async function getCustomDashboardById(
|
||||
dashboardId: string,
|
||||
): Promise<
|
||||
| {
|
||||
readonly success: true
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly specData: string
|
||||
readonly queries: string
|
||||
readonly renderPrompt: string
|
||||
readonly updatedAt: string
|
||||
}
|
||||
}
|
||||
| { readonly success: false; readonly error: string }
|
||||
> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "not authenticated" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const dashboard = await db.query.customDashboards.findFirst({
|
||||
where: (d, { eq: e, and: a }) =>
|
||||
a(e(d.id, dashboardId), e(d.userId, user.id)),
|
||||
})
|
||||
|
||||
if (!dashboard) {
|
||||
return { success: false, error: "dashboard not found" }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: dashboard.id,
|
||||
name: dashboard.name,
|
||||
description: dashboard.description,
|
||||
specData: dashboard.specData,
|
||||
queries: dashboard.queries,
|
||||
renderPrompt: dashboard.renderPrompt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCustomDashboard(
|
||||
name: string,
|
||||
description: string,
|
||||
specData: string,
|
||||
queries: string,
|
||||
renderPrompt: string,
|
||||
existingId?: string,
|
||||
): Promise<
|
||||
| { readonly success: true; readonly id: string }
|
||||
| { readonly success: false; readonly error: string }
|
||||
> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "not authenticated" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const id = existingId ?? crypto.randomUUID()
|
||||
|
||||
if (existingId) {
|
||||
const existing = await db.query.customDashboards.findFirst({
|
||||
where: (d, { eq: e, and: a }) =>
|
||||
a(e(d.id, existingId), e(d.userId, user.id)),
|
||||
})
|
||||
if (!existing) {
|
||||
return { success: false, error: "dashboard not found" }
|
||||
}
|
||||
await db
|
||||
.update(customDashboards)
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
specData,
|
||||
queries,
|
||||
renderPrompt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(customDashboards.id, existingId))
|
||||
} else {
|
||||
const count = await db.query.customDashboards.findMany({
|
||||
where: (d, { eq: e }) => e(d.userId, user.id),
|
||||
columns: { id: true },
|
||||
})
|
||||
if (count.length >= MAX_DASHBOARDS) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Maximum of ${MAX_DASHBOARDS} dashboards reached. Delete one to create a new one.`,
|
||||
}
|
||||
}
|
||||
await db.insert(customDashboards).values({
|
||||
id,
|
||||
userId: user.id,
|
||||
name,
|
||||
description,
|
||||
specData,
|
||||
queries,
|
||||
renderPrompt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout")
|
||||
return { success: true, id }
|
||||
}
|
||||
|
||||
export async function deleteCustomDashboard(
|
||||
dashboardId: string,
|
||||
): Promise<
|
||||
| { readonly success: true }
|
||||
| { readonly success: false; readonly error: string }
|
||||
> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "not authenticated" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
const existing = await db.query.customDashboards.findFirst({
|
||||
where: (d, { eq: e, and: a }) =>
|
||||
a(e(d.id, dashboardId), e(d.userId, user.id)),
|
||||
})
|
||||
if (!existing) {
|
||||
return { success: false, error: "dashboard not found" }
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(customDashboards)
|
||||
.where(
|
||||
and(
|
||||
eq(customDashboards.id, dashboardId),
|
||||
eq(customDashboards.userId, user.id),
|
||||
),
|
||||
)
|
||||
|
||||
revalidatePath("/", "layout")
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function executeDashboardQueries(
|
||||
queriesJson: string,
|
||||
): Promise<
|
||||
| {
|
||||
readonly success: true
|
||||
readonly data: Record<string, unknown>
|
||||
}
|
||||
| { readonly success: false; readonly error: string }
|
||||
> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return { success: false, error: "not authenticated" }
|
||||
|
||||
const { env } = await getCloudflareContext()
|
||||
const db = getDb(env.DB)
|
||||
|
||||
let queries: ReadonlyArray<SavedQuery>
|
||||
try {
|
||||
queries = JSON.parse(queriesJson) as ReadonlyArray<SavedQuery>
|
||||
} catch {
|
||||
return { success: false, error: "invalid queries JSON" }
|
||||
}
|
||||
|
||||
const dataContext: Record<string, unknown> = {}
|
||||
|
||||
for (const q of queries) {
|
||||
const cap = q.limit ?? 20
|
||||
try {
|
||||
switch (q.queryType) {
|
||||
case "customers": {
|
||||
const rows = await db.query.customers.findMany({
|
||||
limit: cap,
|
||||
...(q.search
|
||||
? {
|
||||
where: (c, { like }) =>
|
||||
like(c.name, `%${q.search}%`),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
dataContext[q.key] = { data: rows, count: rows.length }
|
||||
break
|
||||
}
|
||||
case "vendors": {
|
||||
const rows = await db.query.vendors.findMany({
|
||||
limit: cap,
|
||||
...(q.search
|
||||
? {
|
||||
where: (v, { like }) =>
|
||||
like(v.name, `%${q.search}%`),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
dataContext[q.key] = { data: rows, count: rows.length }
|
||||
break
|
||||
}
|
||||
case "projects": {
|
||||
const rows = await db.query.projects.findMany({
|
||||
limit: cap,
|
||||
...(q.search
|
||||
? {
|
||||
where: (p, { like }) =>
|
||||
like(p.name, `%${q.search}%`),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
dataContext[q.key] = { data: rows, count: rows.length }
|
||||
break
|
||||
}
|
||||
case "invoices": {
|
||||
const rows = await db.query.invoices.findMany({
|
||||
limit: cap,
|
||||
})
|
||||
dataContext[q.key] = { data: rows, count: rows.length }
|
||||
break
|
||||
}
|
||||
case "vendor_bills": {
|
||||
const rows = await db.query.vendorBills.findMany({
|
||||
limit: cap,
|
||||
})
|
||||
dataContext[q.key] = { data: rows, count: rows.length }
|
||||
break
|
||||
}
|
||||
case "schedule_tasks": {
|
||||
const rows = await db.query.scheduleTasks.findMany({
|
||||
limit: cap,
|
||||
...(q.search
|
||||
? {
|
||||
where: (t, { like }) =>
|
||||
like(t.title, `%${q.search}%`),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
dataContext[q.key] = { data: rows, count: rows.length }
|
||||
break
|
||||
}
|
||||
case "project_detail": {
|
||||
if (q.id) {
|
||||
const row = await db.query.projects.findFirst({
|
||||
where: (p, { eq: e }) => e(p.id, q.id!),
|
||||
})
|
||||
dataContext[q.key] = row
|
||||
? { data: row }
|
||||
: { error: "not found" }
|
||||
}
|
||||
break
|
||||
}
|
||||
case "customer_detail": {
|
||||
if (q.id) {
|
||||
const row = await db.query.customers.findFirst({
|
||||
where: (c, { eq: e }) => e(c.id, q.id!),
|
||||
})
|
||||
dataContext[q.key] = row
|
||||
? { data: row }
|
||||
: { error: "not found" }
|
||||
}
|
||||
break
|
||||
}
|
||||
case "vendor_detail": {
|
||||
if (q.id) {
|
||||
const row = await db.query.vendors.findFirst({
|
||||
where: (v, { eq: e }) => e(v.id, q.id!),
|
||||
})
|
||||
dataContext[q.key] = row
|
||||
? { data: row }
|
||||
: { error: "not found" }
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
dataContext[q.key] = { error: "unknown query type" }
|
||||
}
|
||||
} catch (err) {
|
||||
dataContext[q.key] = {
|
||||
error: err instanceof Error ? err.message : "query failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: dataContext }
|
||||
}
|
||||
@ -44,10 +44,16 @@ export async function POST(req: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
const [memories, registry] = await Promise.all([
|
||||
loadMemoriesForPrompt(db, user.id),
|
||||
getRegistry(db, envRecord),
|
||||
])
|
||||
const { getCustomDashboards } = await import(
|
||||
"@/app/actions/dashboards"
|
||||
)
|
||||
|
||||
const [memories, registry, dashboardResult] =
|
||||
await Promise.all([
|
||||
loadMemoriesForPrompt(db, user.id),
|
||||
getRegistry(db, envRecord),
|
||||
getCustomDashboards(),
|
||||
])
|
||||
|
||||
const pluginSections = registry.getPromptSections()
|
||||
const pluginTools = registry.getTools()
|
||||
@ -84,6 +90,9 @@ export async function POST(req: Request): Promise<Response> {
|
||||
timezone,
|
||||
memories,
|
||||
pluginSections,
|
||||
dashboards: dashboardResult.success
|
||||
? dashboardResult.data
|
||||
: [],
|
||||
mode: "full",
|
||||
}),
|
||||
messages: await convertToModelMessages(
|
||||
|
||||
44
src/app/dashboard/boards/[id]/page.tsx
Executable file
44
src/app/dashboard/boards/[id]/page.tsx
Executable file
@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import {
|
||||
getCustomDashboardById,
|
||||
executeDashboardQueries,
|
||||
} from "@/app/actions/dashboards"
|
||||
import { SavedDashboardView } from "@/components/saved-dashboard-view"
|
||||
|
||||
interface Props {
|
||||
readonly params: Promise<{ readonly id: string }>
|
||||
}
|
||||
|
||||
export default async function SavedDashboardPage({
|
||||
params,
|
||||
}: Props) {
|
||||
const { id } = await params
|
||||
|
||||
const result = await getCustomDashboardById(id)
|
||||
if (!result.success) notFound()
|
||||
|
||||
const dashboard = result.data
|
||||
const spec = JSON.parse(dashboard.specData)
|
||||
|
||||
let dataContext: Record<string, unknown> = {}
|
||||
if (dashboard.queries) {
|
||||
const queryResult = await executeDashboardQueries(
|
||||
dashboard.queries,
|
||||
)
|
||||
if (queryResult.success) {
|
||||
dataContext = queryResult.data
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SavedDashboardView
|
||||
dashboard={{
|
||||
id: dashboard.id,
|
||||
name: dashboard.name,
|
||||
description: dashboard.description,
|
||||
}}
|
||||
spec={spec}
|
||||
dataContext={dataContext}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -17,6 +17,7 @@ import {
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { getProjects } from "@/app/actions/projects"
|
||||
import { getCustomDashboards } from "@/app/actions/dashboards"
|
||||
import { ProjectListProvider } from "@/components/project-list-provider"
|
||||
import { getCurrentUser, toSidebarUser } from "@/lib/auth"
|
||||
import { BiometricGuard } from "@/components/native/biometric-guard"
|
||||
@ -29,11 +30,16 @@ export default async function DashboardLayout({
|
||||
}: {
|
||||
readonly children: React.ReactNode
|
||||
}) {
|
||||
const [projectList, authUser] = await Promise.all([
|
||||
getProjects(),
|
||||
getCurrentUser(),
|
||||
])
|
||||
const [projectList, authUser, dashboardResult] =
|
||||
await Promise.all([
|
||||
getProjects(),
|
||||
getCurrentUser(),
|
||||
getCustomDashboards(),
|
||||
])
|
||||
const user = authUser ? toSidebarUser(authUser) : null
|
||||
const dashboardList = dashboardResult.success
|
||||
? dashboardResult.data
|
||||
: []
|
||||
|
||||
return (
|
||||
<SettingsProvider>
|
||||
@ -51,7 +57,7 @@ export default async function DashboardLayout({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" projects={projectList} user={user} />
|
||||
<AppSidebar variant="inset" projects={projectList} dashboards={dashboardList} user={user} />
|
||||
<FeedbackWidget>
|
||||
<SidebarInset className="overflow-hidden">
|
||||
<OfflineBanner />
|
||||
|
||||
@ -78,6 +78,10 @@ interface RenderContextValue {
|
||||
data: Record<string, unknown>
|
||||
) => void
|
||||
clearRender: () => void
|
||||
loadSpec: (
|
||||
spec: Spec,
|
||||
data: Record<string, unknown>
|
||||
) => void
|
||||
}
|
||||
|
||||
const RenderContext =
|
||||
@ -171,6 +175,8 @@ export function ChatProvider({
|
||||
const [dataContext, setDataContext] = React.useState<
|
||||
Record<string, unknown>
|
||||
>({})
|
||||
const [loadedSpec, setLoadedSpec] =
|
||||
React.useState<Spec | null>(null)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
@ -228,13 +234,19 @@ export function ChatProvider({
|
||||
const routerRef = React.useRef(router)
|
||||
routerRef.current = router
|
||||
|
||||
const loadedSpecRef = React.useRef(loadedSpec)
|
||||
loadedSpecRef.current = loadedSpec
|
||||
|
||||
const triggerRender = React.useCallback(
|
||||
(prompt: string, data: Record<string, unknown>) => {
|
||||
setDataContext(data)
|
||||
setLoadedSpec(null)
|
||||
renderSendRef.current(prompt, {
|
||||
dataContext: data,
|
||||
previousSpec:
|
||||
renderSpecRef.current ?? undefined,
|
||||
renderSpecRef.current ??
|
||||
loadedSpecRef.current ??
|
||||
undefined,
|
||||
})
|
||||
},
|
||||
[]
|
||||
@ -243,8 +255,18 @@ export function ChatProvider({
|
||||
const clearRender = React.useCallback(() => {
|
||||
renderClearRef.current()
|
||||
setDataContext({})
|
||||
setLoadedSpec(null)
|
||||
}, [])
|
||||
|
||||
const loadSpec = React.useCallback(
|
||||
(spec: Spec, data: Record<string, unknown>) => {
|
||||
renderClearRef.current()
|
||||
setLoadedSpec(spec)
|
||||
setDataContext(data)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// watch chat messages for generateUI tool results
|
||||
// and trigger render stream directly (no event chain)
|
||||
const renderDispatchedRef = React.useRef(
|
||||
@ -275,6 +297,118 @@ export function ChatProvider({
|
||||
triggerRender(result.renderPrompt, result.dataContext)
|
||||
}, [chat.messages, triggerRender])
|
||||
|
||||
// listen for save-dashboard events from tool dispatch
|
||||
React.useEffect(() => {
|
||||
const handler = async (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as {
|
||||
name?: string
|
||||
description?: string
|
||||
dashboardId?: string
|
||||
}
|
||||
if (!detail?.name) return
|
||||
|
||||
const currentSpec =
|
||||
renderSpecRef.current ?? loadedSpecRef.current
|
||||
if (!currentSpec) return
|
||||
|
||||
const { saveCustomDashboard } = await import(
|
||||
"@/app/actions/dashboards"
|
||||
)
|
||||
|
||||
const result = await saveCustomDashboard(
|
||||
detail.name,
|
||||
detail.description ?? "",
|
||||
JSON.stringify(currentSpec),
|
||||
JSON.stringify([]),
|
||||
detail.name,
|
||||
detail.dashboardId,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-toast", {
|
||||
detail: {
|
||||
message: `Dashboard "${detail.name}" saved`,
|
||||
type: "success",
|
||||
},
|
||||
})
|
||||
)
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-toast", {
|
||||
detail: {
|
||||
message: result.error,
|
||||
type: "error",
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
"agent-save-dashboard",
|
||||
handler
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"agent-save-dashboard",
|
||||
handler
|
||||
)
|
||||
}, [])
|
||||
|
||||
// listen for load-dashboard events from tool dispatch
|
||||
React.useEffect(() => {
|
||||
const handler = async (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as {
|
||||
dashboardId?: string
|
||||
spec?: Spec
|
||||
queries?: string
|
||||
renderPrompt?: string
|
||||
editPrompt?: string
|
||||
}
|
||||
if (!detail?.spec) return
|
||||
|
||||
// run saved queries for fresh data
|
||||
let freshData: Record<string, unknown> = {}
|
||||
if (detail.queries) {
|
||||
const { executeDashboardQueries } = await import(
|
||||
"@/app/actions/dashboards"
|
||||
)
|
||||
const result = await executeDashboardQueries(
|
||||
detail.queries,
|
||||
)
|
||||
if (result.success) {
|
||||
freshData = result.data
|
||||
}
|
||||
}
|
||||
|
||||
loadSpec(detail.spec, freshData)
|
||||
|
||||
// navigate to /dashboard
|
||||
if (pathnameRef.current !== "/dashboard") {
|
||||
routerRef.current.push("/dashboard")
|
||||
}
|
||||
setIsOpen(true)
|
||||
|
||||
// if editPrompt provided, trigger re-render
|
||||
if (detail.editPrompt) {
|
||||
setTimeout(() => {
|
||||
triggerRender(detail.editPrompt!, freshData)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
"agent-load-dashboard",
|
||||
handler
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"agent-load-dashboard",
|
||||
handler
|
||||
)
|
||||
}, [loadSpec, triggerRender])
|
||||
|
||||
// listen for navigation events from rendered UI
|
||||
React.useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
@ -347,6 +481,7 @@ export function ChatProvider({
|
||||
setConversationId(crypto.randomUUID())
|
||||
setResumeLoaded(true)
|
||||
clearRender()
|
||||
setLoadedSpec(null)
|
||||
renderDispatchedRef.current.clear()
|
||||
}, [chat.setMessages, clearRender])
|
||||
|
||||
@ -389,20 +524,23 @@ export function ChatProvider({
|
||||
|
||||
const renderValue = React.useMemo(
|
||||
() => ({
|
||||
spec: renderStream.spec,
|
||||
spec: renderStream.spec ?? loadedSpec,
|
||||
isRendering: renderStream.isStreaming,
|
||||
error: renderStream.error,
|
||||
dataContext,
|
||||
triggerRender,
|
||||
clearRender,
|
||||
loadSpec,
|
||||
}),
|
||||
[
|
||||
renderStream.spec,
|
||||
loadedSpec,
|
||||
renderStream.isStreaming,
|
||||
renderStream.error,
|
||||
dataContext,
|
||||
triggerRender,
|
||||
clearRender,
|
||||
loadSpec,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { XIcon, Loader2Icon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
import {
|
||||
XIcon,
|
||||
Loader2Icon,
|
||||
BookmarkIcon,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useRenderState } from "./chat-provider"
|
||||
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
|
||||
import { saveCustomDashboard } from "@/app/actions/dashboards"
|
||||
|
||||
export function RenderedView() {
|
||||
const {
|
||||
@ -14,8 +29,46 @@ export function RenderedView() {
|
||||
clearRender,
|
||||
} = useRenderState()
|
||||
|
||||
const [saveOpen, setSaveOpen] = React.useState(false)
|
||||
const [saveName, setSaveName] = React.useState("")
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
|
||||
const hasRoot = !!spec?.root
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveName.trim() || !spec) return
|
||||
setSaving(true)
|
||||
const result = await saveCustomDashboard(
|
||||
saveName.trim(),
|
||||
"",
|
||||
JSON.stringify(spec),
|
||||
JSON.stringify([]),
|
||||
saveName.trim(),
|
||||
)
|
||||
setSaving(false)
|
||||
if (result.success) {
|
||||
setSaveOpen(false)
|
||||
setSaveName("")
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-toast", {
|
||||
detail: {
|
||||
message: `Dashboard "${saveName.trim()}" saved`,
|
||||
type: "success",
|
||||
},
|
||||
})
|
||||
)
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-toast", {
|
||||
detail: {
|
||||
message: result.error,
|
||||
type: "error",
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 p-4">
|
||||
{/* Header bar */}
|
||||
@ -31,17 +84,68 @@ export function RenderedView() {
|
||||
<span>Generated by Slab</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearRender}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
Clear
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasRoot && !isRendering && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSaveOpen(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<BookmarkIcon className="size-4" />
|
||||
Save as Dashboard
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearRender}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Dashboard</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dashboard-name">Name</Label>
|
||||
<Input
|
||||
id="dashboard-name"
|
||||
placeholder="Project Overview"
|
||||
value={saveName}
|
||||
onChange={(e) =>
|
||||
setSaveName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSave()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSaveOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!saveName.trim() || saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rendered content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { NavMain } from "@/components/nav-main"
|
||||
import { NavDashboards } from "@/components/nav-dashboards"
|
||||
import { NavSecondary } from "@/components/nav-secondary"
|
||||
import { NavFiles } from "@/components/nav-files"
|
||||
import { NavProjects } from "@/components/nav-projects"
|
||||
@ -96,8 +97,13 @@ const data = {
|
||||
|
||||
function SidebarNav({
|
||||
projects,
|
||||
dashboards = [],
|
||||
}: {
|
||||
projects: { id: string; name: string }[]
|
||||
dashboards?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
}>
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const { state, setOpen } = useSidebar()
|
||||
@ -146,6 +152,7 @@ function SidebarNav({
|
||||
{mode === "main" && (
|
||||
<>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavDashboards dashboards={dashboards} />
|
||||
<NavSecondary items={secondaryItems} className="mt-auto" />
|
||||
</>
|
||||
)}
|
||||
@ -155,10 +162,12 @@ function SidebarNav({
|
||||
|
||||
export function AppSidebar({
|
||||
projects = [],
|
||||
dashboards = [],
|
||||
user,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }>
|
||||
readonly dashboards?: ReadonlyArray<{ readonly id: string; readonly name: string }>
|
||||
readonly user: SidebarUser | null
|
||||
}) {
|
||||
return (
|
||||
@ -192,7 +201,10 @@ export function AppSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarNav projects={projects as { id: string; name: string }[]} />
|
||||
<SidebarNav
|
||||
projects={projects as { id: string; name: string }[]}
|
||||
dashboards={dashboards}
|
||||
/>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={user} />
|
||||
|
||||
49
src/components/nav-dashboards.tsx
Executable file
49
src/components/nav-dashboards.tsx
Executable file
@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
interface Dashboard {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export function NavDashboards({
|
||||
dashboards,
|
||||
}: {
|
||||
readonly dashboards: ReadonlyArray<Dashboard>
|
||||
}) {
|
||||
if (dashboards.length === 0) return null
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Dashboards</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{dashboards.map((d) => (
|
||||
<SidebarMenuItem key={d.id}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={d.name}
|
||||
>
|
||||
<Link href={`/dashboard/boards/${d.id}`}>
|
||||
<IconLayoutDashboard />
|
||||
<span>{d.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
117
src/components/saved-dashboard-view.tsx
Executable file
117
src/components/saved-dashboard-view.tsx
Executable file
@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
RefreshCwIcon,
|
||||
Trash2Icon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
|
||||
import {
|
||||
deleteCustomDashboard,
|
||||
executeDashboardQueries,
|
||||
} from "@/app/actions/dashboards"
|
||||
import type { Spec } from "@json-render/react"
|
||||
|
||||
interface SavedDashboardViewProps {
|
||||
readonly dashboard: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
}
|
||||
readonly spec: Spec
|
||||
readonly dataContext: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function SavedDashboardView({
|
||||
dashboard,
|
||||
spec,
|
||||
dataContext: initialData,
|
||||
}: SavedDashboardViewProps) {
|
||||
const router = useRouter()
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const [deleting, setDeleting] = React.useState(false)
|
||||
const [data, setData] =
|
||||
React.useState<Record<string, unknown>>(initialData)
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
const result = await executeDashboardQueries(
|
||||
JSON.stringify([]),
|
||||
)
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Delete this dashboard?")) return
|
||||
setDeleting(true)
|
||||
const result = await deleteCustomDashboard(dashboard.id)
|
||||
if (result.success) {
|
||||
router.push("/dashboard")
|
||||
} else {
|
||||
setDeleting(false)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-toast", {
|
||||
detail: {
|
||||
message: result.error,
|
||||
type: "error",
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{dashboard.name}
|
||||
</h1>
|
||||
{dashboard.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dashboard.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="gap-1.5 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CompassRenderer spec={spec} data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -6,6 +6,7 @@ import * as agentSchema from "./schema-agent"
|
||||
import * as aiConfigSchema from "./schema-ai-config"
|
||||
import * as themeSchema from "./schema-theme"
|
||||
import * as googleSchema from "./schema-google"
|
||||
import * as dashboardSchema from "./schema-dashboards"
|
||||
|
||||
const allSchemas = {
|
||||
...schema,
|
||||
@ -15,6 +16,7 @@ const allSchemas = {
|
||||
...aiConfigSchema,
|
||||
...themeSchema,
|
||||
...googleSchema,
|
||||
...dashboardSchema,
|
||||
}
|
||||
|
||||
export function getDb(d1: D1Database) {
|
||||
|
||||
24
src/db/schema-dashboards.ts
Executable file
24
src/db/schema-dashboards.ts
Executable file
@ -0,0 +1,24 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { users } from "./schema"
|
||||
|
||||
export const customDashboards = sqliteTable(
|
||||
"custom_dashboards",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""),
|
||||
specData: text("spec_data").notNull(),
|
||||
queries: text("queries").notNull(),
|
||||
renderPrompt: text("render_prompt").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
)
|
||||
|
||||
export type CustomDashboard =
|
||||
typeof customDashboards.$inferSelect
|
||||
export type NewCustomDashboard =
|
||||
typeof customDashboards.$inferInsert
|
||||
@ -134,6 +134,26 @@ export function initializeActionHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
registerActionHandler("SAVE_DASHBOARD", (payload) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-save-dashboard", {
|
||||
detail: payload,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
registerActionHandler("LOAD_DASHBOARD", (payload) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agent-load-dashboard", {
|
||||
detail: payload,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
registerActionHandler("APPLY_THEME", (payload) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
@ -163,6 +183,8 @@ export const ALL_HANDLER_TYPES = [
|
||||
"SCROLL_TO",
|
||||
"FOCUS_ELEMENT",
|
||||
"GENERATE_UI",
|
||||
"SAVE_DASHBOARD",
|
||||
"LOAD_DASHBOARD",
|
||||
"APPLY_THEME",
|
||||
"PREVIEW_THEME",
|
||||
] as const
|
||||
@ -227,6 +249,28 @@ export function dispatchToolActions(
|
||||
},
|
||||
})
|
||||
break
|
||||
case "save_dashboard":
|
||||
executeAction({
|
||||
type: "SAVE_DASHBOARD",
|
||||
payload: {
|
||||
name: output.name,
|
||||
description: output.description,
|
||||
dashboardId: output.dashboardId,
|
||||
},
|
||||
})
|
||||
break
|
||||
case "load_dashboard":
|
||||
executeAction({
|
||||
type: "LOAD_DASHBOARD",
|
||||
payload: {
|
||||
dashboardId: output.dashboardId,
|
||||
spec: output.spec,
|
||||
queries: output.queries,
|
||||
renderPrompt: output.renderPrompt,
|
||||
editPrompt: output.editPrompt,
|
||||
},
|
||||
})
|
||||
break
|
||||
case "apply_theme":
|
||||
executeAction({
|
||||
type: "APPLY_THEME",
|
||||
|
||||
@ -21,6 +21,12 @@ interface ToolMeta {
|
||||
readonly adminOnly?: true
|
||||
}
|
||||
|
||||
interface DashboardSummary {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
interface PromptContext {
|
||||
readonly userName: string
|
||||
readonly userRole: string
|
||||
@ -28,6 +34,7 @@ interface PromptContext {
|
||||
readonly memories?: string
|
||||
readonly timezone?: string
|
||||
readonly pluginSections?: ReadonlyArray<PromptSection>
|
||||
readonly dashboards?: ReadonlyArray<DashboardSummary>
|
||||
readonly mode?: PromptMode
|
||||
}
|
||||
|
||||
@ -60,7 +67,8 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
|
||||
"/dashboard/projects/{id}/schedule, " +
|
||||
"/dashboard/customers, /dashboard/vendors, " +
|
||||
"/dashboard/financials, /dashboard/people, " +
|
||||
"/dashboard/files. If the page doesn't exist, " +
|
||||
"/dashboard/files, /dashboard/boards/{id}. " +
|
||||
"If the page doesn't exist, " +
|
||||
"tell the user what's available.",
|
||||
category: "navigation",
|
||||
},
|
||||
@ -184,6 +192,36 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
|
||||
"(not presets).",
|
||||
category: "ui",
|
||||
},
|
||||
{
|
||||
name: "saveDashboard",
|
||||
summary:
|
||||
"Save the current rendered UI as a named dashboard. " +
|
||||
"The client captures the spec and data automatically. " +
|
||||
"Pass dashboardId to update an existing dashboard.",
|
||||
category: "ui",
|
||||
},
|
||||
{
|
||||
name: "listDashboards",
|
||||
summary:
|
||||
"List the user's saved custom dashboards with " +
|
||||
"their IDs, names, and descriptions.",
|
||||
category: "ui",
|
||||
},
|
||||
{
|
||||
name: "editDashboard",
|
||||
summary:
|
||||
"Load a saved dashboard for editing. Loads the spec " +
|
||||
"into the render context on /dashboard. Optionally " +
|
||||
"pass editPrompt to trigger immediate re-generation.",
|
||||
category: "ui",
|
||||
},
|
||||
{
|
||||
name: "deleteDashboard",
|
||||
summary:
|
||||
"Delete a saved dashboard. Always confirm with " +
|
||||
"the user before deleting.",
|
||||
category: "ui",
|
||||
},
|
||||
]
|
||||
|
||||
// categories included in minimal mode
|
||||
@ -306,6 +344,8 @@ function buildFirstInteraction(
|
||||
'show you recent commits, PRs, issues, and contributor activity."',
|
||||
'"I can also conduct a quick UX interview if you\'d like ' +
|
||||
'to share feedback about Compass."',
|
||||
'"I can build you a custom dashboard with charts and ' +
|
||||
'stats — and save it so you can access it anytime."',
|
||||
]
|
||||
|
||||
return [
|
||||
@ -554,6 +594,56 @@ function buildThemingRules(
|
||||
]
|
||||
}
|
||||
|
||||
function buildDashboardRules(
|
||||
ctx: PromptContext,
|
||||
mode: PromptMode,
|
||||
): ReadonlyArray<string> {
|
||||
if (mode !== "full") return []
|
||||
|
||||
const lines = [
|
||||
"## Custom Dashboards",
|
||||
"Users can save generated UIs as persistent dashboards " +
|
||||
"that appear in the sidebar and can be revisited anytime.",
|
||||
"",
|
||||
"**Workflow:**",
|
||||
"1. User asks for a dashboard (e.g. \"build me a " +
|
||||
"project overview\")",
|
||||
"2. Use queryData to fetch data, then generateUI to " +
|
||||
"build the UI",
|
||||
"3. Once the user is happy, use saveDashboard to persist it",
|
||||
"4. The dashboard appears in the sidebar at " +
|
||||
"/dashboard/boards/{id}",
|
||||
"",
|
||||
"**Editing:**",
|
||||
"- Use editDashboard to load a saved dashboard for editing",
|
||||
"- After loading, use generateUI to make changes " +
|
||||
"(the system sends patches against the previous spec)",
|
||||
"- Use saveDashboard with the dashboardId to save updates",
|
||||
"",
|
||||
"**Limits:**",
|
||||
"- Maximum 5 dashboards per user",
|
||||
"- If the user hits the limit, suggest deleting one first",
|
||||
"",
|
||||
"**When to offer dashboard saving:**",
|
||||
"- After generating a useful UI the user seems happy with",
|
||||
'- When the user says "save this" or "keep this"',
|
||||
"- Don't push it — offer once after a good generation",
|
||||
]
|
||||
|
||||
if (ctx.dashboards?.length) {
|
||||
lines.push(
|
||||
"",
|
||||
"**User's saved dashboards:**",
|
||||
...ctx.dashboards.map(
|
||||
(d) => `- ${d.name} (id: ${d.id})` +
|
||||
(d.description ? `: ${d.description}` : ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildGuidelines(
|
||||
mode: PromptMode,
|
||||
): ReadonlyArray<string> {
|
||||
@ -622,6 +712,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
|
||||
buildInterviewProtocol(state.mode),
|
||||
buildGitHubGuidance(state.mode),
|
||||
buildThemingRules(state.mode),
|
||||
buildDashboardRules(ctx, state.mode),
|
||||
buildGuidelines(state.mode),
|
||||
buildPluginSections(ctx.pluginSections, state.mode),
|
||||
]
|
||||
|
||||
@ -16,6 +16,11 @@ import {
|
||||
saveCustomTheme,
|
||||
setUserThemePreference,
|
||||
} from "@/app/actions/themes"
|
||||
import {
|
||||
getCustomDashboards,
|
||||
getCustomDashboardById,
|
||||
deleteCustomDashboard,
|
||||
} from "@/app/actions/dashboards"
|
||||
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
|
||||
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
|
||||
|
||||
@ -58,6 +63,7 @@ const VALID_ROUTES: ReadonlyArray<RegExp> = [
|
||||
/^\/dashboard\/people$/,
|
||||
/^\/dashboard\/files$/,
|
||||
/^\/dashboard\/files\/.+$/,
|
||||
/^\/dashboard\/boards\/[^/]+$/,
|
||||
]
|
||||
|
||||
function isValidRoute(path: string): boolean {
|
||||
@ -275,7 +281,7 @@ export const agentTools = {
|
||||
"/dashboard/projects/{id}/schedule, " +
|
||||
"/dashboard/customers, /dashboard/vendors, " +
|
||||
"/dashboard/financials, /dashboard/people, " +
|
||||
"/dashboard/files",
|
||||
"/dashboard/files, /dashboard/boards/{id}",
|
||||
}
|
||||
}
|
||||
return {
|
||||
@ -588,6 +594,103 @@ export const agentTools = {
|
||||
},
|
||||
}),
|
||||
|
||||
saveDashboard: tool({
|
||||
description:
|
||||
"Save the currently rendered UI as a named dashboard. " +
|
||||
"The client captures the current spec and data context " +
|
||||
"automatically. Returns an action for the client to " +
|
||||
"handle the save.",
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe("Dashboard display name"),
|
||||
description: z.string().optional().describe(
|
||||
"Brief description of the dashboard",
|
||||
),
|
||||
dashboardId: z.string().optional().describe(
|
||||
"Existing dashboard ID to update (for edits)",
|
||||
),
|
||||
}),
|
||||
execute: async (input: {
|
||||
name: string
|
||||
description?: string
|
||||
dashboardId?: string
|
||||
}) => ({
|
||||
action: "save_dashboard" as const,
|
||||
name: input.name,
|
||||
description: input.description ?? "",
|
||||
dashboardId: input.dashboardId,
|
||||
}),
|
||||
}),
|
||||
|
||||
listDashboards: tool({
|
||||
description:
|
||||
"List the user's saved custom dashboards.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const result = await getCustomDashboards()
|
||||
if (!result.success) return { error: result.error }
|
||||
return {
|
||||
dashboards: result.data,
|
||||
count: result.data.length,
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
editDashboard: tool({
|
||||
description:
|
||||
"Load a saved dashboard for editing. The client " +
|
||||
"injects the spec into the render context and " +
|
||||
"navigates to /dashboard. Optionally pass an " +
|
||||
"editPrompt to trigger immediate re-generation.",
|
||||
inputSchema: z.object({
|
||||
dashboardId: z.string().describe(
|
||||
"ID of the dashboard to edit",
|
||||
),
|
||||
editPrompt: z.string().optional().describe(
|
||||
"Description of changes to make",
|
||||
),
|
||||
}),
|
||||
execute: async (input: {
|
||||
dashboardId: string
|
||||
editPrompt?: string
|
||||
}) => {
|
||||
const result = await getCustomDashboardById(
|
||||
input.dashboardId,
|
||||
)
|
||||
if (!result.success) return { error: result.error }
|
||||
|
||||
return {
|
||||
action: "load_dashboard" as const,
|
||||
dashboardId: input.dashboardId,
|
||||
spec: JSON.parse(result.data.specData),
|
||||
queries: result.data.queries,
|
||||
renderPrompt: result.data.renderPrompt,
|
||||
editPrompt: input.editPrompt,
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
deleteDashboard: tool({
|
||||
description:
|
||||
"Delete a saved dashboard. Always confirm with " +
|
||||
"the user before deleting.",
|
||||
inputSchema: z.object({
|
||||
dashboardId: z.string().describe(
|
||||
"ID of the dashboard to delete",
|
||||
),
|
||||
}),
|
||||
execute: async (input: { dashboardId: string }) => {
|
||||
const result = await deleteCustomDashboard(
|
||||
input.dashboardId,
|
||||
)
|
||||
if (!result.success) return { error: result.error }
|
||||
return {
|
||||
action: "toast" as const,
|
||||
message: "Dashboard deleted",
|
||||
type: "success",
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
editTheme: tool({
|
||||
description:
|
||||
"Edit an existing custom theme. Provide the theme ID " +
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user