saved sessions: replace transient history with persistent DB storage and auto-save
- Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete) - Save is instant (no LLM summarization), stores exact conversation with tool calls - Resume loads full message history into chat UI, sends transcript to agent on first message - Auto-save updates DB after every agent response once a session is saved - Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups) - Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "saved_sessions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"context" text,
|
||||
"context_id" text,
|
||||
"title" text NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"raw_messages" jsonb NOT NULL,
|
||||
"cwd" text NOT NULL,
|
||||
"cost" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "chat_groups" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "chat_messages" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "chat_sessions" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "saved_sessions" ADD CONSTRAINT "saved_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_saved_sessions_user_created" ON "saved_sessions" USING btree ("user_id","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1772795711164,
|
||||
"tag": "0000_futuristic_ink",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1772799835768,
|
||||
"tag": "0001_freezing_carmella_unuscione",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -64,5 +64,13 @@ export {
|
||||
upsertDefaults,
|
||||
} from './queries/dashboards';
|
||||
|
||||
export {
|
||||
listSavedSessions,
|
||||
getSavedSession,
|
||||
createSavedSession,
|
||||
updateSavedSessionMessages,
|
||||
deleteSavedSession,
|
||||
} from './queries/saved-sessions';
|
||||
|
||||
export { db } from './db';
|
||||
export * as schema from './schema';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { savedSessions } from '../schema';
|
||||
import type { SavedSessionInsert, SavedSessionSelect } from '../types';
|
||||
|
||||
export async function listSavedSessions(userId: number): Promise<Omit<SavedSessionSelect, 'rawMessages'>[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: savedSessions.id,
|
||||
userId: savedSessions.userId,
|
||||
provider: savedSessions.provider,
|
||||
context: savedSessions.context,
|
||||
contextId: savedSessions.contextId,
|
||||
title: savedSessions.title,
|
||||
summary: savedSessions.summary,
|
||||
cwd: savedSessions.cwd,
|
||||
cost: savedSessions.cost,
|
||||
createdAt: savedSessions.createdAt,
|
||||
})
|
||||
.from(savedSessions)
|
||||
.where(eq(savedSessions.userId, userId))
|
||||
.orderBy(desc(savedSessions.createdAt));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getSavedSession(id: number, userId: number): Promise<SavedSessionSelect | undefined> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(savedSessions)
|
||||
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function createSavedSession(data: SavedSessionInsert): Promise<SavedSessionSelect> {
|
||||
const rows = await db.insert(savedSessions).values(data).returning();
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
export async function updateSavedSessionMessages(
|
||||
id: number,
|
||||
userId: number,
|
||||
rawMessages: unknown,
|
||||
cost: unknown,
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(savedSessions)
|
||||
.set({ rawMessages, cost })
|
||||
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)))
|
||||
.returning({ id: savedSessions.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
export async function deleteSavedSession(id: number, userId: number): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(savedSessions)
|
||||
.where(and(eq(savedSessions.id, id), eq(savedSessions.userId, userId)))
|
||||
.returning({ id: savedSessions.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
@@ -1,56 +1,22 @@
|
||||
import { pgTable, text, integer, boolean, timestamp, jsonb, index, numeric } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, integer, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
|
||||
export const chatSessions = pgTable('chat_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
context: text('context').notNull().default('chat'),
|
||||
contextId: text('context_id'),
|
||||
title: text('title').notNull().default('New Chat'),
|
||||
model: text('model'),
|
||||
cwd: text('cwd'),
|
||||
thinking: text('thinking'),
|
||||
archived: boolean('archived').notNull().default(false),
|
||||
groupSlug: text('group_slug'),
|
||||
messageCount: integer('message_count').notNull().default(0),
|
||||
costInputTokens: integer('cost_input_tokens').notNull().default(0),
|
||||
costOutputTokens: integer('cost_output_tokens').notNull().default(0),
|
||||
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }).notNull().default('0'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index('idx_sessions_user_context').on(table.userId, table.context),
|
||||
index('idx_sessions_user_created').on(table.userId, table.createdAt),
|
||||
index('idx_sessions_user_group').on(table.userId, table.groupSlug),
|
||||
]);
|
||||
|
||||
export const chatMessages = pgTable('chat_messages', {
|
||||
id: text('id').primaryKey(),
|
||||
sessionId: text('session_id').notNull().references(() => chatSessions.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull(),
|
||||
text: text('text'),
|
||||
model: text('model'),
|
||||
toolName: text('tool_name'),
|
||||
toolInput: jsonb('tool_input'),
|
||||
toolCallId: text('tool_call_id'),
|
||||
output: text('output'),
|
||||
isError: boolean('is_error').default(false),
|
||||
costInputTokens: integer('cost_input_tokens'),
|
||||
costOutputTokens: integer('cost_output_tokens'),
|
||||
costTotalUsd: numeric('cost_total_usd', { precision: 12, scale: 6 }),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index('idx_messages_session_order').on(table.sessionId, table.sortOrder),
|
||||
]);
|
||||
|
||||
export const chatGroups = pgTable('chat_groups', {
|
||||
slug: text('slug').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
index('idx_chat_groups_user').on(table.userId),
|
||||
]);
|
||||
export const savedSessions = pgTable(
|
||||
'saved_sessions',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider').notNull(),
|
||||
context: text('context'),
|
||||
contextId: text('context_id'),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
rawMessages: jsonb('raw_messages').notNull(),
|
||||
cwd: text('cwd').notNull(),
|
||||
cost: jsonb('cost').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_saved_sessions_user_created').on(table.userId, table.createdAt)],
|
||||
);
|
||||
|
||||
@@ -34,19 +34,10 @@ export type UserIntegrationInsert = typeof Schema.userIntegrations.$inferInsert;
|
||||
export type DockConfigSelect = typeof Schema.dockConfigs.$inferSelect;
|
||||
export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
|
||||
|
||||
// ── Chat ──
|
||||
// ── Saved Sessions ──
|
||||
|
||||
export type ChatSessionSelect = typeof Schema.chatSessions.$inferSelect;
|
||||
export type ChatSessionInsert = typeof Schema.chatSessions.$inferInsert;
|
||||
export type ChatSession = ChatSessionSelect & {
|
||||
messages?: ChatMessageSelect[];
|
||||
};
|
||||
|
||||
export type ChatMessageSelect = typeof Schema.chatMessages.$inferSelect;
|
||||
export type ChatMessageInsert = typeof Schema.chatMessages.$inferInsert;
|
||||
|
||||
export type ChatGroupSelect = typeof Schema.chatGroups.$inferSelect;
|
||||
export type ChatGroupInsert = typeof Schema.chatGroups.$inferInsert;
|
||||
export type SavedSessionSelect = typeof Schema.savedSessions.$inferSelect;
|
||||
export type SavedSessionInsert = typeof Schema.savedSessions.$inferInsert;
|
||||
|
||||
// ── Dashboards ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user