batch4: chats-in-sessions, force-send, /compact, right-rail file browser
Session 1:N Chat data model with backfill. Workspace switches to client-side multi-tab pane management. Right-rail file browser with float-over viewer and click-drag line selection replaces FileBrowserPane. Adds /compact streaming summarizer (respects compact markers in context builder), force-send (cancels in-flight, persists partial as 'cancelled', awaits cancellation completion via deferred Promise + 5s timeout), message queue, stop generation, chat auto-rename, session archive/unarchive with Closed Sessions section on repo landing page. CHECK constraints on sessions.status, messages.role, messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES / MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the api.panes.* client block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,23 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Message, Session } from '../types/api.js';
|
||||
import { requireUser } from '../auth.js';
|
||||
import type { Chat, Message, Session } from '../types/api.js';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
});
|
||||
|
||||
interface MessageHandlers {
|
||||
enqueueInference: (sessionId: string, assistantMessageId: string, user: string) => void;
|
||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
|
||||
publishUserMessage: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
userMessageId: string,
|
||||
content: string
|
||||
) => void;
|
||||
publishMessagesDeleted: (sessionId: string, messageIds: string[]) => void;
|
||||
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
||||
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function registerMessageRoutes(
|
||||
@@ -32,7 +34,7 @@ export function registerMessageRoutes(
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
const rows = await sql<Message[]>`
|
||||
SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq,
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
|
||||
FROM messages
|
||||
WHERE session_id = ${req.params.id}
|
||||
@@ -43,7 +45,7 @@ export function registerMessageRoutes(
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/sessions/:id/messages',
|
||||
'/api/chats/:id/messages',
|
||||
async (req, reply) => {
|
||||
const parsed = SendBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -51,33 +53,39 @@ export function registerMessageRoutes(
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
||||
if (session.length === 0) {
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
const sessionId = chat.session_id;
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, role, content, status, created_at)
|
||||
VALUES (${req.params.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, role, content, status, created_at)
|
||||
VALUES (${req.params.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${req.params.id}`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
});
|
||||
|
||||
handlers.publishUserMessage(
|
||||
req.params.id,
|
||||
sessionId,
|
||||
chat.id,
|
||||
result.user_message_id,
|
||||
parsed.data.content
|
||||
);
|
||||
handlers.enqueueInference(req.params.id, result.assistant_message_id, requireUser(req));
|
||||
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
@@ -85,14 +93,24 @@ export function registerMessageRoutes(
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string; message_id: string } }>(
|
||||
'/api/sessions/:id/messages/:message_id/regenerate',
|
||||
'/api/chats/:id/messages/:message_id/regenerate',
|
||||
async (req, reply) => {
|
||||
const { id: sessionId, message_id: targetId } = req.params;
|
||||
const { id: chatId, message_id: targetId } = req.params;
|
||||
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${chatId}
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
const sessionId = chat.session_id;
|
||||
|
||||
const target = await sql<{ id: string; role: string; status: string }[]>`
|
||||
SELECT id, role, status
|
||||
FROM messages
|
||||
WHERE session_id = ${sessionId} AND id = ${targetId}
|
||||
WHERE chat_id = ${chatId} AND id = ${targetId}
|
||||
`;
|
||||
if (target.length === 0) {
|
||||
reply.code(404);
|
||||
@@ -109,34 +127,141 @@ export function registerMessageRoutes(
|
||||
}
|
||||
|
||||
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => {
|
||||
// Subquery keeps created_at in postgres at TIMESTAMPTZ µs precision.
|
||||
// Round-tripping through JS Date loses sub-ms precision and can pull
|
||||
// earlier rows (e.g. the triggering user message) into the >= bound.
|
||||
const deletedRows = await tx<{ id: string }[]>`
|
||||
DELETE FROM messages
|
||||
WHERE session_id = ${sessionId}
|
||||
WHERE chat_id = ${chatId}
|
||||
AND created_at >= (
|
||||
SELECT created_at FROM messages WHERE id = ${targetId}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const [row] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
return {
|
||||
newAssistantId: row!.id,
|
||||
deletedIds: deletedRows.map((r) => r.id),
|
||||
};
|
||||
});
|
||||
|
||||
handlers.publishMessagesDeleted(sessionId, deletedIds);
|
||||
handlers.enqueueInference(sessionId, newAssistantId, requireUser(req));
|
||||
handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
|
||||
handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return { assistant_message_id: newAssistantId };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/compact',
|
||||
async (req, reply) => {
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
const sessionId = chat.session_id;
|
||||
|
||||
const [compactMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, kind, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'system', '', 'compact', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
handlers.enqueueCompact(sessionId, chat.id, compactMsg!.id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return { compact_message_id: compactMsg!.id };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/stop',
|
||||
async (req, reply) => {
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id}
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
|
||||
const cancelled = await handlers.cancelInference(chat.session_id, chat.id);
|
||||
if (!cancelled) {
|
||||
reply.code(409);
|
||||
return { error: 'no active generation to stop' };
|
||||
}
|
||||
|
||||
reply.code(200);
|
||||
return { stopped: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/force_send',
|
||||
async (req, reply) => {
|
||||
const parsed = SendBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
const sessionId = chat.session_id;
|
||||
|
||||
// Await actual cancellation completion (catch block persists state).
|
||||
// 5s timeout guards against llama-swap stalls; if hit, proceed anyway.
|
||||
await Promise.race([
|
||||
handlers.cancelInference(sessionId, chat.id).then(() => undefined),
|
||||
new Promise<void>((_, rej) =>
|
||||
setTimeout(() => rej(new Error('cancel-timeout')), 5000)
|
||||
),
|
||||
]).catch((e: Error) => {
|
||||
if (e.message !== 'cancel-timeout') throw e;
|
||||
req.log.warn({ chatId: chat.id }, 'cancel timeout exceeded, proceeding with force-send');
|
||||
});
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
});
|
||||
|
||||
handlers.publishUserMessage(
|
||||
sessionId,
|
||||
chat.id,
|
||||
result.user_message_id,
|
||||
parsed.data.content
|
||||
);
|
||||
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user