import { tool } from "ai" import { z } from "zod/v4" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { getCurrentUser } from "@/lib/auth" import { saveMemory, searchMemories } from "@/lib/agent/memory" import { installSkill as installSkillAction, uninstallSkill as uninstallSkillAction, toggleSkill as toggleSkillAction, getInstalledSkills as getInstalledSkillsAction, } from "@/app/actions/plugins" import { getCustomThemes, getCustomThemeById, 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" const queryDataInputSchema = z.object({ queryType: z.enum([ "customers", "vendors", "projects", "invoices", "vendor_bills", "schedule_tasks", "project_detail", "customer_detail", "vendor_detail", ]), id: z .string() .optional() .describe("Record ID for detail queries"), search: z .string() .optional() .describe("Search term to filter results"), limit: z .number() .optional() .describe("Max results to return (default 20)"), }) type QueryDataInput = z.infer const VALID_ROUTES: ReadonlyArray = [ /^\/dashboard$/, /^\/dashboard\/contacts$/, /^\/dashboard\/customers$/, /^\/dashboard\/vendors$/, /^\/dashboard\/projects$/, /^\/dashboard\/projects\/[^/]+$/, /^\/dashboard\/projects\/[^/]+\/schedule$/, /^\/dashboard\/financials$/, /^\/dashboard\/people$/, /^\/dashboard\/files$/, /^\/dashboard\/files\/.+$/, /^\/dashboard\/boards\/[^/]+$/, ] function isValidRoute(path: string): boolean { return VALID_ROUTES.some((r) => r.test(path)) } const navigateInputSchema = z.object({ path: z .string() .describe("The URL path to navigate to"), reason: z .string() .optional() .describe("Brief explanation of why navigating"), }) type NavigateInput = z.infer const notificationInputSchema = z.object({ message: z.string().describe("The notification message"), type: z .enum(["default", "success", "error"]) .optional() .describe("Notification style"), }) type NotificationInput = z.infer< typeof notificationInputSchema > const generateUIInputSchema = z.object({ description: z.string().describe( "Layout and content description for the " + "dashboard to generate. Be specific about " + "what components and data to display." ), dataContext: z .record(z.string(), z.unknown()) .optional() .describe( "Data to include in the rendered UI. " + "Pass query results here." ), }) type GenerateUIInput = z.infer< typeof generateUIInputSchema > const rememberInputSchema = z.object({ content: z.string().describe( "What to remember (a preference, decision, fact, or workflow)" ), memoryType: z.enum([ "preference", "workflow", "fact", "decision", ]).describe("Category of memory"), tags: z .string() .optional() .describe("Comma-separated tags for categorization"), importance: z .number() .min(0.3) .max(1.0) .optional() .describe("Importance weight 0.3-1.0 (default 0.7)"), }) type RememberInput = z.infer const recallInputSchema = z.object({ query: z .string() .describe("What to search for in memories"), limit: z .number() .optional() .describe("Max results (default 5)"), }) type RecallInput = z.infer async function executeQueryData(input: QueryDataInput) { const { env } = await getCloudflareContext() const db = getDb(env.DB) const cap = input.limit ?? 20 switch (input.queryType) { case "customers": { const rows = await db.query.customers.findMany({ limit: cap, ...(input.search ? { where: (c, { like }) => like(c.name, `%${input.search}%`), } : {}), }) return { data: rows, count: rows.length } } case "vendors": { const rows = await db.query.vendors.findMany({ limit: cap, ...(input.search ? { where: (v, { like }) => like(v.name, `%${input.search}%`), } : {}), }) return { data: rows, count: rows.length } } case "projects": { const rows = await db.query.projects.findMany({ limit: cap, ...(input.search ? { where: (p, { like }) => like(p.name, `%${input.search}%`), } : {}), }) return { data: rows, count: rows.length } } case "invoices": { const rows = await db.query.invoices.findMany({ limit: cap, }) return { data: rows, count: rows.length } } case "vendor_bills": { const rows = await db.query.vendorBills.findMany({ limit: cap, }) return { data: rows, count: rows.length } } case "schedule_tasks": { const rows = await db.query.scheduleTasks.findMany({ limit: cap, ...(input.search ? { where: (t, { like }) => like(t.title, `%${input.search}%`), } : {}), }) return { data: rows, count: rows.length } } case "project_detail": { if (!input.id) { return { error: "id required for detail query" } } const row = await db.query.projects.findFirst({ where: (p, { eq }) => eq(p.id, input.id!), }) return row ? { data: row } : { error: "not found" } } case "customer_detail": { if (!input.id) { return { error: "id required for detail query" } } const row = await db.query.customers.findFirst({ where: (c, { eq }) => eq(c.id, input.id!), }) return row ? { data: row } : { error: "not found" } } case "vendor_detail": { if (!input.id) { return { error: "id required for detail query" } } const row = await db.query.vendors.findFirst({ where: (v, { eq }) => eq(v.id, input.id!), }) return row ? { data: row } : { error: "not found" } } default: return { error: "unknown query type" } } } export const agentTools = { queryData: tool({ description: "Query the application database. Describe what data " + "you need in natural language and provide a query type.", inputSchema: queryDataInputSchema, execute: async (input: QueryDataInput) => executeQueryData(input), }), navigateTo: tool({ description: "Navigate the user to a page in the application. " + "Returns navigation data for the client to execute.", inputSchema: navigateInputSchema, execute: async (input: NavigateInput) => { if (!isValidRoute(input.path)) { return { error: `"${input.path}" is not a valid page. ` + "Valid: /dashboard, /dashboard/projects, " + "/dashboard/projects/{id}, " + "/dashboard/projects/{id}/schedule, " + "/dashboard/contacts, " + "/dashboard/financials, /dashboard/people, " + "/dashboard/files, /dashboard/boards/{id}", } } return { action: "navigate" as const, path: input.path, reason: input.reason ?? null, } }, }), showNotification: tool({ description: "Show a toast notification to the user. Use for " + "confirmations or important alerts.", inputSchema: notificationInputSchema, execute: async (input: NotificationInput) => ({ action: "toast" as const, message: input.message, type: input.type ?? "default", }), }), generateUI: tool({ description: "Generate a rich interactive UI dashboard. " + "Use when the user wants to see structured " + "data (tables, charts, stats, forms). Always " + "fetch data with queryData first, then pass " + "it here as dataContext.", inputSchema: generateUIInputSchema, execute: async (input: GenerateUIInput) => ({ action: "generateUI" as const, renderPrompt: input.description, dataContext: input.dataContext ?? {}, }), }), rememberContext: tool({ description: "Save something to persistent memory. Use when the " + "user shares a preference, makes a decision, or " + "mentions a fact worth remembering across sessions.", inputSchema: rememberInputSchema, execute: async (input: RememberInput) => { const { env } = await getCloudflareContext() const db = getDb(env.DB) const user = await getCurrentUser() if (!user) return { error: "not authenticated" } const id = await saveMemory( db, user.id, input.content, input.memoryType, input.tags, input.importance, ) return { action: "memory_saved" as const, id, content: input.content, memoryType: input.memoryType, } }, }), recallMemory: tool({ description: "Search persistent memories for this user. Use when " + "the user asks if you remember something or when you " + "need to look up a past preference or decision.", inputSchema: recallInputSchema, execute: async (input: RecallInput) => { const { env } = await getCloudflareContext() const db = getDb(env.DB) const user = await getCurrentUser() if (!user) return { error: "not authenticated" } const results = await searchMemories( db, user.id, input.query, input.limit, ) return { action: "memory_recall" as const, results, count: results.length, } }, }), installSkill: tool({ description: "Install a skill from GitHub (skills.sh format). " + "Source format: owner/repo or owner/repo/skill-name. " + "Requires admin role. Always confirm with the user " + "what skill they want before installing.", inputSchema: z.object({ source: z.string().describe( "GitHub source path, e.g. " + "'cloudflare/skills/wrangler'", ), }), execute: async (input: { source: string }) => { const user = await getCurrentUser() if (!user || user.role !== "admin") { return { error: "admin role required to install skills" } } return installSkillAction(input.source) }, }), listInstalledSkills: tool({ description: "List all installed agent skills with their status.", inputSchema: z.object({}), execute: async () => getInstalledSkillsAction(), }), toggleInstalledSkill: tool({ description: "Enable or disable an installed skill.", inputSchema: z.object({ pluginId: z.string().describe("The plugin ID of the skill"), enabled: z.boolean().describe("true to enable, false to disable"), }), execute: async (input: { pluginId: string enabled: boolean }) => { const user = await getCurrentUser() if (!user || user.role !== "admin") { return { error: "admin role required" } } return toggleSkillAction(input.pluginId, input.enabled) }, }), uninstallSkill: tool({ description: "Remove an installed skill permanently. " + "Requires admin role. Always confirm before uninstalling.", inputSchema: z.object({ pluginId: z.string().describe("The plugin ID of the skill"), }), execute: async (input: { pluginId: string }) => { const user = await getCurrentUser() if (!user || user.role !== "admin") { return { error: "admin role required" } } return uninstallSkillAction(input.pluginId) }, }), listThemes: tool({ description: "List available visual themes (presets + user custom themes).", inputSchema: z.object({}), execute: async () => { const user = await getCurrentUser() if (!user) return { error: "not authenticated" } const presets = THEME_PRESETS.map((p) => ({ id: p.id, name: p.name, description: p.description, isPreset: true, })) const customResult = await getCustomThemes() const customs = customResult.success ? customResult.data.map((c) => ({ id: c.id, name: c.name, description: c.description, isPreset: false, })) : [] return { themes: [...presets, ...customs] } }, }), setTheme: tool({ description: "Switch the user's visual theme. Use a preset ID " + "(native-compass, corpo, notebook, doom-64, bubblegum, " + "developers-choice, anslopics-clood, violet-bloom, soy, " + "mocha) or a custom theme UUID.", inputSchema: z.object({ themeId: z.string().describe( "The theme ID to activate", ), }), execute: async (input: { themeId: string }) => { const result = await setUserThemePreference(input.themeId) if (!result.success) return { error: result.error } return { action: "apply_theme" as const, themeId: input.themeId, } }, }), generateTheme: tool({ description: "Generate and save a custom visual theme. Provide " + "complete light and dark color maps (all 32 keys), " + "fonts, optional Google Font names, and design tokens. " + "All colors must be in oklch() format.", inputSchema: z.object({ name: z.string().describe("Theme display name"), description: z.string().describe("Brief theme description"), light: z.record(z.string(), z.string()).describe( "Light mode color map with all 32 ThemeColorKey entries", ), dark: z.record(z.string(), z.string()).describe( "Dark mode color map with all 32 ThemeColorKey entries", ), fonts: z.object({ sans: z.string(), serif: z.string(), mono: z.string(), }).describe("CSS font-family strings"), googleFonts: z.array(z.string()).optional().describe( "Google Font names to load (case-sensitive)", ), radius: z.string().optional().describe( "Border radius (e.g. '0.5rem')", ), spacing: z.string().optional().describe( "Base spacing (e.g. '0.25rem')", ), }), execute: async (input: { name: string description: string light: Record dark: Record fonts: { sans: string; serif: string; mono: string } googleFonts?: ReadonlyArray radius?: string spacing?: string }) => { const user = await getCurrentUser() if (!user) return { error: "not authenticated" } // build a full ThemeDefinition for storage const nativePreset = findPreset("native-compass") if (!nativePreset) return { error: "internal error" } const tokens: ThemeTokens = { radius: input.radius ?? "0.5rem", spacing: input.spacing ?? "0.25rem", trackingNormal: "0em", shadowColor: "#000000", shadowOpacity: "0.1", shadowBlur: "3px", shadowSpread: "0px", shadowOffsetX: "0", shadowOffsetY: "1px", } const defaultShadows: ThemeShadows = { "2xs": "0 1px 3px 0px hsl(0 0% 0% / 0.05)", xs: "0 1px 3px 0px hsl(0 0% 0% / 0.05)", sm: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)", default: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)", md: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10)", lg: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10)", xl: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10)", "2xl": "0 1px 3px 0px hsl(0 0% 0% / 0.25)", } const theme: ThemeDefinition = { id: "", // will be set by saveCustomTheme name: input.name, description: input.description, light: input.light as unknown as ColorMap, dark: input.dark as unknown as ColorMap, fonts: input.fonts as ThemeFonts, fontSources: { googleFonts: input.googleFonts ?? [], }, tokens, shadows: { light: defaultShadows, dark: defaultShadows }, isPreset: false, previewColors: { primary: input.light["primary"] ?? "oklch(0.5 0.1 200)", background: input.light["background"] ?? "oklch(0.97 0 0)", foreground: input.light["foreground"] ?? "oklch(0.2 0 0)", }, } const saveResult = await saveCustomTheme( input.name, input.description, JSON.stringify(theme), ) if (!saveResult.success) return { error: saveResult.error } const savedTheme = { ...theme, id: saveResult.id } return { action: "preview_theme" as const, themeId: saveResult.id, themeData: savedTheme, } }, }), 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 " + "and only the properties you want to change. " + "Unspecified properties are preserved from the " + "existing theme. Only works on custom themes " + "(not presets).", inputSchema: z.object({ themeId: z.string().describe( "ID of existing custom theme to edit", ), name: z.string().optional().describe( "New display name", ), description: z.string().optional().describe( "New description", ), light: z.record(z.string(), z.string()).optional() .describe( "Partial light color overrides " + "(only changed keys)", ), dark: z.record(z.string(), z.string()).optional() .describe( "Partial dark color overrides " + "(only changed keys)", ), fonts: z.object({ sans: z.string().optional(), serif: z.string().optional(), mono: z.string().optional(), }).optional().describe( "Partial font overrides", ), googleFonts: z.array(z.string()).optional() .describe("Replace Google Font list"), radius: z.string().optional().describe( "New border radius", ), spacing: z.string().optional().describe( "New base spacing", ), }), execute: async (input: { themeId: string name?: string description?: string light?: Record dark?: Record fonts?: { sans?: string serif?: string mono?: string } googleFonts?: ReadonlyArray radius?: string spacing?: string }) => { const user = await getCurrentUser() if (!user) return { error: "not authenticated" } const existing = await getCustomThemeById(input.themeId) if (!existing.success) { return { error: existing.error } } const prev = JSON.parse( existing.data.themeData, ) as ThemeDefinition const mergedLight = input.light ? ({ ...prev.light, ...input.light } as unknown as ColorMap) : prev.light const mergedDark = input.dark ? ({ ...prev.dark, ...input.dark } as unknown as ColorMap) : prev.dark const mergedFonts: ThemeFonts = input.fonts ? { sans: input.fonts.sans ?? prev.fonts.sans, serif: input.fonts.serif ?? prev.fonts.serif, mono: input.fonts.mono ?? prev.fonts.mono, } : prev.fonts const mergedTokens: ThemeTokens = { ...prev.tokens, ...(input.radius ? { radius: input.radius } : {}), ...(input.spacing ? { spacing: input.spacing } : {}), } const mergedFontSources = input.googleFonts ? { googleFonts: input.googleFonts } : prev.fontSources const name = input.name ?? existing.data.name const description = input.description ?? existing.data.description const merged: ThemeDefinition = { ...prev, id: input.themeId, name, description, light: mergedLight, dark: mergedDark, fonts: mergedFonts, fontSources: mergedFontSources, tokens: mergedTokens, previewColors: { primary: mergedLight.primary, background: mergedLight.background, foreground: mergedLight.foreground, }, } const saveResult = await saveCustomTheme( name, description, JSON.stringify(merged), input.themeId, ) if (!saveResult.success) { return { error: saveResult.error } } return { action: "preview_theme" as const, themeId: input.themeId, themeData: merged, } }, }), }