This commit is contained in:
Timothy Jaeryang Baek 2026-07-23 02:55:06 -04:00
parent cf887b68ea
commit 73421c5b42
8 changed files with 103 additions and 21 deletions

View file

@ -920,6 +920,47 @@ export const cloneChatById = async (token: string, id: string, title?: string) =
return res;
};
export const forkChatById = async (token: string, id: string, messageId?: string | null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/fork`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
message_id: messageId ?? null
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const cloneSharedChatById = async (token: string, id: string) => {
let error = null;

View file

@ -70,10 +70,10 @@
import {
archiveChatById,
cloneChatById,
compactChatById,
createNewChat,
deleteChatById,
forkChatById,
getAllTags,
getChatById,
getTagsById,
@ -1884,6 +1884,9 @@
(chatContent?.history ?? undefined) !== undefined
? chatContent.history
: convertMessagesToHistory(chatContent.messages);
if (chat?.current_message_id && history?.messages?.[chat.current_message_id]) {
history.currentId = chat.current_message_id;
}
// Sanitize history: repair orphaned references and structurally-malformed
// nodes from failed regenerations (#24424, #24157, #20474)
@ -2466,7 +2469,7 @@
document.getElementById('chat-input')?.focus();
};
const handleForkChat = async () => {
const handleForkChat = async (messageId: string | null = null) => {
if (!$chatId || !history?.currentId) {
toast.message($i18n.t('No chat to fork'));
return;
@ -2489,13 +2492,7 @@
const toastId = toast.loading($i18n.t('Forking chat...'));
try {
const result = await cloneChatById(
localStorage.token,
$chatId,
$i18n.t('Clone of {{TITLE}}', {
TITLE: $chatTitle || 'Chat'
})
);
const result = await forkChatById(localStorage.token, $chatId, messageId ?? history.currentId);
if (result?.id) {
if (!embedded) {
@ -3725,6 +3722,7 @@
{mergeResponses}
{chatActionHandler}
{addMessages}
forkHandler={generating || taskIds?.length ? null : handleForkChat}
topPadding={!embedded}
bottomPadding={files.length > 0}
{onSelect}

View file

@ -183,10 +183,10 @@
</button>
</Tooltip>
{:else if item.data.id === 'fork'}
<Tooltip content="Clone this chat into a new copy." placement="top">
<Tooltip content="Fork the current chat branch into a new chat." placement="top">
<button
type="button"
aria-label="Fork: clone this chat into a new copy."
aria-label="Fork: fork the current chat branch into a new chat."
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
{commandIdx === selectedIdx ? 'app-interactive-active' : ''} disabled:opacity-50"
disabled={forkDisabled}
@ -222,7 +222,7 @@
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Fork</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">Clone chat</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">Current branch</span>
</span>
</button>
</Tooltip>

View file

@ -40,6 +40,7 @@
export let showMessage: Function = () => {};
export let submitMessage: Function = () => {};
export let addMessages: Function = () => {};
export let forkHandler: Function | null = null;
export let readOnly = false;
export let editCodeBlock = true;
@ -539,6 +540,7 @@
{continueResponse}
{mergeResponses}
{addMessages}
{forkHandler}
{triggerScroll}
{readOnly}
{editCodeBlock}

View file

@ -39,6 +39,7 @@
export let mergeResponses;
export let addMessages;
export let forkHandler: Function | null = null;
export let triggerScroll;
export let readOnly = false;
export let editCodeBlock = true;
@ -97,6 +98,7 @@
{continueResponse}
{regenerateResponse}
{addMessages}
{forkHandler}
{readOnly}
{editCodeBlock}
{topPadding}
@ -122,6 +124,7 @@
{mergeResponses}
{triggerScroll}
{addMessages}
{forkHandler}
{readOnly}
{editCodeBlock}
{topPadding}

View file

@ -45,6 +45,7 @@
export let mergeResponses: Function;
export let addMessages: Function;
export let forkHandler: Function | null = null;
export let triggerScroll: Function;
@ -319,6 +320,7 @@
groupedMessageIds[selectedModelIdx].messageIds.length - 1;
}}
{addMessages}
{forkHandler}
{readOnly}
{topPadding}
{onInsertToNote}
@ -376,6 +378,7 @@
groupedMessageIds[modelIdx].messageIds.length - 1;
}}
{addMessages}
{forkHandler}
{readOnly}
{editCodeBlock}
{topPadding}

View file

@ -159,6 +159,7 @@
export let submitMessage: Function;
export let continueResponse: Function;
export let regenerateResponse: Function;
export let forkHandler: Function | null = null;
export let addMessages: Function;
@ -1054,6 +1055,37 @@
</button>
</Tooltip>
{#if message.done && !readOnly && forkHandler}
<Tooltip content="Fork chat" placement="bottom">
<button
aria-label="Fork chat"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
forkHandler?.(message.id);
}}
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
</svg>
</button>
</Tooltip>
{/if}
{#if onInsertToNote && visibleResponseContent}
<Tooltip content={$i18n.t('Insert into note')} placement="bottom">
<button

View file

@ -203,25 +203,28 @@ export const convertMessagesToHistory = (messages) => {
let messageId = null;
for (const message of messages) {
messageId = uuidv4();
if (parentMessageId !== null) {
history.messages[parentMessageId].childrenIds = [
...history.messages[parentMessageId].childrenIds,
messageId
];
}
messageId = message?.id ?? uuidv4();
const parentId = message?.parentId ?? parentMessageId;
history.messages[messageId] = {
...message,
id: messageId,
parentId: parentMessageId,
parentId,
childrenIds: []
};
parentMessageId = messageId;
}
for (const message of Object.values(history.messages)) {
if (message.parentId && history.messages[message.parentId]) {
history.messages[message.parentId].childrenIds = [
...history.messages[message.parentId].childrenIds,
message.id
];
}
}
history.currentId = messageId;
return history;
};