diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts index 6917d87bf..140e45568 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts @@ -44,6 +44,8 @@ export { accountsTable, authenticatorsTable, mediaTable, sessionsTable, usersTab // ============================================================ // APP-SPECIFIC TABLES // ============================================================ +export { aiConversationsTable } from '../models/AiConversation'; +export { aiMessagesTable } from '../models/AiMessage'; export { changelogEntriesTable } from '../models/ChangelogEntry'; export { todosTable } from '../models/Todo'; diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts index 0bd67a87f..4298bd4bc 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts @@ -33,6 +33,8 @@ import { verificationTokensTable, } from '@ottabase/ottaorm'; import { getEnabledPackageTables } from '../config.migrations'; +import { aiConversationsTable } from '../models/AiConversation'; +import { aiMessagesTable } from '../models/AiMessage'; import { changelogEntriesTable } from '../models/ChangelogEntry'; import { todosTable } from '../models/Todo'; @@ -61,6 +63,8 @@ export function getAllSchemas() { // 2. App-specific schemas const appTables = { + aiConversationsTable, + aiMessagesTable, changelogEntriesTable, todosTable, }; @@ -103,6 +107,8 @@ export function getSchemaSummary() { }; const appTables = { + aiConversationsTable, + aiMessagesTable, changelogEntriesTable, todosTable, }; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/AiConversation.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/AiConversation.schema.ts new file mode 100644 index 000000000..5fad88010 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/AiConversation.schema.ts @@ -0,0 +1,35 @@ +// ============================================================ +// AI Conversation table schema (App-specific) +// ============================================================ + +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * AI Conversation table schema + * Stores chat conversation metadata for the AI chat feature. + */ +export const aiConversationsTable = sqliteTable('ai_conversations', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + /** Conversation title (auto-generated from first message or user-set) */ + title: text('title').notNull().default('New Chat'), + /** AI model used (e.g. "@cf/meta/llama-3.1-8b-instruct", "gpt-4o") */ + model: text('model').notNull().default('@cf/meta/llama-3.1-8b-instruct'), + /** AI provider (e.g. "workers-ai", "openai", "anthropic") */ + provider: text('provider').notNull().default('workers-ai'), + /** Optional system prompt for this conversation */ + systemPrompt: text('system_prompt'), + /** User who owns this conversation */ + userId: text('user_id').notNull(), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export type AiConversationType = typeof aiConversationsTable.$inferSelect; +export type NewAiConversationType = typeof aiConversationsTable.$inferInsert; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/AiConversation.ts b/apps/ottabase-template-app-tanstack/ottabase/models/AiConversation.ts new file mode 100644 index 000000000..24d57a776 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/AiConversation.ts @@ -0,0 +1,164 @@ +// ============================================================ +// AI Conversation Model (App-specific) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { aiConversationsTable, type AiConversationType, type NewAiConversationType } from './AiConversation.schema'; + +export { aiConversationsTable, type AiConversationType, type NewAiConversationType } from './AiConversation.schema'; + +/** + * AiConversation model - stores AI chat conversation metadata. + * + * @example + * ```typescript + * const conversation = await AiConversation.create({ + * title: 'Code Review Help', + * model: '@cf/meta/llama-3.1-8b-instruct', + * provider: 'workers-ai', + * userId: 'user-123', + * }); + * + * const messages = await conversation.messages(); + * ``` + */ +export class AiConversation extends BaseModel { + static entity = 'ai_conversations'; + static table = aiConversationsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + createdAt: 'date' as const, + updatedAt: 'date' as const, + }; + + protected static defaults = { + title: 'New Chat', + model: '@cf/meta/llama-3.1-8b-instruct', + provider: 'workers-ai', + }; + + static writable = { + create: ['title', 'model', 'provider', 'systemPrompt', 'userId'], + update: ['title', 'model', 'provider', 'systemPrompt'], + }; + + protected static fields: ModelFields = { + id: { + type: 'id', + primaryKey: true, + editable: false, + uiConfig: { label: 'ID' }, + }, + title: { + type: 'string', + editable: true, + searchable: true, + sortable: true, + uiConfig: { + label: 'Title', + description: 'Conversation title', + placeholder: 'New Chat', + }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 'auto' }, + validation: { rules: 'required', messages: { required: 'Title is required' } }, + }, + model: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'Model', description: 'AI model identifier' }, + formConfig: { visible: true, fieldType: 'select' }, + tableConfig: { visible: true, colWidth: 200 }, + }, + provider: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'Provider', description: 'AI provider' }, + formConfig: { visible: true, fieldType: 'select' }, + tableConfig: { visible: true, colWidth: 150 }, + }, + systemPrompt: { + type: 'string', + editable: true, + uiConfig: { label: 'System Prompt', description: 'System instructions for the AI' }, + formConfig: { visible: true, fieldType: 'textarea' }, + tableConfig: { visible: false }, + }, + userId: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'User' }, + tableConfig: { visible: false }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created' }, + tableConfig: { visible: true, colWidth: 150 }, + }, + updatedAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Updated' }, + tableConfig: { visible: false }, + }, + }; + + // ============================================================ + // RELATIONSHIPS + // ============================================================ + + /** Get all messages in this conversation */ + async messages(select?: string[]) { + const { AiMessage } = await import('./AiMessage'); + return this.hasMany(AiMessage, 'conversationId', { + select, + orderBy: 'createdAt', + orderDirection: 'asc', + }); + } + + /** Get the user who owns this conversation */ + async user(select?: string[]) { + const { User } = await import('@ottabase/ottaorm'); + return this.belongsTo(User, 'userId', { + select: select || ['id', 'name', 'email'], + }); + } + + // ============================================================ + // HELPER METHODS + // ============================================================ + + /** Truncate a message to use as a conversation title (max 80 chars) */ + static truncateToTitle(message: string): string { + return message.length > 80 ? message.substring(0, 77) + '...' : message; + } + + /** Update the conversation title from the first user message */ + async updateTitleFromMessage(message: string) { + this.set('title', AiConversation.truncateToTitle(message)); + return this.save(); + } + + /** Get conversations for a specific user, ordered by most recent */ + static async forUser(userId: string, options?: { limit?: number; offset?: number }) { + return this.where( + { userId }, + { + orderBy: 'updatedAt', + orderDirection: 'desc', + limit: options?.limit || 50, + offset: options?.offset || 0, + }, + ); + } +} diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.schema.ts new file mode 100644 index 000000000..7f1c3016e --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.schema.ts @@ -0,0 +1,35 @@ +// ============================================================ +// AI Message table schema (App-specific) +// ============================================================ + +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * AI Message table schema + * Stores individual messages within an AI conversation. + */ +export const aiMessagesTable = sqliteTable('ai_messages', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + /** Conversation this message belongs to */ + conversationId: text('conversation_id').notNull(), + /** Message role: user, assistant, or system */ + role: text('role').notNull(), + /** Message content (text or markdown) */ + content: text('content').notNull(), + /** Model that generated this response (for assistant messages) */ + model: text('model'), + /** Provider that served this response (for assistant messages) */ + provider: text('provider'), + /** Token usage as JSON string (for assistant messages) */ + usage: text('usage'), + /** File attachments as JSON string (array of { url, name, type, size }) for multimodal messages */ + attachments: text('attachments'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), +}); + +export type AiMessageType = typeof aiMessagesTable.$inferSelect; +export type NewAiMessageType = typeof aiMessagesTable.$inferInsert; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.ts b/apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.ts new file mode 100644 index 000000000..834613788 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.ts @@ -0,0 +1,123 @@ +// ============================================================ +// AI Message Model (App-specific) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { aiMessagesTable, type AiMessageType, type NewAiMessageType } from './AiMessage.schema'; + +export { aiMessagesTable, type AiMessageType, type NewAiMessageType } from './AiMessage.schema'; + +/** + * AiMessage model - stores individual messages in an AI conversation. + * + * @example + * ```typescript + * const message = await AiMessage.create({ + * conversationId: 'conv-123', + * role: 'user', + * content: 'What is TypeScript?', + * }); + * ``` + */ +export class AiMessage extends BaseModel { + static entity = 'ai_messages'; + static table = aiMessagesTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + createdAt: 'date' as const, + }; + + static writable = { + create: ['conversationId', 'role', 'content', 'model', 'provider', 'usage', 'attachments'], + update: [], + }; + + protected static fields: ModelFields = { + id: { + type: 'id', + primaryKey: true, + editable: false, + uiConfig: { label: 'ID' }, + }, + conversationId: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'Conversation' }, + tableConfig: { visible: false }, + }, + role: { + type: 'string', + editable: false, + filterable: true, + uiConfig: { label: 'Role', description: 'Message role (user/assistant/system)' }, + tableConfig: { visible: true, colWidth: 100 }, + }, + content: { + type: 'string', + editable: false, + searchable: true, + uiConfig: { label: 'Content' }, + tableConfig: { visible: true, colWidth: 'auto' }, + }, + model: { + type: 'string', + editable: false, + uiConfig: { label: 'Model' }, + tableConfig: { visible: true, colWidth: 150 }, + }, + provider: { + type: 'string', + editable: false, + uiConfig: { label: 'Provider' }, + tableConfig: { visible: true, colWidth: 120 }, + }, + usage: { + type: 'string', + editable: false, + uiConfig: { label: 'Token Usage' }, + tableConfig: { visible: false }, + }, + attachments: { + type: 'string', + editable: false, + uiConfig: { label: 'Attachments', description: 'File attachments as JSON array' }, + tableConfig: { visible: false }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created' }, + tableConfig: { visible: true, colWidth: 150 }, + }, + }; + + // ============================================================ + // RELATIONSHIPS + // ============================================================ + + /** Get the conversation this message belongs to */ + async conversation(select?: string[]) { + const { AiConversation } = await import('./AiConversation'); + return this.belongsTo(AiConversation, 'conversationId', { select }); + } + + // ============================================================ + // HELPER METHODS + // ============================================================ + + /** Get all messages for a conversation, ordered chronologically */ + static async forConversation(conversationId: string) { + return this.where( + { conversationId }, + { + orderBy: 'createdAt', + orderDirection: 'asc', + }, + ); + } +} diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/ai-chat.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/ai-chat.test.ts new file mode 100644 index 000000000..bcb1b2065 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/__tests__/ai-chat.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Tests for AI Chat models (AiConversation & AiMessage schemas). + * Validates schema definitions, default values, and type exports. + */ + +describe('AI Chat Schema Tests', () => { + describe('AiConversation Schema', () => { + it('should export aiConversationsTable with correct columns', async () => { + const { aiConversationsTable } = await import('../../ottabase/models/AiConversation.schema'); + expect(aiConversationsTable).toBeDefined(); + + // Check required columns exist + const columns = Object.keys(aiConversationsTable); + expect(columns).toContain('id'); + expect(columns).toContain('title'); + expect(columns).toContain('model'); + expect(columns).toContain('provider'); + expect(columns).toContain('systemPrompt'); + expect(columns).toContain('userId'); + expect(columns).toContain('createdAt'); + expect(columns).toContain('updatedAt'); + }); + + it('should export type definitions', async () => { + const schema = await import('../../ottabase/models/AiConversation.schema'); + expect(schema.aiConversationsTable).toBeDefined(); + // Type exports are validated at compile time + }); + }); + + describe('AiMessage Schema', () => { + it('should export aiMessagesTable with correct columns', async () => { + const { aiMessagesTable } = await import('../../ottabase/models/AiMessage.schema'); + expect(aiMessagesTable).toBeDefined(); + + const columns = Object.keys(aiMessagesTable); + expect(columns).toContain('id'); + expect(columns).toContain('conversationId'); + expect(columns).toContain('role'); + expect(columns).toContain('content'); + expect(columns).toContain('model'); + expect(columns).toContain('provider'); + expect(columns).toContain('usage'); + expect(columns).toContain('attachments'); + expect(columns).toContain('createdAt'); + }); + }); + + describe('AiConversation Model', () => { + it('should have correct entity name and primary key', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + expect(AiConversation.entity).toBe('ai_conversations'); + expect(AiConversation.primaryKey).toBe('id'); + }); + + it('should have correct writable fields', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + expect(AiConversation.writable.create).toContain('title'); + expect(AiConversation.writable.create).toContain('model'); + expect(AiConversation.writable.create).toContain('provider'); + expect(AiConversation.writable.create).toContain('systemPrompt'); + expect(AiConversation.writable.create).toContain('userId'); + }); + + it('should have correct defaults', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + expect((AiConversation as any).defaults.title).toBe('New Chat'); + expect((AiConversation as any).defaults.model).toBe('@cf/meta/llama-3.1-8b-instruct'); + expect((AiConversation as any).defaults.provider).toBe('workers-ai'); + }); + + it('should have correct casts', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + expect(AiConversation.casts).toEqual({ + createdAt: 'date', + updatedAt: 'date', + }); + }); + }); + + describe('AiMessage Model', () => { + it('should have correct entity name and primary key', async () => { + const { AiMessage } = await import('../../ottabase/models/AiMessage'); + expect(AiMessage.entity).toBe('ai_messages'); + expect(AiMessage.primaryKey).toBe('id'); + }); + + it('should have correct writable fields', async () => { + const { AiMessage } = await import('../../ottabase/models/AiMessage'); + expect(AiMessage.writable.create).toContain('conversationId'); + expect(AiMessage.writable.create).toContain('role'); + expect(AiMessage.writable.create).toContain('content'); + expect(AiMessage.writable.create).toContain('model'); + expect(AiMessage.writable.create).toContain('provider'); + expect(AiMessage.writable.create).toContain('usage'); + expect(AiMessage.writable.create).toContain('attachments'); + // Update should be empty (messages are immutable) + expect(AiMessage.writable.update).toHaveLength(0); + }); + + it('should have correct casts', async () => { + const { AiMessage } = await import('../../ottabase/models/AiMessage'); + expect(AiMessage.casts).toEqual({ + createdAt: 'date', + }); + }); + }); + + describe('AiConversation.truncateToTitle', () => { + it('should return short messages unchanged', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + expect(AiConversation.truncateToTitle('Hello world')).toBe('Hello world'); + }); + + it('should return 80-char messages unchanged', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + const msg = 'A'.repeat(80); + expect(AiConversation.truncateToTitle(msg)).toBe(msg); + }); + + it('should truncate messages longer than 80 chars with ellipsis', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + const msg = 'A'.repeat(100); + const result = AiConversation.truncateToTitle(msg); + expect(result.length).toBe(80); + expect(result.endsWith('...')).toBe(true); + }); + + it('should handle empty strings', async () => { + const { AiConversation } = await import('../../ottabase/models/AiConversation'); + expect(AiConversation.truncateToTitle('')).toBe(''); + }); + }); +}); + +describe('AI Chat Route Handler Tests', () => { + const mockHandleAiModelsList = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('handleAiModelsList should return models and providers', async () => { + const { handleAiModelsList } = await import('../../worker/routes/ai-chat'); + const mockContext = { + request: new Request('http://localhost/api/ai/models'), + env: {}, + url: new URL('http://localhost/api/ai/models'), + route: '/api/ai/models', + method: 'GET', + withAuthCors: (r: Response) => r, + corsHeaders: {}, + }; + + const response = await handleAiModelsList(mockContext as any); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toHaveProperty('models'); + expect(data).toHaveProperty('providers'); + + // Check providers list + expect(data.providers.length).toBeGreaterThan(0); + expect(data.providers.some((p: any) => p.key === 'workers-ai')).toBe(true); + expect(data.providers.some((p: any) => p.key === 'openai')).toBe(true); + + // Check models per provider + expect(data.models['workers-ai']).toBeDefined(); + expect(data.models['workers-ai'].length).toBeGreaterThan(0); + expect(data.models.openai).toBeDefined(); + expect(data.models.anthropic).toBeDefined(); + expect(data.models['google-ai-studio']).toBeDefined(); + + // Each model should have id, name, context + const firstModel = data.models['workers-ai'][0]; + expect(firstModel).toHaveProperty('id'); + expect(firstModel).toHaveProperty('name'); + expect(firstModel).toHaveProperty('context'); + }); + + it('should export handleAiChatStream for streaming responses', async () => { + const { handleAiChatStream } = await import('../../worker/routes/ai-chat'); + expect(handleAiChatStream).toBeDefined(); + expect(typeof handleAiChatStream).toBe('function'); + }); +}); + +describe('AI Tables Migration Registration', () => { + it('getAllSchemas should include AI tables', async () => { + const { getAllSchemas } = await import('../../ottabase/db/schemas-helper'); + const schemas = getAllSchemas(); + + expect(schemas).toHaveProperty('aiConversationsTable'); + expect(schemas).toHaveProperty('aiMessagesTable'); + }); + + it('getSchemaSummary should list AI tables in app tables', async () => { + const { getSchemaSummary } = await import('../../ottabase/db/schemas-helper'); + const summary = getSchemaSummary(); + + expect(summary.app).toContain('aiConversationsTable'); + expect(summary.app).toContain('aiMessagesTable'); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts index 5667a31f0..373e207e7 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts +++ b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts @@ -12,6 +12,7 @@ const NAV_LINKS_ALL: NavLink[] = [ { to: '/changelog', label: "What's New" }, { to: '/blog', label: 'Blog' }, { to: '/demo', label: 'Demo' }, + { to: '/ai/chat', label: 'AI Chat', authRequired: true }, { to: '/shortlinks', label: 'Links' }, { to: '/analytics', label: 'Analytics', authRequired: true }, { to: '/admin', label: 'Admin' }, diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminAiPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminAiPage.tsx new file mode 100644 index 000000000..e8fe4deec --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminAiPage.tsx @@ -0,0 +1,295 @@ +/** + * Admin AI Settings Page — View AI provider status and configuration. + * + * Shows which AI providers are configured, available models, + * and links to the AI Chat page. + */ +import { api } from '@/lib/api'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Separator, + Skeleton, +} from '@ottabase/ui-shadcn'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { ArrowLeft, Bot, CheckCircle, ExternalLink, MessageSquare, Server, ShieldCheck, XCircle } from 'lucide-react'; + +interface AIStatus { + workersAI: boolean; + aiGateway: boolean; + openai: boolean; + anthropic: boolean; + googleAI: boolean; +} + +interface ModelInfo { + id: string; + name: string; + context: string; +} + +interface ModelsResponse { + models: Record; + providers: Array<{ key: string; name: string }>; +} + +const PROVIDER_DETAILS: Record = { + workersAI: { + name: 'Workers AI', + description: 'Run AI models directly on Cloudflare edge network. No API key needed — uses native binding.', + docsUrl: 'https://developers.cloudflare.com/workers-ai/', + }, + aiGateway: { + name: 'AI Gateway', + description: 'Proxy requests to any AI provider with caching, rate limiting, logging, and analytics.', + docsUrl: 'https://developers.cloudflare.com/ai-gateway/', + }, + openai: { + name: 'OpenAI', + description: 'GPT-4o, GPT-4 Turbo, and GPT-3.5 models via AI Gateway.', + docsUrl: 'https://developers.cloudflare.com/ai-gateway/providers/openai/', + }, + anthropic: { + name: 'Anthropic', + description: 'Claude family of models via AI Gateway.', + docsUrl: 'https://developers.cloudflare.com/ai-gateway/providers/anthropic/', + }, + googleAI: { + name: 'Google AI Studio', + description: 'Gemini models via AI Gateway.', + docsUrl: 'https://developers.cloudflare.com/ai-gateway/providers/google-ai-studio/', + }, +}; + +function StatusIcon({ configured }: { configured: boolean }) { + return configured ? ( + + ) : ( + + ); +} + +export function AdminAiPage() { + const statusQuery = useQuery({ + queryKey: ['ai-status'], + queryFn: () => api('/api/cloudflare/ai/status') as Promise, + }); + + const modelsQuery = useQuery({ + queryKey: ['ai-models'], + queryFn: () => api('/api/ai/models') as Promise, + }); + + const status = statusQuery.data; + const models = modelsQuery.data; + const configuredCount = status ? Object.values(status).filter(Boolean).length : 0; + + /** Map provider key (e.g. "workers-ai") to AIStatus key (e.g. "workersAI") */ + const mapProviderKeyToStatusKey = (providerKey: string): keyof AIStatus => { + const mapping: Record = { + 'workers-ai': 'workersAI', + 'google-ai-studio': 'googleAI', + }; + return mapping[providerKey] ?? (providerKey as keyof AIStatus); + }; + + return ( +
+ {/* Header */} +
+ + + +
+

+ + AI Configuration +

+

+ Manage AI providers, models, and chat settings. +

+
+ + + +
+ + {/* Overview card */} + + + + + Provider Status + + + {configuredCount > 0 + ? `${configuredCount} of ${Object.keys(PROVIDER_DETAILS).length} providers configured` + : 'Loading provider status...'} + + + + {statusQuery.isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : status ? ( +
+ {Object.entries(PROVIDER_DETAILS).map(([key, details]) => { + const isConfigured = status[key as keyof AIStatus] ?? false; + return ( +
+ +
+
+ {details.name} + + {isConfigured ? 'Configured' : 'Not configured'} + +
+

+ {details.description} +

+
+ + + +
+ ); + })} +
+ ) : ( +

Failed to load provider status.

+ )} +
+
+ + {/* Available Models */} + + + + + Available Models + + Models available for each configured provider. + + + {modelsQuery.isLoading ? ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ) : models ? ( +
+ {Object.entries(models.models).map(([providerKey, providerModels]) => { + const providerName = + models.providers.find((p) => p.key === providerKey)?.name || providerKey; + const isConfigured = status?.[mapProviderKeyToStatusKey(providerKey)] ?? false; + + return ( +
+
+

{providerName}

+ + {isConfigured ? 'Active' : 'Inactive'} + +
+
+ {providerModels.map((m) => ( +
+ {m.name} + + {m.context} + +
+ ))} +
+ +
+ ); + })} +
+ ) : ( +

Failed to load models.

+ )} +
+
+ + {/* Quick Links */} + + + Quick Links + + +
+ + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx index 704fe3d40..327713542 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx @@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react'; import { Activity, Bell, + Bot, Building2, Clock, Database, @@ -146,6 +147,17 @@ const ADMIN_CATEGORIES: AdminCategory[] = [ }, ], }, + { + label: 'AI & Intelligence', + links: [ + { + title: 'AI Configuration', + description: 'View AI provider status, available models, and manage AI settings.', + href: '/admin/ai', + icon: Bot, + }, + ], + }, { label: 'Infrastructure', links: [ @@ -257,6 +269,9 @@ export function AdminIndexPage() {

Quick Links

+ + AI Chat + Component Demos diff --git a/apps/ottabase-template-app-tanstack/src/pages/ai/AiChatPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/ai/AiChatPage.tsx new file mode 100644 index 000000000..b32fb5b9c --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/ai/AiChatPage.tsx @@ -0,0 +1,989 @@ +/** + * AI Chat Page — Full-featured chat interface with conversation history. + * + * Features: + * - Conversation sidebar with create/delete/rename + * - Multi-turn chat with message history + * - Streaming AI responses via SSE + * - File attachments for multimodal chats (text/image) + * - Model & provider selection (switchable in header and settings) + * - System prompt configuration + * - Auto-scroll, loading states, markdown support + * - Dark mode support + */ +import { api, isApiError } from '@/lib/api'; +import { + Badge, + Button, + Card, + CardContent, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + ScrollArea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, + Skeleton, + Textarea, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@ottabase/ui-shadcn'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { + ArrowLeft, + Bot, + ChevronDown, + Copy, + ImagePlus, + Loader2, + MessageSquarePlus, + MoreVertical, + Paperclip, + PanelLeftClose, + PanelLeftOpen, + Send, + Settings, + Trash2, + User, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +// ============================================================ +// Types +// ============================================================ + +interface Conversation { + id: string; + title: string; + model: string; + provider: string; + systemPrompt: string | null; + userId: string; + createdAt: number; + updatedAt: number; +} + +interface Message { + id: string; + conversationId: string; + role: 'user' | 'assistant' | 'system'; + content: string; + model: string | null; + provider: string | null; + usage: string | null; + attachments: string | null; + createdAt: number; +} + +interface Attachment { + url: string; + name: string; + type: string; + size?: number; +} + +interface ModelInfo { + id: string; + name: string; + context: string; +} + +interface ModelsResponse { + models: Record; + providers: Array<{ key: string; name: string }>; +} + +// ============================================================ +// Constants +// ============================================================ + +const DEFAULT_PROVIDER = 'workers-ai'; +const DEFAULT_MODEL = '@cf/meta/llama-3.1-8b-instruct'; + +// ============================================================ +// API Helpers +// ============================================================ + +async function fetchConversations(): Promise { + const data = (await api('/api/ai/conversations')) as { conversations: Conversation[] }; + return data.conversations; +} + +async function fetchConversation(id: string): Promise<{ conversation: Conversation; messages: Message[] }> { + return api(`/api/ai/conversations/${id}`) as Promise<{ conversation: Conversation; messages: Message[] }>; +} + +async function fetchModels(): Promise { + return api('/api/ai/models') as Promise; +} + +// ============================================================ +// Message Bubble Component +// ============================================================ + +function MessageBubble({ message, isLast }: { message: Message; isLast: boolean }) { + const isUser = message.role === 'user'; + const usage = message.usage ? JSON.parse(message.usage) : null; + const attachments: Attachment[] = message.attachments ? JSON.parse(message.attachments) : []; + + const handleCopy = () => { + navigator.clipboard.writeText(message.content); + toast.success('Copied to clipboard'); + }; + + return ( +
+ {/* Avatar */} + {!isUser && ( +
+ +
+ )} + +
+ {/* Attachments */} + {attachments.length > 0 && ( +
+ {attachments.map((att, idx) => + att.type.startsWith('image/') ? ( + {att.name} + ) : ( + + + {att.name} + + ), + )} +
+ )} + + {/* Message content */} +
+ {/* Render content as paragraphs, preserving line breaks */} +
{message.content}
+
+ + {/* Metadata row */} +
+ {!isUser && message.model && ( + {message.model} + )} + {!isUser && usage && ( + + {usage.total_tokens || usage.completion_tokens || ''} tokens + + )} + +
+
+ + {/* User avatar */} + {isUser && ( +
+ +
+ )} +
+ ); +} + +// ============================================================ +// Typing Indicator +// ============================================================ + +function TypingIndicator() { + return ( +
+
+ +
+
+
+ + + +
+
+
+ ); +} + +// ============================================================ +// Empty State +// ============================================================ + +function EmptyChat({ onExampleClick }: { onExampleClick: (text: string) => void }) { + const examples = [ + 'Explain how Cloudflare Workers work', + 'Write a TypeScript function to sort an array', + 'What are the benefits of edge computing?', + 'Help me debug a React component', + ]; + + return ( +
+
+ +
+

Ottabase AI

+

+ Powered by Cloudflare Workers AI & AI Gateway. Start a conversation or try one of these examples: +

+
+ {examples.map((example) => ( + + ))} +
+
+ ); +} + +// ============================================================ +// Conversation Sidebar +// ============================================================ + +function ConversationList({ + conversations, + activeId, + onSelect, + onDelete, + onNewChat, + isLoading, +}: { + conversations: Conversation[]; + activeId: string | null; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + onNewChat: () => void; + isLoading: boolean; +}) { + return ( +
+ {/* New Chat button */} +
+ +
+ + + + {/* Conversation list */} + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : conversations.length === 0 ? ( +

No conversations yet

+ ) : ( +
+ {conversations.map((conv) => ( +
onSelect(conv.id)} + > + + {conv.title} + +
+ ))} +
+ )} +
+ + {/* Footer */} + +
+ + + AI Settings + +
+
+ ); +} + +// ============================================================ +// Settings Panel +// ============================================================ + +function SettingsPanel({ + provider, + model, + systemPrompt, + models, + onProviderChange, + onModelChange, + onSystemPromptChange, +}: { + provider: string; + model: string; + systemPrompt: string; + models: ModelsResponse | null; + onProviderChange: (v: string) => void; + onModelChange: (v: string) => void; + onSystemPromptChange: (v: string) => void; +}) { + const providerModels = models?.models[provider] || []; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ +