Files
veridian/server/api/topic/[topicId]/fork.post.ts
T
zoeissleeping 5aebd30808 fix: copy tool calls, generations, and timestamps when forking topics
Forked topics dropped toolCall/generation relations and rewrote part
lastUpdatedAt, which broke tool rendering, token stats, and reasoning durations.
2026-07-27 17:37:40 -05:00

275 lines
8.9 KiB
TypeScript

import * as z from 'zod';
import { messages, messageParts, attachments, topics, generations, toolCalls } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { nanoid } from 'nanoid';
import { eq, and, inArray } from 'drizzle-orm';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const topicId = getRouterParam(event, 'topicId')!;
const result = await readValidatedBody(event, (body) =>
z
.object({
messageId: z.string(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const { messageId } = result.data;
const topic = await db.select().from(topics).where(and(
eq(topics.id, topicId),
eq(topics.userId, userId),
)).limit(1).then(rows => rows[0]);
if (!topic) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'Topic not found',
});
}
const topicMessages = await db.select().from(messages)
.where(eq(messages.topicId, topicId))
.orderBy(messages.createdAt);
const forkMessage = topicMessages.find(m => m.id === messageId);
if (!forkMessage) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'Message not found in topic',
});
}
const childrenMap = new Map<string, typeof topicMessages>();
for (const msg of topicMessages) {
if (msg.parentMessageId) {
const siblings = childrenMap.get(msg.parentMessageId) || [];
siblings.push(msg);
childrenMap.set(msg.parentMessageId, siblings);
}
}
const messageIdsToCopy = new Set<string>();
const messageMap = new Map(topicMessages.map(m => [m.id, m]));
const addMessageAndSiblings = (msgId: string) => {
const msg = messageMap.get(msgId);
if (!msg) return;
if (msg.parentMessageId) {
const siblings = childrenMap.get(msg.parentMessageId) || [];
for (const sibling of siblings) {
messageIdsToCopy.add(sibling.id);
}
} else {
messageIdsToCopy.add(msgId);
}
};
let current: typeof forkMessage | undefined = forkMessage;
while (current) {
addMessageAndSiblings(current.id);
current = current.parentMessageId ? messageMap.get(current.parentMessageId) : undefined;
}
const forkIndex = topicMessages.findIndex(m => m.id === messageId);
for (let i = 0; i <= forkIndex; i++) {
const msg = topicMessages[i]!;
if (!msg.parentMessageId) {
messageIdsToCopy.add(msg.id);
const children = childrenMap.get(msg.id) || [];
for (const child of children) {
messageIdsToCopy.add(child.id);
}
}
}
const addDescendants = (msgId: string) => {
const children = childrenMap.get(msgId) || [];
for (const child of children) {
if (!messageIdsToCopy.has(child.id)) {
messageIdsToCopy.add(child.id);
addDescendants(child.id);
}
}
};
const initialIds = Array.from(messageIdsToCopy);
for (const id of initialIds) {
addDescendants(id);
}
const messagesToCopy = topicMessages
.filter(m => messageIdsToCopy.has(m.id))
.sort((a, b) => {
if (!a.parentMessageId && b.parentMessageId) return -1;
if (a.parentMessageId && !b.parentMessageId) return 1;
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
const newTopicId = nanoid();
const newTopicName = `${topic.name} (fork)`;
await db.insert(topics).values({
id: newTopicId,
userId,
name: newTopicName,
agentId: topic.agentId,
createdAt: new Date(),
});
const messageIdMap = new Map<string, string>();
const generationIdMap = new Map<string, string>();
const toolCallIdMap = new Map<string, string>();
const originalMessageIds = messagesToCopy.map(m => m.id);
const originalGenerationIds = [
...new Set(
messagesToCopy
.map(m => m.generationId)
.filter((id): id is string => id !== null),
),
];
// Copy generations first so messages can reference them
if (originalGenerationIds.length > 0) {
const generationsToCopy = await db.select().from(generations)
.where(inArray(generations.id, originalGenerationIds));
for (const generation of generationsToCopy) {
const newGenerationId = nanoid();
generationIdMap.set(generation.id, newGenerationId);
await db.insert(generations).values({
id: newGenerationId,
userId,
topicId: newTopicId,
modelId: generation.modelId,
status: generation.status,
tokens: generation.tokens,
error: generation.error,
});
}
}
for (const msg of messagesToCopy) {
const newMessageId = nanoid();
messageIdMap.set(msg.id, newMessageId);
await db.insert(messages).values({
id: newMessageId,
userId,
topicId: newTopicId,
parentMessageId: msg.parentMessageId ? (messageIdMap.get(msg.parentMessageId) ?? null) : null,
generationId: msg.generationId ? (generationIdMap.get(msg.generationId) ?? null) : null,
role: msg.role,
content: msg.content,
activeChildId: null,
deleted: msg.deleted,
createdAt: msg.createdAt,
updatedAt: msg.updatedAt,
});
}
for (const msg of messagesToCopy) {
if (msg.activeChildId) {
const newMessageId = messageIdMap.get(msg.id);
const newActiveChildId = messageIdMap.get(msg.activeChildId);
if (newMessageId && newActiveChildId) {
await db.update(messages)
.set({ activeChildId: newActiveChildId })
.where(eq(messages.id, newMessageId));
}
}
}
if (originalMessageIds.length > 0) {
const parts = await db.select().from(messageParts)
.where(inArray(messageParts.messageId, originalMessageIds));
// Copy referenced tool calls before parts so FKs resolve
const originalToolCallIds = [
...new Set(
parts
.map(p => p.toolCallId)
.filter((id): id is string => id !== null),
),
];
if (originalToolCallIds.length > 0) {
const toolCallsToCopy = await db.select().from(toolCalls)
.where(inArray(toolCalls.id, originalToolCallIds));
for (const toolCall of toolCallsToCopy) {
const newToolCallId = nanoid();
toolCallIdMap.set(toolCall.id, newToolCallId);
await db.insert(toolCalls).values({
id: newToolCallId,
userId,
toolName: toolCall.toolName,
status: toolCall.status,
input: toolCall.input,
output: toolCall.output,
error: toolCall.error,
createdAt: toolCall.createdAt,
});
}
}
for (const part of parts) {
const newMessageId = messageIdMap.get(part.messageId);
if (!newMessageId) continue;
await db.insert(messageParts).values({
userId,
topicId: newTopicId,
messageId: newMessageId,
toolCallId: part.toolCallId ? (toolCallIdMap.get(part.toolCallId) ?? null) : null,
type: part.type,
content: part.content,
providerOptions: part.providerOptions,
finished: part.finished,
// Preserve original timestamps so reasoning duration stays accurate
createdAt: part.createdAt,
lastUpdatedAt: part.lastUpdatedAt,
});
}
const attachmentsResult = await db.select().from(attachments)
.where(inArray(attachments.messageId, originalMessageIds));
for (const attachment of attachmentsResult) {
const newMessageId = messageIdMap.get(attachment.messageId);
if (!newMessageId) continue;
await db.insert(attachments).values({
userId,
topicId: newTopicId,
messageId: newMessageId,
fileId: attachment.fileId,
createdAt: attachment.createdAt,
});
}
}
return {
ok: true,
topicId: newTopicId,
name: newTopicName,
};
});