feat: Add persistent chat sessions for iterative phase/task refinement
Introduces a chat loop where users send instructions to an agent that applies changes (create/update/delete phases, tasks, pages) and stays alive for follow-up messages. Includes schema + migration, repository layer, chat prompt, file-io action field extension, output handler chat mode, revert support for deletes, tRPC procedures, events, frontend slide-over UI with inline changeset display and revert, and docs.
This commit is contained in:
@@ -11,7 +11,7 @@ export type CreateChangeSetData = {
|
||||
agentId: string | null;
|
||||
agentName: string;
|
||||
initiativeId: string;
|
||||
mode: 'plan' | 'detail' | 'refine';
|
||||
mode: 'plan' | 'detail' | 'refine' | 'chat';
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
|
||||
31
apps/server/db/repositories/chat-session-repository.ts
Normal file
31
apps/server/db/repositories/chat-session-repository.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Chat Session Repository Port Interface
|
||||
*
|
||||
* Port for chat session and message persistence operations.
|
||||
*/
|
||||
|
||||
import type { ChatSession, ChatMessage } from '../schema.js';
|
||||
|
||||
export interface CreateChatSessionData {
|
||||
targetType: 'phase' | 'task';
|
||||
targetId: string;
|
||||
initiativeId: string;
|
||||
agentId?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateChatMessageData {
|
||||
chatSessionId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
changeSetId?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatSessionRepository {
|
||||
createSession(data: CreateChatSessionData): Promise<ChatSession>;
|
||||
findSessionById(id: string): Promise<ChatSession | null>;
|
||||
findActiveSession(targetType: 'phase' | 'task', targetId: string): Promise<ChatSession | null>;
|
||||
findActiveSessionByAgentId(agentId: string): Promise<ChatSession | null>;
|
||||
updateSession(id: string, data: { agentId?: string | null; status?: 'active' | 'closed' }): Promise<ChatSession>;
|
||||
createMessage(data: CreateChatMessageData): Promise<ChatMessage>;
|
||||
findMessagesBySessionId(sessionId: string): Promise<ChatMessage[]>;
|
||||
}
|
||||
107
apps/server/db/repositories/drizzle/chat-session.ts
Normal file
107
apps/server/db/repositories/drizzle/chat-session.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Drizzle Chat Session Repository Adapter
|
||||
*
|
||||
* Implements ChatSessionRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { chatSessions, chatMessages, type ChatSession, type ChatMessage } from '../../schema.js';
|
||||
import type { ChatSessionRepository, CreateChatSessionData, CreateChatMessageData } from '../chat-session-repository.js';
|
||||
|
||||
export class DrizzleChatSessionRepository implements ChatSessionRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async createSession(data: CreateChatSessionData): Promise<ChatSession> {
|
||||
const now = new Date();
|
||||
const id = nanoid();
|
||||
await this.db.insert(chatSessions).values({
|
||||
id,
|
||||
targetType: data.targetType,
|
||||
targetId: data.targetId,
|
||||
initiativeId: data.initiativeId,
|
||||
agentId: data.agentId ?? null,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return this.findSessionById(id) as Promise<ChatSession>;
|
||||
}
|
||||
|
||||
async findSessionById(id: string): Promise<ChatSession | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(chatSessions)
|
||||
.where(eq(chatSessions.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveSession(targetType: 'phase' | 'task', targetId: string): Promise<ChatSession | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(chatSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatSessions.targetType, targetType),
|
||||
eq(chatSessions.targetId, targetId),
|
||||
eq(chatSessions.status, 'active' as 'active' | 'closed'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveSessionByAgentId(agentId: string): Promise<ChatSession | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(chatSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatSessions.agentId, agentId),
|
||||
eq(chatSessions.status, 'active' as 'active' | 'closed'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateSession(id: string, data: { agentId?: string | null; status?: 'active' | 'closed' }): Promise<ChatSession> {
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.agentId !== undefined) updates.agentId = data.agentId;
|
||||
if (data.status !== undefined) updates.status = data.status;
|
||||
await this.db
|
||||
.update(chatSessions)
|
||||
.set(updates)
|
||||
.where(eq(chatSessions.id, id));
|
||||
return this.findSessionById(id) as Promise<ChatSession>;
|
||||
}
|
||||
|
||||
async createMessage(data: CreateChatMessageData): Promise<ChatMessage> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
await this.db.insert(chatMessages).values({
|
||||
id,
|
||||
chatSessionId: data.chatSessionId,
|
||||
role: data.role,
|
||||
content: data.content,
|
||||
changeSetId: data.changeSetId ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(eq(chatMessages.id, id))
|
||||
.limit(1);
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
async findMessagesBySessionId(sessionId: string): Promise<ChatMessage[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(chatMessages)
|
||||
.where(eq(chatMessages.chatSessionId, sessionId))
|
||||
.orderBy(asc(chatMessages.createdAt));
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@ export { DrizzleAccountRepository } from './account.js';
|
||||
export { DrizzleChangeSetRepository } from './change-set.js';
|
||||
export { DrizzleLogChunkRepository } from './log-chunk.js';
|
||||
export { DrizzleConversationRepository } from './conversation.js';
|
||||
export { DrizzleChatSessionRepository } from './chat-session.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ export class DrizzlePageRepository implements PageRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreatePageData): Promise<Page> {
|
||||
const id = nanoid();
|
||||
const id = data.id ?? nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(pages).values({
|
||||
|
||||
@@ -25,7 +25,7 @@ export class DrizzleTaskRepository implements TaskRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateTaskData): Promise<Task> {
|
||||
const id = nanoid();
|
||||
const id = data.id ?? nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(tasks).values({
|
||||
|
||||
@@ -72,3 +72,9 @@ export type {
|
||||
ConversationRepository,
|
||||
CreateConversationData,
|
||||
} from './conversation-repository.js';
|
||||
|
||||
export type {
|
||||
ChatSessionRepository,
|
||||
CreateChatSessionData,
|
||||
CreateChatMessageData,
|
||||
} from './chat-session-repository.js';
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Page, NewPage } from '../schema.js';
|
||||
* Data for creating a new page.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'> & { id?: string };
|
||||
|
||||
/**
|
||||
* Data for updating a page.
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Task, NewTask, TaskCategory } from '../schema.js';
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
* At least one of phaseId, initiativeId, or parentTaskId should be provided.
|
||||
*/
|
||||
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'> & { id?: string };
|
||||
|
||||
/**
|
||||
* Data for updating a task.
|
||||
|
||||
@@ -264,7 +264,7 @@ export const agents = sqliteTable('agents', {
|
||||
})
|
||||
.notNull()
|
||||
.default('idle'),
|
||||
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine'] })
|
||||
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine', 'chat'] })
|
||||
.notNull()
|
||||
.default('execute'),
|
||||
pid: integer('pid'),
|
||||
@@ -308,7 +308,7 @@ export const changeSets = sqliteTable('change_sets', {
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
mode: text('mode', { enum: ['plan', 'detail', 'refine'] }).notNull(),
|
||||
mode: text('mode', { enum: ['plan', 'detail', 'refine', 'chat'] }).notNull(),
|
||||
summary: text('summary'),
|
||||
status: text('status', { enum: ['applied', 'reverted'] })
|
||||
.notNull()
|
||||
@@ -536,3 +536,71 @@ export const conversations = sqliteTable('conversations', {
|
||||
|
||||
export type Conversation = InferSelectModel<typeof conversations>;
|
||||
export type NewConversation = InferInsertModel<typeof conversations>;
|
||||
|
||||
// ============================================================================
|
||||
// CHAT SESSIONS
|
||||
// ============================================================================
|
||||
|
||||
export const chatSessions = sqliteTable('chat_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
targetType: text('target_type', { enum: ['phase', 'task'] }).notNull(),
|
||||
targetId: text('target_id').notNull(),
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }),
|
||||
status: text('status', { enum: ['active', 'closed'] })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('chat_sessions_target_idx').on(table.targetType, table.targetId),
|
||||
index('chat_sessions_initiative_id_idx').on(table.initiativeId),
|
||||
]);
|
||||
|
||||
export const chatSessionsRelations = relations(chatSessions, ({ one, many }) => ({
|
||||
initiative: one(initiatives, {
|
||||
fields: [chatSessions.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
agent: one(agents, {
|
||||
fields: [chatSessions.agentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
messages: many(chatMessages),
|
||||
}));
|
||||
|
||||
export type ChatSession = InferSelectModel<typeof chatSessions>;
|
||||
export type NewChatSession = InferInsertModel<typeof chatSessions>;
|
||||
|
||||
// ============================================================================
|
||||
// CHAT MESSAGES
|
||||
// ============================================================================
|
||||
|
||||
export const chatMessages = sqliteTable('chat_messages', {
|
||||
id: text('id').primaryKey(),
|
||||
chatSessionId: text('chat_session_id')
|
||||
.notNull()
|
||||
.references(() => chatSessions.id, { onDelete: 'cascade' }),
|
||||
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
changeSetId: text('change_set_id').references(() => changeSets.id, { onDelete: 'set null' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('chat_messages_session_id_idx').on(table.chatSessionId),
|
||||
]);
|
||||
|
||||
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
|
||||
chatSession: one(chatSessions, {
|
||||
fields: [chatMessages.chatSessionId],
|
||||
references: [chatSessions.id],
|
||||
}),
|
||||
changeSet: one(changeSets, {
|
||||
fields: [chatMessages.changeSetId],
|
||||
references: [changeSets.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type ChatMessage = InferSelectModel<typeof chatMessages>;
|
||||
export type NewChatMessage = InferInsertModel<typeof chatMessages>;
|
||||
|
||||
Reference in New Issue
Block a user