Created
May 12, 2026 14:32
-
-
Save tomac4t/a5474567ee4cdeda350b5ec0e2d05028 to your computer and use it in GitHub Desktop.
DeepSeek Offline Chat Viewer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>DeepSeek 历史对话查看器 · 离线版</title> | |
| <!-- 使用 Font Awesome 图标 & 基础样式 --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"> | |
| <!-- Markdown 渲染 & 安全过滤 --> | |
| <script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/lib/marked.umd.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.5/purify.min.js"></script> | |
| <!-- JSZip 解压 zip --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | |
| } | |
| body { | |
| background: #f0f2f5; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* 顶部栏 */ | |
| .header { | |
| background: #1e1e2f; | |
| color: white; | |
| padding: 12px 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.15); | |
| flex-shrink: 0; | |
| } | |
| .logo { | |
| font-weight: 600; | |
| font-size: 1.4rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .logo i { | |
| color: #4b9eff; | |
| } | |
| .upload-area { | |
| flex: 1; | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| background: #2c2c3f; | |
| padding: 6px 12px; | |
| border-radius: 30px; | |
| max-width: 400px; | |
| } | |
| .upload-area label { | |
| cursor: pointer; | |
| color: #ccc; | |
| font-size: 0.9rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .upload-area label:hover { | |
| color: white; | |
| } | |
| #file-input { | |
| display: none; | |
| } | |
| #file-name { | |
| color: #aaa; | |
| font-size: 0.85rem; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .user-info { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| background: #2c2c3f; | |
| padding: 5px 15px; | |
| border-radius: 30px; | |
| font-size: 0.9rem; | |
| } | |
| .user-avatar { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| background: #3f3f5e; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #b0b0d0; | |
| overflow: hidden; | |
| } | |
| .user-avatar img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .user-avatar i { | |
| font-size: 1.6rem; | |
| } | |
| .user-detail { | |
| display: flex; | |
| flex-direction: column; | |
| line-height: 1.3; | |
| } | |
| .user-name { | |
| font-weight: 500; | |
| color: white; | |
| } | |
| .user-phone { | |
| font-size: 0.75rem; | |
| color: #aaa; | |
| } | |
| /* 主内容区 */ | |
| .main { | |
| display: flex; | |
| flex: 1; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| /* 左侧对话列表 */ | |
| .sidebar { | |
| width: 280px; | |
| background: white; | |
| border-right: 1px solid #e6e6e9; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| flex-shrink: 0; | |
| } | |
| .sidebar-header { | |
| padding: 16px 16px 8px; | |
| font-weight: 600; | |
| color: #1e1e2f; | |
| border-bottom: 1px solid #eee; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .conversation-count { | |
| background: #eef2f6; | |
| color: #2c3e50; | |
| font-size: 0.8rem; | |
| padding: 2px 8px; | |
| border-radius: 30px; | |
| } | |
| .conversation-list { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 8px 12px; | |
| } | |
| .conv-item { | |
| padding: 12px 14px; | |
| border-radius: 10px; | |
| margin-bottom: 6px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| border-left: 3px solid transparent; | |
| background: #f9fafc; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.02); | |
| } | |
| .conv-item:hover { | |
| background: #eef2f6; | |
| } | |
| .conv-item.active { | |
| background: #e6f0ff; | |
| border-left-color: #1e88e5; | |
| } | |
| .conv-title { | |
| font-weight: 500; | |
| font-size: 0.95rem; | |
| color: #1e293b; | |
| margin-bottom: 6px; | |
| word-break: break-word; | |
| display: -webkit-box; | |
| -webkit-line-clamp: 2; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| .conv-meta { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.7rem; | |
| color: #687385; | |
| } | |
| .conv-time i { | |
| margin-right: 3px; | |
| font-size: 0.65rem; | |
| } | |
| /* 右侧聊天区域 */ | |
| .chat-area { | |
| flex: 1; | |
| background: #ffffff; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| .chat-header { | |
| padding: 16px 24px; | |
| border-bottom: 1px solid #eceef2; | |
| background: #fafbfc; | |
| font-weight: 600; | |
| color: #1e293b; | |
| font-size: 1.1rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .chat-header i { | |
| color: #4b9eff; | |
| } | |
| .message-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 24px 20px; | |
| background: #f8fafd; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| } | |
| /* 消息气泡 */ | |
| .message { | |
| display: flex; | |
| gap: 14px; | |
| max-width: 85%; | |
| } | |
| .message.user { | |
| align-self: flex-end; | |
| flex-direction: row-reverse; | |
| } | |
| .message-avatar { | |
| width: 38px; | |
| height: 38px; | |
| border-radius: 50%; | |
| background: #d9e2ef; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| overflow: hidden; | |
| color: #2c3e50; | |
| font-size: 1.2rem; | |
| } | |
| .message-avatar img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .message-bubble { | |
| background: white; | |
| border-radius: 20px; | |
| padding: 14px 18px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.03); | |
| position: relative; | |
| word-wrap: break-word; | |
| max-width: calc(100% - 60px); | |
| } | |
| .message.user .message-bubble { | |
| background: #1e88e5; | |
| color: white; | |
| } | |
| .message-header { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 10px; | |
| margin-bottom: 6px; | |
| font-size: 0.8rem; | |
| color: #5e6f8d; | |
| } | |
| .message.user .message-header { | |
| color: #e0e0ff; | |
| justify-content: flex-end; | |
| } | |
| .sender-name { | |
| font-weight: 600; | |
| } | |
| .message-time { | |
| font-size: 0.65rem; | |
| opacity: 0.7; | |
| } | |
| .message-content { | |
| font-size: 0.95rem; | |
| line-height: 1.5; | |
| } | |
| .message.user .message-content { | |
| color: white; | |
| } | |
| .message-content p { | |
| margin: 0 0 8px 0; | |
| } | |
| .message-content p:last-child { | |
| margin-bottom: 0; | |
| } | |
| /* 思考片段特殊样式 */ | |
| .think-fragment { | |
| background: #f2f4f8; | |
| border-left: 3px solid #b3c7e5; | |
| padding: 10px 14px; | |
| margin: 8px 0; | |
| border-radius: 8px; | |
| font-style: italic; | |
| color: #2d3a4f; | |
| font-size: 0.9rem; | |
| } | |
| .message.user .think-fragment { | |
| background: rgba(255,255,255,0.15); | |
| border-left-color: #b3d0ff; | |
| color: #eef5ff; | |
| } | |
| .think-fragment::before { | |
| content: "🧠 思考"; | |
| display: block; | |
| font-weight: 600; | |
| font-style: normal; | |
| font-size: 0.75rem; | |
| margin-bottom: 4px; | |
| color: #4f6f9f; | |
| } | |
| .message.user .think-fragment::before { | |
| color: #c7d9ff; | |
| } | |
| /* 代码块基础 */ | |
| pre { | |
| background: #1e293b; | |
| color: #e2e8f0; | |
| padding: 12px; | |
| border-radius: 8px; | |
| overflow-x: auto; | |
| font-size: 0.85rem; | |
| } | |
| code { | |
| font-family: 'Fira Code', monospace; | |
| } | |
| .empty-state { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| color: #94a3b8; | |
| gap: 16px; | |
| } | |
| .empty-state i { | |
| font-size: 4rem; | |
| opacity: 0.3; | |
| } | |
| hr { | |
| margin: 10px 0; | |
| border: 0; | |
| border-top: 1px solid #e2e8f0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <div class="logo"><i class="fas fa-history"></i> DeepSeek 历史查看</div> | |
| <div class="upload-area"> | |
| <label for="file-input"><i class="fas fa-cloud-upload-alt"></i> 上传 deepseek_data-*.zip</label> | |
| <span id="file-name">未选择文件</span> | |
| </div> | |
| <div class="user-info" id="user-info-bar"> | |
| <div class="user-avatar" id="avatar-display"><i class="fas fa-user-circle"></i></div> | |
| <div class="user-detail" id="user-detail-display"> | |
| <span class="user-name">未登录用户</span> | |
| <span class="user-phone"></span> | |
| </div> | |
| </div> | |
| <input type="file" id="file-input" accept=".zip"> | |
| </div> | |
| <div class="main"> | |
| <!-- 左侧对话列表 --> | |
| <div class="sidebar"> | |
| <div class="sidebar-header"> | |
| <span><i class="far fa-comments" style="margin-right: 6px;"></i>对话历史</span> | |
| <span class="conversation-count" id="conv-count">0</span> | |
| </div> | |
| <div class="conversation-list" id="conversation-list"> | |
| <div class="empty-state" style="height: 100px; justify-content: center;"> | |
| <i class="far fa-folder-open"></i> 上传zip以加载对话 | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 右侧聊天区域 --> | |
| <div class="chat-area"> | |
| <div class="chat-header" id="chat-header"> | |
| <i class="far fa-comment-dots"></i> | |
| <span id="current-conv-title">选择一个对话</span> | |
| </div> | |
| <div class="message-container" id="message-container"> | |
| <div class="empty-state"> | |
| <i class="far fa-comments"></i> | |
| <p>左侧点击对话查看消息</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function() { | |
| // 全局存储解析后的数据 | |
| let userData = null; | |
| let conversations = []; // 原始对话数组 | |
| let processedConversations = []; // 每个对话包含 id, title, updated_at, messages (排序后) | |
| let currentConvId = null; | |
| // 获取 DOM 元素 | |
| const fileInput = document.getElementById('file-input'); | |
| const fileNameSpan = document.getElementById('file-name'); | |
| const userAvatarDiv = document.getElementById('avatar-display'); | |
| const userDetailSpan = document.getElementById('user-detail-display'); | |
| const convListDiv = document.getElementById('conversation-list'); | |
| const convCountSpan = document.getElementById('conv-count'); | |
| const chatHeader = document.getElementById('current-conv-title'); | |
| const msgContainer = document.getElementById('message-container'); | |
| // 辅助: 安全解析 JSON 字段 (如 profile_json) | |
| function safeJsonParse(str, def = {}) { | |
| if (!str || typeof str !== 'string') return def; | |
| try { | |
| return JSON.parse(str); | |
| } catch { | |
| return def; | |
| } | |
| } | |
| // 格式化时间 | |
| function formatTime(isoString) { | |
| if (!isoString) return ''; | |
| const d = new Date(isoString); | |
| return d.toLocaleString('zh-CN', { hour12: false, month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); | |
| } | |
| // 渲染用户信息 | |
| function renderUserInfo(user) { | |
| if (!user) return; | |
| let displayName = 'DeepSeek用户'; | |
| let avatarUrl = ''; | |
| let phoneStr = ''; | |
| // 从 oauth_profiles 中提取 | |
| if (user.oauth_profiles && user.oauth_profiles.length > 0) { | |
| const profile = user.oauth_profiles[0]; | |
| const profileData = safeJsonParse(profile.profile_json); | |
| if (profileData.name) displayName = profileData.name; | |
| if (profileData.picture) avatarUrl = profileData.picture; | |
| } | |
| // 手机号脱敏 | |
| if (user.mobile && user.mobile.mobile_number) { | |
| const m = user.mobile.mobile_number; | |
| phoneStr = m.length > 7 ? m.slice(0, 3) + '****' + m.slice(-4) : m; | |
| } | |
| // 更新头像区域 | |
| userAvatarDiv.innerHTML = avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : `<i class="fas fa-user-astronaut"></i>`; | |
| userDetailSpan.innerHTML = `<span class="user-name">${displayName}</span><span class="user-phone">${phoneStr}</span>`; | |
| } | |
| // 处理消息片段 -> 生成 HTML (带 think 样式,markdown渲染,XSS过滤) | |
| function renderFragments(fragments, isUser) { | |
| if (!Array.isArray(fragments)) return ''; | |
| let html = ''; | |
| fragments.forEach(frag => { | |
| if (!frag || typeof frag !== 'object' || !frag.type || !frag.content) return; // 过滤无效片段 | |
| const content = frag.content || ''; | |
| let contentHtml = ''; | |
| try { | |
| // 使用 marked 渲染 markdown,再用 purify 过滤 | |
| contentHtml = DOMPurify.sanitize(marked.parse(content)); | |
| } catch (e) { | |
| contentHtml = DOMPurify.sanitize(content); // 降级 | |
| } | |
| if (frag.type === 'THINK') { | |
| // 思考片段包裹特殊 class | |
| html += `<div class="think-fragment">${contentHtml}</div>`; | |
| } else { | |
| // REQUEST / RESPONSE 直接渲染,但包裹统一内容区 | |
| html += `<div class="fragment-${frag.type.toLowerCase()}">${contentHtml}</div>`; | |
| } | |
| }); | |
| return html; | |
| } | |
| // 从 mapping 中提取并排序消息 (按 inserted_at) | |
| function extractMessagesFromMapping(mapping) { | |
| if (!mapping || typeof mapping !== 'object') return []; | |
| const nodes = []; | |
| for (const [nodeId, node] of Object.entries(mapping)) { | |
| // 跳过没有 message 或 message 为 null 的节点 (如 root) | |
| if (!node.message || typeof node.message !== 'object') continue; | |
| // 确保有 fragments 数组 | |
| const fragments = Array.isArray(node.message.fragments) ? node.message.fragments : []; | |
| // 过滤掉 fragments 中异常项 (可能混入 root 对象) | |
| const validFragments = fragments.filter(f => f && typeof f === 'object' && f.type && typeof f.content === 'string'); | |
| if (validFragments.length === 0) continue; // 无有效内容的消息忽略 | |
| // 确定角色: 如果有 type=REQUEST 则是用户,否则助手 | |
| const hasRequest = validFragments.some(f => f.type === 'REQUEST'); | |
| const role = hasRequest ? 'user' : 'assistant'; | |
| // 提取模型 (如果是助手) | |
| const model = node.message.model || (role === 'assistant' ? 'deepseek-model' : ''); | |
| const insertedAt = node.message.inserted_at || new Date().toISOString(); | |
| nodes.push({ | |
| id: node.id, | |
| role, | |
| model, | |
| inserted_at: insertedAt, | |
| fragments: validFragments, | |
| rawNode: node, | |
| }); | |
| } | |
| // 按时间排序 (升序) | |
| nodes.sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : -1)); | |
| return nodes; | |
| } | |
| // 处理所有对话,生成便于展示的结构 | |
| function processConversations(convArray) { | |
| return convArray.map(conv => { | |
| const messages = extractMessagesFromMapping(conv.mapping); | |
| return { | |
| id: conv.id, | |
| title: conv.title || '无标题对话', | |
| updated_at: conv.updated_at || conv.inserted_at, | |
| messages: messages, | |
| }; | |
| }).sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1)); // 按最近更新降序 | |
| } | |
| // 渲染左侧对话列表 | |
| function renderConversationList(convs) { | |
| if (!convs || convs.length === 0) { | |
| convListDiv.innerHTML = `<div class="empty-state" style="height: 100px;"><i class="far fa-frown"></i> 暂无对话</div>`; | |
| convCountSpan.innerText = '0'; | |
| return; | |
| } | |
| let html = ''; | |
| convs.forEach(conv => { | |
| const activeClass = (currentConvId === conv.id) ? 'active' : ''; | |
| const timeStr = formatTime(conv.updated_at); | |
| html += ` | |
| <div class="conv-item ${activeClass}" data-conv-id="${conv.id}"> | |
| <div class="conv-title">${escapeHtml(conv.title)}</div> | |
| <div class="conv-meta"> | |
| <span class="conv-time"><i class="far fa-clock"></i> ${timeStr}</span> | |
| <span class="conv-msg-count">${conv.messages.length}条</span> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| convListDiv.innerHTML = html; | |
| convCountSpan.innerText = convs.length; | |
| // 绑定点击事件 | |
| document.querySelectorAll('.conv-item').forEach(el => { | |
| el.addEventListener('click', (e) => { | |
| const id = el.dataset.convId; | |
| if (id) showConversation(id); | |
| }); | |
| }); | |
| } | |
| // 简单的防XSS转义 (用于标题) | |
| function escapeHtml(unsafe) { | |
| if (!unsafe) return ''; | |
| return unsafe.replace(/[&<>"]/g, function(m) { | |
| if (m === '&') return '&'; | |
| if (m === '<') return '<'; | |
| if (m === '>') return '>'; | |
| if (m === '"') return '"'; | |
| return m; | |
| }); | |
| } | |
| // 显示特定对话 | |
| function showConversation(convId) { | |
| const conv = processedConversations.find(c => c.id === convId); | |
| if (!conv) return; | |
| currentConvId = convId; | |
| // 高亮左侧 | |
| document.querySelectorAll('.conv-item').forEach(el => el.classList.remove('active')); | |
| document.querySelector(`.conv-item[data-conv-id="${convId}"]`)?.classList.add('active'); | |
| // 更新头部标题 | |
| chatHeader.innerText = conv.title; | |
| // 渲染消息 | |
| if (!conv.messages || conv.messages.length === 0) { | |
| msgContainer.innerHTML = `<div class="empty-state"><i class="far fa-sad-tear"></i><p>该对话暂无消息</p></div>`; | |
| return; | |
| } | |
| let msgHtml = ''; | |
| conv.messages.forEach(msg => { | |
| const isUser = msg.role === 'user'; | |
| const senderName = isUser ? '我' : (msg.model || 'DeepSeek'); | |
| const timeStr = formatTime(msg.inserted_at); | |
| const avatarHtml = isUser ? (userAvatarDiv.innerHTML) : '<i class="fas fa-robot"></i>'; // 助手用机器人图标,后续可优化 | |
| // 渲染内容 | |
| const contentHtml = renderFragments(msg.fragments, isUser); | |
| msgHtml += ` | |
| <div class="message ${isUser ? 'user' : 'assistant'}"> | |
| <div class="message-avatar"> | |
| ${isUser ? userAvatarDiv.innerHTML : '<i class="fas fa-robot" style="color:#2b5f9e;"></i>'} | |
| </div> | |
| <div class="message-bubble"> | |
| <div class="message-header"> | |
| <span class="sender-name">${senderName}</span> | |
| <span class="message-time">${timeStr}</span> | |
| </div> | |
| <div class="message-content"> | |
| ${contentHtml} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| msgContainer.innerHTML = msgHtml; | |
| // 滚动到底部 | |
| msgContainer.scrollTop = msgContainer.scrollHeight; | |
| } | |
| // 处理上传的 zip | |
| async function handleZip(file) { | |
| try { | |
| const zip = await JSZip.loadAsync(file); | |
| // 读取 conversations.json 和 user.json | |
| const convFile = zip.file('conversations.json'); | |
| const userFile = zip.file('user.json'); | |
| if (!convFile || !userFile) { | |
| alert('ZIP 文件中缺少 conversations.json 或 user.json'); | |
| return; | |
| } | |
| const convText = await convFile.async('string'); | |
| const userText = await userFile.async('string'); | |
| conversations = JSON.parse(convText); | |
| userData = JSON.parse(userText); | |
| // 更新用户信息 | |
| renderUserInfo(userData); | |
| // 处理对话 | |
| processedConversations = processConversations(conversations); | |
| renderConversationList(processedConversations); | |
| // 如果之前有选中对话,清除 | |
| currentConvId = null; | |
| chatHeader.innerText = '选择一个对话'; | |
| msgContainer.innerHTML = `<div class="empty-state"><i class="far fa-comments"></i><p>左侧点击对话查看消息</p></div>`; | |
| fileNameSpan.innerText = file.name; | |
| } catch (e) { | |
| console.error(e); | |
| alert('解析失败,请检查zip文件内容格式是否正确。\n' + e.message); | |
| } | |
| } | |
| // 监听文件选择 | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| handleZip(file); | |
| }); | |
| // 初始化: 清空状态 | |
| window.onload = function() { | |
| fileNameSpan.innerText = '未选择文件'; | |
| }; | |
| })(); | |
| </script> | |
| <!-- 附加说明:若 marked 或 DOMPurify 加载失败可以回退,但 CDN 通常可靠 --> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment