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-ai-config.ts",
|
||||||
"./src/db/schema-theme.ts",
|
"./src/db/schema-theme.ts",
|
||||||
"./src/db/schema-google.ts",
|
"./src/db/schema-google.ts",
|
||||||
|
"./src/db/schema-dashboards.ts",
|
||||||
],
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dialect: "sqlite",
|
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,
|
"when": 1770442889458,
|
||||||
"tag": "0017_outstanding_colonel_america",
|
"tag": "0017_outstanding_colonel_america",
|
||||||
"breakpoints": true
|
"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([
|
const { getCustomDashboards } = await import(
|
||||||
loadMemoriesForPrompt(db, user.id),
|
"@/app/actions/dashboards"
|
||||||
getRegistry(db, envRecord),
|
)
|
||||||
])
|
|
||||||
|
const [memories, registry, dashboardResult] =
|
||||||
|
await Promise.all([
|
||||||
|
loadMemoriesForPrompt(db, user.id),
|
||||||
|
getRegistry(db, envRecord),
|
||||||
|
getCustomDashboards(),
|
||||||
|
])
|
||||||
|
|
||||||
const pluginSections = registry.getPromptSections()
|
const pluginSections = registry.getPromptSections()
|
||||||
const pluginTools = registry.getTools()
|
const pluginTools = registry.getTools()
|
||||||
@ -84,6 +90,9 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
timezone,
|
timezone,
|
||||||
memories,
|
memories,
|
||||||
pluginSections,
|
pluginSections,
|
||||||
|
dashboards: dashboardResult.success
|
||||||
|
? dashboardResult.data
|
||||||
|
: [],
|
||||||
mode: "full",
|
mode: "full",
|
||||||
}),
|
}),
|
||||||
messages: await convertToModelMessages(
|
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,
|
SidebarProvider,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { getProjects } from "@/app/actions/projects"
|
import { getProjects } from "@/app/actions/projects"
|
||||||
|
import { getCustomDashboards } from "@/app/actions/dashboards"
|
||||||
import { ProjectListProvider } from "@/components/project-list-provider"
|
import { ProjectListProvider } from "@/components/project-list-provider"
|
||||||
import { getCurrentUser, toSidebarUser } from "@/lib/auth"
|
import { getCurrentUser, toSidebarUser } from "@/lib/auth"
|
||||||
import { BiometricGuard } from "@/components/native/biometric-guard"
|
import { BiometricGuard } from "@/components/native/biometric-guard"
|
||||||
@ -29,11 +30,16 @@ export default async function DashboardLayout({
|
|||||||
}: {
|
}: {
|
||||||
readonly children: React.ReactNode
|
readonly children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [projectList, authUser] = await Promise.all([
|
const [projectList, authUser, dashboardResult] =
|
||||||
getProjects(),
|
await Promise.all([
|
||||||
getCurrentUser(),
|
getProjects(),
|
||||||
])
|
getCurrentUser(),
|
||||||
|
getCustomDashboards(),
|
||||||
|
])
|
||||||
const user = authUser ? toSidebarUser(authUser) : null
|
const user = authUser ? toSidebarUser(authUser) : null
|
||||||
|
const dashboardList = dashboardResult.success
|
||||||
|
? dashboardResult.data
|
||||||
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@ -51,7 +57,7 @@ export default async function DashboardLayout({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSidebar variant="inset" projects={projectList} user={user} />
|
<AppSidebar variant="inset" projects={projectList} dashboards={dashboardList} user={user} />
|
||||||
<FeedbackWidget>
|
<FeedbackWidget>
|
||||||
<SidebarInset className="overflow-hidden">
|
<SidebarInset className="overflow-hidden">
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
|
|||||||
@ -78,6 +78,10 @@ interface RenderContextValue {
|
|||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
) => void
|
) => void
|
||||||
clearRender: () => void
|
clearRender: () => void
|
||||||
|
loadSpec: (
|
||||||
|
spec: Spec,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const RenderContext =
|
const RenderContext =
|
||||||
@ -171,6 +175,8 @@ export function ChatProvider({
|
|||||||
const [dataContext, setDataContext] = React.useState<
|
const [dataContext, setDataContext] = React.useState<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>({})
|
>({})
|
||||||
|
const [loadedSpec, setLoadedSpec] =
|
||||||
|
React.useState<Spec | null>(null)
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@ -228,13 +234,19 @@ export function ChatProvider({
|
|||||||
const routerRef = React.useRef(router)
|
const routerRef = React.useRef(router)
|
||||||
routerRef.current = router
|
routerRef.current = router
|
||||||
|
|
||||||
|
const loadedSpecRef = React.useRef(loadedSpec)
|
||||||
|
loadedSpecRef.current = loadedSpec
|
||||||
|
|
||||||
const triggerRender = React.useCallback(
|
const triggerRender = React.useCallback(
|
||||||
(prompt: string, data: Record<string, unknown>) => {
|
(prompt: string, data: Record<string, unknown>) => {
|
||||||
setDataContext(data)
|
setDataContext(data)
|
||||||
|
setLoadedSpec(null)
|
||||||
renderSendRef.current(prompt, {
|
renderSendRef.current(prompt, {
|
||||||
dataContext: data,
|
dataContext: data,
|
||||||
previousSpec:
|
previousSpec:
|
||||||
renderSpecRef.current ?? undefined,
|
renderSpecRef.current ??
|
||||||
|
loadedSpecRef.current ??
|
||||||
|
undefined,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@ -243,8 +255,18 @@ export function ChatProvider({
|
|||||||
const clearRender = React.useCallback(() => {
|
const clearRender = React.useCallback(() => {
|
||||||
renderClearRef.current()
|
renderClearRef.current()
|
||||||
setDataContext({})
|
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
|
// watch chat messages for generateUI tool results
|
||||||
// and trigger render stream directly (no event chain)
|
// and trigger render stream directly (no event chain)
|
||||||
const renderDispatchedRef = React.useRef(
|
const renderDispatchedRef = React.useRef(
|
||||||
@ -275,6 +297,118 @@ export function ChatProvider({
|
|||||||
triggerRender(result.renderPrompt, result.dataContext)
|
triggerRender(result.renderPrompt, result.dataContext)
|
||||||
}, [chat.messages, triggerRender])
|
}, [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
|
// listen for navigation events from rendered UI
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
@ -347,6 +481,7 @@ export function ChatProvider({
|
|||||||
setConversationId(crypto.randomUUID())
|
setConversationId(crypto.randomUUID())
|
||||||
setResumeLoaded(true)
|
setResumeLoaded(true)
|
||||||
clearRender()
|
clearRender()
|
||||||
|
setLoadedSpec(null)
|
||||||
renderDispatchedRef.current.clear()
|
renderDispatchedRef.current.clear()
|
||||||
}, [chat.setMessages, clearRender])
|
}, [chat.setMessages, clearRender])
|
||||||
|
|
||||||
@ -389,20 +524,23 @@ export function ChatProvider({
|
|||||||
|
|
||||||
const renderValue = React.useMemo(
|
const renderValue = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
spec: renderStream.spec,
|
spec: renderStream.spec ?? loadedSpec,
|
||||||
isRendering: renderStream.isStreaming,
|
isRendering: renderStream.isStreaming,
|
||||||
error: renderStream.error,
|
error: renderStream.error,
|
||||||
dataContext,
|
dataContext,
|
||||||
triggerRender,
|
triggerRender,
|
||||||
clearRender,
|
clearRender,
|
||||||
|
loadSpec,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
renderStream.spec,
|
renderStream.spec,
|
||||||
|
loadedSpec,
|
||||||
renderStream.isStreaming,
|
renderStream.isStreaming,
|
||||||
renderStream.error,
|
renderStream.error,
|
||||||
dataContext,
|
dataContext,
|
||||||
triggerRender,
|
triggerRender,
|
||||||
clearRender,
|
clearRender,
|
||||||
|
loadSpec,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,24 @@
|
|||||||
"use client"
|
"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 { 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 { useRenderState } from "./chat-provider"
|
||||||
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
|
import { CompassRenderer } from "@/lib/agent/render/compass-renderer"
|
||||||
|
import { saveCustomDashboard } from "@/app/actions/dashboards"
|
||||||
|
|
||||||
export function RenderedView() {
|
export function RenderedView() {
|
||||||
const {
|
const {
|
||||||
@ -14,8 +29,46 @@ export function RenderedView() {
|
|||||||
clearRender,
|
clearRender,
|
||||||
} = useRenderState()
|
} = useRenderState()
|
||||||
|
|
||||||
|
const [saveOpen, setSaveOpen] = React.useState(false)
|
||||||
|
const [saveName, setSaveName] = React.useState("")
|
||||||
|
const [saving, setSaving] = React.useState(false)
|
||||||
|
|
||||||
const hasRoot = !!spec?.root
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col min-h-0 p-4">
|
<div className="flex flex-1 flex-col min-h-0 p-4">
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
@ -31,17 +84,68 @@ export function RenderedView() {
|
|||||||
<span>Generated by Slab</span>
|
<span>Generated by Slab</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{hasRoot && !isRendering && (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={clearRender}
|
variant="ghost"
|
||||||
className="gap-1.5"
|
size="sm"
|
||||||
>
|
onClick={() => setSaveOpen(true)}
|
||||||
<XIcon className="size-4" />
|
className="gap-1.5"
|
||||||
Clear
|
>
|
||||||
</Button>
|
<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>
|
</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 */}
|
{/* Rendered content */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
import { NavMain } from "@/components/nav-main"
|
import { NavMain } from "@/components/nav-main"
|
||||||
|
import { NavDashboards } from "@/components/nav-dashboards"
|
||||||
import { NavSecondary } from "@/components/nav-secondary"
|
import { NavSecondary } from "@/components/nav-secondary"
|
||||||
import { NavFiles } from "@/components/nav-files"
|
import { NavFiles } from "@/components/nav-files"
|
||||||
import { NavProjects } from "@/components/nav-projects"
|
import { NavProjects } from "@/components/nav-projects"
|
||||||
@ -96,8 +97,13 @@ const data = {
|
|||||||
|
|
||||||
function SidebarNav({
|
function SidebarNav({
|
||||||
projects,
|
projects,
|
||||||
|
dashboards = [],
|
||||||
}: {
|
}: {
|
||||||
projects: { id: string; name: string }[]
|
projects: { id: string; name: string }[]
|
||||||
|
dashboards?: ReadonlyArray<{
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
}>
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { state, setOpen } = useSidebar()
|
const { state, setOpen } = useSidebar()
|
||||||
@ -146,6 +152,7 @@ function SidebarNav({
|
|||||||
{mode === "main" && (
|
{mode === "main" && (
|
||||||
<>
|
<>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={data.navMain} />
|
||||||
|
<NavDashboards dashboards={dashboards} />
|
||||||
<NavSecondary items={secondaryItems} className="mt-auto" />
|
<NavSecondary items={secondaryItems} className="mt-auto" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -155,10 +162,12 @@ function SidebarNav({
|
|||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
projects = [],
|
projects = [],
|
||||||
|
dashboards = [],
|
||||||
user,
|
user,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & {
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }>
|
readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }>
|
||||||
|
readonly dashboards?: ReadonlyArray<{ readonly id: string; readonly name: string }>
|
||||||
readonly user: SidebarUser | null
|
readonly user: SidebarUser | null
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -192,7 +201,10 @@ export function AppSidebar({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarNav projects={projects as { id: string; name: string }[]} />
|
<SidebarNav
|
||||||
|
projects={projects as { id: string; name: string }[]}
|
||||||
|
dashboards={dashboards}
|
||||||
|
/>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={user} />
|
<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 aiConfigSchema from "./schema-ai-config"
|
||||||
import * as themeSchema from "./schema-theme"
|
import * as themeSchema from "./schema-theme"
|
||||||
import * as googleSchema from "./schema-google"
|
import * as googleSchema from "./schema-google"
|
||||||
|
import * as dashboardSchema from "./schema-dashboards"
|
||||||
|
|
||||||
const allSchemas = {
|
const allSchemas = {
|
||||||
...schema,
|
...schema,
|
||||||
@ -15,6 +16,7 @@ const allSchemas = {
|
|||||||
...aiConfigSchema,
|
...aiConfigSchema,
|
||||||
...themeSchema,
|
...themeSchema,
|
||||||
...googleSchema,
|
...googleSchema,
|
||||||
|
...dashboardSchema,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDb(d1: D1Database) {
|
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) => {
|
registerActionHandler("APPLY_THEME", (payload) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
@ -163,6 +183,8 @@ export const ALL_HANDLER_TYPES = [
|
|||||||
"SCROLL_TO",
|
"SCROLL_TO",
|
||||||
"FOCUS_ELEMENT",
|
"FOCUS_ELEMENT",
|
||||||
"GENERATE_UI",
|
"GENERATE_UI",
|
||||||
|
"SAVE_DASHBOARD",
|
||||||
|
"LOAD_DASHBOARD",
|
||||||
"APPLY_THEME",
|
"APPLY_THEME",
|
||||||
"PREVIEW_THEME",
|
"PREVIEW_THEME",
|
||||||
] as const
|
] as const
|
||||||
@ -227,6 +249,28 @@ export function dispatchToolActions(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
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":
|
case "apply_theme":
|
||||||
executeAction({
|
executeAction({
|
||||||
type: "APPLY_THEME",
|
type: "APPLY_THEME",
|
||||||
|
|||||||
@ -21,6 +21,12 @@ interface ToolMeta {
|
|||||||
readonly adminOnly?: true
|
readonly adminOnly?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DashboardSummary {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly description: string
|
||||||
|
}
|
||||||
|
|
||||||
interface PromptContext {
|
interface PromptContext {
|
||||||
readonly userName: string
|
readonly userName: string
|
||||||
readonly userRole: string
|
readonly userRole: string
|
||||||
@ -28,6 +34,7 @@ interface PromptContext {
|
|||||||
readonly memories?: string
|
readonly memories?: string
|
||||||
readonly timezone?: string
|
readonly timezone?: string
|
||||||
readonly pluginSections?: ReadonlyArray<PromptSection>
|
readonly pluginSections?: ReadonlyArray<PromptSection>
|
||||||
|
readonly dashboards?: ReadonlyArray<DashboardSummary>
|
||||||
readonly mode?: PromptMode
|
readonly mode?: PromptMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +67,8 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
|
|||||||
"/dashboard/projects/{id}/schedule, " +
|
"/dashboard/projects/{id}/schedule, " +
|
||||||
"/dashboard/customers, /dashboard/vendors, " +
|
"/dashboard/customers, /dashboard/vendors, " +
|
||||||
"/dashboard/financials, /dashboard/people, " +
|
"/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.",
|
"tell the user what's available.",
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
},
|
},
|
||||||
@ -184,6 +192,36 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
|
|||||||
"(not presets).",
|
"(not presets).",
|
||||||
category: "ui",
|
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
|
// categories included in minimal mode
|
||||||
@ -306,6 +344,8 @@ function buildFirstInteraction(
|
|||||||
'show you recent commits, PRs, issues, and contributor activity."',
|
'show you recent commits, PRs, issues, and contributor activity."',
|
||||||
'"I can also conduct a quick UX interview if you\'d like ' +
|
'"I can also conduct a quick UX interview if you\'d like ' +
|
||||||
'to share feedback about Compass."',
|
'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 [
|
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(
|
function buildGuidelines(
|
||||||
mode: PromptMode,
|
mode: PromptMode,
|
||||||
): ReadonlyArray<string> {
|
): ReadonlyArray<string> {
|
||||||
@ -622,6 +712,7 @@ export function buildSystemPrompt(ctx: PromptContext): string {
|
|||||||
buildInterviewProtocol(state.mode),
|
buildInterviewProtocol(state.mode),
|
||||||
buildGitHubGuidance(state.mode),
|
buildGitHubGuidance(state.mode),
|
||||||
buildThemingRules(state.mode),
|
buildThemingRules(state.mode),
|
||||||
|
buildDashboardRules(ctx, state.mode),
|
||||||
buildGuidelines(state.mode),
|
buildGuidelines(state.mode),
|
||||||
buildPluginSections(ctx.pluginSections, state.mode),
|
buildPluginSections(ctx.pluginSections, state.mode),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -16,6 +16,11 @@ import {
|
|||||||
saveCustomTheme,
|
saveCustomTheme,
|
||||||
setUserThemePreference,
|
setUserThemePreference,
|
||||||
} from "@/app/actions/themes"
|
} from "@/app/actions/themes"
|
||||||
|
import {
|
||||||
|
getCustomDashboards,
|
||||||
|
getCustomDashboardById,
|
||||||
|
deleteCustomDashboard,
|
||||||
|
} from "@/app/actions/dashboards"
|
||||||
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
|
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
|
||||||
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
|
import type { ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens, ThemeShadows } from "@/lib/theme/types"
|
||||||
|
|
||||||
@ -58,6 +63,7 @@ const VALID_ROUTES: ReadonlyArray<RegExp> = [
|
|||||||
/^\/dashboard\/people$/,
|
/^\/dashboard\/people$/,
|
||||||
/^\/dashboard\/files$/,
|
/^\/dashboard\/files$/,
|
||||||
/^\/dashboard\/files\/.+$/,
|
/^\/dashboard\/files\/.+$/,
|
||||||
|
/^\/dashboard\/boards\/[^/]+$/,
|
||||||
]
|
]
|
||||||
|
|
||||||
function isValidRoute(path: string): boolean {
|
function isValidRoute(path: string): boolean {
|
||||||
@ -275,7 +281,7 @@ export const agentTools = {
|
|||||||
"/dashboard/projects/{id}/schedule, " +
|
"/dashboard/projects/{id}/schedule, " +
|
||||||
"/dashboard/customers, /dashboard/vendors, " +
|
"/dashboard/customers, /dashboard/vendors, " +
|
||||||
"/dashboard/financials, /dashboard/people, " +
|
"/dashboard/financials, /dashboard/people, " +
|
||||||
"/dashboard/files",
|
"/dashboard/files, /dashboard/boards/{id}",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
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({
|
editTheme: tool({
|
||||||
description:
|
description:
|
||||||
"Edit an existing custom theme. Provide the theme ID " +
|
"Edit an existing custom theme. Provide the theme ID " +
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user