Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
212 lines
7.5 KiB
JavaScript
212 lines
7.5 KiB
JavaScript
/**
|
|
* Migration 019: Create Conversations & Messages (ADR-024)
|
|
*
|
|
* Messenger-style architecture for AI chat:
|
|
* - conversations: chat sessions with task binding support
|
|
* - messages: individual messages with threading, mentions, FTS
|
|
* - conversation_participants: who is in the chat
|
|
* - message_reactions: emoji reactions
|
|
*/
|
|
|
|
export async function up(knex) {
|
|
const isPostgres = knex.client.config.client === 'pg' || knex.client.config.client === 'postgresql';
|
|
|
|
// ========================================
|
|
// CONVERSATIONS TABLE
|
|
// ========================================
|
|
await knex.schema.createTable('conversations', (table) => {
|
|
table.increments('id').primary();
|
|
|
|
// Ticket #41154 / ADR-091: Unified type enum [chat, task, row]
|
|
table.string('type', 20).notNullable().defaultTo('chat');
|
|
|
|
// Basic info
|
|
table.string('title', 255);
|
|
table.text('description');
|
|
|
|
// Owner (создатель чата)
|
|
table.integer('created_by').unsigned().references('id').inTable('users').onDelete('SET NULL');
|
|
|
|
// Space context (опционально)
|
|
table.integer('space_id').unsigned().references('id').inTable('projects').onDelete('CASCADE');
|
|
|
|
// AI Agent context (для AI чатов)
|
|
table.integer('agent_id'); // ссылка на table_rows где агент
|
|
table.integer('agent_table_id'); // какая таблица
|
|
|
|
// Task binding (если чат привязан к задаче)
|
|
table.integer('bound_table_id');
|
|
table.integer('bound_row_id');
|
|
|
|
// Denormalized for performance
|
|
table.integer('last_message_id');
|
|
table.timestamp('last_message_at');
|
|
table.string('last_message_preview', 200);
|
|
table.integer('messages_count').defaultTo(0);
|
|
|
|
// Settings (JSON/JSONB)
|
|
if (isPostgres) {
|
|
table.jsonb('settings').defaultTo('{}');
|
|
} else {
|
|
table.text('settings').defaultTo('{}');
|
|
}
|
|
|
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
|
});
|
|
|
|
// Indexes for conversations
|
|
await knex.schema.alterTable('conversations', (table) => {
|
|
table.index('space_id', 'idx_conversations_space');
|
|
table.index('created_by', 'idx_conversations_created_by');
|
|
table.index(['bound_table_id', 'bound_row_id'], 'idx_conversations_bound');
|
|
table.index('type', 'idx_conversations_type');
|
|
table.index('last_message_at', 'idx_conversations_last_message');
|
|
});
|
|
|
|
// ========================================
|
|
// MESSAGES TABLE
|
|
// ========================================
|
|
await knex.schema.createTable('messages', (table) => {
|
|
table.increments('id').primary();
|
|
|
|
// Parent conversation
|
|
table.integer('conversation_id').unsigned().notNullable()
|
|
.references('id').inTable('conversations').onDelete('CASCADE');
|
|
|
|
// Sender (user or agent)
|
|
table.integer('sender_id').unsigned().references('id').inTable('users').onDelete('SET NULL');
|
|
table.string('sender_type', 20).defaultTo('human'); // 'human', 'agent', 'system'
|
|
|
|
// Content
|
|
table.string('role', 20).notNullable(); // 'user', 'assistant', 'system', 'tool'
|
|
table.text('content').notNullable();
|
|
table.string('content_type', 50).defaultTo('text'); // 'text', 'markdown', 'code', 'image'
|
|
|
|
// For AI responses
|
|
table.integer('agent_id');
|
|
table.string('model_used', 100);
|
|
table.integer('tokens_in');
|
|
table.integer('tokens_out');
|
|
table.integer('latency_ms');
|
|
|
|
// Threading (reply to specific message)
|
|
table.integer('parent_id').unsigned().references('id').inTable('messages').onDelete('SET NULL');
|
|
table.integer('reply_count').defaultTo(0);
|
|
|
|
// Task binding (отдельное сообщение может быть про задачу)
|
|
table.integer('bound_table_id');
|
|
table.integer('bound_row_id');
|
|
|
|
// Mentions (JSON/JSONB)
|
|
if (isPostgres) {
|
|
table.jsonb('mentions').defaultTo('[]');
|
|
} else {
|
|
table.text('mentions').defaultTo('[]');
|
|
}
|
|
|
|
// Tool results for agent mode (JSON/JSONB)
|
|
if (isPostgres) {
|
|
table.jsonb('tool_results');
|
|
} else {
|
|
table.text('tool_results');
|
|
}
|
|
|
|
// Attachments (JSON/JSONB)
|
|
if (isPostgres) {
|
|
table.jsonb('attachments').defaultTo('[]');
|
|
} else {
|
|
table.text('attachments').defaultTo('[]');
|
|
}
|
|
|
|
// Status
|
|
table.boolean('is_edited').defaultTo(false);
|
|
table.boolean('is_deleted').defaultTo(false);
|
|
|
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
|
});
|
|
|
|
// Indexes for messages
|
|
await knex.schema.alterTable('messages', (table) => {
|
|
table.index(['conversation_id', 'created_at'], 'idx_messages_conversation');
|
|
table.index('sender_id', 'idx_messages_sender');
|
|
table.index('parent_id', 'idx_messages_parent');
|
|
table.index(['bound_table_id', 'bound_row_id'], 'idx_messages_bound');
|
|
});
|
|
|
|
// PostgreSQL-specific: GIN index for JSONB mentions and full-text search
|
|
if (isPostgres) {
|
|
await knex.raw(`
|
|
CREATE INDEX idx_messages_mentions ON messages USING GIN(mentions);
|
|
`);
|
|
|
|
await knex.raw(`
|
|
CREATE INDEX idx_messages_content_fts ON messages
|
|
USING GIN(to_tsvector('russian', content));
|
|
`);
|
|
}
|
|
|
|
// ========================================
|
|
// CONVERSATION PARTICIPANTS TABLE
|
|
// ========================================
|
|
await knex.schema.createTable('conversation_participants', (table) => {
|
|
table.increments('id').primary();
|
|
|
|
table.integer('conversation_id').unsigned().notNullable()
|
|
.references('id').inTable('conversations').onDelete('CASCADE');
|
|
table.integer('user_id').unsigned().notNullable()
|
|
.references('id').inTable('users').onDelete('CASCADE');
|
|
|
|
// Role in conversation
|
|
table.string('role', 20).defaultTo('member'); // 'owner', 'admin', 'member', 'viewer'
|
|
|
|
// User type (для различия agent-users)
|
|
table.string('user_type', 20).defaultTo('human'); // 'human', 'agent', 'bot'
|
|
|
|
// Read status
|
|
table.integer('last_read_message_id');
|
|
table.integer('unread_count').defaultTo(0);
|
|
|
|
// Notifications
|
|
table.boolean('is_muted').defaultTo(false);
|
|
|
|
table.timestamp('joined_at').defaultTo(knex.fn.now());
|
|
|
|
table.unique(['conversation_id', 'user_id']);
|
|
});
|
|
|
|
await knex.schema.alterTable('conversation_participants', (table) => {
|
|
table.index('user_id', 'idx_participants_user');
|
|
table.index('conversation_id', 'idx_participants_conversation');
|
|
});
|
|
|
|
// ========================================
|
|
// MESSAGE REACTIONS TABLE
|
|
// ========================================
|
|
await knex.schema.createTable('message_reactions', (table) => {
|
|
table.increments('id').primary();
|
|
|
|
table.integer('message_id').unsigned().notNullable()
|
|
.references('id').inTable('messages').onDelete('CASCADE');
|
|
table.integer('user_id').unsigned().notNullable()
|
|
.references('id').inTable('users').onDelete('CASCADE');
|
|
|
|
table.string('emoji', 10).notNullable(); // '👍', '❤️', '🎉', etc.
|
|
|
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
|
|
table.unique(['message_id', 'user_id', 'emoji']);
|
|
});
|
|
|
|
await knex.schema.alterTable('message_reactions', (table) => {
|
|
table.index('message_id', 'idx_reactions_message');
|
|
});
|
|
}
|
|
|
|
export async function down(knex) {
|
|
await knex.schema.dropTableIfExists('message_reactions');
|
|
await knex.schema.dropTableIfExists('conversation_participants');
|
|
await knex.schema.dropTableIfExists('messages');
|
|
await knex.schema.dropTableIfExists('conversations');
|
|
}
|