diff --git a/apps/openfaith/app/routeTree.gen.ts b/apps/openfaith/app/routeTree.gen.ts index a3b401e9..b1158cc5 100644 --- a/apps/openfaith/app/routeTree.gen.ts +++ b/apps/openfaith/app/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as MarketingFeaturesRouteImport } from './routes/_marketing/featu import { Route as MarketingBlogRouteImport } from './routes/_marketing/blog' import { Route as AuthSignInRouteImport } from './routes/_auth/sign-in' import { Route as AppDashboardRouteImport } from './routes/_app/dashboard' +import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as AppGroupRouteRouteImport } from './routes/_app/$group/route' import { Route as AppSettingsTeamRouteImport } from './routes/_app/settings/team' import { Route as AppSettingsProfileRouteImport } from './routes/_app/settings/profile' @@ -39,6 +40,7 @@ import { Route as AppGroupEntityEntityIdRouteImport } from './routes/_app/$group import { ServerRoute as ApiSplatServerRouteImport } from './routes/api/$' import { ServerRoute as ApiAuthRefreshServerRouteImport } from './routes/api/auth/refresh' import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth/$' +import { ServerRoute as ApiAiChatServerRouteImport } from './routes/api/ai/chat' const rootServerRouteImport = createServerRootRoute() @@ -108,6 +110,11 @@ const AppDashboardRoute = AppDashboardRouteImport.update({ path: '/dashboard', getParentRoute: () => AppRouteRoute, } as any) +const AppAiRoute = AppAiRouteImport.update({ + id: '/ai', + path: '/ai', + getParentRoute: () => AppRouteRoute, +} as any) const AppGroupRouteRoute = AppGroupRouteRouteImport.update({ id: '/$group', path: '/$group', @@ -178,9 +185,15 @@ const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootServerRouteImport, } as any) +const ApiAiChatServerRoute = ApiAiChatServerRouteImport.update({ + id: '/api/ai/chat', + path: '/api/ai/chat', + getParentRoute: () => rootServerRouteImport, +} as any) export interface FileRoutesByFullPath { '/$group': typeof AppGroupRouteRouteWithChildren + '/ai': typeof AppAiRoute '/dashboard': typeof AppDashboardRoute '/sign-in': typeof AuthSignInRoute '/blog': typeof MarketingBlogRoute @@ -204,6 +217,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/$group': typeof AppGroupRouteRouteWithChildren + '/ai': typeof AppAiRoute '/dashboard': typeof AppDashboardRoute '/sign-in': typeof AuthSignInRoute '/blog': typeof MarketingBlogRoute @@ -231,6 +245,7 @@ export interface FileRoutesById { '/_marketing': typeof MarketingRouteRouteWithChildren '/_onboarding': typeof OnboardingRouteRouteWithChildren '/_app/$group': typeof AppGroupRouteRouteWithChildren + '/_app/ai': typeof AppAiRoute '/_app/dashboard': typeof AppDashboardRoute '/_auth/sign-in': typeof AuthSignInRoute '/_marketing/blog': typeof MarketingBlogRoute @@ -256,6 +271,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/$group' + | '/ai' | '/dashboard' | '/sign-in' | '/blog' @@ -279,6 +295,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/$group' + | '/ai' | '/dashboard' | '/sign-in' | '/blog' @@ -305,6 +322,7 @@ export interface FileRouteTypes { | '/_marketing' | '/_onboarding' | '/_app/$group' + | '/_app/ai' | '/_app/dashboard' | '/_auth/sign-in' | '/_marketing/blog' @@ -336,30 +354,39 @@ export interface RootRouteChildren { } export interface FileServerRoutesByFullPath { '/api/$': typeof ApiSplatServerRoute + '/api/ai/chat': typeof ApiAiChatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/refresh': typeof ApiAuthRefreshServerRoute } export interface FileServerRoutesByTo { '/api/$': typeof ApiSplatServerRoute + '/api/ai/chat': typeof ApiAiChatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/refresh': typeof ApiAuthRefreshServerRoute } export interface FileServerRoutesById { __root__: typeof rootServerRouteImport '/api/$': typeof ApiSplatServerRoute + '/api/ai/chat': typeof ApiAiChatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/refresh': typeof ApiAuthRefreshServerRoute } export interface FileServerRouteTypes { fileServerRoutesByFullPath: FileServerRoutesByFullPath - fullPaths: '/api/$' | '/api/auth/$' | '/api/auth/refresh' + fullPaths: '/api/$' | '/api/ai/chat' | '/api/auth/$' | '/api/auth/refresh' fileServerRoutesByTo: FileServerRoutesByTo - to: '/api/$' | '/api/auth/$' | '/api/auth/refresh' - id: '__root__' | '/api/$' | '/api/auth/$' | '/api/auth/refresh' + to: '/api/$' | '/api/ai/chat' | '/api/auth/$' | '/api/auth/refresh' + id: + | '__root__' + | '/api/$' + | '/api/ai/chat' + | '/api/auth/$' + | '/api/auth/refresh' fileServerRoutesById: FileServerRoutesById } export interface RootServerRouteChildren { ApiSplatServerRoute: typeof ApiSplatServerRoute + ApiAiChatServerRoute: typeof ApiAiChatServerRoute ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute ApiAuthRefreshServerRoute: typeof ApiAuthRefreshServerRoute } @@ -464,6 +491,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppDashboardRouteImport parentRoute: typeof AppRouteRoute } + '/_app/ai': { + id: '/_app/ai' + path: '/ai' + fullPath: '/ai' + preLoaderRoute: typeof AppAiRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/$group': { id: '/_app/$group' path: '/$group' @@ -566,6 +600,13 @@ declare module '@tanstack/react-start/server' { preLoaderRoute: typeof ApiAuthSplatServerRouteImport parentRoute: typeof rootServerRouteImport } + '/api/ai/chat': { + id: '/api/ai/chat' + path: '/api/ai/chat' + fullPath: '/api/ai/chat' + preLoaderRoute: typeof ApiAiChatServerRouteImport + parentRoute: typeof rootServerRouteImport + } } } @@ -596,6 +637,7 @@ const AppGroupRouteRouteWithChildren = AppGroupRouteRoute._addFileChildren( interface AppRouteRouteChildren { AppGroupRouteRoute: typeof AppGroupRouteRouteWithChildren + AppAiRoute: typeof AppAiRoute AppDashboardRoute: typeof AppDashboardRoute AppAdminOrgsRoute: typeof AppAdminOrgsRoute AppAdminUsersRoute: typeof AppAdminUsersRoute @@ -608,6 +650,7 @@ interface AppRouteRouteChildren { const AppRouteRouteChildren: AppRouteRouteChildren = { AppGroupRouteRoute: AppGroupRouteRouteWithChildren, + AppAiRoute: AppAiRoute, AppDashboardRoute: AppDashboardRoute, AppAdminOrgsRoute: AppAdminOrgsRoute, AppAdminUsersRoute: AppAdminUsersRoute, @@ -680,6 +723,7 @@ export const routeTree = rootRouteImport ._addFileTypes() const rootServerRouteChildren: RootServerRouteChildren = { ApiSplatServerRoute: ApiSplatServerRoute, + ApiAiChatServerRoute: ApiAiChatServerRoute, ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, ApiAuthRefreshServerRoute: ApiAuthRefreshServerRoute, } diff --git a/apps/openfaith/app/routes/_app/ai.tsx b/apps/openfaith/app/routes/_app/ai.tsx new file mode 100644 index 00000000..22c2f143 --- /dev/null +++ b/apps/openfaith/app/routes/_app/ai.tsx @@ -0,0 +1,190 @@ +import { useChat } from '@ai-sdk/react' +import { Results } from '@openfaith/openfaith/features/ai/results' +import type { Config, Result } from '@openfaith/openfaith/features/ai/tools' +import { + Action, + Actions, + Conversation, + ConversationContent, + ConversationScrollButton, + CopyIcon, + Loader, + Message, + MessageContent, + PromptInput, + PromptInputActionAddAttachments, + PromptInputActionMenu, + PromptInputActionMenuContent, + PromptInputActionMenuTrigger, + PromptInputAttachment, + PromptInputAttachments, + PromptInputBody, + PromptInputFooter, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + Reasoning, + ReasoningContent, + ReasoningTrigger, + RefreshIcon, + Response, + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from '@openfaith/ui' +import { createFileRoute } from '@tanstack/react-router' +import { DefaultChatTransport } from 'ai' +import { Array, pipe } from 'effect' +import { Fragment, useState } from 'react' + +export const Route = createFileRoute('/_app/ai')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [input, setInput] = useState('') + const { messages, sendMessage, status, regenerate } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/ai/chat', + }), + }) + + const handleSubmit = (message: PromptInputMessage) => { + const hasText = Boolean(message.text) + const hasAttachments = Boolean(message.files?.length) + + if (!(hasText || hasAttachments)) { + return + } + + sendMessage({ + text: message.text || 'Sent with attachments', + }) + setInput('') + } + + return ( +
+
+ + + {pipe( + messages, + Array.map((message) => ( +
+ {message.role === 'assistant' && + pipe( + message.parts, + Array.filter((part) => part.type === 'source-url'), + Array.length, + ) > 0 && ( + + part.type === 'source-url').length} + /> + {pipe( + message.parts, + Array.filter((part) => part.type === 'source-url'), + Array.map((part, i) => ( + + + + )), + )} + + )} + {pipe( + message.parts, + Array.map((part, i) => { + switch (part.type) { + case 'text': + return ( + + + + {part.text} + + + {message.role === 'assistant' && i === messages.length - 1 && ( + + regenerate()}> + + + navigator.clipboard.writeText(part.text)} + > + + + + )} + + ) + case 'tool-generateChartConfig': { + const output = part.output as { config?: Config } | undefined + const input = part.input as { results?: Array } | undefined + + if (output && output.config && input && input.results) { + return ( + + + + + + ) + } + return null + } + case 'reasoning': + return ( + + + {part.text} + + ) + default: + return null + } + }), + )} +
+ )), + )} + {status === 'submitted' && } +
+ +
+ + + + + {(attachment) => } + + setInput(e.target.value)} value={input} /> + + + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/openfaith/app/routes/api/ai/chat.ts b/apps/openfaith/app/routes/api/ai/chat.ts new file mode 100644 index 00000000..c6a5162c --- /dev/null +++ b/apps/openfaith/app/routes/api/ai/chat.ts @@ -0,0 +1,47 @@ +import { anthropic } from '@ai-sdk/anthropic' +import { + generateChartConfigTool, + generateQueryTool, + runQueryTool, +} from '@openfaith/openfaith/features/ai/tools' +import { createServerFileRoute } from '@tanstack/react-start/server' +import { convertToModelMessages, stepCountIs, streamText } from 'ai' + +export const ServerRoute = createServerFileRoute('/api/ai/chat').methods({ + POST: async ({ request }) => { + try { + const { messages } = await request.json() + + const result = streamText({ + messages: convertToModelMessages(messages), + model: anthropic('claude-haiku-4-5'), + stopWhen: stepCountIs(5), + system: `You are a helpful assistant that helps OpenFaith users interact with their data. You have access to the following tools: + - generateChartConfig: Generate a chart config based on the data and user query. + - generateQuery: Generate a SQL query based on the user query. + - runQuery: Run a SQL query and return the results. + + When the user asks a question, you should use the generateQuery tool to generate a SQL query. Then you should use the runQuery tool to run the query and return the results. + Then you should use the generateChartConfig tool to generate a chart config based on the data and user query. + Then you should return the chart config to the user. + + If the user asks a question that is not related to the data, you should say that you are not sure how to help with that. + `, + temperature: 0.7, + tools: { + generateChartConfig: generateChartConfigTool, + generateQuery: generateQueryTool, + runQuery: runQueryTool, + }, + }) + + return result.toUIMessageStreamResponse() + } catch (error) { + console.error('Chat API error:', error) + return new Response(JSON.stringify({ error: 'Failed to process chat request' }), { + headers: { 'Content-Type': 'application/json' }, + status: 500, + }) + } + }, +}) diff --git a/apps/openfaith/components/navigation/adminNav.tsx b/apps/openfaith/components/navigation/adminNav.tsx index ae99c0e6..42d62169 100644 --- a/apps/openfaith/components/navigation/adminNav.tsx +++ b/apps/openfaith/components/navigation/adminNav.tsx @@ -1,7 +1,7 @@ import { adminNavItems } from '@openfaith/openfaith/components/navigation/navShared' import { SideBarItem } from '@openfaith/openfaith/components/navigation/sideBarItem' import { nullOp } from '@openfaith/shared' -import { SidebarGroup, SidebarGroupLabel, SidebarMenu } from '@openfaith/ui' +import { SidebarGroup, SidebarGroupLabel, SidebarMenu, StarsIcon } from '@openfaith/ui' import { useRouter } from '@tanstack/react-router' import { Array, Option, pipe } from 'effect' @@ -21,6 +21,7 @@ export const AdminNav = () => { Admin + } title='AI' url='/ai' /> {pipe( adminNavItems, Array.map((x) => ), diff --git a/apps/openfaith/features/ai/chart.tsx b/apps/openfaith/features/ai/chart.tsx new file mode 100644 index 00000000..b41ad158 --- /dev/null +++ b/apps/openfaith/features/ai/chart.tsx @@ -0,0 +1,317 @@ +'use client' + +import { cn } from '@openfaith/ui' +import * as React from 'react' +import type { TooltipContentProps } from 'recharts' +import * as RechartsPrimitive from 'recharts' +import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent' + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { dark: '.dark', light: '' } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error('useChart must be used within a ') + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + config: ChartConfig + children: React.ComponentProps['children'] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` + + return ( + +
+ + {children} +
+
+ ) +}) +ChartContainer.displayName = 'Chart' + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color) + + if (!colorConfig.length) { + return null + } + + return ( +