feat: 聊天记录功能支持上传zip

This commit is contained in:
sylarchen1389 2025-11-22 15:14:58 +08:00
parent b4853a0673
commit e3222752dc
11 changed files with 308 additions and 597 deletions

View file

@ -6,7 +6,12 @@
"Bash(git rev-parse:*)", "Bash(git rev-parse:*)",
"Bash(chmod:*)", "Bash(chmod:*)",
"Bash(test:*)", "Bash(test:*)",
"Bash(lsof:*)" "Bash(lsof:*)",
"WebSearch",
"Bash(npm install:*)",
"Bash(ls:*)",
"Bash(npm run build:*)",
"Bash(cat:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
Open WebUI 是一个功能丰富的自托管 AI 平台,支持完全离线运行。核心技术栈: CyberLover 是一个功能丰富的自托管 AI 平台,支持完全离线运行。核心技术栈:
- **前端**: SvelteKit 4 + TypeScript + Vite 5 + Tailwind CSS 4 - **前端**: SvelteKit 4 + TypeScript + Vite 5 + Tailwind CSS 4
- **后端**: Python 3.11 + FastAPI + SQLAlchemy - **后端**: Python 3.11 + FastAPI + SQLAlchemy
- **部署**: Docker 多阶段构建,生产环境前后端同容器运行 - **部署**: Docker 多阶段构建,生产环境前后端同容器运行

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -2087,6 +2087,11 @@ async def healthcheck_with_db():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/favicon.png")
async def get_favicon():
return FileResponse(os.path.join(STATIC_DIR, "favicon.png"))
@app.get("/cache/{path:path}") @app.get("/cache/{path:path}")
async def serve_cache_file( async def serve_cache_file(
path: str, path: str,

View file

@ -1,6 +1,6 @@
{ {
"name": "Open WebUI", "name": "CyberLover",
"short_name": "WebUI", "short_name": "Lover",
"icons": [ "icons": [
{ {
"src": "/static/web-app-manifest-192x192.png", "src": "/static/web-app-manifest-192x192.png",

714
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,7 @@
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/jszip": "^3.4.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
"cypress": "^13.15.0", "cypress": "^13.15.0",
@ -107,6 +108,7 @@
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"jspdf": "^3.0.0", "jspdf": "^3.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.22", "katex": "^0.16.22",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@ -141,6 +143,7 @@
"vega-lite": "^6.4.1", "vega-lite": "^6.4.1",
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"y-prosemirror": "^1.3.7", "y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yaml": "^2.7.1", "yaml": "^2.7.1",
"yjs": "^13.6.27" "yjs": "^13.6.27"
}, },

View file

@ -20,6 +20,7 @@
getPinnedChatList getPinnedChatList
} from '$lib/apis/chats'; } from '$lib/apis/chats';
import { getImportOrigin, convertOpenAIChats } from '$lib/utils'; import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
import { extractChatsFromFile } from '$lib/utils/chatImport';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -41,22 +42,25 @@
$: if (importFiles) { $: if (importFiles) {
console.log(importFiles); console.log(importFiles);
let reader = new FileReader(); if (importFiles.length > 0) {
reader.onload = (event) => { extractChatsFromFile(importFiles[0])
let chats = JSON.parse(event.target.result); .then((chats) => {
console.log(chats); console.log(chats);
if (getImportOrigin(chats) == 'openai') { if (getImportOrigin(chats) == 'openai') {
try { try {
chats = convertOpenAIChats(chats); chats = convertOpenAIChats(chats);
} catch (error) { } catch (error) {
console.log('Unable to import chats:', error); console.log('Unable to import chats:', error);
toast.error($i18n.t('Failed to convert OpenAI chats'));
return;
} }
} }
importChats(chats); importChats(chats);
}; })
.catch((error) => {
if (importFiles.length > 0) { console.error('Import error:', error);
reader.readAsText(importFiles[0]); toast.error($i18n.t(error.message));
});
} }
} }
@ -134,7 +138,7 @@
bind:this={chatImportInputElement} bind:this={chatImportInputElement}
bind:files={importFiles} bind:files={importFiles}
type="file" type="file"
accept=".json" accept=".json,.zip"
hidden hidden
/> />
<button <button

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte'; import { getContext, createEventDispatcher, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -9,6 +10,7 @@
import Collapsible from './Collapsible.svelte'; import Collapsible from './Collapsible.svelte';
import Tooltip from './Tooltip.svelte'; import Tooltip from './Tooltip.svelte';
import Plus from '../icons/Plus.svelte'; import Plus from '../icons/Plus.svelte';
import { extractChatsFromFile } from '$lib/utils/chatImport';
export let open = true; export let open = true;
@ -48,26 +50,29 @@
// If dropped items aren't files, reject them // If dropped items aren't files, reject them
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile(); const file = item.getAsFile();
if (file && file.type === 'application/json') { const isJSON = file && file.type === 'application/json';
console.log('Dropped file is a JSON file!'); const isZIP =
file &&
(file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed' ||
file.name.endsWith('.zip'));
// Read the JSON file with FileReader if (file && (isJSON || isZIP)) {
const reader = new FileReader(); console.log('Dropped file is a JSON or ZIP file!');
reader.onload = async function (event) {
try { extractChatsFromFile(file)
const fileContent = JSON.parse(event.target.result); .then((fileContent) => {
console.log('Parsed JSON Content: ', fileContent); console.log('Parsed Content: ', fileContent);
open = true; open = true;
dispatch('import', fileContent); dispatch('import', fileContent);
} catch (error) { })
console.error('Error parsing JSON file:', error); .catch((error) => {
} console.error('Error processing file:', error);
}; toast.error($i18n.t(error.message));
});
// Start reading the file
reader.readAsText(file);
} else { } else {
console.error('Only JSON file types are supported.'); console.error('Only JSON and ZIP file types are supported.');
toast.error($i18n.t('Only JSON and ZIP file types are supported.'));
} }
} else { } else {
open = true; open = true;

View file

@ -63,6 +63,7 @@
import Note from '../icons/Note.svelte'; import Note from '../icons/Note.svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { getImportOrigin, convertOpenAIChats } from '$lib/utils'; import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
import { extractChatsFromFile } from '$lib/utils/chatImport';
const BREAKPOINT = 768; const BREAKPOINT = 768;
@ -358,22 +359,25 @@
$: if (importFiles) { $: if (importFiles) {
console.log(importFiles); console.log(importFiles);
let reader = new FileReader(); if (importFiles.length > 0) {
reader.onload = (event) => { extractChatsFromFile(importFiles[0])
let chats = JSON.parse(event.target.result); .then((chats) => {
console.log(chats); console.log(chats);
if (getImportOrigin(chats) == 'openai') { if (getImportOrigin(chats) == 'openai') {
try { try {
chats = convertOpenAIChats(chats); chats = convertOpenAIChats(chats);
} catch (error) { } catch (error) {
console.log('Unable to import chats:', error); console.log('Unable to import chats:', error);
toast.error($i18n.t('Failed to convert OpenAI chats'));
return;
} }
} }
importChatsHandler(chats); importChatsHandler(chats);
}; })
.catch((error) => {
if (importFiles.length > 0) { console.error('Import error:', error);
reader.readAsText(importFiles[0]); toast.error($i18n.t(error.message));
});
} }
} }
@ -1264,7 +1268,7 @@
bind:this={chatImportInputElement} bind:this={chatImportInputElement}
bind:files={importFiles} bind:files={importFiles}
type="file" type="file"
accept=".json" accept=".json,.zip"
hidden hidden
/> />

View file

@ -0,0 +1,61 @@
import JSZip from 'jszip';
/**
*
* JSON ZIP
*
* @param file - (JSON ZIP)
* @returns Promise<any> -
* @throws Error - ZIP JSON
*/
export async function extractChatsFromFile(file: File): Promise<any> {
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (fileExtension === 'json') {
// 直接读取 JSON 文件
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const chats = JSON.parse(content);
resolve(chats);
} catch (error) {
reject(new Error('Invalid JSON format'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
} else if (fileExtension === 'zip') {
// 解压 ZIP 并提取 conversations.json
try {
const zip = await JSZip.loadAsync(file);
const conversationsFile = zip.file('conversations.json');
if (!conversationsFile) {
throw new Error('conversations.json not found in ZIP archive');
}
const content = await conversationsFile.async('text');
const chats = JSON.parse(content);
return chats;
} catch (error) {
// 细化错误信息
if (error instanceof Error) {
if (error.message.includes('not found')) {
throw new Error('ZIP file must contain conversations.json in root directory');
} else if (error.message.includes('Unexpected token')) {
throw new Error('conversations.json contains invalid JSON format');
} else if (error.message.includes('corrupted')) {
throw new Error('ZIP file is corrupted or invalid');
} else {
throw new Error(`Failed to extract ZIP file: ${error.message}`);
}
}
throw new Error('Failed to process ZIP file');
}
} else {
throw new Error('Unsupported file format. Please upload .json or .zip file');
}
}