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(chmod:*)",
"Bash(test:*)",
"Bash(lsof:*)"
"Bash(lsof:*)",
"WebSearch",
"Bash(npm install:*)",
"Bash(ls:*)",
"Bash(npm run build:*)",
"Bash(cat:*)"
],
"deny": [],
"ask": []

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Open WebUI 是一个功能丰富的自托管 AI 平台,支持完全离线运行。核心技术栈:
CyberLover 是一个功能丰富的自托管 AI 平台,支持完全离线运行。核心技术栈:
- **前端**: SvelteKit 4 + TypeScript + Vite 5 + Tailwind CSS 4
- **后端**: Python 3.11 + FastAPI + SQLAlchemy
- **部署**: 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.get("/favicon.png")
async def get_favicon():
return FileResponse(os.path.join(STATIC_DIR, "favicon.png"))
@app.get("/cache/{path:path}")
async def serve_cache_file(
path: str,

View file

@ -1,6 +1,6 @@
{
"name": "Open WebUI",
"short_name": "WebUI",
"name": "CyberLover",
"short_name": "Lover",
"icons": [
{
"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/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.13",
"@types/jszip": "^3.4.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"cypress": "^13.15.0",
@ -107,6 +108,7 @@
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"jspdf": "^3.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
"leaflet": "^1.9.4",
@ -141,6 +143,7 @@
"vega-lite": "^6.4.1",
"vite-plugin-static-copy": "^2.2.0",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yaml": "^2.7.1",
"yjs": "^13.6.27"
},

View file

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

View file

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

View file

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