mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
modified: src/lib/components/layout/ImportChatsModal.svelte
modified: src/lib/components/layout/Sidebar.svelte
This commit is contained in:
parent
c6a9ba6e42
commit
e34177ba69
2 changed files with 1171 additions and 1308 deletions
|
|
@ -258,7 +258,7 @@
|
|||
|
||||
{#if rawChats.length > 0}
|
||||
<div class="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="font-mono">Total: {rawChats.length} | Selected: {selectedIndices.size}</span>
|
||||
<span class="font-mono">总计: {rawChats.length} | 已选: {selectedIndices.size}</span>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -337,7 +337,7 @@
|
|||
{#if importing}
|
||||
<Spinner className="size-4" />
|
||||
{/if}
|
||||
<span>导出并导入 ({selectedIndices.size})</span>
|
||||
<span>导入 ({selectedIndices.size})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -227,15 +227,122 @@
|
|||
chatListLoading = false;
|
||||
};
|
||||
|
||||
// Helper to convert OpenAI/DeepSeek "mapping" format to OpenWebUI format
|
||||
const convertLegacyChat = (convo) => {
|
||||
const mapping = convo['mapping'];
|
||||
const messages = {};
|
||||
let currentId = null;
|
||||
let lastId = null; // Fallback to track linear progression
|
||||
|
||||
for (const message_id in mapping) {
|
||||
const node = mapping[message_id];
|
||||
const message = node.message;
|
||||
|
||||
// Skip empty updates or null messages
|
||||
if (!message) continue;
|
||||
|
||||
let content = '';
|
||||
let role = message.author?.role === 'assistant' ? 'assistant' : 'user';
|
||||
|
||||
// Extract content based on format (OpenAI 'parts' or DeepSeek 'fragments'/'content')
|
||||
if (message.content) {
|
||||
if (Array.isArray(message.content.parts)) {
|
||||
// OpenAI format
|
||||
content = message.content.parts.join('');
|
||||
} else if (typeof message.content === 'string') {
|
||||
// Simple string format
|
||||
content = message.content;
|
||||
} else if (Array.isArray(message.fragments)) {
|
||||
// DeepSeek fragments format (if inside content)
|
||||
content = message.fragments.map(f => f.content).join('');
|
||||
}
|
||||
}
|
||||
// Check top-level fragments (DeepSeek sometimes puts fragments here)
|
||||
else if (Array.isArray(message.fragments)) {
|
||||
content = message.fragments.map(f => f.content).join('');
|
||||
}
|
||||
|
||||
// Fallback for role inference if missing
|
||||
if (role === 'user' && message.author?.role !== 'user' && message.author?.role !== 'system') {
|
||||
// DeepSeek sometimes uses 'author': { 'role': 'tool' } or others
|
||||
role = 'assistant';
|
||||
}
|
||||
|
||||
// Skip messages with no content unless strictly necessary (e.g. system instructions, though OpenWebUI handles those differently)
|
||||
if (!content && role !== 'system') continue;
|
||||
|
||||
const newChat = {
|
||||
id: message_id,
|
||||
parentId: node.parent || null,
|
||||
childrenIds: node.children || [],
|
||||
role: role,
|
||||
content: content,
|
||||
model: message.model || message.metadata?.model_slug || 'gpt-3.5-turbo',
|
||||
done: true,
|
||||
context: null,
|
||||
timestamp: message.create_time || convo.create_time || Date.now() / 1000
|
||||
};
|
||||
|
||||
messages[message_id] = newChat;
|
||||
|
||||
// Attempt to determine the "current" (latest) node
|
||||
// A node with no children is a candidate for being the leaf node
|
||||
if (!node.children || node.children.length === 0) {
|
||||
// If multiple branches exist, this simple logic takes the last one processed
|
||||
// In linear chats, this is correct.
|
||||
currentId = message_id;
|
||||
}
|
||||
lastId = message_id;
|
||||
}
|
||||
|
||||
return {
|
||||
history: {
|
||||
currentId: currentId || lastId,
|
||||
messages: messages
|
||||
},
|
||||
models: Object.values(messages).map(m => m.model).filter((v, i, a) => a.indexOf(v) === i),
|
||||
title: convo.title || 'New Chat',
|
||||
timestamp: convo.create_time || convo.inserted_at || Date.now() / 1000
|
||||
};
|
||||
};
|
||||
|
||||
const importChatHandler = async (items, pinned = false, folderId = null) => {
|
||||
console.log('importChatHandler', items, pinned, folderId);
|
||||
|
||||
for (const item of items) {
|
||||
console.log(item);
|
||||
if (item.chat) {
|
||||
let chatPayload = item.chat;
|
||||
let meta = item.meta || {};
|
||||
|
||||
// [NEW] Check if the item is a raw OpenAI/DeepSeek export (has 'mapping')
|
||||
// and convert it before processing
|
||||
if (item.mapping) {
|
||||
console.log("Detected OpenAI/DeepSeek Mapping format, converting...");
|
||||
try {
|
||||
chatPayload = convertLegacyChat(item);
|
||||
// Use title from the export if available
|
||||
if (item.title) {
|
||||
chatPayload.title = item.title;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Conversion failed", e);
|
||||
toast.error(`Format conversion failed for ${item.title || 'chat'}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// [NEW] Handle potential nested structure from DeepSeek JSON exports
|
||||
// (Where the item itself has 'chat' key but is an array element)
|
||||
else if (!chatPayload && item.history) {
|
||||
// If item IS the chat object (OpenWebUI export style)
|
||||
chatPayload = item;
|
||||
}
|
||||
|
||||
console.log("Importing:", chatPayload);
|
||||
|
||||
if (chatPayload) {
|
||||
await importChat(
|
||||
localStorage.token,
|
||||
item.chat,
|
||||
item?.meta ?? {},
|
||||
chatPayload,
|
||||
meta,
|
||||
pinned,
|
||||
folderId,
|
||||
item?.created_at ?? null,
|
||||
|
|
@ -257,7 +364,9 @@
|
|||
|
||||
try {
|
||||
const chatItems = JSON.parse(content);
|
||||
importChatHandler(chatItems);
|
||||
// Ensure we handle both single object and array imports
|
||||
const itemsToImport = Array.isArray(chatItems) ? chatItems : [chatItems];
|
||||
importChatHandler(itemsToImport);
|
||||
} catch {
|
||||
toast.error($i18n.t(`Invalid file format.`));
|
||||
}
|
||||
|
|
@ -358,6 +467,18 @@
|
|||
const importChatsHandler = async (_chats) => {
|
||||
let chatsToImport = _chats;
|
||||
|
||||
// [MODIFIED] Logic to intercept DeepSeek/OpenAI imports
|
||||
// Check if the input is in OpenAI/DeepSeek export format (has 'mapping')
|
||||
// and convert if necessary before passing to the standard import logic.
|
||||
if (Array.isArray(chatsToImport)) {
|
||||
chatsToImport = chatsToImport.map(item => {
|
||||
if (item.mapping) {
|
||||
return { chat: convertLegacyChat(item), meta: {} };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
const origin = getImportOrigin(chatsToImport);
|
||||
if (origin === 'deepseek') {
|
||||
try {
|
||||
|
|
@ -547,8 +668,6 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
||||
{#if $showSidebar}
|
||||
<div
|
||||
class=" {$isApp
|
||||
|
|
@ -659,87 +778,6 @@
|
|||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div class="">
|
||||
<Tooltip content={$i18n.t('Search')} placement="right">
|
||||
<button
|
||||
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
|
||||
on:click={(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
|
||||
showSearch.set(true);
|
||||
}}
|
||||
draggable="false"
|
||||
aria-label={$i18n.t('Search')}
|
||||
>
|
||||
<div class=" self-center flex items-center justify-center size-9">
|
||||
<Search className="size-4.5" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div> -->
|
||||
|
||||
<!-- {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
|
||||
<div class="">
|
||||
<Tooltip content={$i18n.t('Notes')} placement="right">
|
||||
<a
|
||||
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
|
||||
href="/notes"
|
||||
on:click={async (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
|
||||
goto('/notes');
|
||||
itemClickHandler();
|
||||
}}
|
||||
draggable="false"
|
||||
aria-label={$i18n.t('Notes')}
|
||||
>
|
||||
<div class=" self-center flex items-center justify-center size-9">
|
||||
<Note className="size-4.5" />
|
||||
</div>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if} -->
|
||||
|
||||
<!-- {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
|
||||
<div class="">
|
||||
<Tooltip content={$i18n.t('Workspace')} placement="right">
|
||||
<a
|
||||
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
|
||||
href="/workspace"
|
||||
on:click={async (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
|
||||
goto('/workspace');
|
||||
itemClickHandler();
|
||||
}}
|
||||
aria-label={$i18n.t('Workspace')}
|
||||
draggable="false"
|
||||
>
|
||||
<div class=" self-center flex items-center justify-center size-9">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if} -->
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
|
@ -874,26 +912,6 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<!-- <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<button
|
||||
id="sidebar-search-button"
|
||||
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
on:click={() => {
|
||||
showSearch.set(true);
|
||||
}}
|
||||
draggable="false"
|
||||
aria-label={$i18n.t('Search')}
|
||||
>
|
||||
<div class="self-center">
|
||||
<Search strokeWidth="2" className="size-4.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div> -->
|
||||
|
||||
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
id="sidebar-memory-button"
|
||||
|
|
@ -912,147 +930,12 @@
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
|
||||
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
id="sidebar-notes-button"
|
||||
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
href="/notes"
|
||||
on:click={itemClickHandler}
|
||||
draggable="false"
|
||||
aria-label={$i18n.t('Notes')}
|
||||
>
|
||||
<div class="self-center">
|
||||
<Note className="size-4.5" strokeWidth="2" />
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/if} -->
|
||||
|
||||
<!-- {#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
|
||||
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
id="sidebar-workspace-button"
|
||||
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
href="/workspace"
|
||||
on:click={itemClickHandler}
|
||||
draggable="false"
|
||||
aria-label={$i18n.t('Workspace')}
|
||||
>
|
||||
<div class="self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-4.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/if} -->
|
||||
</div>
|
||||
|
||||
{#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0}
|
||||
<PinnedModelList bind:selectedChatId {shiftKey} />
|
||||
{/if}
|
||||
|
||||
<!-- {#if $config?.features?.enable_channels && ($user?.role === 'admin' || $channels.length > 0)}
|
||||
<Folder
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Channels')}
|
||||
chevron={false}
|
||||
dragAndDrop={false}
|
||||
onAdd={async () => {
|
||||
if ($user?.role === 'admin') {
|
||||
await tick();
|
||||
|
||||
setTimeout(() => {
|
||||
showCreateChannel = true;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onAddLabel={$i18n.t('Create Channel')}
|
||||
>
|
||||
{#each $channels as channel}
|
||||
<ChannelItem
|
||||
{channel}
|
||||
onUpdate={async () => {
|
||||
await initChannels();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</Folder>
|
||||
{/if} -->
|
||||
|
||||
<!-- {#if folders}
|
||||
<Folder
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Folders')}
|
||||
chevron={false}
|
||||
onAdd={() => {
|
||||
showCreateFolderModal = true;
|
||||
}}
|
||||
onAddLabel={$i18n.t('New Folder')}
|
||||
on:drop={async (e) => {
|
||||
const { type, id, item } = e.detail;
|
||||
|
||||
if (type === 'folder') {
|
||||
if (folders[id].parent_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await updateFolderParentIdById(localStorage.token, id, null).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
await initFolders();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Folders
|
||||
bind:folderRegistry
|
||||
{folders}
|
||||
{shiftKey}
|
||||
onDelete={async (folderId) => {
|
||||
selectedFolder.set(null);
|
||||
await initChatList();
|
||||
}}
|
||||
on:update={async () => {
|
||||
await initChatList();
|
||||
}}
|
||||
on:import={(e) => {
|
||||
const { folderId, items } = e.detail;
|
||||
importChatHandler(items, false, folderId);
|
||||
}}
|
||||
on:change={async () => {
|
||||
await initChatList();
|
||||
}}
|
||||
/>
|
||||
</Folder>
|
||||
{/if} -->
|
||||
|
||||
<Folder
|
||||
className="px-2 mt-0.5"
|
||||
name={$i18n.t('Chats')}
|
||||
|
|
@ -1217,24 +1100,6 @@
|
|||
: 'pt-5'} pb-1.5"
|
||||
>
|
||||
{$i18n.t(chat.time_range)}
|
||||
<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
|
||||
{$i18n.t('Today')}
|
||||
{$i18n.t('Yesterday')}
|
||||
{$i18n.t('Previous 7 days')}
|
||||
{$i18n.t('Previous 30 days')}
|
||||
{$i18n.t('January')}
|
||||
{$i18n.t('February')}
|
||||
{$i18n.t('March')}
|
||||
{$i18n.t('April')}
|
||||
{$i18n.t('May')}
|
||||
{$i18n.t('June')}
|
||||
{$i18n.t('July')}
|
||||
{$i18n.t('August')}
|
||||
{$i18n.t('September')}
|
||||
{$i18n.t('October')}
|
||||
{$i18n.t('November')}
|
||||
{$i18n.t('December')}
|
||||
-->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -1289,7 +1154,6 @@
|
|||
</Folder>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<hr class="border-gray-100 dark:border-gray-850 mx-1.5 my-1.5" />
|
||||
|
||||
<div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 -mt-3 sidebar">
|
||||
|
|
@ -1297,7 +1161,6 @@
|
|||
class=" sidebar-bg-gradient-to-t bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mt-6"
|
||||
></div>
|
||||
|
||||
<!-- Import Chats Button -->
|
||||
<button
|
||||
class="flex w-full items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100/50 dark:hover:bg-gray-900 transition outline-none"
|
||||
on:click={() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue