This commit is contained in:
Timothy Jaeryang Baek 2026-10-10 12:35:44 +04:00
parent f6901e75c4
commit 5de5bf235b
4 changed files with 91 additions and 42 deletions

View file

@ -1743,18 +1743,24 @@ async def fork_chat_by_id(
):
await require_chat_import_permission(request, user, db)
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
chat = await Chats.get_accessible_chat_by_id(id, user, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
chat = ChatResponse.model_validate(await get_shared_chat_by_id(id, user=user, db=db))
if await has_active_tasks(request.app.state.redis, id):
is_snapshot = chat.id == chat.share_id
is_owner = chat.user_id == user.id
if not is_snapshot and await has_active_tasks(request.app.state.redis, chat.id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='Wait for the current response to finish before forking.',
)
history = (chat.chat or {}).get('history') or {}
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
# A share token grants access to its snapshot, not later messages in the original chat.
messages_map = history.get('messages') or {}
if not is_snapshot:
messages_map = await Chats.get_messages_map_by_chat_id(chat.id) or messages_map
source_message_id = (
(form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
@ -1792,6 +1798,7 @@ async def fork_chat_by_id(
updated_chat = {**(chat.chat or {})}
updated_chat.pop('currentId', None)
updated_chat.pop('share_mode', None)
updated_chat.update(
{
'originalChatId': chat.id,
@ -1801,14 +1808,16 @@ async def fork_chat_by_id(
'messages': fork_messages,
}
)
if not is_owner:
updated_chat = (await shared_chat_response(chat.model_copy(update={'chat': updated_chat}), user, db=db))['chat']
meta = {
**(chat.meta or {}),
**((chat.meta or {}) if is_owner else {}),
'forked_from': chat.id,
'forked_from_message_id': source_message_id,
}
# The source chat's folder may no longer be writable by the caller.
folder_id = chat.folder_id
folder_id = chat.folder_id if is_owner else None
if folder_id is not None and not await has_folder_write_access(user.id, folder_id, db=db):
folder_id = None
@ -1820,13 +1829,13 @@ async def fork_chat_by_id(
internal_meta=meta,
)
if fork and chat.variables:
if fork and is_owner and chat.variables:
fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
if not fork:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
if chat.pinned:
if is_owner and chat.pinned:
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
await publish_event(

View file

@ -4901,8 +4901,15 @@
{#if readOnly && chat?.chat?.share_mode !== 'continue'}
{#if canClone}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center bg-linear-to-t from-white dark:from-gray-900 to-transparent pb-5 pt-10"
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center gap-2 bg-linear-to-t from-white dark:from-gray-900 to-transparent pb-5 pt-10"
>
<button
class="pointer-events-auto rounded-full bg-black px-3.5 py-1.5 text-xs font-medium text-white transition hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200 disabled:opacity-50"
disabled={generating || !!taskIds?.length || !history.currentId}
on:click={() => handleForkChat()}
>
{$i18n.t('Fork chat')}
</button>
<button
class="pointer-events-auto rounded-full bg-black px-3.5 py-1.5 text-xs font-medium text-white transition hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200 disabled:opacity-50"
disabled={cloning}

View file

@ -1256,6 +1256,37 @@
</Tooltip>
{/if}
{#if $user && message.done && forkHandler && ($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true))}
<Tooltip content={$i18n.t('Fork chat')} placement="bottom">
<button
aria-label={$i18n.t('Fork chat')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} 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 (!readOnly && (!message.user_id || message.user_id === $user?.id)) || (shareMode === 'continue' && message.user_id === $user?.id)}
{#if !readOnly && !$temporaryChatEnabled && ($config?.features.enable_message_rating ?? true) && ($user?.role === 'admin' || ($user?.permissions?.chat?.rate_response ?? true))}
<Tooltip content={$i18n.t('Good Response')} placement="bottom">
@ -1530,37 +1561,6 @@
</Tooltip>
{/each}
{#if message.done && !readOnly && forkHandler && ($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true))}
<Tooltip content={$i18n.t('Fork chat')} placement="bottom">
<button
aria-label={$i18n.t('Fork chat')}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} 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 $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)}
{#if siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom">

View file

@ -9,7 +9,7 @@
import { settings, chatId, WEBUI_NAME, models, config, user as sessionUser } from '$lib/stores';
import { convertMessagesToHistory, createMessagesList } from '$lib/utils';
import { getChatByShareId, cloneSharedChatById } from '$lib/apis/chats';
import { getChatByShareId, cloneSharedChatById, forkChatById } from '$lib/apis/chats';
import Messages from '$lib/components/chat/Messages.svelte';
@ -21,6 +21,7 @@
dayjs.extend(localizedFormat);
let loaded = false;
let forking = false;
let autoScroll = true;
let processing = '';
@ -150,6 +151,30 @@
}
};
const forkSharedChat = async (messageId: string | null = null) => {
const shareId = $page.params.id;
if (!canClone || !shareId || forking) return;
forking = true;
const toastId = toast.loading($i18n.t('Forking chat...'));
try {
const result = await forkChatById(
localStorage.token,
shareId,
messageId ?? history.currentId
);
if (result?.id) {
await goto(`/c/${result.id}`);
toast.success($i18n.t('Chat forked'), { id: toastId });
} else {
toast.error($i18n.t('Failed to fork chat'), { id: toastId });
}
} catch (error) {
toast.error(`${error}`, { id: toastId });
} finally {
forking = false;
}
};
const cloneSharedChat = async () => {
if (!canClone) {
toast.error($i18n.t('Access prohibited'));
@ -224,6 +249,7 @@
{user}
chatId={$chatId}
readOnly={true}
forkHandler={canClone ? forkSharedChat : null}
{selectedModels}
{processing}
bind:history
@ -240,8 +266,15 @@
{#if canClone}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center bg-linear-to-t from-white dark:from-gray-900 to-transparent pb-5 pt-10"
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center gap-2 bg-linear-to-t from-white dark:from-gray-900 to-transparent pb-5 pt-10"
>
<button
class="pointer-events-auto rounded-full bg-black px-3.5 py-1.5 text-xs font-medium text-white transition hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200 disabled:opacity-50"
disabled={forking || !history.currentId}
on:click={() => forkSharedChat()}
>
{$i18n.t('Fork chat')}
</button>
<button
class="pointer-events-auto rounded-full bg-black px-3.5 py-1.5 text-xs font-medium text-white transition hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200 disabled:opacity-50"
on:click={cloneSharedChat}