modified: src/lib/components/layout/ImportChatsModal.svelte

modified:   src/lib/components/layout/Sidebar.svelte
This commit is contained in:
xinyan 2025-12-09 22:12:37 +08:00
parent c6a9ba6e42
commit e34177ba69
2 changed files with 1171 additions and 1308 deletions

View file

@ -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>

View file

@ -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={() => {