├── icon.ico ├── main interface.png ├── requirements.txt ├── .gitignore ├── file_version_info.txt ├── Readme.md ├── export_cursor_chat.py └── export_cursor_chat_gui.py /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cranberrycrisp/Cursor-Chat-Exporter/HEAD/icon.ico -------------------------------------------------------------------------------- /main interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cranberrycrisp/Cursor-Chat-Exporter/HEAD/main interface.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6==6.7.1 2 | PyQt6-Qt6==6.7.3 3 | PyQt6_sip==13.9.0 4 | pyinstaller==6.11.1 5 | pyinstaller-hooks-contrib==2024.10 6 | altgraph==0.17.4 7 | packaging==24.2 8 | pefile==2023.2.7 9 | pywin32-ctypes==0.2.3 10 | colorama==0.4.6 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | develop-eggs/ 8 | dist/ 9 | downloads/ 10 | eggs/ 11 | .eggs/ 12 | lib/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | wheels/ 18 | *.egg-info/ 19 | .installed.cfg 20 | *.egg 21 | 22 | # PyInstaller 23 | build/ 24 | dist/ 25 | *.manifest 26 | *.spec 27 | 28 | # Virtual Environment 29 | venv/ 30 | env/ 31 | ENV/ 32 | .env 33 | .venv 34 | env.bak/ 35 | venv.bak/ 36 | 37 | # IDE 38 | .idea/ 39 | .vscode/ 40 | *.swp 41 | *.swo 42 | *~ 43 | .vs/ 44 | 45 | # Project specific 46 | cursor_chats/ 47 | cursor_chats_json/ 48 | *.log 49 | *.db 50 | *.sqlite3 51 | 52 | # Windows 53 | Thumbs.db 54 | ehthumbs.db 55 | Desktop.ini 56 | $RECYCLE.BIN/ 57 | 58 | # macOS 59 | .DS_Store 60 | .AppleDouble 61 | .LSOverride 62 | ._* 63 | 64 | # Linux 65 | .directory 66 | .Trash-* 67 | 68 | # Temp files 69 | *.bak 70 | *.tmp 71 | *.temp -------------------------------------------------------------------------------- /file_version_info.txt: -------------------------------------------------------------------------------- 1 | # file_version_info.txt 2 | VSVersionInfo( 3 | ffi=FixedFileInfo( 4 | filevers=(1, 0, 0, 0), 5 | prodvers=(1, 0, 0, 0), 6 | mask=0x3f, 7 | flags=0x0, 8 | OS=0x40004, 9 | fileType=0x1, 10 | subtype=0x0, 11 | date=(0, 0) 12 | ), 13 | kids=[ 14 | StringFileInfo( 15 | [ 16 | StringTable( 17 | u'080404b0', 18 | [StringStruct(u'CompanyName', u''), 19 | StringStruct(u'FileDescription', u'Cursor Chat Exporter'), 20 | StringStruct(u'FileVersion', u'1.0.0'), 21 | StringStruct(u'InternalName', u'cursor-chat-exporter'), 22 | StringStruct(u'LegalCopyright', u''), 23 | StringStruct(u'OriginalFilename', u'cursor-chat-exporter.exe'), 24 | StringStruct(u'ProductName', u'Cursor Chat Exporter'), 25 | StringStruct(u'ProductVersion', u'1.0.0')]) 26 | ]), 27 | VarFileInfo([VarStruct(u'Translation', [2052, 1200])]) 28 | ] 29 | ) -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Cursor Chat Exporter 2 | 3 | 一个简单的工具,用于导出 Cursor AI 的聊天记录到 Markdown 和 JSON 文件。(适用于0.43以前的版本) 4 | 5 | 只能导出旧版本创建的对话,新版本中创建的聊天记录无法导出,**谨慎降级**!!旧版本自动更新的,可以覆盖安装后使用。https://forum.cursor.com/t/0-42-5-build-links/30521/13 6 | 7 | `Cursor 0.42.5` 版本,来源:[0.42.5 Build Links - Cursor - Community Forum](https://forum.cursor.com/t/0-42-5-build-links/30521) 8 | 9 | Mac - https://downloader.cursor.sh/builds/24111460bf2loz1/mac/installer/universal 10 | 11 | Windows x64 - https://downloader.cursor.sh/builds/24111460bf2loz1/windows/nsis/x64 12 | 13 | Linux x64 - https://downloader.cursor.sh/builds/24111460bf2loz1/linux/appImage/x64 14 | 15 | 版本查看:`Help > About` 16 | 17 | 关闭自动更新:`文件 > 首选项 > 设置` 搜索框输入 update,`应用程序 > 更新` 关闭自动更新相关设置 18 | 19 | 程序会根据操作系统自动检测 Cursor 工作区存储位置: 20 | 21 | - Windows: `%APPDATA%\Cursor\User\workspaceStorage` 22 | - WSL2: `/mnt/c/Users//AppData/Roaming/Cursor/User/workspaceStorage` 23 | - macOS: `~/Library/Application Support/Cursor/User/workspaceStorage` 24 | - Linux: `~/.config/Cursor/User/workspaceStorage` 25 | 26 | 如果自动检测失败,可以在左上角 ⚙️ 中手动配置路径。 27 | 28 | ![Main Interface](https://raw.githubusercontent.com/Cranberrycrisp/Cursor-Chat-Exporter/refs/heads/main/main%20interface.png) 29 | 30 | ## 功能特点 31 | 32 | - 快速导出所有 Cursor AI 聊天记录到 Markdown 文件,可选择同时导出 JSON 格式 33 | - 文件名添加创建时间前缀(默认)。考虑到文件名重复会被覆盖,且聊天记录过多时可以根据文件名排序,可手动取消勾选。 34 | - 保留代码块和格式 35 | - 简洁的图形界面 36 | - 导出进度显示 37 | 38 | ## 下载使用 39 | 40 | ### 方式一:直接下载 41 | 42 | windows 从 [Releases](https://github.com/Cranberrycrisp/Cursor-Chat-Exporter/releases/) 页面下载最新版本的`exe`可执行文件。 43 | 44 | ### 方式二:python脚本运行 45 | 46 | 安装依赖 47 | 48 | ```bash 49 | # 1. 创建虚拟环境 50 | python -m venv venv 51 | 52 | # 2. 激活虚拟环境 53 | # Windows: 54 | venv\Scripts\activate 55 | # Linux/Mac: 56 | # source venv/bin/activate 57 | 58 | # 3. 安装依赖 59 | pip install -r requirements.txt 60 | 61 | ``` 62 | 63 | ``` 64 | # 命令行版本 65 | python export_cursor_chat.py 66 | 67 | # GUI版本 68 | python export_cursor_chat_gui.py 69 | ``` 70 | 71 | ### 方式三:自行打包 72 | 73 | 1. 克隆仓库 74 | 75 | ```bash 76 | git clone https://github.com/Cranberrycrisp/Cursor-Chat-Exporter.git 77 | cd cursor-chat-exporter 78 | ``` 79 | 80 | 2. 创建虚拟环境(推荐) 81 | 82 | ```bash 83 | python -m venv venv 84 | venv\Scripts\activate # Windows 85 | source venv/bin/activate # Linux/Mac 86 | ``` 87 | 88 | 3. 安装依赖 89 | 90 | ```bash 91 | pip install PyQt6 pyinstaller 92 | ``` 93 | 94 | 4. 打包程序 95 | 96 | ```bash 97 | pyinstaller cursor-chat-exporter-gui.spec 98 | 99 | # 打包命令行版本 100 | # pyinstaller cursor-chat-exporter.spec 101 | ``` 102 | 103 | 打包后的程序位于 `dist` 目录下。 104 | 105 | ## 使用说明 106 | 107 | 1. 运行程序 108 | 2. 选择是否同时导出 JSON 文件 109 | 3. 点击"开始导出" 110 | 4. 等待导出完成 111 | 5. 导出的文件将保存在程序所在目录的 `cursor_chats` 文件夹中 112 | 113 | ## 其他 114 | 115 | - `state.vscdb`文件可以 pip 安装 datasette,`pip install datasette` 运行 `datasette state.vscdb`,在浏览器 `http://localhost:8001/state?` 查看 116 | 117 | ## 开发环境 118 | 119 | - Python 3.11.10 (3.8+) 120 | - PyQt6 121 | - Windows 10/11 122 | 123 | ## 贡献 124 | 125 | 欢迎提交 Issue 和 Pull Request! 126 | 127 | ## 许可证 128 | 129 | [MIT License](https://github.com/thomas-pedersen/cursor-chat-browser/blob/main/LICENSE) 130 | -------------------------------------------------------------------------------- /export_cursor_chat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/12/7 09:42 3 | # @Author : flyrr 4 | # @File : /export_cursor_chat.py 5 | # @IDE : pycharm 6 | import os 7 | import json 8 | import re 9 | import sqlite3 10 | from pathlib import Path 11 | from datetime import datetime 12 | import sys 13 | import locale 14 | 15 | 16 | def supports_emoji(): 17 | """检查终端是否支持emoji""" 18 | return sys.stdout.encoding.lower() in ('utf-8', 'utf8') 19 | 20 | 21 | class Icons: 22 | """定义图标,根据终端支持情况使用emoji或ASCII字符""" 23 | 24 | def __init__(self): 25 | self.use_emoji = supports_emoji() 26 | 27 | # 定义图标映射 28 | self.icons = { 29 | 'success': '✅' if self.use_emoji else '[OK]', 30 | 'error': '❌' if self.use_emoji else '[ERROR]', 31 | 'folder': '📁' if self.use_emoji else '[DIR]', 32 | 'loading': '⏳' if self.use_emoji else '[...]', 33 | 'wave': '👋' if self.use_emoji else '[BYE]', 34 | 'info': 'ℹ️' if self.use_emoji else '[INFO]' 35 | } 36 | 37 | def get(self, name): 38 | """获取图标""" 39 | return self.icons.get(name, '') 40 | 41 | 42 | # 创建全局图标对象 43 | icons = Icons() 44 | 45 | 46 | def sanitize_filename(filename): 47 | """清理文件名,移除非法字符,只保留字母、数字、中文和基本标点""" 48 | filename = re.sub(r'[<>:"/\\|?*]', '', filename) 49 | return filename.strip() or 'untitled' 50 | 51 | 52 | def format_timestamp(timestamp): 53 | """将时间戳转换为易读的日期时间格式""" 54 | try: 55 | dt = datetime.fromtimestamp(timestamp / 1000) 56 | # return dt.strftime('%Y-%m-%d_%H-%M-%S') 57 | return dt.strftime('%Y-%m-%d_%H-%M') 58 | except Exception: 59 | return 'unknown_time' 60 | 61 | 62 | def print_banner(): 63 | """打印欢迎信息""" 64 | banner = f""" 65 | ╔══════════════════════════════════════════╗ 66 | ║ Cursor Chat Exporter v1.0 ║ 67 | ║ ║ 68 | ║ 导出您的 Cursor AI 聊天记录到MD/JSON文件 ║ 69 | ╚══════════════════════════════════════════╝ 70 | 71 | {icons.get('info')} 支持导出格式:Markdown 和 JSON 72 | """ 73 | print(banner) 74 | 75 | 76 | def get_user_choice(): 77 | """获取用户选择""" 78 | while True: 79 | print("\n请选择导出格式:") 80 | print("1. 仅导出 Markdown 文件 (默认)") 81 | print("2. 同时导出 Markdown 和 JSON 文件") 82 | print("3. 退出程序") 83 | 84 | choice = input("\n请输入选项 (1-3) [默认:1]: ").strip() or "1" 85 | 86 | if choice in ["1", "2", "3"]: 87 | return choice 88 | else: 89 | print(f"\n{icons.get('error')} 无效的选项,请重新选择") 90 | 91 | 92 | def export_cursor_chat(export_json=False): 93 | """ 94 | 导出 Cursor 聊天记录 95 | Args: 96 | export_json: 是否同时导出 JSON 文件,默认为 False 97 | """ 98 | try: 99 | # 获取用户主目录 100 | home = str(Path.home()) 101 | 102 | possible_paths = [ 103 | os.path.join(home, 'AppData/Roaming/Cursor/User/workspaceStorage'), # Windows路径 104 | os.path.join(home, '.config/Cursor/User/workspaceStorage'), # Linux路径 105 | os.path.join(home, 'Library/Application Support/Cursor/User/workspaceStorage'), 106 | ] 107 | 108 | # 找到第一个存在的路径 109 | workspace_path = None 110 | for path in possible_paths: 111 | if os.path.exists(path): 112 | workspace_path = path 113 | break 114 | 115 | if not workspace_path: 116 | print("找不到Cursor工作区目录") 117 | return 118 | 119 | chats = [] 120 | 121 | # 遍历所有工作区文件夹 122 | for workspace in os.listdir(workspace_path): 123 | db_path = os.path.join(workspace_path, workspace, 'state.vscdb') 124 | 125 | if not os.path.exists(db_path): 126 | continue 127 | 128 | # 连接数据库 129 | conn = sqlite3.connect(db_path) 130 | cursor = conn.cursor() 131 | 132 | # 获取所有需要的数据 133 | cursor.execute(""" 134 | SELECT [key], value 135 | FROM ItemTable 136 | WHERE [key] IN ( 137 | 'workbench.panel.aichat.view.aichat.chatdata', 138 | 'composer.composerData' 139 | ) 140 | """) 141 | 142 | for row in cursor.fetchall(): 143 | key, value = row 144 | try: 145 | data = json.loads(value) 146 | chats.append({ 147 | 'workspace': workspace, 148 | 'type': key, 149 | 'data': data 150 | }) 151 | except Exception as e: 152 | continue 153 | 154 | conn.close() 155 | 156 | if not chats: 157 | print("没有找到任何聊天记录") 158 | return 159 | 160 | # 创建输出目录 161 | md_output_dir = 'cursor_chats' 162 | os.makedirs(md_output_dir, exist_ok=True) 163 | 164 | if export_json: 165 | json_output_dir = 'cursor_chats_json' 166 | os.makedirs(json_output_dir, exist_ok=True) 167 | 168 | # 记录统计信息 169 | total_chats = len(chats) 170 | total_tabs = 0 171 | md_count = 0 172 | json_count = 0 173 | 174 | # 遍历并导出每个对话 175 | for chat in chats: 176 | if chat['type'] == 'workbench.panel.aichat.view.aichat.chatdata': 177 | data = chat['data'] 178 | if 'tabs' in data: 179 | total_tabs += len(data['tabs']) 180 | for tab in data['tabs']: 181 | # 获取对话标题和时间戳 182 | title = tab.get('chatTitle', '') 183 | timestamp = tab.get('lastSendTime', 0) 184 | if not title: 185 | title = f"{format_timestamp(timestamp)}_Untitled_Chat" if timestamp else "Untitled_Chat" 186 | 187 | # 清理文件名 188 | safe_title = sanitize_filename(title) 189 | time_str = format_timestamp(timestamp) 190 | 191 | # 导出 Markdown 文件 192 | md_filename = f"{time_str}_{safe_title}.md" 193 | md_path = os.path.join(md_output_dir, md_filename) 194 | 195 | with open(md_path, 'w', encoding='utf-8') as f: 196 | # 写入标题 197 | f.write(f"# {title}\n\n") 198 | 199 | # 写入工作区信息 200 | f.write(f"Workspace: `{chat['workspace']}`\n\n") 201 | 202 | # 写入时间信息 203 | if timestamp: 204 | f.write(f"Last Updated: {timestamp}\n\n") 205 | 206 | # 写入对话内容 207 | if 'bubbles' in tab: 208 | for bubble in tab['bubbles']: 209 | # 用户消息 210 | if bubble.get('type') == 'user': 211 | if 'text' in bubble: 212 | f.write(f"## User\n\n{bubble['text']}\n\n") 213 | 214 | # 添加代码选择 215 | if bubble.get('selections'): 216 | f.write("Selected code:\n") 217 | for selection in bubble['selections']: 218 | f.write(f"```{selection.get('uri', {}).get('path', '')}\n") 219 | f.write(f"{selection.get('text', '')}\n```\n\n") 220 | 221 | # AI消息 222 | elif bubble.get('type') == 'ai': 223 | if 'text' in bubble: 224 | f.write(f"## Assistant\n\n{bubble['text']}\n\n") 225 | 226 | # 添加代码块 227 | if bubble.get('codeBlocks'): 228 | for code_block in bubble['codeBlocks']: 229 | f.write(f"```{code_block.get('language', '')}\n") 230 | f.write(f"{code_block.get('code', '')}\n```\n\n") 231 | 232 | # 可选:导出 JSON 文件 233 | if export_json: 234 | json_filename = f"{time_str}_{safe_title}.json" 235 | json_path = os.path.join(json_output_dir, json_filename) 236 | 237 | # 创建单个对话的 JSON 数据 238 | chat_data = { 239 | 'workspace': chat['workspace'], 240 | 'title': title, 241 | 'lastSendTime': timestamp, 242 | 'bubbles': tab.get('bubbles', []) 243 | } 244 | 245 | with open(json_path, 'w', encoding='utf-8') as f: 246 | json.dump(chat_data, f, ensure_ascii=False, indent=2) 247 | json_count += 1 248 | 249 | md_count += 1 250 | 251 | # 关闭数据库连接 252 | conn.close() 253 | 254 | # 输出统计信息 255 | print(f"\n{icons.get('success')} 导出完成!") 256 | print(f"- 找到 {total_chats} 个聊天记录") 257 | print(f"- 包含 {total_tabs} 个对话标签页") 258 | print(f"{icons.get('folder')} Markdown文件位置: {os.path.abspath(md_output_dir)} ({md_count} 个)") 259 | if export_json: 260 | print(f"{icons.get('folder')} JSON文件位置: {os.path.abspath(json_output_dir)} ({json_count} 个)") 261 | 262 | except Exception as e: 263 | print(f"\n{icons.get('error')} 发生错误: {e}") 264 | return False 265 | return True 266 | 267 | 268 | def main(): 269 | """主函数""" 270 | print_banner() 271 | 272 | while True: 273 | choice = get_user_choice() 274 | 275 | if choice == "3": 276 | print(f"\n{icons.get('wave')} 感谢使用,再见!") 277 | break 278 | 279 | export_json = (choice == "2") 280 | 281 | print(f"\n{icons.get('loading')} 正在导出聊天记录...") 282 | success = export_cursor_chat(export_json) 283 | 284 | if success: 285 | print("\n是否继续导出? (y/n) [默认:n]: ", end="") 286 | if input().lower() != 'y': 287 | print(f"\n{icons.get('wave')} 感谢使用,再见!") 288 | break 289 | else: 290 | print("\n是否重试? (y/n) [默认:y]: ", end="") 291 | if input().lower() == 'n': 292 | print(f"\n{icons.get('wave')} 感谢使用,再见!") 293 | break 294 | 295 | # 添加暂停,让用户看到最后的消息 296 | print("\n按任意键退出...", end="") 297 | input() 298 | 299 | 300 | if __name__ == "__main__": 301 | # 默认只导出 Markdown 文件 302 | # export_cursor_chat() 303 | 304 | # 如果需要同时导出 JSON,可以这样调用: 305 | # export_cursor_chat(export_json=True) 306 | 307 | # 交互式使用 308 | main() 309 | # 打包命令行版本 310 | # pip install pyinstaller 311 | # pyinstaller cursor-chat-exporter.spec 312 | -------------------------------------------------------------------------------- /export_cursor_chat_gui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Time : 2024/12/7 09:42 3 | # @Author : flyrr 4 | # @File : /export_cursor_chat_gui.py 5 | # @IDE : pycharm 6 | import os 7 | import json 8 | import re 9 | import sqlite3 10 | from pathlib import Path 11 | from datetime import datetime 12 | from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, 13 | QHBoxLayout, QPushButton, QLabel, QCheckBox, 14 | QTextEdit, QProgressBar, QMessageBox, QFileDialog, 15 | QFrame, QDialog, QLineEdit) 16 | from PyQt6.QtCore import Qt, QThread, pyqtSignal 17 | from PyQt6.QtGui import QFont 18 | 19 | import sys 20 | import platform 21 | 22 | 23 | def sanitize_filename(filename): 24 | """清理文件名,移除非法字符,限制长度""" 25 | # 移除换行符和多余空格 26 | filename = ' '.join(filename.split()) 27 | 28 | # 移除非法字符 29 | filename = re.sub(r'[<>:"/\\|?*\n\r]', '', filename) 30 | 31 | # 限制文件名长度(不包括扩展名)为50个字符 32 | filename = filename[:50] 33 | 34 | # 确保文件名不为空 35 | return filename.strip() or 'untitled' 36 | 37 | 38 | def format_timestamp(timestamp): 39 | """将时间戳转换为易读的日期时间格式""" 40 | try: 41 | dt = datetime.fromtimestamp(timestamp / 1000) 42 | # return dt.strftime('%Y-%m-%d_%H-%M-%S') 43 | return dt.strftime('%Y-%m-%d_%H-%M') 44 | except Exception: 45 | return 'unknown_time' 46 | 47 | 48 | class PathConfigDialog(QDialog): 49 | """路径配置对话框""" 50 | 51 | def __init__(self, current_path, parent=None): 52 | super().__init__(parent) 53 | self.setWindowTitle('工作区路径配置 ⚙️') 54 | self.setFixedSize(500, 150) 55 | 56 | layout = QVBoxLayout() 57 | 58 | # 说明文字 59 | desc = QLabel('请选择 Cursor 工作区存储路径:') 60 | layout.addWidget(desc) 61 | 62 | # 路径输入框和浏览按钮 63 | path_layout = QHBoxLayout() 64 | self.path_edit = QLineEdit(current_path) 65 | path_layout.addWidget(self.path_edit) 66 | 67 | browse_btn = QPushButton('浏览...') 68 | browse_btn.clicked.connect(self.browse_path) 69 | path_layout.addWidget(browse_btn) 70 | 71 | layout.addLayout(path_layout) 72 | 73 | # 确定取消按钮 74 | btn_layout = QHBoxLayout() 75 | ok_btn = QPushButton('确定') 76 | ok_btn.clicked.connect(self.accept) 77 | cancel_btn = QPushButton('取消') 78 | cancel_btn.clicked.connect(self.reject) 79 | btn_layout.addWidget(ok_btn) 80 | btn_layout.addWidget(cancel_btn) 81 | 82 | layout.addLayout(btn_layout) 83 | self.setLayout(layout) 84 | 85 | def browse_path(self): 86 | """浏览文件夹""" 87 | path = QFileDialog.getExistingDirectory(self, '选择工作区目录') 88 | if path: 89 | self.path_edit.setText(path) 90 | 91 | def get_path(self): 92 | """获取选择的路径""" 93 | return self.path_edit.text() 94 | 95 | 96 | class ExportWorker(QThread): 97 | """后台导出线程""" 98 | progress = pyqtSignal(str) # 进度信号 99 | finished = pyqtSignal(bool, str) # 完成信号:(是否成功, 消息) 100 | 101 | def __init__(self, workspace_path, export_json=False, include_timestamp=True): 102 | super().__init__() 103 | self.workspace_path = workspace_path 104 | self.export_json = export_json 105 | self.include_timestamp = include_timestamp 106 | 107 | def run(self): 108 | try: 109 | if not os.path.exists(self.workspace_path): 110 | self.finished.emit(False, "工作区路径不存在") 111 | return 112 | 113 | chats = [] 114 | workspace_path = self.workspace_path 115 | 116 | # 遍历所有工作区文件夹 117 | for workspace in os.listdir(workspace_path): 118 | db_path = os.path.join(workspace_path, workspace, 'state.vscdb') 119 | 120 | if not os.path.exists(db_path): 121 | continue 122 | 123 | # 连接数据库 124 | conn = sqlite3.connect(db_path) 125 | cursor = conn.cursor() 126 | 127 | # 获取所有需要的数据 128 | cursor.execute(""" 129 | SELECT [key], value 130 | FROM ItemTable 131 | WHERE [key] IN ( 132 | 'workbench.panel.aichat.view.aichat.chatdata', 133 | 'composer.composerData' 134 | ) 135 | """) 136 | 137 | for row in cursor.fetchall(): 138 | key, value = row 139 | try: 140 | data = json.loads(value) 141 | chats.append({ 142 | 'workspace': workspace, 143 | 'type': key, 144 | 'data': data 145 | }) 146 | except Exception as e: 147 | continue 148 | 149 | conn.close() 150 | 151 | if not chats: 152 | self.finished.emit(False, "没有找到任何聊天记录") 153 | return 154 | 155 | self.progress.emit("🔍 开始导出...") 156 | 157 | # 创建输出目录 158 | md_output_dir = 'cursor_chats' 159 | os.makedirs(md_output_dir, exist_ok=True) 160 | 161 | if self.export_json: 162 | json_output_dir = 'cursor_chats_json' 163 | os.makedirs(json_output_dir, exist_ok=True) 164 | 165 | # 记录统计信息 166 | total_chats = len(chats) 167 | total_tabs = 0 168 | md_count = 0 169 | json_count = 0 170 | # 遍历并导出每个对话 171 | for chat in chats: 172 | if chat['type'] == 'workbench.panel.aichat.view.aichat.chatdata': 173 | data = chat['data'] 174 | if 'tabs' in data: 175 | total_tabs += len(data['tabs']) 176 | for tab in data['tabs']: 177 | # 获取对话标题和时间戳 178 | title = tab.get('chatTitle', '') 179 | timestamp = tab.get('lastSendTime', 0) 180 | time_str = format_timestamp(timestamp) 181 | 182 | if not title: 183 | title = f"Chat_{time_str}" 184 | 185 | # 清理文件名 186 | safe_title = sanitize_filename(title) 187 | 188 | # 获取时间戳字符串 189 | time_str = format_timestamp(timestamp) if self.include_timestamp else "" 190 | 191 | # 处理 Markdown 文件名 192 | md_filename = (f"{time_str}_{safe_title}.md" if time_str 193 | else f"{safe_title}.md") 194 | md_path = os.path.join(md_output_dir, md_filename) 195 | 196 | # 导出 Markdown 文件 197 | with open(md_path, 'w', encoding='utf-8') as f: 198 | # 写入标题 199 | f.write(f"# {title}\n\n") 200 | 201 | # 写入工作区信息 202 | f.write(f"Workspace: `{chat['workspace']}`\n\n") 203 | 204 | # 写入时间信息 205 | if timestamp: 206 | f.write(f"Last Updated: {timestamp}\n\n") 207 | 208 | # 写入对话内容 209 | if 'bubbles' in tab: 210 | for bubble in tab['bubbles']: 211 | # 用户消息 212 | if bubble.get('type') == 'user': 213 | if 'text' in bubble: 214 | f.write(f"## User\n\n{bubble['text']}\n\n") 215 | 216 | # 添加代码选择 217 | if bubble.get('selections'): 218 | f.write("Selected code:\n") 219 | for selection in bubble['selections']: 220 | f.write(f"```{selection.get('uri', {}).get('path', '')}\n") 221 | f.write(f"{selection.get('text', '')}\n```\n\n") 222 | 223 | # AI消息 224 | elif bubble.get('type') == 'ai': 225 | if 'text' in bubble: 226 | f.write(f"## Assistant\n\n{bubble['text']}\n\n") 227 | 228 | # 添加代码块 229 | if bubble.get('codeBlocks'): 230 | for code_block in bubble['codeBlocks']: 231 | f.write(f"```{code_block.get('language', '')}\n") 232 | f.write(f"{code_block.get('code', '')}\n```\n\n") 233 | 234 | # 可选:导出 JSON 文件 235 | if self.export_json: 236 | json_filename = (f"{time_str}_{safe_title}.json" if time_str 237 | else f"{safe_title}.json") 238 | json_path = os.path.join(json_output_dir, json_filename) 239 | 240 | chat_data = { 241 | 'workspace': chat['workspace'], 242 | 'title': title, 243 | 'lastSendTime': timestamp, 244 | 'bubbles': tab.get('bubbles', []) 245 | } 246 | 247 | with open(json_path, 'w', encoding='utf-8') as f: 248 | json.dump(chat_data, f, ensure_ascii=False, indent=2) 249 | json_count += 1 250 | 251 | md_count += 1 252 | self.progress.emit(f"📝 已导出: {md_count} 个文件...") 253 | 254 | success_msg = f"✨ 导出完成!\n📊 共导出 {md_count} 个 Markdown 文件\n📂 位置: {os.path.abspath(md_output_dir)}" 255 | if self.export_json: 256 | success_msg += f"\n📊 同时导出 {json_count} 个 JSON 文件\n📂 位置: {os.path.abspath(json_output_dir)}" 257 | 258 | self.finished.emit(True, success_msg) 259 | 260 | except Exception as e: 261 | self.finished.emit(False, f"⚠️ 发生错误: {str(e)}") 262 | 263 | 264 | class MainWindow(QMainWindow): 265 | def __init__(self): 266 | super().__init__() 267 | self.workspace_path = self.get_default_workspace_path() 268 | self.initUI() 269 | 270 | def get_default_workspace_path(self): 271 | """获取默认工作区路径""" 272 | system = platform.system() 273 | home = str(Path.home()) 274 | 275 | # 检测操作系统类型 276 | if system == 'Windows': 277 | path = os.path.join(os.getenv('APPDATA'), 'Cursor', 'User', 'workspaceStorage') 278 | elif system == 'Darwin': # macOS 279 | path = os.path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage') 280 | elif system == 'Linux': 281 | # 检查是否是 WSL 282 | if 'microsoft' in platform.uname().release.lower(): 283 | # WSL2 路径 284 | windows_home = os.path.join('/mnt/c/Users', os.getenv('USER')) 285 | path = os.path.join(windows_home, 'AppData/Roaming/Cursor/User/workspaceStorage') 286 | else: 287 | # 普通 Linux 路径 288 | path = os.path.join(home, '.config', 'Cursor', 'User', 'workspaceStorage') 289 | else: 290 | path = '' 291 | 292 | return path if os.path.exists(path) else '' 293 | 294 | def initUI(self): 295 | """初始化UI""" 296 | self.setWindowTitle('Cursor Chat Exporter 📤') 297 | self.setFixedSize(600, 400) 298 | 299 | # 设置字体,确保支持 emoji 300 | emoji_font = QFont() 301 | emoji_font.setFamilies(['Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', 'Segoe UI Symbol', 'Arial']) 302 | QApplication.setFont(emoji_font) 303 | 304 | # 主窗口部件 305 | main_widget = QWidget() 306 | self.setCentralWidget(main_widget) 307 | layout = QVBoxLayout(main_widget) 308 | 309 | # 标题 310 | title = QLabel('Cursor Chat Exporter 📤') 311 | title.setAlignment(Qt.AlignmentFlag.AlignCenter) 312 | title.setFont(QFont('Arial', 16, QFont.Weight.Bold)) 313 | layout.addWidget(title) 314 | 315 | # 说明文字 316 | desc = QLabel('导出您的 Cursor AI 聊天记录到MD/JSON文件 💾') 317 | desc.setAlignment(Qt.AlignmentFlag.AlignCenter) 318 | layout.addWidget(desc) 319 | 320 | # 选项区域 321 | options_layout = QVBoxLayout() 322 | 323 | # JSON导出选项 324 | self.json_checkbox = QCheckBox('同时导出JSON文件 📄') 325 | options_layout.addWidget(self.json_checkbox) 326 | 327 | # 时间戳选项 328 | self.timestamp_checkbox = QCheckBox('文件名添加创建时间 🕒') 329 | self.timestamp_checkbox.setChecked(True) # 默认选中 330 | options_layout.addWidget(self.timestamp_checkbox) 331 | 332 | layout.addLayout(options_layout) 333 | 334 | # 添加菜单栏 335 | menubar = self.menuBar() 336 | settings_menu = menubar.addMenu('设置 ⚙️') 337 | 338 | # 添加路径配置选项 339 | path_action = settings_menu.addAction('配置工作区路径') 340 | path_action.triggered.connect(self.configure_path) 341 | 342 | # 日志显示区域 343 | self.log_text = QTextEdit() 344 | self.log_text.setReadOnly(True) 345 | layout.addWidget(self.log_text) 346 | 347 | # 进度条 348 | self.progress_bar = QProgressBar() 349 | self.progress_bar.setTextVisible(False) 350 | layout.addWidget(self.progress_bar) 351 | 352 | # 按钮区域 353 | button_layout = QHBoxLayout() 354 | 355 | self.export_button = QPushButton('开始导出 📥') 356 | self.export_button.clicked.connect(self.start_export) 357 | button_layout.addWidget(self.export_button) 358 | 359 | self.close_button = QPushButton('关闭 ❌') 360 | self.close_button.clicked.connect(self.close) 361 | button_layout.addWidget(self.close_button) 362 | 363 | layout.addLayout(button_layout) 364 | 365 | # 在底部添加作者信息 366 | author_layout = QHBoxLayout() 367 | 368 | # 使用 QLabel 的 HTML 功能添加带链接的文本 369 | author_label = QLabel('Made by Cranberrycrisp 🚀') 370 | author_label.setOpenExternalLinks(True) # 允许打开外部链接 371 | author_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 372 | 373 | # 设置样式 374 | author_label.setStyleSheet(""" 375 | QLabel { 376 | color: #666; 377 | padding: 5px; 378 | margin-top: 10px; 379 | } 380 | QLabel a { 381 | color: #0366d6; 382 | text-decoration: none; 383 | } 384 | QLabel a:hover { 385 | text-decoration: underline; 386 | } 387 | """) 388 | 389 | author_layout.addWidget(author_label) 390 | layout.addLayout(author_layout) # 添加到主布局 391 | 392 | # 添加一条分隔线 393 | separator = QFrame() 394 | separator.setFrameShape(QFrame.Shape.HLine) 395 | separator.setFrameShadow(QFrame.Shadow.Sunken) 396 | separator.setStyleSheet("background-color: #ddd;") 397 | layout.addWidget(separator) 398 | 399 | # 添加版本信息 400 | version_label = QLabel('v1.0.1') 401 | version_label.setAlignment(Qt.AlignmentFlag.AlignRight) 402 | version_label.setStyleSheet("color: #666; padding: 5px;") 403 | layout.addWidget(version_label) 404 | 405 | def log(self, message): 406 | """添加日志""" 407 | self.log_text.append(message) 408 | 409 | def configure_path(self): 410 | """配置工作区路径""" 411 | dialog = PathConfigDialog(self.workspace_path, self) 412 | if dialog.exec() == QDialog.DialogCode.Accepted: 413 | new_path = dialog.get_path() 414 | if os.path.exists(new_path): 415 | self.workspace_path = new_path 416 | self.log(f"✅ 工作区路径已更新: {new_path}") 417 | else: 418 | QMessageBox.warning(self, "错误", "所选路径不存在!") 419 | 420 | def start_export(self): 421 | """开始导出""" 422 | if not self.workspace_path: 423 | QMessageBox.warning(self, "错误", "请先配置工作区路径!") 424 | self.configure_path() 425 | return 426 | 427 | if not os.path.exists(self.workspace_path): 428 | QMessageBox.warning(self, "错误", "工作区路径不存在!") 429 | self.configure_path() 430 | return 431 | 432 | self.export_button.setEnabled(False) 433 | self.progress_bar.setMaximum(0) 434 | self.log("🚀 开始导出...") 435 | 436 | # 传递工作区路径给导出线程 437 | self.worker = ExportWorker( 438 | workspace_path=self.workspace_path, 439 | export_json=self.json_checkbox.isChecked(), 440 | include_timestamp=self.timestamp_checkbox.isChecked() 441 | ) 442 | self.worker.progress.connect(self.log) 443 | self.worker.finished.connect(self.export_finished) 444 | self.worker.start() 445 | 446 | def export_finished(self, success, message): 447 | """导出完成的处理""" 448 | self.progress_bar.setMaximum(100) 449 | self.progress_bar.setValue(100) 450 | self.export_button.setEnabled(True) 451 | 452 | if success: 453 | self.log(message) 454 | QMessageBox.information(self, "成功 ✨", message) 455 | else: 456 | self.log(message) 457 | QMessageBox.warning(self, "错误 ⚠️", message) 458 | 459 | 460 | def main(): 461 | app = QApplication([]) 462 | 463 | # 设置应用程序样式 464 | app.setStyle('Fusion') 465 | 466 | window = MainWindow() 467 | window.show() 468 | 469 | app.exec() 470 | 471 | 472 | if __name__ == '__main__': 473 | main() 474 | # 打包 475 | # pyinstaller cursor-chat-exporter-gui.spec 476 | --------------------------------------------------------------------------------