fix branching

This commit is contained in:
2026-09-05 21:02:03 +02:00
parent 7277dc5382
commit c0e3a6b693
5 changed files with 166 additions and 17 deletions
+30 -10
View File
@@ -475,20 +475,31 @@ async function editChatMessage(messageId, fallbackIndex = -1) {
const content = (nextContent || "").trim(); const content = (nextContent || "").trim();
if (!content) return; if (!content) return;
if (message.role === "user") { if (message.role === "user") {
const confirmed = await confirmPopup({
eyebrow: "Create branch",
title: "Create an edited chat branch?",
description: "This creates a new chat from the messages before this one and sends your edited message there. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed) return;
const sourceChatId = activeChatId;
setLoadingState(true, { setLoadingState(true, {
eyebrow: "Branching", eyebrow: "Branching",
title: "Editing and resending", title: "Editing and resending",
detail: "Creating a new branch from this point." detail: "Creating and opening a new chat branch."
}); });
try { try {
const res = await post(`/chats/${activeChatId}/messages/${message.id}/edit-resend`, { const res = await post(`/chats/${sourceChatId}/messages/${message.id}/edit-resend`, {
chatId: activeChatId, chatId: sourceChatId,
message: content, message: content,
model: modelSelect.value model: modelSelect.value,
confirmed: true
}); });
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
await setActiveChat(activeChatId); chatSessions = [res.chat, ...chatSessions.filter((chat) => chat.id !== res.chat.id)];
return setNotice("Created a new branch from the edited message.", "success"); renderChatList();
await setActiveChat(res.chat.id);
return setNotice("Created and opened the edited chat branch.", "success");
} finally { } finally {
setLoadingState(false); setLoadingState(false);
} }
@@ -591,12 +602,21 @@ async function switchRetryVariant(messageId, direction, fallbackIndex = -1) {
async function branchFromMessage(messageId) { async function branchFromMessage(messageId) {
if (!activeChatId || !messageId) return; if (!activeChatId || !messageId) return;
const res = await post(`/chats/${activeChatId}/branches/${messageId}/activate`, {}); const confirmed = await confirmPopup({
eyebrow: "Create branch",
title: "Create a new chat branch?",
description: "The new chat will contain the conversation through this message. The original chat stays unchanged.",
confirmLabel: "Create branch"
});
if (!confirmed) return;
const sourceChatId = activeChatId;
const res = await post(`/chats/${sourceChatId}/messages/${messageId}/branch`, {confirmed: true});
if (res.error) return setNotice(res.error, "error"); if (res.error) return setNotice(res.error, "error");
currentMessages = res.messages || []; chatSessions = [res.chat, ...chatSessions.filter((chat) => chat.id !== res.chat.id)];
renderMessages(); renderChatList();
await setActiveChat(res.chat.id);
msgInput.focus(); msgInput.focus();
setNotice("Branch point selected. Your next message continues from here.", "success"); setNotice("Created and opened a new chat branch.", "success");
} }
function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) { function addMessage(contentOrMessage, isUser = false, isLoading = false, options = {}) {
+14
View File
@@ -218,6 +218,19 @@ export function createRepositories(db, config) {
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
`), `),
getChatBranchThroughMessage: db.prepare(`
WITH RECURSIVE branch AS (
SELECT * FROM chat_messages WHERE chat_id = ? AND id = ?
UNION ALL
SELECT parent.* FROM chat_messages parent
JOIN branch child ON child.parent_message_id = parent.id
)
SELECT id, role, content, model_name, retry_variants, retry_active_index, retry_retries_used,
retry_prompt_message_id, delivery_status, error_message, parent_message_id, created_at
FROM branch
WHERE delivery_status = 'complete'
ORDER BY id
`),
getActiveBranchMessages: db.prepare(` getActiveBranchMessages: db.prepare(`
WITH RECURSIVE branch AS ( WITH RECURSIVE branch AS (
SELECT cm.* FROM chat_messages cm SELECT cm.* FROM chat_messages cm
@@ -946,6 +959,7 @@ export function createRepositories(db, config) {
deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username), deleteChat: (chatId, username) => statements.deleteChatSession.run(chatId, username),
listChatMessages: (chatId) => statements.getChatMessages.all(chatId), listChatMessages: (chatId) => statements.getChatMessages.all(chatId),
listActiveBranchMessages: (chatId) => statements.getActiveBranchMessages.all(chatId), listActiveBranchMessages: (chatId) => statements.getActiveBranchMessages.all(chatId),
listChatBranchThroughMessage: (chatId, messageId) => statements.getChatBranchThroughMessage.all(chatId, messageId),
listSiblingChatMessages: (chatId, role, parentMessageId) => statements.getSiblingChatMessages.all(chatId, role, parentMessageId ?? null), listSiblingChatMessages: (chatId, role, parentMessageId) => statements.getSiblingChatMessages.all(chatId, role, parentMessageId ?? null),
getLatestDescendantMessage: (chatId, messageId) => statements.getLatestDescendantMessage.get(chatId, messageId, chatId), getLatestDescendantMessage: (chatId, messageId) => statements.getLatestDescendantMessage.get(chatId, messageId, chatId),
getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit), getRecentChatMessages: (chatId) => statements.getRecentChatMessages.all(chatId, config.chatHistoryLimit),
+12
View File
@@ -191,6 +191,17 @@ export function createChatsRouter({repositories, chatService, modelService, conf
return res.json(result); return res.json(result);
}); });
router.post("/chats/:id/messages/:messageId/branch", (req, res) => {
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating a branch"});
const result = chatService.createBranchedChat({
user: req.session.user,
chatId: Number(req.params.id),
messageId: Number(req.params.messageId)
});
if (result.error) return res.status(result.status).json({error: result.error});
return res.json(result);
});
router.post("/chats/:id/stop", (req, res) => { router.post("/chats/:id/stop", (req, res) => {
const result = chatService.stopGeneration({user: req.session.user, chatId: Number(req.params.id)}); const result = chatService.stopGeneration({user: req.session.user, chatId: Number(req.params.id)});
if (result.error) return res.status(result.status).json({error: result.error}); if (result.error) return res.status(result.status).json({error: result.error});
@@ -198,6 +209,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf
}); });
router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), async (req, res) => { router.post("/chats/:id/messages/:messageId/edit-resend", validateBody(sendMessageValidator), async (req, res) => {
if (req.body?.confirmed !== true) return res.status(400).json({error: "Confirmation is required before creating an edited branch"});
const chatId = Number(req.params.id); const chatId = Number(req.params.id);
const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId)); const targetMessage = repositories.getChatMessage(chatId, Number(req.params.messageId));
if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"}); if (!repositories.getChat(chatId, req.session.user)) return res.status(404).json({error: "Chat not found"});
+90 -4
View File
@@ -372,6 +372,70 @@ export function createChatService(repositories, modelService, config) {
}; };
}); });
const cloneChatThroughMessage = ({user, sourceChat, messageId = null}) => {
if (repositories.countChats(user) >= config.chatLimit) {
return {error: `Maximum of ${config.chatLimit} chats reached. Please delete an old chat to create a new one.`, status: 400};
}
const sourceMessages = messageId == null
? []
: repositories.listChatBranchThroughMessage(sourceChat.id, messageId);
if (messageId != null && !sourceMessages.some((message) => message.id === messageId)) {
return {error: "Message not found", status: 404};
}
const sourceTitle = String(sourceChat.stored_title || sourceChat.title || "Chat").trim();
const clonedChat = repositories.createChat(
user,
`${sourceTitle} (branch)`,
sourceChat.assistant_persona_id ?? null,
sourceChat.user_persona_id ?? null,
sourceChat.scenario_prompt ?? null,
sourceChat.scenario_summary ?? null,
{folderName: sourceChat.folder_name ?? null}
);
try {
repositories.updateChatGenerationSettings(clonedChat.id, user, {
preferredModel: sourceChat.preferred_model ?? null,
temperature: sourceChat.temperature ?? null,
contextLength: sourceChat.context_length ?? null,
responseLength: sourceChat.response_length ?? null,
systemInstruction: sourceChat.system_instruction ?? null
});
let parentMessageId = null;
for (const sourceMessage of sourceMessages) {
const inserted = repositories.insertBranchChatMessage(
clonedChat.id,
sourceMessage.role,
sourceMessage.content,
{
retryVariants: [sourceMessage.content],
retryActiveIndex: 0,
retryRetriesUsed: 0,
retryPromptMessageId: null
},
sourceMessage.model_name ?? null,
"complete",
null,
parentMessageId
);
parentMessageId = Number(inserted.lastInsertRowid);
}
} catch (error) {
repositories.deleteChat(clonedChat.id, user);
throw error;
}
return {
chat: repositories.getChat(clonedChat.id, user),
messages: listActiveBranchMessages(clonedChat.id),
sourceChatId: sourceChat.id,
branchedFromMessageId: messageId
};
};
return { return {
formatChatMessage, formatChatMessage,
getChatMessageByIndex, getChatMessageByIndex,
@@ -427,15 +491,37 @@ export function createChatService(repositories, modelService, config) {
}, },
async editAndResendMessage({user, chatId, targetMessage, content, model, onChunk = null, signal = null}) { async editAndResendMessage({user, chatId, targetMessage, content, model, onChunk = null, signal = null}) {
if (!targetMessage || targetMessage.role !== "user") return {error: "Only user messages can be edited and resent", status: 400}; if (!targetMessage || targetMessage.role !== "user") return {error: "Only user messages can be edited and resent", status: 400};
return generateChatTurn({ const sourceChat = repositories.getChat(chatId, user);
if (!sourceChat) return {error: "Chat not found", status: 404};
const cloned = cloneChatThroughMessage({
user, user,
chatId, sourceChat,
messageId: targetMessage.parent_message_id ?? null
});
if (cloned.error) return cloned;
const generated = await generateChatTurn({
user,
chatId: cloned.chat.id,
message: content, message: content,
model, model,
onChunk, onChunk,
signal, signal
parentMessageId: targetMessage.parent_message_id ?? null
}); });
if (generated.error) return {...generated, chat: cloned.chat};
return {
...generated,
chat: repositories.getChat(cloned.chat.id, user),
messages: listActiveBranchMessages(cloned.chat.id),
sourceChatId: chatId,
branchedFromMessageId: targetMessage.id
};
},
createBranchedChat({user, chatId, messageId}) {
const sourceChat = repositories.getChat(chatId, user);
if (!sourceChat) return {error: "Chat not found", status: 404};
if (!repositories.getChatMessage(chatId, messageId)) return {error: "Message not found", status: 404};
return cloneChatThroughMessage({user, sourceChat, messageId});
}, },
activateBranch({user, chatId, messageId, includeDescendants = false}) { activateBranch({user, chatId, messageId, includeDescendants = false}) {
const session = repositories.getChat(chatId, user); const session = repositories.getChat(chatId, user);
+20 -3
View File
@@ -456,13 +456,30 @@ test("conversation branches, edit-resend, and generation settings work", async (
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar}); active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
assert.equal(active.json.messages.length, 4); assert.equal(active.json.messages.length, 4);
const unconfirmedBranch = await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/branch`, {
method: "POST", body: {}, jar
});
assert.equal(unconfirmedBranch.status, 400);
const branched = await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/branch`, {
method: "POST", body: {confirmed: true}, jar
});
assert.equal(branched.status, 200);
assert.notEqual(branched.json.chat.id, chatId);
assert.deepEqual(branched.json.messages.map((message) => message.content), ["first", "reply-1"]);
assert.equal(branched.json.chat.preferred_model, "preferred:model");
assert.equal(branched.json.chat.temperature, 0.4);
const edited = await request(server.baseUrl, `/chats/${chatId}/messages/${active.json.messages[0].id}/edit-resend`, { const edited = await request(server.baseUrl, `/chats/${chatId}/messages/${active.json.messages[0].id}/edit-resend`, {
method: "POST", body: {chatId, message: "edited first", model: "ignored:model"}, jar method: "POST", body: {chatId, message: "edited first", model: "ignored:model", confirmed: true}, jar
}); });
assert.equal(edited.status, 200); assert.equal(edited.status, 200);
assert.notEqual(edited.json.chat.id, chatId);
assert.notEqual(edited.json.chat.id, branched.json.chat.id);
assert.deepEqual(edited.json.messages.map((message) => message.content), ["edited first", "reply-4"]);
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar}); active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
assert.deepEqual(active.json.messages.map((message) => message.content), ["edited first", "reply-4"]); assert.deepEqual(active.json.messages.map((message) => message.content), ["first", "reply-1", "second", "reply-2"]);
assert.equal(active.json.messages[0].siblingCount, 2);
await server.close(); await server.close();
}); });