Skip to content

Instantly share code, notes, and snippets.

@tomac4t
Created May 12, 2026 14:32
Show Gist options
  • Select an option

  • Save tomac4t/a5474567ee4cdeda350b5ec0e2d05028 to your computer and use it in GitHub Desktop.

Select an option

Save tomac4t/a5474567ee4cdeda350b5ec0e2d05028 to your computer and use it in GitHub Desktop.
DeepSeek Offline Chat Viewer
<!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 '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
if (m === '"') return '&quot;';
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