Skip to content
2 changes: 2 additions & 0 deletions apps/ottabase-template-app-tanstack/ottabase/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -61,6 +63,8 @@ export function getAllSchemas() {

// 2. App-specific schemas
const appTables = {
aiConversationsTable,
aiMessagesTable,
changelogEntriesTable,
todosTable,
};
Expand Down Expand Up @@ -103,6 +107,8 @@ export function getSchemaSummary() {
};

const appTables = {
aiConversationsTable,
aiMessagesTable,
changelogEntriesTable,
todosTable,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
123 changes: 123 additions & 0 deletions apps/ottabase-template-app-tanstack/ottabase/models/AiMessage.ts
Original file line number Diff line number Diff line change
@@ -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',
},
);
}
}
Loading