├── .gitignore ├── README.md ├── app.py ├── config.ini ├── electron.vite.config.js ├── frontend ├── index.html ├── package-lock.json ├── package.json ├── public │ └── index.html ├── src │ ├── AnalysisPage.jsx │ ├── SettingsModal.jsx │ ├── components │ │ ├── ChatMessage.jsx │ │ ├── ChatUI.jsx │ │ ├── DataAnalysisSelector.jsx │ │ └── index.js │ ├── index.css │ ├── main.jsx │ └── services │ │ └── messageService.js └── vite.config.js ├── main.js ├── package-lock.json ├── package.json ├── preload.js └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | frontend/node_modules/ 2 | frontend/dist/ 3 | __pycache__ 4 | *.sqlite3 5 | temp/ 6 | todo 7 | node_modules/ 8 | dist/ 9 | build/ 10 | out/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![WOHIF1RQPT@5F}VQ`0L)0XL](https://github.com/user-attachments/assets/54a8bebe-55ed-47f3-b054-9839eecce3d5) 3 | 4 | # VRCX 用户数据分析工具 5 | 6 | ## 项目简介 7 | 8 | 本项目是一个基于**大语言模型**分析**VRCX用户数据**的工具,旨在分析指定用户或多名用户的社交状态、心理状态和行为特征等,提供深度数据洞察。 9 | 10 | --- 11 | 12 | ## ⚠️ 注意事项 13 | 14 | 1. 本工具**仅限 VRCX 用户使用**。 15 | - VRCX 使用时间越长,数据越丰富,分析结果越准确。 16 | 17 | 2. 本项目仍处于开发阶段,目前仅支持基础功能。 18 | - 欢迎在 GitHub 提交 Issue 提供建议或报告问题。 19 | 20 | 3. 只能分析你的好友数据。如需分析非好友,请获取其他用户提供的数据库文件。 21 | 22 | 4. **本软件仅供娱乐,分析结果不得用于攻击或骚扰他人。** 23 | 24 | --- 25 | 26 | ## 🚀 使用说明 27 | 28 | ### 步骤 0:下载软件 29 | 30 | 1. 从 [releases](https://github.com/oniyakun/VRCX-Data-Analysis/releases) 下载最新版本的软件。 31 | 2. 解压软件,双击 `VRCX Data Analysis.exe` 运行。 32 | 33 | ### 步骤 1:配置 API 34 | 35 | 打开软件后,请先配置你的 API 信息: 36 | 37 | - 支持所有符合 OpenAI API 标准的服务。 38 | - 配置方法: 39 | - 点击设置,填写 API Endpoint、API Key 和模型名。 40 | - 获取你的API,可以使用从 [nuwaapi](https://api.nuwaapi.com/register?aff=p4T5) 购买的API 41 | 42 | ### 示例: 43 | 44 | ```ini 45 | API Endpoint: https://api.nuwaapi.com/v1/chat/completions 46 | API Key: 你的API密钥 47 | Model: deepseek-r1 48 | ``` 49 | 50 | --- 51 | 52 | ## 2. 加载 VRCX 数据库 53 | 54 | - 点击“自动加载 VRCX 数据库”按钮。 55 | - 若自动加载失败,请手动上传文件: 56 | 1. 在 VRCX 中打开 `设置 - 高级 - 常用文件夹 - AppData(VRCX)`。 57 | 2. 找到 `VRCX.sqlite3` 文件,通过软件中的“上传 SQLite 文件”按钮手动加载。 58 | 59 | ## 3. 数据筛选与选择 60 | 61 | - 加载完成后,选择你想分析的表格(例如状态、简介)。 62 | - 推荐选中以下字段进行分析: 63 | - `created_at` (创建时间) 64 | - `user_id`(用户 ID,推荐) 65 | - `display_name`(用户名称) 66 | - `status`(状态) 67 | - `status_description`(状态描述) 68 | - `bio`(个人简介) 69 | - 使用筛选框进行筛选: 70 | - 输入`user_id`或`display_name`并回车,可添加多个用户。 71 | - 获取`user_id`的方法: 72 | - 在VRCX中打开好友页面,复制下方的“玩家ID”。 73 | 74 | ## 4. 开始分析 75 | 76 | - 在页面底部,选择或输入一个 Prompt,例如: 77 | ```markdown 78 | 分析指定用户过去一周的状态变化,推测心理状态波动情况。 79 | ``` 80 | - 点击“开始分析”,等待分析结果返回。 81 | 82 | --- 83 | 84 | ## 🛠 开发指南 85 | 86 | ### 本地开发环境搭建: 87 | 88 | ```bash 89 | git clone https://github.com/oniyakun/VRCX-Data-Analysis.git 90 | cd frontend 91 | npm install -i 92 | cd .. 93 | npm install -i 94 | pip install -r requirements.txt 95 | pyinstaller --noconsole --clean app.py 96 | npm run dev 97 | ``` 98 | 99 | ### 项目打包指南: 100 | 101 | ```bash 102 | git clone https://github.com/oniyakun/VRCX-Data-Analysis.git 103 | cd frontend 104 | npm install -i 105 | cd .. 106 | npm install -i 107 | pip install -r requirements.txt 108 | pyinstaller --noconsole --clean app.py 109 | npm run build 110 | ``` 111 | 112 | --- 113 | 114 | ## 📌 配置文件 (config.ini) 115 | 116 | 示例如下: 117 | 118 | ```ini 119 | [prompts] 120 | prompt1 = 请分析指定用户的社交状态变化。 121 | prompt2 = 分析该用户的心理状态特征。 122 | prompt3 = 提取用户近期的行为特征并总结。 123 | ``` 124 | 125 | - config.ini文件位于程序```resources\frontend```目录下,用户可自行按格式添加自定义的Prompt。 126 | - 用户可自定义多条 Prompt,用于快速选择。 127 | 128 | --- 129 | 130 | ## ✅ 项目待办事项 131 | 132 | ### 🖥 前端优化 133 | - [x] 重构前端页面 134 | - [x] 自由选择分析数据类型 135 | - [x] 筛选页面增加提示信息,帮助用户选择 136 | - [x] 一定要筛选框有内容的情况下才能触发分析 137 | - [x] 单独选择消息保存为图片的功能 138 | - [x] 分别给数据筛选和分析结果页面增加不同的prompt预设 139 | 140 | ### 📊 数据分析功能 141 | - [x] 支持多表数据组合分析 142 | - [x] 通过`user_id`确定用户,避免误差 143 | - [x] 自动分析和提取数据库数据 144 | - [x] 自动加载 VRCX 数据库文件 145 | - [x] 预设 prompt 功能 146 | - [x] 支持 prompt 自由选择(单人/多人) 147 | - [x] 连续对话支持 148 | - [x] 在连续对话中途筛选内容并添加到对话中分析 149 | 150 | ### ⚙️ 系统与 API 接入 151 | - [x] 自动化部署流程 152 | - [x] 任务完成后自动清理缓存文件 153 | - [x] 支持 DeepSeek API 154 | - [x] 用户自定义 API 配置页面 155 | 156 | ### 📦 应用打包 157 | - [x] 支持 Electron 打包应用 158 | 159 | --- 160 | 161 | ## ⚠️ 免责声明 162 | 163 | **本软件仅供娱乐!分析结果不可作为任何正式依据。禁止用于恶意攻击、骚扰或违法用途,违者后果自负。** 164 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify 2 | from flask_cors import CORS 3 | import sqlite3 4 | import pandas as pd 5 | import tempfile 6 | import os 7 | import logging 8 | import sys 9 | 10 | app = Flask(__name__) 11 | app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB限制 12 | CORS(app) 13 | 14 | # 配置日志 15 | logging.basicConfig(level=logging.INFO, 16 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 17 | handlers=[logging.StreamHandler(sys.stdout)]) 18 | logger = logging.getLogger(__name__) 19 | 20 | def clear_temp_directory(temp_dir): 21 | """清理临时目录中的所有文件""" 22 | for filename in os.listdir(temp_dir): 23 | file_path = os.path.join(temp_dir, filename) 24 | try: 25 | if os.path.isfile(file_path): 26 | os.remove(file_path) 27 | logger.info(f'成功删除临时文件: {file_path}') 28 | except Exception as e: 29 | logger.error(f'删除临时文件失败: {file_path}, 错误: {str(e)}') 30 | 31 | @app.route('/upload', methods=['POST']) 32 | def upload_file(): 33 | logger.info('收到文件上传请求') 34 | if 'file' not in request.files: 35 | logger.error('请求中没有文件') 36 | return jsonify({'error': 'No file uploaded'}), 400 37 | 38 | file = request.files['file'] 39 | if file.filename == '': 40 | logger.error('文件名为空') 41 | return jsonify({'error': 'Empty filename'}), 400 42 | 43 | logger.info(f'接收到文件: {file.filename}, 内容类型: {file.content_type}') 44 | 45 | temp_dir = os.path.join(os.path.dirname(__file__), 'temp') 46 | os.makedirs(temp_dir, exist_ok=True) 47 | logger.info(f'临时目录: {temp_dir}') 48 | 49 | # 在处理新文件之前清理临时目录 50 | clear_temp_directory(temp_dir) 51 | 52 | tmp = None 53 | conn = None 54 | try: 55 | # 使用with语句创建临时文件,确保资源管理 56 | with tempfile.NamedTemporaryFile(suffix='.sqlite3', dir=temp_dir, delete=False) as tmp: 57 | logger.info(f'创建临时文件: {tmp.name}') 58 | file.save(tmp.name) 59 | tmp.close() 60 | 61 | file_size = os.path.getsize(tmp.name) 62 | logger.info(f'保存文件成功,文件大小: {file_size} 字节') 63 | 64 | os.environ['SQLITE_TMPDIR'] = os.path.dirname(tmp.name) 65 | 66 | # 使用上下文管理器连接数据库 67 | logger.info('连接数据库...') 68 | with sqlite3.connect(tmp.name) as conn: 69 | cursor = conn.cursor() 70 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") 71 | tables = [row[0] for row in cursor.fetchall() if row[0].strip()] 72 | logger.info(f'找到 {len(tables)} 个表格: {tables}') 73 | 74 | tables_metadata = [] 75 | for table_name in tables: 76 | try: 77 | logger.info(f'处理表格: {table_name}') 78 | cursor.execute(f'SELECT * FROM "{table_name}" LIMIT 1') 79 | columns = [desc[0] for desc in cursor.description] 80 | logger.info(f'表格 {table_name} 有 {len(columns)} 列') 81 | 82 | cursor.execute(f'SELECT * FROM "{table_name}"') 83 | data = [[str(item) for item in row] for row in cursor.fetchall()] 84 | logger.info(f'表格 {table_name} 有 {len(data)} 行数据') 85 | 86 | tables_metadata.append({ 87 | 'name': table_name, 88 | 'columns': columns, 89 | 'data': data 90 | }) 91 | except Exception as e: 92 | logger.error(f'处理表格 {table_name} 时出错: {str(e)}') 93 | 94 | # 保留临时文件用于调试(可根据需要调整) 95 | logger.info(f'保留临时文件用于调试: {tmp.name}') 96 | 97 | response_data = {'tables_metadata': tables_metadata} 98 | logger.info(f'返回 {len(tables_metadata)} 个表格的元数据') 99 | return jsonify(response_data), 200 100 | 101 | except Exception as e: 102 | logger.error('文件处理失败: %s', str(e), exc_info=True) 103 | if conn: 104 | conn.close() 105 | if tmp and os.path.exists(tmp.name): 106 | try: 107 | # 保留临时文件用于调试(可根据需要删除) 108 | logger.info(f'保留临时文件用于调试: {tmp.name}') 109 | except Exception as del_error: 110 | logger.error('删除临时文件失败: %s', str(del_error), exc_info=True) 111 | return jsonify({'error': '服务器处理文件时发生错误', 'details': str(e)}), 500 112 | 113 | if __name__ == '__main__': 114 | logger.info('启动Flask应用...') 115 | app.run(debug=True) -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [filter_prompts] 2 | filter_prompt1 = 你的任务是根据以下提供的一组数据,对其中的个体从时间线上进行深度的精神状态和行为模式分析。请注意以下数据包括时间戳、状态(active或ask me代表空闲和繁忙)、位置信息以及自我介绍。请依照以下步骤进行分析,并生成一份详尽且富有情感的报告:步骤1:了解整个时间线,关注状态(active和ask me)的变化,结合时间戳评估其空闲和繁忙的时段,推测其日常作息规律。步骤2:结合位置信息,分析其在不同地点的活动情况,推断其经常活动的区域及这些区域可能与生活或工作的关联。步骤3:解析自我介绍(bio),理解个体在不同时间段自我表达的变化,这反映其精神状态和自我认知的变化。步骤4:综合所有信息,评估其行为模式和性格特征,尤其是以下几点: - 从繁忙到空闲过渡时是否有显著的情感波动; - 经常活跃的时间和地点是否有一致性,暗示其习惯; - 自我介绍的变化是否反映了生活或职业上的重大变化或压力来源; - 这些因素综合反映其精神状态的稳定性或波动性。生成的报告需包含详细的时间线解析,并从情感和行为模式上提供深入洞察。以下是数据: 3 | filter_prompt2 = 你的任务是根据以下提供的一组数据,对其中的个体从时间线上进行深度的精神状态和行为模式分析。步骤1:解析自我介绍(bio),理解个体在不同时间段自我表达的变化,这反映其精神状态和自我认知的变化。步骤2:综合所有信息,评估其行为模式和性格特征,尤其是以下几点: - 从繁忙到空闲过渡时是否有显著的情感波动; - 经常活跃的时间和地点是否有一致性,暗示其习惯; - 自我介绍的变化是否反映了生活或职业上的重大变化或压力来源; - 这些因素综合反映其精神状态的稳定性或波动性。生成的报告需包含详细的时间线解析,并从情感和行为模式上提供深入洞察。以下是数据: 4 | filter_prompt3 = 请基于同一用户的时间戳数据流,深度解析其行为模式与心理画像。要求:1.建立时间轴动态关联状态切换(active/ask me)、地理位置迁移(world_name)及自我介绍文本(bio)演变;2.量化分析状态持续时长与空间移动的耦合规律(如工作日/周末模式、高频活跃时段),揭示压力周期与社交需求波动;3.解构bio文本的情感语义变迁(用词倾向、自我表达强度),结合时空坐标识别身份认知转折点;4.构建三维模型(时间密度x空间轨迹x语言特征)推演潜在生活事件(如职业变更、人际关系重构),着重解析状态切换临界时刻的异常行为簇;5.输出包含情绪温度曲线、空间依存度图谱及人格进化路径的整合报告,以下是数据: 5 | 6 | [analysis_prompts] 7 | analysis_prompt1 = 请继续分析以下提供内容里面的人,请你结合时间戳,结合之前的分析他们之间潜在的关系和行为动机,给出详尽的有深度和情感的报告! 8 | analysis_prompt2 = 请你简要总结一下 9 | analysis_prompt3 = 请你尝试模仿这个人和我说话 10 | analysis_prompt4 = 用贴吧老哥的语气总结一下 11 | -------------------------------------------------------------------------------- /electron.vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'electron-vite'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | main: { 6 | build: { 7 | outDir: 'frontend/dist', 8 | rollupOptions: { 9 | input: path.resolve(__dirname, 'main.js'), // 指定主进程入口文件 10 | }, 11 | }, 12 | }, 13 | preload: { 14 | build: { 15 | outDir: 'frontend/dist', 16 | rollupOptions: { 17 | input: path.resolve(__dirname, 'preload.js'), // 添加 preload 脚本入口 18 | }, 19 | }, 20 | }, 21 | renderer: { 22 | root: 'frontend', // 前端项目根目录 23 | build: { 24 | outDir: 'frontend/dist', 25 | rollupOptions: { 26 | input: path.resolve(__dirname, 'frontend/index.html'), // 前端入口文件 27 | }, 28 | }, 29 | }, 30 | }); -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | VRCX Data Analysis 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqlite-analyzer", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.11.1", 13 | "@emotion/styled": "^11.11.0", 14 | "@mui/icons-material": "^5.14.16", 15 | "@mui/material": "^5.14.15", 16 | "axios": "^1.8.2", 17 | "echarts": "^5.6.0", 18 | "echarts-for-react": "^3.0.3", 19 | "html2canvas": "^1.4.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-markdown": "^10.1.0", 23 | "react-router-dom": "^7.3.0", 24 | "react-syntax-highlighter": "^15.6.1", 25 | "remark-gfm": "^4.0.1", 26 | "xlsx": "^0.18.5" 27 | }, 28 | "devDependencies": { 29 | "@types/react": "^18.2.15", 30 | "@types/react-dom": "^18.2.7", 31 | "@vitejs/plugin-react": "^4.3.4", 32 | "vite": "^4.4.5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | VRChat 数据分析 15 | 16 | 17 |
18 | 19 | -------------------------------------------------------------------------------- /frontend/src/AnalysisPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useRef, useEffect } from 'react'; 2 | import ChatUI from './components/ChatUI'; 3 | import { 4 | Container, 5 | Toolbar, 6 | Button, 7 | Card, 8 | CardContent, 9 | Typography, 10 | Chip, 11 | Table, 12 | TableBody, 13 | TableCell, 14 | TableContainer, 15 | TableHead, 16 | TableRow, 17 | Paper, 18 | Pagination, 19 | Alert, 20 | Autocomplete, 21 | TextField, 22 | CssBaseline, 23 | Dialog, 24 | DialogTitle, 25 | DialogContent, 26 | DialogActions, 27 | GlobalStyles, 28 | MenuItem, 29 | Grid, 30 | Box, 31 | List, 32 | ListItem, 33 | ListItemButton, 34 | ListItemIcon, 35 | ListItemText, 36 | Divider, 37 | CircularProgress, 38 | Link, 39 | Snackbar, 40 | } from '@mui/material'; 41 | import { createTheme, ThemeProvider } from '@mui/material/styles'; 42 | import { Upload, BarChart, Chat, GitHub } from '@mui/icons-material'; 43 | import axios from 'axios'; 44 | import { useNavigate } from 'react-router-dom'; 45 | import ReactECharts from 'echarts-for-react'; 46 | import html2canvas from 'html2canvas'; 47 | import SettingsModal from './SettingsModal'; 48 | import { handleStreamResponse, sendMessageToAPI } from './services/messageService'; 49 | 50 | // 暗色主题配置 51 | const modernDarkTheme = createTheme({ 52 | palette: { 53 | mode: 'dark', 54 | background: { 55 | default: '#2c2c2c', 56 | paper: '#1e1e1e', 57 | }, 58 | text: { 59 | primary: '#ffffff', 60 | secondary: '#b0b0b0', 61 | }, 62 | primary: { 63 | main: '#6abf4b', 64 | }, 65 | secondary: { 66 | main: '#03dac6', 67 | }, 68 | }, 69 | typography: { 70 | fontSize: 15, 71 | fontFamily: [ 72 | 'Noto Sans SC', 73 | '-apple-system', 74 | 'BlinkMacSystemFont', 75 | 'Microsoft YaHei', 76 | 'Segoe UI', 77 | 'Roboto', 78 | 'Oxygen', 79 | 'Ubuntu', 80 | 'Cantarell', 81 | 'Fira Sans', 82 | 'Droid Sans', 83 | 'Helvetica Neue', 84 | 'sans-serif', 85 | ].join(','), 86 | }, 87 | components: { 88 | MuiCard: { 89 | styleOverrides: { 90 | root: { 91 | backgroundColor: '#1e1e1e', 92 | borderRadius: '16px', 93 | boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', 94 | overflow: 'visible', 95 | }, 96 | }, 97 | }, 98 | MuiDialog: { 99 | styleOverrides: { 100 | paper: { 101 | backgroundColor: '#1e1e1e', 102 | borderRadius: '16px', 103 | boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', 104 | }, 105 | }, 106 | }, 107 | MuiButton: { 108 | styleOverrides: { 109 | root: { 110 | borderRadius: '12px', 111 | textTransform: 'none', 112 | boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)', 113 | transition: 'transform 0.2s ease-in-out', 114 | '&:hover': { 115 | boxShadow: '0 4px 16px rgba(0, 0, 0, 0.3)', 116 | transform: 'scale(1.05)', 117 | }, 118 | }, 119 | }, 120 | }, 121 | MuiTextField: { 122 | styleOverrides: { 123 | root: { 124 | '& .MuiOutlinedInput-root': { 125 | borderRadius: '12px', 126 | backgroundColor: '#2a2a2a', 127 | transition: 'background-color 0.3s', 128 | '&:hover': { 129 | backgroundColor: '#333333', 130 | }, 131 | '&.Mui-focused': { 132 | backgroundColor: '#404040', 133 | }, 134 | }, 135 | }, 136 | }, 137 | }, 138 | MuiTable: { 139 | styleOverrides: { 140 | root: { 141 | borderRadius: '12px', 142 | overflow: 'visible', 143 | }, 144 | }, 145 | }, 146 | MuiTableCell: { 147 | styleOverrides: { 148 | head: { 149 | backgroundColor: '#2a2a2a', 150 | fontWeight: 'bold', 151 | color: 'white', 152 | }, 153 | body: { 154 | transition: 'background-color 0.2s', 155 | color: 'white', 156 | '&:hover': { 157 | backgroundColor: '#252525', 158 | }, 159 | }, 160 | }, 161 | }, 162 | MuiTableRow: { 163 | styleOverrides: { 164 | root: { 165 | '&:nth-of-type(odd)': { 166 | backgroundColor: '#1e1e1e', 167 | }, 168 | '&:nth-of-type(even)': { 169 | backgroundColor: '#252525', 170 | }, 171 | '&:hover': { 172 | backgroundColor: '#333333 !important', 173 | }, 174 | }, 175 | }, 176 | }, 177 | MuiPaper: { 178 | styleOverrides: { 179 | root: { 180 | overflow: 'visible', 181 | }, 182 | }, 183 | }, 184 | MuiCardContent: { 185 | styleOverrides: { 186 | root: { 187 | overflow: 'visible', 188 | }, 189 | }, 190 | }, 191 | }, 192 | }); 193 | 194 | export default function AnalysisPage() { 195 | const [tables, setTables] = useState([]); 196 | const [filters, setFilters] = useState({}); 197 | const [selectedColumns, setSelectedColumns] = useState({}); 198 | const [pagination, setPagination] = useState({}); 199 | const [loading, setLoading] = useState(false); 200 | const [error, setError] = useState(null); 201 | const [alertInfo, setAlertInfo] = useState({ open: false, message: '', severity: 'error' }); 202 | 203 | // 关闭提示信息 204 | const handleCloseAlert = () => { 205 | setAlertInfo({ ...alertInfo, open: false }); 206 | }; 207 | 208 | // 显示提示信息 209 | const showAlert = (message, severity = 'error') => { 210 | setAlertInfo({ open: true, message, severity }); 211 | }; 212 | const [chatHistory, setChatHistory] = useState([]); 213 | const [chartOption, setChartOption] = useState({ 214 | title: { 215 | text: '请上传数据文件', 216 | left: 'center', 217 | top: 'center', 218 | textStyle: { color: '#999', fontSize: 16 }, 219 | }, 220 | xAxis: { show: false }, 221 | yAxis: { show: false }, 222 | }); 223 | const [promptInput, setPromptInput] = useState(''); 224 | const [apiResponse, setApiResponse] = useState(''); 225 | const navigate = useNavigate(); 226 | 227 | const [filterPrompts, setFilterPrompts] = useState([]); 228 | const [analysisPrompts, setAnalysisPrompts] = useState([]); 229 | const [selectedPreset, setSelectedPreset] = useState(''); 230 | const [settingsOpen, setSettingsOpen] = useState(false); 231 | const [apiConfig, setApiConfig] = useState({ 232 | endpoint: '', 233 | apiKey: '', 234 | model: '', 235 | }); 236 | 237 | const [activeTab, setActiveTab] = useState('analysis'); 238 | const responseContainerRef = useRef(null); 239 | const [imageDataUrl, setImageDataUrl] = useState(null); 240 | const [dialogOpen, setDialogOpen] = useState(false); 241 | const [isCapturing, setIsCapturing] = useState(false); 242 | const [validationDialogOpen, setValidationDialogOpen] = useState(false); 243 | const [filterInputs, setFilterInputs] = useState({}); 244 | 245 | // 新增:表名到编号的映射 246 | const [tableNameToIdMap, setTableNameToIdMap] = useState({}); 247 | 248 | const allowedFeedTypes = ['feed_status', 'feed_gps', 'feed_bio', 'feed_avatar']; 249 | 250 | // 初始化时恢复筛选条件 251 | useEffect(() => { 252 | const savedEndpoint = localStorage.getItem('apiEndpoint') || ''; 253 | const savedApiKey = localStorage.getItem('apiKey') || ''; 254 | const savedModel = localStorage.getItem('model') || ''; 255 | const savedFilterInputs = localStorage.getItem('savedFilterInputs'); 256 | 257 | setApiConfig({ endpoint: savedEndpoint, apiKey: savedApiKey, model: savedModel }); 258 | 259 | if (savedFilterInputs) { 260 | try { 261 | const parsedFilterInputs = JSON.parse(savedFilterInputs); 262 | setFilterInputs(parsedFilterInputs); 263 | setFilters(parsedFilterInputs); 264 | } catch (error) { 265 | console.error('解析保存的筛选条件时出错:', error); 266 | } 267 | } 268 | 269 | if (!savedEndpoint || !savedApiKey || !savedModel) { 270 | setSettingsOpen(true); 271 | } 272 | window.electronAPI.ipcRenderer.invoke('read-config').then((result) => { 273 | if (result.success) { 274 | if (Array.isArray(result.filterPrompts)) { 275 | setFilterPrompts(result.filterPrompts); 276 | console.log('成功加载筛选页面预设prompts:', result.filterPrompts); 277 | } 278 | if (Array.isArray(result.analysisPrompts)) { 279 | setAnalysisPrompts(result.analysisPrompts); 280 | console.log('成功加载分析结果页面预设prompts:', result.analysisPrompts); 281 | } 282 | } else { 283 | console.error('加载预设prompts失败:', result); 284 | } 285 | }).catch(error => { 286 | console.error('调用read-config失败:', error); 287 | }); 288 | }, []); 289 | 290 | // 监听页面切换,恢复筛选条件 291 | useEffect(() => { 292 | if (activeTab === 'analysis') { 293 | const savedFilterInputs = localStorage.getItem('savedFilterInputs'); 294 | if (savedFilterInputs) { 295 | try { 296 | const parsedFilterInputs = JSON.parse(savedFilterInputs); 297 | setFilterInputs(parsedFilterInputs); 298 | setFilters(parsedFilterInputs); 299 | } catch (error) { 300 | console.error('解析保存的筛选条件时出错:', error); 301 | } 302 | } 303 | } 304 | }, [activeTab]); 305 | 306 | // 监听 filterInputs 变化,保存到本地存储 307 | useEffect(() => { 308 | if (Object.keys(filterInputs).length > 0) { 309 | localStorage.setItem('savedFilterInputs', JSON.stringify(filterInputs)); 310 | } 311 | }, [filterInputs]); 312 | 313 | const getDisplayNameAndHint = useCallback((tableName) => { 314 | if (!tableName || typeof tableName !== 'string') { 315 | console.error('getDisplayNameAndHint: tableName不是有效字符串', tableName); 316 | return { displayName: null, hint: null }; 317 | } 318 | 319 | for (const feedType of allowedFeedTypes) { 320 | if (tableName.includes(feedType)) { 321 | let usrPart = ''; 322 | try { 323 | const parts = tableName.split(feedType); 324 | if (parts.length > 0) { 325 | usrPart = parts[0].replace(/_$/, ''); 326 | } 327 | } catch (err) { 328 | console.error(`解析表格名称 ${tableName} 出错:`, err); 329 | return { displayName: null, hint: null }; 330 | } 331 | 332 | switch (feedType) { 333 | case 'feed_gps': 334 | return { 335 | displayName: `${usrPart}的历史位置信息`, 336 | hint: '正常不建议添加此项进行分析,可以根据实际情况修改 created_at, display_name, world_name 选项,当用户以前修改过名字时,请使用用户的 user_id 筛选', 337 | }; 338 | case 'feed_bio': 339 | return { 340 | displayName: `${usrPart}的历史简介信息`, 341 | hint: '建议选择 created_at, display_name, bio 选项,当用户以前修改过名字时,请使用用户的 user_id 筛选', 342 | }; 343 | case 'feed_avatar': 344 | return { 345 | displayName: `${usrPart}的历史模型信息`, 346 | hint: '不建议添加此项进行分析,没有太大作用', 347 | }; 348 | case 'feed_status': 349 | return { 350 | displayName: `${usrPart}的历史状态信息`, 351 | hint: '建议选择 created_at, d isplay_name, status, status_description 选项,当用户以前修改过名字时,请使用用户的 user_id 筛选', 352 | }; 353 | default: 354 | return { displayName: null, hint: null }; 355 | } 356 | } 357 | } 358 | 359 | return { displayName: null, hint: null }; 360 | }, []); 361 | 362 | const handleUpload = useCallback(async (e) => { 363 | const file = e.target.files[0]; 364 | if (!file) return; 365 | setLoading(true); 366 | setError(null); 367 | setAlertInfo({ open: false, message: '', severity: 'error' }); 368 | setTables([]); 369 | setSelectedColumns({}); 370 | setFilters({}); 371 | setFilterInputs({}); 372 | setPagination({}); 373 | setTableNameToIdMap({}); 374 | 375 | try { 376 | const formData = new FormData(); 377 | formData.append('file', file); 378 | 379 | // 请求前显示提示 380 | showAlert('正在处理数据,这可能需要几分钟时间...', 'info'); 381 | 382 | const response = await axios.post('http://localhost:5000/upload', formData, { 383 | maxContentLength: Number.POSITIVE_INFINITY, 384 | maxBodyLength: Number.POSITIVE_INFINITY, 385 | timeout: 300000, // 增加超时时间到5分钟 386 | responseType: 'blob', // 使用blob类型接收大文件响应 387 | onUploadProgress: (progressEvent) => { 388 | const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); 389 | console.log(`上传进度: ${percentCompleted}%`); 390 | } 391 | }); 392 | 393 | // 处理大型Blob响应 394 | try { 395 | showAlert('正在解析响应数据...', 'info'); 396 | 397 | // 使用更高效的方式处理大型JSON响应 398 | const processResponseData = async (blob) => { 399 | try { 400 | // 分块读取大型文件 401 | const chunks = []; 402 | const reader = blob.stream().getReader(); 403 | const decoder = new TextDecoder(); 404 | let result = ''; 405 | 406 | while (true) { 407 | const { done, value } = await reader.read(); 408 | if (done) break; 409 | result += decoder.decode(value, { stream: !done }); 410 | } 411 | 412 | return JSON.parse(result); 413 | } catch (error) { 414 | console.error('解析响应失败:', error); 415 | throw new Error(`解析返回数据失败:${error.message || '未知错误'}`); 416 | } 417 | }; 418 | 419 | // 处理响应数据 420 | const responseData = await processResponseData(response.data); 421 | 422 | if (!responseData.tables_metadata || !Array.isArray(responseData.tables_metadata)) { 423 | setError('返回的数据格式不正确'); 424 | showAlert('返回的数据格式不正确', 'error'); 425 | setLoading(false); 426 | return; 427 | } 428 | 429 | const validatedTables = responseData.tables_metadata.map((table) => ({ 430 | name: String(table.name), 431 | columns: Array.isArray(table.columns) ? table.columns : [], 432 | data: Array.isArray(table.data) ? table.data : [], 433 | total_rows: table.total_rows || table.data.length, 434 | is_truncated: !!table.is_truncated 435 | })); 436 | 437 | const validTables = validatedTables.filter((t) => { 438 | return ( 439 | t.columns.length > 0 && 440 | t.data.length > 0 && 441 | getDisplayNameAndHint(t.name).displayName !== null 442 | ); 443 | }); 444 | 445 | if (validTables.length === 0) { 446 | setError('未找到有效表格数据'); 447 | showAlert('未找到有效表格数据', 'error'); 448 | setLoading(false); 449 | return; 450 | } 451 | 452 | // 检查是否有数据被截断 453 | const truncatedTables = validTables.filter(t => t.is_truncated); 454 | if (truncatedTables.length > 0) { 455 | const truncatedTableNames = truncatedTables.map(t => getDisplayNameAndHint(t.name).displayName || t.name).join(', '); 456 | showAlert(`注意: 部分表格数据量过大已被截断。受影响的表格: ${truncatedTableNames}`, 'warning'); 457 | } 458 | 459 | const tableMap = {}; 460 | validTables.forEach((table, index) => { 461 | tableMap[table.name] = `表${index + 1}`; 462 | }); 463 | setTableNameToIdMap(tableMap); 464 | 465 | setTables(validTables); 466 | 467 | const newPagination = {}; 468 | for (const table of validTables) { 469 | newPagination[table.name] = { page: 1, rowsPerPage: 10 }; 470 | } 471 | setPagination(newPagination); 472 | 473 | const newFilterInputs = {}; 474 | for (const table of validTables) { 475 | newFilterInputs[table.name] = {}; 476 | for (const col of table.columns) { 477 | newFilterInputs[table.name][col] = []; 478 | } 479 | } 480 | setFilterInputs(newFilterInputs); 481 | 482 | const initialSelectedColumns = {}; 483 | for (const table of validTables) { 484 | initialSelectedColumns[table.name] = {}; 485 | for (let i = 0; i < table.columns.length; i++) { 486 | initialSelectedColumns[table.name][table.columns[i]] = false; 487 | } 488 | } 489 | 490 | setTimeout(() => { 491 | setSelectedColumns(initialSelectedColumns); 492 | setLoading(false); 493 | }, 100); 494 | } catch (err) { 495 | setError(err.message || '解析响应数据失败'); 496 | showAlert(err.message || '解析响应数据失败', 'error'); 497 | setTables([]); 498 | setLoading(false); 499 | } 500 | } catch (err) { 501 | setError(err.message || '上传文件处理错误'); 502 | showAlert(err.message || '上传文件处理错误', 'error'); 503 | setTables([]); 504 | setLoading(false); 505 | } 506 | }, [getDisplayNameAndHint, showAlert]); 507 | 508 | const handleAutoLoadVrcx = async () => { 509 | setLoading(true); 510 | setError(null); 511 | setAlertInfo({ open: false, message: '', severity: 'error' }); 512 | setTables([]); 513 | setSelectedColumns({}); 514 | setFilters({}); 515 | setFilterInputs({}); 516 | setPagination({}); 517 | setTableNameToIdMap({}); 518 | 519 | try { 520 | // 显示加载提示 521 | const { success, data, message, useFile, filePath, tableCount } = await window.electronAPI.ipcRenderer.invoke( 522 | 'auto-load-vrcx-db' 523 | ); 524 | 525 | if (!success) { 526 | setError(message || '自动加载失败'); 527 | showAlert(message || '自动加载失败', 'error'); 528 | setLoading(false); 529 | return; 530 | } 531 | 532 | // 处理大型数据文件方式传输 533 | let tablesData; 534 | if (useFile && filePath) { 535 | showAlert(`数据较大(${tableCount}个表),正从临时文件加载...`, 'info'); 536 | 537 | try { 538 | // 请求加载文件数据 539 | const loadFileResponse = await window.electronAPI.ipcRenderer.invoke( 540 | 'load-temp-file', filePath 541 | ); 542 | 543 | if (!loadFileResponse.success) { 544 | throw new Error(loadFileResponse.message || '加载临时文件失败'); 545 | } 546 | 547 | tablesData = loadFileResponse.data; 548 | } catch (fileErr) { 549 | setError(fileErr.message || '处理临时数据文件失败'); 550 | showAlert(fileErr.message || '处理临时数据文件失败', 'error'); 551 | setLoading(false); 552 | return; 553 | } 554 | } else { 555 | tablesData = data; 556 | } 557 | 558 | if (!tablesData?.tables_metadata || !Array.isArray(tablesData.tables_metadata)) { 559 | setError('返回的数据格式不正确'); 560 | showAlert('返回的数据格式不正确', 'error'); 561 | setLoading(false); 562 | return; 563 | } 564 | 565 | const validatedTables = tablesData.tables_metadata.map((table) => ({ 566 | name: String(table.name || ''), 567 | columns: Array.isArray(table.columns) ? table.columns : [], 568 | data: Array.isArray(table.data) ? table.data : [], 569 | })); 570 | 571 | const validTables = validatedTables.filter((table) => { 572 | return ( 573 | table.columns.length > 0 && 574 | table.data.length > 0 && 575 | getDisplayNameAndHint(table.name).displayName !== null 576 | ); 577 | }); 578 | 579 | if (validTables.length === 0) { 580 | setError('未找到有效表格数据'); 581 | showAlert('未找到有效表格数据', 'error'); 582 | setLoading(false); 583 | return; 584 | } 585 | 586 | const tableMap = {}; 587 | validTables.forEach((table, index) => { 588 | tableMap[table.name] = `表${index + 1}`; 589 | }); 590 | setTableNameToIdMap(tableMap); 591 | 592 | setTables(validTables); 593 | 594 | const newPagination = {}; 595 | for (const table of validTables) { 596 | newPagination[table.name] = { page: 1, rowsPerPage: 10 }; 597 | } 598 | setPagination(newPagination); 599 | 600 | const newFilterInputs = {}; 601 | for (const table of validTables) { 602 | newFilterInputs[table.name] = {}; 603 | for (const col of table.columns) { 604 | newFilterInputs[table.name][col] = []; 605 | } 606 | } 607 | setFilterInputs(newFilterInputs); 608 | 609 | const newSelectedColumns = {}; 610 | for (const table of validTables) { 611 | newSelectedColumns[table.name] = {}; 612 | for (let i = 0; i < table.columns.length; i++) { 613 | newSelectedColumns[table.name][table.columns[i]] = false; 614 | } 615 | } 616 | 617 | await new Promise((resolve) => { 618 | setTimeout(() => { 619 | setSelectedColumns(newSelectedColumns); 620 | resolve(); 621 | }, 500); 622 | }); 623 | 624 | setLoading(false); 625 | } catch (err) { 626 | setError(err.message || '自动加载VRCX数据库错误'); 627 | showAlert(err.message || '自动加载VRCX数据库错误', 'error'); 628 | setLoading(false); 629 | } 630 | }; 631 | 632 | const getMergedFilteredData = () => { 633 | const mergedData = {}; 634 | for (const table of tables) { 635 | const tableFilters = filterInputs[table.name] || {}; 636 | const hasFilters = Object.values(tableFilters).some((filter) => filter.length > 0); 637 | 638 | if (hasFilters) { 639 | const tableId = tableNameToIdMap[table.name]; 640 | if (!tableId) continue; 641 | 642 | const filteredData = table.data.filter((row) => { 643 | return Object.entries(tableFilters).every(([column, values]) => { 644 | if (!values || values.length === 0) return true; 645 | const cellValue = row[table.columns.indexOf(column)]; 646 | return values.some((value) => 647 | cellValue?.toString().toLowerCase().includes(value.toLowerCase()) 648 | ); 649 | }); 650 | }); 651 | 652 | if (filteredData.length > 0) { 653 | const selectedData = filteredData.map((row) => { 654 | const rowData = {}; 655 | const colEntries = Object.entries(selectedColumns[table.name] || {}); 656 | 657 | for (const [column, isSelected] of colEntries) { 658 | if (isSelected) { 659 | rowData[column] = row[table.columns.indexOf(column)]; 660 | } 661 | } 662 | return rowData; 663 | }); 664 | mergedData[tableId] = selectedData; 665 | } 666 | } 667 | } 668 | 669 | return mergedData; 670 | }; 671 | 672 | const handleSendMessage = async (message) => { 673 | try { 674 | setLoading(true); 675 | setApiResponse(''); 676 | 677 | const filteredData = getMergedFilteredData(); 678 | const hasFilteredData = Object.keys(filteredData).length > 0; 679 | 680 | const messages = [ 681 | { 682 | role: 'user', 683 | content: hasFilteredData 684 | ? `${message}\n\n以下是相关的数据:\n${JSON.stringify(filteredData, null, 2)}` 685 | : message, 686 | }, 687 | ]; 688 | 689 | const response = await fetch(apiConfig.endpoint, { 690 | method: 'POST', 691 | headers: { 692 | 'Content-Type': 'application/json', 693 | Authorization: `Bearer ${apiConfig.apiKey}`, 694 | }, 695 | body: JSON.stringify({ 696 | model: apiConfig.model, 697 | messages: messages, 698 | stream: true, 699 | }), 700 | }); 701 | 702 | if (!response.ok) { 703 | throw new Error(`API请求失败: ${response.status} ${response.statusText}`); 704 | } 705 | 706 | const reader = response.body.getReader(); 707 | const decoder = new TextDecoder(); 708 | 709 | const callbacks = { 710 | onThinkContent: (content) => { 711 | if (activeTab === 'analysis') { 712 | setChatHistory((prev) => { 713 | const newHistory = [...prev]; 714 | const lastIndex = newHistory.length - 1; 715 | if (lastIndex >= 0 && !newHistory[lastIndex].isUser) { 716 | newHistory[lastIndex].thinkContent = content; 717 | newHistory[lastIndex].isThinking = true; 718 | } 719 | return newHistory; 720 | }); 721 | } 722 | }, 723 | onDisplayContent: (content) => { 724 | if (activeTab === 'analysis') { 725 | setChatHistory((prev) => { 726 | const newHistory = [...prev]; 727 | const lastIndex = newHistory.length - 1; 728 | if (lastIndex >= 0 && !newHistory[lastIndex].isUser) { 729 | newHistory[lastIndex].content = content; 730 | } 731 | return newHistory; 732 | }); 733 | } 734 | }, 735 | onComplete: () => { 736 | if (activeTab === 'analysis') { 737 | setChatHistory((prev) => { 738 | const newHistory = [...prev]; 739 | const lastIndex = newHistory.length - 1; 740 | if (lastIndex >= 0 && !newHistory[lastIndex].isUser) { 741 | newHistory[lastIndex].isThinking = false; 742 | } 743 | return newHistory; 744 | }); 745 | } 746 | setLoading(false); 747 | }, 748 | onError: (error) => { 749 | console.error(error); 750 | setError(error); 751 | showAlert(error.toString(), 'error'); 752 | setLoading(false); 753 | }, 754 | }; 755 | 756 | if (activeTab === 'analysis') { 757 | setChatHistory((prev) => [...prev, { content: '', thinkContent: '', isUser: false }]); 758 | } 759 | 760 | await handleStreamResponse(reader, decoder, callbacks); 761 | } catch (err) { 762 | setError(err.message || '发送消息失败'); 763 | showAlert(err.message || '发送消息失败', 'error'); 764 | setLoading(false); 765 | } 766 | }; 767 | 768 | const handleChatAnalysis = async () => { 769 | const hasFilterInput = Object.values(filterInputs).some((tableFilters) => 770 | Object.values(tableFilters).some((filter) => filter.length > 0) 771 | ); 772 | if (!hasFilterInput || !promptInput.trim()) { 773 | setValidationDialogOpen(true); 774 | return; 775 | } 776 | 777 | try { 778 | setActiveTab('chat'); 779 | localStorage.setItem('savedFilterInputs', JSON.stringify(filterInputs)); 780 | 781 | let messageContent = promptInput; 782 | const analysisData = getMergedFilteredData(); 783 | if (Object.keys(analysisData).length > 0) { 784 | messageContent = `${promptInput}\n数据分析页面的筛选数据:\n${JSON.stringify(analysisData, null, 2)}`; 785 | } 786 | 787 | const messages = chatHistory.map((msg) => ({ 788 | role: msg.isUser ? 'user' : 'assistant', 789 | content: msg.content, 790 | })); 791 | messages.push({ role: 'user', content: messageContent }); 792 | 793 | const response = await sendMessageToAPI(messages, apiConfig); 794 | const reader = response.body.getReader(); 795 | const decoder = new TextDecoder(); 796 | 797 | setChatHistory((prev) => [...prev, { content: '', thinkContent: '', isUser: false }]); 798 | 799 | await handleStreamResponse(reader, decoder, { 800 | onThinkContent: (content) => { 801 | setChatHistory((prev) => { 802 | const newHistory = [...prev]; 803 | const lastIndex = newHistory.length - 1; 804 | if (lastIndex >= 0 && !newHistory[lastIndex].isUser) { 805 | newHistory[lastIndex].thinkContent = content; 806 | newHistory[lastIndex].isThinking = true; 807 | } 808 | return newHistory; 809 | }); 810 | }, 811 | onDisplayContent: (content) => { 812 | setChatHistory((prev) => { 813 | const newHistory = [...prev]; 814 | const lastIndex = newHistory.length - 1; 815 | if (lastIndex >= 0 && !newHistory[lastIndex].isUser) { 816 | newHistory[lastIndex].content = content; 817 | } 818 | return newHistory; 819 | }); 820 | }, 821 | onComplete: () => { 822 | setChatHistory((prev) => { 823 | const newHistory = [...prev]; 824 | const lastIndex = newHistory.length - 1; 825 | if (lastIndex >= 0 && !newHistory[lastIndex].isUser) { 826 | newHistory[lastIndex].isThinking = false; 827 | } 828 | return newHistory; 829 | }); 830 | }, 831 | onError: (error) => { 832 | setChatHistory((prev) => [ 833 | ...prev, 834 | { content: `错误: ${error}`, isUser: false }, 835 | ]); 836 | }, 837 | }); 838 | 839 | setPromptInput(''); 840 | } catch (err) { 841 | setChatHistory((prev) => [ 842 | ...prev, 843 | { content: `发生错误: ${err.message}`, isUser: false }, 844 | ]); 845 | } 846 | }; 847 | 848 | useEffect(() => { 849 | if (responseContainerRef.current) { 850 | responseContainerRef.current.scrollTop = responseContainerRef.current.scrollHeight; 851 | } 852 | }, []); 853 | 854 | const handleSaveAnalysisAsImage = () => { 855 | if (!responseContainerRef.current) return; 856 | setIsCapturing(true); 857 | }; 858 | 859 | useEffect(() => { 860 | if (isCapturing) { 861 | const capture = async () => { 862 | try { 863 | const canvas = await html2canvas(responseContainerRef.current, { 864 | backgroundColor: '#2c2c2c', 865 | scale: 2, 866 | useCORS: true, 867 | }); 868 | const dataUrl = canvas.toDataURL('image/png'); 869 | setImageDataUrl(dataUrl); 870 | setDialogOpen(true); 871 | } catch (error) { 872 | console.error('Error saving analysis as image:', error); 873 | } finally { 874 | setIsCapturing(false); 875 | } 876 | }; 877 | capture(); 878 | } 879 | }, [isCapturing]); 880 | 881 | const handleDownloadImage = () => { 882 | if (!imageDataUrl) return; 883 | const link = document.createElement('a'); 884 | link.href = imageDataUrl; 885 | link.download = 'analysis_result.png'; 886 | document.body.appendChild(link); 887 | link.click(); 888 | document.body.removeChild(link); 889 | }; 890 | 891 | const handleCopyToClipboard = async () => { 892 | if (!imageDataUrl) return; 893 | try { 894 | const response = await window.electronAPI.ipcRenderer.invoke('copy-to-clipboard', imageDataUrl); 895 | if (!response.success) { 896 | throw new Error(response.error); 897 | } 898 | } catch (error) { 899 | console.error('复制失败:', error); 900 | alert('复制失败,请重试'); 901 | } 902 | }; 903 | 904 | const renderTableHeader = (table, visibleColumns) => ( 905 | 906 | 907 | {visibleColumns.map((col) => ( 908 | 909 | { 934 | setFilters((prev) => ({ 935 | ...prev, 936 | [table.name]: { ...prev[table.name], [col]: value }, 937 | })); 938 | setFilterInputs((prev) => ({ 939 | ...prev, 940 | [table.name]: { ...prev[table.name], [col]: value }, 941 | })); 942 | }} 943 | renderInput={(params) => ( 944 | e.key === 'Enter' && e.preventDefault()} 949 | /> 950 | )} 951 | /> 952 | 953 | ))} 954 | 955 | 956 | ); 957 | 958 | const handleColumnToggle = (tableName, column) => { 959 | const newSelectedColumns = JSON.parse(JSON.stringify(selectedColumns)); 960 | if (!newSelectedColumns[tableName]) { 961 | newSelectedColumns[tableName] = {}; 962 | } 963 | const currentValue = newSelectedColumns[tableName][column] || false; 964 | newSelectedColumns[tableName][column] = !currentValue; 965 | setSelectedColumns(newSelectedColumns); 966 | }; 967 | 968 | const saveFilteredDataAsJSON = useCallback(() => { 969 | if (tables.length === 0) { 970 | setError('没有可导出的数据'); 971 | showAlert('没有可导出的数据', 'error'); 972 | return; 973 | } 974 | 975 | let exportCount = 0; 976 | 977 | for (const table of tables) { 978 | const tableSelectedColumns = selectedColumns[table.name] || {}; 979 | const visibleColumns = table.columns.filter((col) => tableSelectedColumns[col]); 980 | 981 | if (visibleColumns.length === 0) { 982 | continue; 983 | } 984 | 985 | const tableFilters = filters[table.name] || {}; 986 | 987 | const filteredData = table.data.filter((row) => { 988 | return visibleColumns.every((col) => { 989 | const filterValues = tableFilters[col] || []; 990 | if (filterValues.length === 0) return true; 991 | 992 | return filterValues.some((filterValue) => { 993 | const cellValue = String(row[table.columns.indexOf(col)]).toLowerCase(); 994 | return cellValue.includes(filterValue.toLowerCase()); 995 | }); 996 | }); 997 | }); 998 | 999 | if (filteredData.length === 0) continue; 1000 | 1001 | const selectedData = filteredData.map((row) => { 1002 | const newRow = {}; 1003 | for (const col of visibleColumns) { 1004 | newRow[col] = row[table.columns.indexOf(col)]; 1005 | } 1006 | return newRow; 1007 | }); 1008 | 1009 | const jsonData = JSON.stringify(selectedData, null, 2); 1010 | const blob = new Blob([jsonData], { type: 'application/json' }); 1011 | const url = URL.createObjectURL(blob); 1012 | const a = document.createElement('a'); 1013 | a.href = url; 1014 | a.download = `${table.name}_filtered.json`; 1015 | a.click(); 1016 | URL.revokeObjectURL(url); 1017 | 1018 | exportCount++; 1019 | } 1020 | 1021 | if (exportCount === 0) { 1022 | setError('没有符合条件的数据可导出'); 1023 | showAlert('没有符合条件的数据可导出', 'error'); 1024 | } 1025 | }, [tables, filters, selectedColumns, showAlert]); 1026 | 1027 | useEffect(() => { 1028 | if (loading) { 1029 | setChartOption({ 1030 | title: { 1031 | text: '正在解析数据文件,如果数据过大可能会短暂无响应,请稍等...', 1032 | left: 'center', 1033 | top: 'center', 1034 | textStyle: { color: '#999', fontSize: 16 }, 1035 | }, 1036 | xAxis: { show: false }, 1037 | yAxis: { show: false }, 1038 | }); 1039 | } else if (tables.length === 0) { 1040 | setChartOption({ 1041 | title: { 1042 | text: '请上传数据文件', 1043 | left: 'center', 1044 | top: 'center', 1045 | textStyle: { color: '#999', fontSize: 16 }, 1046 | }, 1047 | xAxis: { show: false }, 1048 | yAxis: { show: false }, 1049 | }); 1050 | } 1051 | }, [loading, tables, showAlert]); 1052 | 1053 | return ( 1054 | 1055 | 1056 | 1104 | 1105 | 1106 | 1107 | 1108 | 1109 | 1110 | 1111 | setActiveTab('analysis')} 1114 | sx={{ 1115 | borderRadius: '8px', 1116 | m: 1, 1117 | '&.Mui-selected': { 1118 | backgroundColor: '#6abf4b', 1119 | color: '#fff', 1120 | '&:hover': { 1121 | backgroundColor: '#5aaf3b', 1122 | }, 1123 | }, 1124 | }} 1125 | > 1126 | 1127 | 1128 | 1129 | 1130 | 1131 | 1132 | 1133 | setActiveTab('chat')} 1136 | sx={{ 1137 | borderRadius: '8px', 1138 | m: 1, 1139 | '&.Mui-selected': { 1140 | backgroundColor: '#6abf4b', 1141 | color: '#fff', 1142 | '&:hover': { 1143 | backgroundColor: '#5aaf3b', 1144 | }, 1145 | }, 1146 | }} 1147 | > 1148 | 1149 | 1150 | 1151 | 1152 | 1153 | 1154 | 1155 | 1156 | 1157 | 1167 | 1176 | 1185 | 1193 | 1194 | 1195 | { 1197 | e.preventDefault(); 1198 | try { 1199 | await window.electronAPI.openExternal('https://github.com/oniyakun/VRCX-Data-Analysis'); 1200 | } catch (error) { 1201 | console.error('打开链接失败:', error); 1202 | } 1203 | }} 1204 | sx={{ 1205 | display: 'flex', 1206 | alignItems: 'center', 1207 | justifyContent: 'center', 1208 | color: 'inherit', 1209 | '&:hover': { color: '#6abf4b' }, 1210 | mb: 1, 1211 | cursor: 'pointer' 1212 | }} 1213 | > 1214 | 1215 | GitHub 1216 | 1217 | 1218 | Made with ♥ by { 1220 | e.preventDefault(); 1221 | window.electronAPI.openExternal('https://vrchat.com/home/user/usr_0e9c75fa-ec70-4043-9fca-264c9e0af6ba'); 1222 | }} 1223 | sx={{ color: 'inherit', '&:hover': { color: '#6abf4b' }, cursor: 'pointer' }} 1224 | >Oniyakun 1225 | 1226 | 1227 | 结果仅供娱乐,请勿当真! 1228 | 1229 | 1230 | 1231 | 1232 | 1233 | 1234 | 1235 | {activeTab === 'analysis' && ( 1236 | <> 1237 | {tables.length === 0 && ( 1238 | 1239 | 1240 | 1241 | 1242 | 1243 | )} 1244 | 1245 | {tables.map((table) => { 1246 | const state = pagination[table.name] || { page: 1, rowsPerPage: 10 }; 1247 | const tableSelectedColumns = selectedColumns[table.name] || {}; 1248 | const visibleColumns = table.columns.filter((col) => tableSelectedColumns[col]); 1249 | 1250 | if (visibleColumns.length === 0) { 1251 | return ( 1252 | 1253 | 1254 | 1255 | {getDisplayNameAndHint(table.name).displayName} 1256 | 1257 | 请选择至少一列以显示数据 1258 |
1267 | {table.columns.map((col) => ( 1268 | handleColumnToggle(table.name, col)} 1273 | sx={{ borderRadius: '16px' }} 1274 | /> 1275 | ))} 1276 |
1277 |
1278 |
1279 | ); 1280 | } 1281 | 1282 | const tableFilters = filters[table.name] || {}; 1283 | const filteredData = table.data.filter((row) => 1284 | visibleColumns.every((col) => { 1285 | const filterValues = tableFilters[col] || []; 1286 | return ( 1287 | filterValues.length === 0 || 1288 | filterValues.some((f) => 1289 | String(row[table.columns.indexOf(col)]) 1290 | .toLowerCase() 1291 | .includes(f.toLowerCase()) 1292 | ) 1293 | ); 1294 | }) 1295 | ); 1296 | 1297 | const startIndex = (state.page - 1) * state.rowsPerPage; 1298 | const paginatedData = filteredData.slice( 1299 | startIndex, 1300 | startIndex + state.rowsPerPage 1301 | ); 1302 | 1303 | const { displayName, hint } = getDisplayNameAndHint(table.name); 1304 | return ( 1305 | 1306 | 1307 | 1308 | {displayName} 1309 | 1310 | 1311 | {hint} 1312 | 1313 |
1316 | {table.columns.map((col) => ( 1317 | handleColumnToggle(table.name, col)} 1322 | sx={{ borderRadius: '16px' }} 1323 | /> 1324 | ))} 1325 |
1326 | 1347 | 1348 | {renderTableHeader(table, visibleColumns)} 1349 | 1350 | {paginatedData.length > 0 ? ( 1351 | paginatedData.map((row, rowIndex) => ( 1352 | 1353 | {visibleColumns.map((col, colIndex) => { 1354 | const cellIndex = table.columns.indexOf(col); 1355 | const cellValue = 1356 | cellIndex >= 0 && cellIndex < row.length 1357 | ? row[cellIndex] 1358 | : ''; 1359 | 1360 | return ( 1361 | 1375 |
1384 | {cellValue} 1385 |
1386 |
1387 | ); 1388 | })} 1389 |
1390 | )) 1391 | ) : ( 1392 | 1393 | 1394 | 没有数据 1395 | 1396 | 1397 | )} 1398 |
1399 |
1400 |
1401 | 1405 | setPagination((prev) => ({ 1406 | ...prev, 1407 | [table.name]: { ...prev[table.name], page }, 1408 | })) 1409 | } 1410 | sx={{ mt: 3, display: 'flex', justifyContent: 'center' }} 1411 | /> 1412 |
1413 |
1414 | ); 1415 | })} 1416 | 1417 | 1418 | 1419 | { 1424 | setSelectedPreset(e.target.value); 1425 | setPromptInput(e.target.value); 1426 | }} 1427 | fullWidth 1428 | sx={{ mb: 3 }} 1429 | SelectProps={{ 1430 | MenuProps: { 1431 | PaperProps: { 1432 | sx: { 1433 | maxWidth: '50%', 1434 | width: 'auto', 1435 | maxHeight: 'none', 1436 | overflowY: 'visible', 1437 | '& .MuiMenuItem-root': { 1438 | whiteSpace: 'normal', 1439 | wordWrap: 'break-word', 1440 | padding: '8px 16px', 1441 | width: '100%', 1442 | '&:hover': { 1443 | backgroundColor: 'rgba(255, 255, 255, 0.08)' 1444 | } 1445 | } 1446 | } 1447 | } 1448 | } 1449 | }} 1450 | > 1451 | {filterPrompts.map(option => ( 1452 | 1456 | {option} 1457 | 1458 | ))} 1459 | 1460 | setPromptInput(e.target.value)} 1467 | placeholder="请输入您的分析需求,可以根据实际情况修改Prompt" 1468 | variant="outlined" 1469 | sx={{ mb: 3 }} 1470 | /> 1471 |
1472 | 1481 | 1490 |
1491 |
1492 |
1493 | 1494 | )} 1495 | 1496 | {activeTab === 'chat' && ( 1497 | 1509 | )} 1510 |
1511 |
1512 |
1513 | 1514 | setSettingsOpen(false)} 1517 | onSave={(config) => { 1518 | localStorage.setItem('apiEndpoint', config.endpoint); 1519 | localStorage.setItem('apiKey', config.apiKey); 1520 | localStorage.setItem('model', config.model); 1521 | setApiConfig(config); 1522 | }} 1523 | initialConfig={apiConfig} 1524 | /> 1525 | 1526 | setDialogOpen(false)} maxWidth="lg" fullWidth> 1527 | 分析结果图片 1528 | 1529 | {imageDataUrl && ( 1530 | Analysis Result 1535 | )} 1536 | 1537 | 1538 | 1539 | 1540 | 1541 | 1542 | 1543 | 1544 | setValidationDialogOpen(false)}> 1545 | 提示 1546 | 1547 | 请至少在一个多条件筛选框内输入内容,并输入分析Prompt。 1548 | 1549 | 1550 | 1551 | 1552 | 1553 | 1554 | 1560 | 1565 | {alertInfo.message} 1566 | 1567 | 1568 |
1569 |
1570 | )}; -------------------------------------------------------------------------------- /frontend/src/SettingsModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Modal, Box, Typography, TextField, Button } from '@mui/material'; 3 | 4 | const SettingsModal = ({ open, onClose, onSave, initialConfig }) => { 5 | // State for input fields 6 | const [endpoint, setEndpoint] = useState(''); 7 | const [apiKey, setApiKey] = useState(''); 8 | const [model, setModel] = useState(''); 9 | 10 | // Populate fields with initialConfig when it changes or modal opens 11 | useEffect(() => { 12 | if (initialConfig) { 13 | setEndpoint(initialConfig.endpoint || ''); 14 | setApiKey(initialConfig.apiKey || ''); 15 | setModel(initialConfig.model || ''); 16 | } 17 | }, [initialConfig]); 18 | 19 | // Handle save action 20 | const handleSave = () => { 21 | const newConfig = { endpoint, apiKey, model }; 22 | onSave(newConfig); 23 | onClose(); 24 | }; 25 | 26 | return ( 27 | 28 | 41 | 42 | API 设置 43 | 44 | setEndpoint(e.target.value)} 49 | sx={{ mb: 2 }} 50 | /> 51 | setApiKey(e.target.value)} 57 | sx={{ mb: 2 }} 58 | /> 59 | setModel(e.target.value)} 64 | sx={{ mb: 2 }} 65 | /> 66 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | export default SettingsModal; -------------------------------------------------------------------------------- /frontend/src/components/ChatMessage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Paper, Typography, Box, Collapse, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; 3 | import { Image } from '@mui/icons-material'; 4 | import ReactMarkdown from 'react-markdown'; 5 | import remarkGfm from 'remark-gfm'; 6 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 7 | import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; 8 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 9 | import ExpandLessIcon from '@mui/icons-material/ExpandLess'; 10 | import html2canvas from 'html2canvas'; 11 | 12 | const ChatMessage = ({ message, isUser, thinkContent = '', isThinking = false, showAlert }) => { 13 | const [expanded, setExpanded] = useState(true); 14 | const [dialogOpen, setDialogOpen] = useState(false); 15 | const [imageDataUrl, setImageDataUrl] = useState(null); 16 | const [isCapturing, setIsCapturing] = useState(false); 17 | const messageRef = useRef(null); 18 | 19 | // 监听思考状态的变化,只在思考完成时自动折叠 20 | useEffect(() => { 21 | // 如果思考完成,则折叠 22 | if (!isThinking && thinkContent) { 23 | setExpanded(false); 24 | } 25 | // 如果开始思考,则展开 26 | if (isThinking) { 27 | setExpanded(true); 28 | } 29 | }, [isThinking, thinkContent]); 30 | 31 | const handleSaveAsImage = () => { 32 | if (!messageRef.current) return; 33 | 34 | const paperElement = messageRef.current; 35 | setIsCapturing(true); 36 | 37 | html2canvas(paperElement, { 38 | backgroundColor: '#2a2a2a', 39 | scale: 2, 40 | useCORS: true, 41 | }).then((canvas) => { 42 | const dataUrl = canvas.toDataURL('image/png'); 43 | setImageDataUrl(dataUrl); 44 | setDialogOpen(true); 45 | setIsCapturing(false); 46 | }).catch((error) => { 47 | console.error('Error saving message as image:', error); 48 | setIsCapturing(false); 49 | }); 50 | }; 51 | 52 | const handleDownloadImage = () => { 53 | if (!imageDataUrl) return; 54 | const link = document.createElement('a'); 55 | link.href = imageDataUrl; 56 | link.download = `message_${new Date().toISOString().slice(0, 10)}.png`; 57 | document.body.appendChild(link); 58 | link.click(); 59 | document.body.removeChild(link); 60 | }; 61 | 62 | const handleCopyToClipboard = async () => { 63 | if (!imageDataUrl) return; 64 | try { 65 | const response = await window.electronAPI.ipcRenderer.invoke('copy-to-clipboard', imageDataUrl); 66 | if (!response.success) { 67 | throw new Error(response.error); 68 | } 69 | showAlert('复制成功', 'success'); // 使用传入的 showAlert 70 | } catch (error) { 71 | console.error('复制失败:', error); 72 | showAlert('复制失败,请重试'); // 使用传入的 showAlert 73 | } 74 | }; 75 | 76 | // 如果是用户消息,直接返回原始消息 77 | if (isUser) { 78 | return ( 79 | 86 | 98 | {message} 99 | 100 | 101 | ); 102 | } 103 | 104 | return ( 105 | 112 | 125 | {/* 思考过程部分 */} 126 | {thinkContent && ( 127 | 128 | {!isThinking && ( 129 | 142 | )} 143 | 144 | 145 | 154 | {isThinking && ( 155 | 164 | 思考中... 165 | 166 | )} 167 | 179 | {String(children).replace(/\n$/, '')} 180 | 181 | ) : ( 182 | 183 | {children} 184 | 185 | ); 186 | }, 187 | }} 188 | > 189 | {thinkContent} 190 | 191 | 192 | 193 | 194 | )} 195 | 196 | {/* 消息内容部分 */} 197 | {message && ( 198 | 210 | {String(children).replace(/\n$/, '')} 211 | 212 | ) : ( 213 | 214 | {children} 215 | 216 | ); 217 | }, 218 | }} 219 | > 220 | {message} 221 | 222 | )} 223 | 224 | {/* 添加截图按钮 */} 225 | {!isUser && message && ( 226 | 227 | 237 | 238 | )} 239 | 240 | 241 | setDialogOpen(false)}> 242 | 保存消息 243 | 244 | 消息截图 245 | 246 | 249 | 252 | 253 | 254 | 255 | 256 | ); 257 | }; 258 | 259 | export default ChatMessage; -------------------------------------------------------------------------------- /frontend/src/components/ChatUI.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, useCallback } from 'react'; 2 | import { 3 | Box, 4 | TextField, 5 | Button, 6 | Paper, 7 | Typography, 8 | IconButton, 9 | Divider, 10 | Dialog, 11 | DialogActions, 12 | DialogContent, 13 | DialogTitle, 14 | Alert, 15 | Snackbar, 16 | MenuItem 17 | } from '@mui/material'; 18 | import { Send, Delete, Image, Stop } from '@mui/icons-material'; 19 | import ChatMessage from './ChatMessage'; 20 | import DataAnalysisSelector from './DataAnalysisSelector'; 21 | import { handleStreamResponse, sendMessageToAPI } from '../services/messageService'; 22 | import html2canvas from 'html2canvas'; 23 | 24 | const ChatUI = ({ 25 | apiConfig, 26 | presetPrompts, 27 | selectedPreset, 28 | setSelectedPreset, 29 | chatHistory = [], 30 | setChatHistory, 31 | getMergedFilteredData, 32 | loading, 33 | setLoading, 34 | onError, 35 | }) => { 36 | const [input, setInput] = useState(''); 37 | const [includeAnalysisData, setIncludeAnalysisData] = useState(false); 38 | const messagesEndRef = useRef(null); 39 | const [isCapturing, setIsCapturing] = useState(false); 40 | const [imageDataUrl, setImageDataUrl] = useState(null); 41 | const [dialogOpen, setDialogOpen] = useState(false); 42 | const chatContainerRef = useRef(null); 43 | const [isGenerating, setIsGenerating] = useState(false); 44 | const readerRef = useRef(null); 45 | const [alertInfo, setAlertInfo] = useState({ open: false, message: '', severity: 'error' }); 46 | 47 | // 滚动到最新消息 48 | function scrollToBottom() { 49 | messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 50 | } 51 | 52 | // 关闭提示信息 53 | const handleCloseAlert = () => { 54 | setAlertInfo({ ...alertInfo, open: false }); 55 | }; 56 | 57 | // 显示提示信息 58 | const showAlert = (message, severity = 'error') => { 59 | setAlertInfo({ open: true, message, severity }); 60 | }; 61 | 62 | useEffect(() => { 63 | scrollToBottom(); 64 | }, [chatHistory]); 65 | 66 | // 添加消息事件监听器 67 | useEffect(() => { 68 | const handleMessage = (event) => { 69 | if (event.data.type === 'showAlert') { 70 | showAlert(event.data.message, event.data.severity); 71 | } 72 | }; 73 | 74 | window.addEventListener('message', handleMessage); 75 | return () => window.removeEventListener('message', handleMessage); 76 | }, []); 77 | 78 | // 发送消息 79 | const handleStopGeneration = async () => { 80 | if (readerRef.current) { 81 | try { 82 | await readerRef.current.cancel(); 83 | setIsGenerating(false); 84 | setLoading(false); 85 | } catch (error) { 86 | console.error('停止生成失败:', error); 87 | } 88 | } 89 | }; 90 | 91 | const handleSendMessage = async () => { 92 | if (!input.trim()) return; 93 | 94 | try { 95 | setLoading(true); 96 | setIsGenerating(true); 97 | 98 | // 构建消息内容(仅用于发送给 API) 99 | let messageContent = input; 100 | if (includeAnalysisData) { 101 | const analysisData = getMergedFilteredData(); 102 | if (analysisData && Object.keys(analysisData).length > 0) { 103 | messageContent = `${input}\n\n以下是数据分析页面的筛选数据:\n${JSON.stringify(analysisData, null, 2)}`; 104 | } else { 105 | showAlert('未找到筛选数据,请先在数据分析页面进行数据筛选', 'warning'); 106 | setLoading(false); 107 | setIsGenerating(false); 108 | return; 109 | } 110 | } 111 | 112 | // 添加用户消息到历史记录,使用实际输入内容 113 | setChatHistory(prev => [...prev, { content: input, isUser: true }]); 114 | setInput(''); 115 | 116 | // 添加一个空的 AI 响应到历史记录 117 | const newMessageIndex = chatHistory.length; 118 | setChatHistory(prev => [...prev, { content: '', thinkContent: '', isUser: false }]); 119 | 120 | // 构建完整的对话历史(用于发送给 API) 121 | const messages = chatHistory.map(msg => ({ 122 | role: msg.isUser ? 'user' : 'assistant', 123 | content: msg.isUser ? msg.content : 124 | msg.thinkContent ? `<思考内容>${msg.thinkContent}\n${msg.content}` : msg.content 125 | })); 126 | messages.push({ role: 'user', content: messageContent }); 127 | 128 | // 发送消息到 API 129 | const response = await sendMessageToAPI(messages, apiConfig); 130 | const reader = response.body.getReader(); 131 | readerRef.current = reader; 132 | const decoder = new TextDecoder(); 133 | 134 | // 处理流式响应 135 | await handleStreamResponse(reader, decoder, { 136 | onThinkContent: (content) => { 137 | setChatHistory(prev => { 138 | const newHistory = [...prev]; 139 | if (newHistory[newMessageIndex + 1]) { 140 | newHistory[newMessageIndex + 1].thinkContent = content; 141 | newHistory[newMessageIndex + 1].isThinking = true; 142 | } 143 | return newHistory; 144 | }); 145 | }, 146 | onDisplayContent: (content) => { 147 | setChatHistory(prev => { 148 | const newHistory = [...prev]; 149 | if (newHistory[newMessageIndex + 1]) { 150 | newHistory[newMessageIndex + 1].content = content; 151 | } 152 | return newHistory; 153 | }); 154 | }, 155 | onComplete: () => { 156 | setChatHistory(prev => { 157 | const newHistory = [...prev]; 158 | if (newHistory[newMessageIndex + 1]) { 159 | newHistory[newMessageIndex + 1].isThinking = false; 160 | } 161 | return newHistory; 162 | }); 163 | }, 164 | onError: (error) => { 165 | showAlert(`错误: ${error}`, 'error'); 166 | onError(error); 167 | setChatHistory(prev => [...prev, { content: `错误: ${error}`, isUser: false }]); 168 | }, 169 | }); 170 | } catch (error) { 171 | onError(`发送消息失败: ${error.message}`); 172 | setChatHistory(prev => [...prev, { content: `错误: ${error.message}`, isUser: false }]); 173 | } finally { 174 | setLoading(false); 175 | setIsGenerating(false); 176 | readerRef.current = null; 177 | setIncludeAnalysisData(false); // 每次发送消息后取消勾选 178 | } 179 | }; 180 | 181 | // 清空聊天历史 182 | const clearChat = () => { 183 | setChatHistory([]); 184 | }; 185 | 186 | const handleSaveAsImage = () => { 187 | // 获取最后一条AI消息 188 | const lastAiMessage = chatHistory.filter(msg => !msg.isUser).pop(); 189 | if (!lastAiMessage) { 190 | showAlert('没有找到AI回复内容'); 191 | return; 192 | } 193 | 194 | // 获取最后一条消息的DOM元素 195 | const lastMessageElement = chatContainerRef.current.lastElementChild.previousElementSibling; 196 | if (!lastMessageElement) return; 197 | 198 | // 直接获取Paper元素(实际内容区域) 199 | const paperElement = lastMessageElement.querySelector('.MuiPaper-root'); 200 | if (!paperElement) return; 201 | 202 | setIsCapturing(true); 203 | html2canvas(paperElement, { 204 | backgroundColor: '#2a2a2a', 205 | scale: 2, 206 | useCORS: true, 207 | }).then((canvas) => { 208 | const dataUrl = canvas.toDataURL('image/png'); 209 | setImageDataUrl(dataUrl); 210 | setDialogOpen(true); 211 | setIsCapturing(false); 212 | }).catch((error) => { 213 | console.error('Error saving chat as image:', error); 214 | setIsCapturing(false); 215 | }); 216 | }; 217 | 218 | const handleDownloadImage = () => { 219 | if (!imageDataUrl) return; 220 | const link = document.createElement('a'); 221 | link.href = imageDataUrl; 222 | link.download = `chat_${new Date().toISOString().slice(0, 10)}.png`; 223 | document.body.appendChild(link); 224 | link.click(); 225 | document.body.removeChild(link); 226 | }; 227 | 228 | const handleCopyToClipboard = async () => { 229 | if (!imageDataUrl) return; 230 | try { 231 | const response = await window.electronAPI.ipcRenderer.invoke('copy-to-clipboard', imageDataUrl); 232 | if (!response.success) { 233 | throw new Error(response.error); 234 | } 235 | showAlert('复制成功', 'success'); 236 | } catch (error) { 237 | console.error('复制失败:', error); 238 | showAlert('复制失败,请重试'); 239 | } 240 | }; 241 | 242 | return ( 243 | <> 244 | 255 | {/* 聊天头部 */} 256 | 266 | 聊天 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | {/* 聊天消息区域 */} 280 | 293 | {chatHistory.length === 0 ? ( 294 | 304 | {loading ? '正在思考中...' : '开始新的对话'} 305 | 306 | {apiConfig.endpoint ? '已连接到API' : '请先设置API'} 307 | 308 | 309 | ) : ( 310 | chatHistory.map((msg, index) => ( 311 | !msg.isAnalysisRequest && ( 312 | 320 | ) 321 | )) 322 | )} 323 |
324 | 325 | 326 | 327 | 328 | {/* 输入区域 */} 329 | 338 | {/* 上部控制区:数据分析选择器和预设提示词选择器并排 */} 339 | 340 | {/* 数据分析选择器 - 调整为紧凑型 */} 341 | 342 | 346 | 347 | 348 | {/* 预设提示词选择 */} 349 | {presetPrompts && presetPrompts.length > 0 && ( 350 | 351 | { 356 | setSelectedPreset(e.target.value); 357 | setInput(e.target.value); 358 | }} 359 | fullWidth 360 | size="small" 361 | SelectProps={{ 362 | MenuProps: { 363 | PaperProps: { 364 | sx: { 365 | maxWidth: '50%', 366 | width: 'auto', 367 | maxHeight: 'none', 368 | overflowY: 'visible', 369 | '& .MuiMenuItem-root': { 370 | whiteSpace: 'normal', 371 | wordWrap: 'break-word', 372 | padding: '8px 16px', 373 | width: '100%', 374 | // 移除maxHeight和overflowY设置,防止每个菜单项出现滚动条 375 | '&:hover': { 376 | backgroundColor: 'rgba(255, 255, 255, 0.08)' 377 | } 378 | } 379 | } 380 | } 381 | } 382 | }} 383 | > 384 | {presetPrompts.map(option => ( 385 | 389 | {option} 390 | 391 | ))} 392 | 393 | 394 | )} 395 | 396 | 397 | {/* 输入框和发送按钮 */} 398 | 399 | setInput(e.target.value)} 405 | onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()} 406 | multiline 407 | maxRows={5} 408 | minRows={2} 409 | size="small" 410 | sx={{ 411 | '& .MuiOutlinedInput-root': { 412 | borderRadius: '12px', 413 | backgroundColor: '#333', 414 | padding: '8px 12px', 415 | }, 416 | '& .MuiInputBase-inputMultiline': { 417 | lineHeight: '1.5', 418 | } 419 | }} 420 | disabled={loading} 421 | /> 422 | 423 | 441 | 442 | 443 | 444 | 445 | 446 | setDialogOpen(false)}> 447 | 保存对话 448 | 449 | 对话截图 450 | 451 | 454 | 457 | 458 | 459 | 460 | 461 | 467 | 472 | {alertInfo.message} 473 | 474 | 475 | 476 | ); 477 | }; 478 | 479 | export default ChatUI; -------------------------------------------------------------------------------- /frontend/src/components/DataAnalysisSelector.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | FormControlLabel, 4 | Checkbox, 5 | Box, 6 | Typography, 7 | Paper, 8 | Tooltip, 9 | } from '@mui/material'; 10 | 11 | const DataAnalysisSelector = ({ includeAnalysisData, setIncludeAnalysisData }) => { 12 | return ( 13 | 25 | 26 | 27 | setIncludeAnalysisData(e.target.checked)} 32 | sx={{ 33 | color: '#6abf4b', 34 | '&.Mui-checked': { 35 | color: '#6abf4b', 36 | }, 37 | padding: '4px', 38 | }} 39 | size="small" 40 | /> 41 | } 42 | label={ 43 | 44 | 包含数据分析页面的筛选数据 45 | 46 | } 47 | sx={{ margin: 0 }} 48 | /> 49 | 50 | 51 | {includeAnalysisData && ( 52 | 63 | 注意:将会把数据分析页面中已筛选的数据一并发送给AI 64 | 65 | )} 66 | 67 | ); 68 | }; 69 | 70 | export default DataAnalysisSelector; -------------------------------------------------------------------------------- /frontend/src/components/index.js: -------------------------------------------------------------------------------- 1 | // 导出所有组件 2 | export { default as ChatMessage } from './ChatMessage'; 3 | export { default as ChatUI } from './ChatUI'; -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #2c2c2c; 9 | color: #ffffff; 10 | } 11 | 12 | * { 13 | font-family: inherit; 14 | } 15 | 16 | code { 17 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 18 | } 19 | 20 | .analysis-result { 21 | font-family: inherit; 22 | font-size: 16px; 23 | line-height: 1.8; 24 | } -------------------------------------------------------------------------------- /frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { HashRouter } from 'react-router-dom'; 4 | import AnalysisPage from './AnalysisPage.jsx'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render( 7 | 8 | 9 | 10 | 11 | 12 | ); -------------------------------------------------------------------------------- /frontend/src/services/messageService.js: -------------------------------------------------------------------------------- 1 | export const handleStreamResponse = async (reader, decoder, callbacks) => { 2 | const { 3 | onThinkContent, 4 | onDisplayContent, 5 | onComplete, 6 | onError, 7 | } = callbacks; 8 | 9 | let buffer = ''; 10 | let isInThinkTag = false; 11 | let thinkContent = ''; 12 | let displayContent = ''; 13 | let sentence = ''; 14 | 15 | const flushSentence = () => { 16 | if (sentence.trim()) { 17 | if (isInThinkTag) { 18 | thinkContent += sentence; 19 | onThinkContent?.(thinkContent); 20 | } else { 21 | displayContent += sentence; 22 | onDisplayContent?.(displayContent); 23 | } 24 | sentence = ''; 25 | } 26 | }; 27 | 28 | try { 29 | while (true) { 30 | const { done, value } = await reader.read(); 31 | if (done) { 32 | flushSentence(); 33 | onComplete?.(); 34 | break; 35 | } 36 | 37 | buffer += decoder.decode(value, { stream: true }); 38 | const lines = buffer.split('\n'); 39 | buffer = lines.pop() || ''; 40 | 41 | for (const line of lines) { 42 | if (!line.trim() || !line.startsWith('data: ')) continue; 43 | 44 | const data = line.slice(5).trim(); 45 | if (data === '[DONE]') { 46 | flushSentence(); 47 | onComplete?.(); 48 | return; 49 | } 50 | 51 | try { 52 | const json = JSON.parse(data); 53 | const content = json.choices?.[0]?.delta?.content || ''; 54 | if (!content) continue; 55 | 56 | let i = 0; 57 | while (i < content.length) { 58 | if (content.slice(i).startsWith('')) { 59 | flushSentence(); 60 | isInThinkTag = true; 61 | i += 6; 62 | continue; 63 | } 64 | 65 | if (content.slice(i).startsWith('')) { 66 | flushSentence(); 67 | isInThinkTag = false; 68 | i += 7; 69 | continue; 70 | } 71 | 72 | sentence += content[i]; 73 | if ('.。!!??\n'.includes(content[i]) || i === content.length - 1) { 74 | flushSentence(); 75 | } 76 | i++; 77 | } 78 | } catch (e) { 79 | console.error('解析消息失败:', e); 80 | onError?.(`解析消息失败: ${e.message}`); 81 | } 82 | } 83 | } 84 | } catch (e) { 85 | console.error('处理流失败:', e); 86 | onError?.(`处理流失败: ${e.message}`); 87 | } 88 | }; 89 | 90 | export const sendMessageToAPI = async (messages, apiConfig) => { 91 | const payload = { 92 | model: apiConfig.model, 93 | messages: messages, 94 | stream: true, 95 | }; 96 | 97 | const response = await fetch(apiConfig.endpoint, { 98 | method: 'POST', 99 | headers: { 100 | 'Content-Type': 'application/json', 101 | Authorization: `Bearer ${apiConfig.apiKey}`, 102 | }, 103 | body: JSON.stringify(payload), 104 | }); 105 | 106 | if (!response.ok) { 107 | throw new Error(`API请求失败: ${response.statusText}`); 108 | } 109 | 110 | return response; 111 | }; -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | build: { 7 | rollupOptions: { 8 | input: './src/main.jsx' 9 | } 10 | }, 11 | optimizeDeps: { 12 | include: ['react', 'react-dom', '@mui/material', 'axios', 'xlsx'] 13 | } 14 | }); -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, ipcMain, clipboard, nativeImage, shell } = require('electron'); 2 | const { spawn } = require('node:child_process'); 3 | const path = require('node:path'); 4 | const fs = require('node:fs'); 5 | const os = require('node:os'); 6 | const axios = require('axios'); 7 | const FormData = require('form-data'); 8 | 9 | let backendProcess; 10 | let mainWindow; 11 | // 添加临时文件跟踪数组 12 | const tempFiles = []; 13 | 14 | // 添加创建临时文件的函数,自动跟踪 15 | function createTempFile(prefix, suffix = '.json') { 16 | const tempFile = path.join(os.tmpdir(), `${prefix}_${Date.now()}${suffix}`); 17 | tempFiles.push(tempFile); 18 | return tempFile; 19 | } 20 | 21 | // 添加安全删除临时文件的函数 22 | function safeDeleteFile(filePath) { 23 | try { 24 | if (fs.existsSync(filePath)) { 25 | fs.unlinkSync(filePath); 26 | console.log('临时文件已删除:', filePath); 27 | 28 | // 从跟踪数组中移除 29 | const index = tempFiles.indexOf(filePath); 30 | if (index > -1) { 31 | tempFiles.splice(index, 1); 32 | } 33 | return true; 34 | } 35 | } catch (err) { 36 | console.error('删除临时文件失败:', err); 37 | return false; 38 | } 39 | return false; 40 | } 41 | 42 | // 添加清理所有临时文件的函数 43 | function cleanupAllTempFiles() { 44 | console.log('清理所有临时文件...'); 45 | const filesToRemove = [...tempFiles]; // 创建副本避免迭代时修改原数组 46 | 47 | for (const file of filesToRemove) { 48 | safeDeleteFile(file); 49 | } 50 | 51 | // 查找并清理额外的临时文件 52 | try { 53 | const tempDir = os.tmpdir(); 54 | const files = fs.readdirSync(tempDir); 55 | for (const file of files) { 56 | if (file.startsWith('vrcx_') && (file.endsWith('.json') || file.endsWith('.sqlite3'))) { 57 | const fullPath = path.join(tempDir, file); 58 | // 检查文件是否超过30分钟 59 | const stats = fs.statSync(fullPath); 60 | const fileAgeMs = Date.now() - stats.mtimeMs; 61 | if (fileAgeMs > 30 * 60 * 1000) { // 30分钟 62 | safeDeleteFile(fullPath); 63 | } 64 | } 65 | } 66 | } catch (err) { 67 | console.error('清理额外临时文件时出错:', err); 68 | } 69 | } 70 | 71 | function createWindow() { 72 | mainWindow = new BrowserWindow({ 73 | width: 1550, 74 | height: 1050, 75 | webPreferences: { 76 | preload: path.join(__dirname, 'preload.js'), 77 | contextIsolation: true, 78 | nodeIntegration: false, 79 | }, 80 | }); 81 | 82 | mainWindow.setMenuBarVisibility(false); 83 | 84 | if (process.env.NODE_ENV === 'development') { 85 | mainWindow.loadURL('http://localhost:5173'); 86 | } else { 87 | mainWindow.loadFile(path.join(__dirname, '../frontend/index.html')); 88 | } 89 | } 90 | 91 | // 每小时清理一次临时文件 92 | const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1小时 93 | let cleanupInterval = null; 94 | 95 | app.whenReady().then(() => { 96 | // 启动时清理临时文件 97 | cleanupAllTempFiles(); 98 | 99 | // 设置定时清理临时文件 100 | cleanupInterval = setInterval(cleanupAllTempFiles, CLEANUP_INTERVAL); 101 | 102 | // 启动后端进程 103 | const backendPath = process.env.NODE_ENV === 'development' 104 | ? path.join(__dirname, 'dist/app/app.exe') // 开发环境路径 105 | : path.join(process.resourcesPath, 'dist/app.exe'); // 打包后路径 106 | backendProcess = spawn(backendPath); 107 | 108 | backendProcess.stdout.on('data', (data) => { 109 | console.log(`后端输出: ${data}`); 110 | }); 111 | backendProcess.stderr.on('data', (data) => { 112 | console.error(`后端错误: ${data}`); 113 | }); 114 | 115 | createWindow(); 116 | 117 | // 添加openExternal的IPC处理程序 118 | ipcMain.handle('open-external', async (event, url) => { 119 | try { 120 | await shell.openExternal(url); 121 | return { success: true }; 122 | } catch (error) { 123 | console.error('打开外部链接失败:', error); 124 | return { success: false, error: error.message }; 125 | } 126 | }); 127 | // 自动加载 VRCX 数据库 128 | ipcMain.handle('auto-load-vrcx-db', async () => { 129 | try { 130 | console.log('开始自动加载VRCX数据库...'); 131 | const vrcxPath = path.join(os.homedir(), 'AppData', 'Roaming', 'VRCX', 'VRCX.sqlite3'); 132 | console.log('VRCX数据库路径:', vrcxPath); 133 | 134 | if (!fs.existsSync(vrcxPath)) { 135 | console.error('未找到VRCX数据库文件'); 136 | return { success: false, message: '未找到VRCX数据库文件,请手动加载' }; 137 | } 138 | 139 | console.log('读取VRCX数据库文件...'); 140 | const fileBuffer = fs.readFileSync(vrcxPath); 141 | console.log('文件大小:', fileBuffer.length, '字节'); 142 | 143 | const formData = new FormData(); 144 | formData.append('file', fileBuffer, { 145 | filename: 'VRCX.sqlite3', 146 | contentType: 'application/octet-stream', 147 | }); 148 | 149 | console.log('发送请求到后端...'); 150 | const resp = await axios.post('http://localhost:5000/upload', formData, { 151 | headers: formData.getHeaders(), 152 | maxContentLength: Number.POSITIVE_INFINITY, 153 | maxBodyLength: Number.POSITIVE_INFINITY, 154 | responseType: 'arraybuffer', // 使用arraybuffer类型,避免过早转换成字符串 155 | timeout: 120000 // 增加超时时间到120秒 156 | }); 157 | 158 | console.log('后端响应状态:', resp.status); 159 | 160 | // 分块处理大型JSON 161 | try { 162 | // 将buffer分割成更小的部分来处理 163 | const buffer = Buffer.from(resp.data); 164 | const totalSize = buffer.length; 165 | 166 | console.log('总响应大小:', totalSize, '字节'); 167 | 168 | // 处理方法:将响应写入临时文件,然后使用流式处理而不是一次性读取 169 | const tempFile = createTempFile('vrcx_response'); 170 | fs.writeFileSync(tempFile, buffer); 171 | console.log('已写入临时文件:', tempFile); 172 | console.log('临时文件大小:', fs.statSync(tempFile).size, '字节'); 173 | 174 | // 使用更直接的方法处理大型JSON 175 | // 先获取响应中所有表格的元数据部分 176 | const { parser } = require('stream-json'); 177 | const { streamValues } = require('stream-json/streamers/StreamValues'); 178 | 179 | // 开始计时 180 | const startTime = Date.now(); 181 | 182 | // 创建一个表元数据数组来存储结果 183 | const tables_metadata = []; 184 | 185 | // 处理数据的promise 186 | const processData = new Promise((resolve, reject) => { 187 | try { 188 | const pipeline = fs.createReadStream(tempFile, { 189 | highWaterMark: 1024 * 1024 // 增大缓冲区大小为1MB 190 | }) 191 | .pipe(parser()) 192 | .pipe(streamValues()); 193 | 194 | let dataReceived = false; 195 | let tableCount = 0; 196 | 197 | pipeline.on('data', data => { 198 | // 记录进度 199 | dataReceived = true; 200 | console.log('正在处理JSON流数据...'); 201 | 202 | // 仅收集tables_metadata数组的内容 203 | if (data?.value?.tables_metadata && Array.isArray(data.value.tables_metadata)) { 204 | tableCount = data.value.tables_metadata.length; 205 | console.log(`找到${tableCount}个表格的元数据`); 206 | // 一次性获取完整的表格元数据数组 207 | resolve(data.value.tables_metadata); 208 | } 209 | }); 210 | 211 | pipeline.on('end', () => { 212 | const endTime = Date.now(); 213 | console.log(`JSON处理完成,耗时: ${(endTime - startTime) / 1000} 秒`); 214 | 215 | // 如果正常结束但没有找到表格元数据 216 | if (!dataReceived) { 217 | console.warn('未找到任何表格元数据,JSON可能格式不正确'); 218 | // 在完成解析后删除临时文件 219 | safeDeleteFile(tempFile); 220 | resolve([]); 221 | } else if (!tableCount) { 222 | // 处理完流但没有解析到tables_metadata的情况 223 | safeDeleteFile(tempFile); 224 | resolve([]); 225 | } 226 | }); 227 | 228 | pipeline.on('error', err => { 229 | console.error('处理JSON流时出错:', err); 230 | // 确保错误情况下也删除临时文件 231 | safeDeleteFile(tempFile); 232 | reject(err); 233 | }); 234 | } catch (err) { 235 | console.error('创建流处理管道时出错:', err); 236 | // 确保在设置流处理器时出错也删除临时文件 237 | safeDeleteFile(tempFile); 238 | reject(err); 239 | } 240 | }); 241 | 242 | // 等待处理完成 243 | const tables_metadata_array = await processData; 244 | const jsonData = { tables_metadata: tables_metadata_array }; 245 | 246 | if (!jsonData.tables_metadata || !Array.isArray(jsonData.tables_metadata)) { 247 | console.error('后端响应数据格式不正确'); 248 | return { success: false, message: '后端响应数据格式不正确' }; 249 | } 250 | 251 | console.log('表格数量:', jsonData.tables_metadata.length); 252 | 253 | // 检查每个表格的结构并打印详细信息 254 | for (const table of jsonData.tables_metadata) { 255 | console.log('表格名称:', table.name); 256 | console.log('列数量:', table.columns ? table.columns.length : 'undefined'); 257 | console.log('数据行数:', table.data ? table.data.length : 'undefined'); 258 | 259 | // 打印前几列的名称,帮助调试 260 | if (table.columns && table.columns.length > 0) { 261 | console.log('前几列名称:', table.columns.slice(0, Math.min(5, table.columns.length))); 262 | } 263 | 264 | // 打印第一行数据,帮助调试 265 | if (table.data && table.data.length > 0) { 266 | console.log('第一行数据示例:', table.data[0]); 267 | } 268 | } 269 | 270 | // 确保数据格式正确 271 | const validatedData = { 272 | tables_metadata: jsonData.tables_metadata.map(table => { 273 | // 确保name是字符串 274 | const name = String(table.name || ''); 275 | // 确保columns是数组 276 | const columns = Array.isArray(table.columns) ? table.columns : []; 277 | // 确保data是二维数组 278 | let data = []; 279 | if (Array.isArray(table.data)) { 280 | data = table.data.map(row => { 281 | if (Array.isArray(row)) { 282 | // 确保所有单元格都是字符串,并且处理null和undefined 283 | return row.map(cell => { 284 | if (cell === null || cell === undefined) { 285 | return ''; 286 | } 287 | 288 | // 尝试确保字符串编码正确 289 | try { 290 | const cellStr = String(cell); 291 | 292 | // 检查是否包含可能的编码问题 293 | if (cellStr.includes('鈥') || cellStr.includes('銆') || cellStr.includes('鍦')) { 294 | console.log('检测到可能的编码问题:', cellStr); 295 | 296 | // 尝试修复编码问题 - 这只是一个示例,可能需要更复杂的处理 297 | try { 298 | // 如果是UTF-8编码被错误解析为其他编码,可以尝试重新编码 299 | // 这里我们只是记录问题,实际修复可能需要更复杂的逻辑 300 | console.log('原始数据类型:', typeof cell); 301 | } catch (encodeErr) { 302 | console.error('尝试修复编码时出错:', encodeErr); 303 | } 304 | } 305 | 306 | // 尝试解决编码问题 - 将可能的乱码替换为空格 307 | // 这是一个临时解决方案,可能需要更复杂的处理 308 | const cleanedStr = cellStr 309 | .replace(/鈥/g, ' ') 310 | .replace(/銆/g, ' ') 311 | .replace(/鍦/g, ' ') 312 | .replace(/\uFFFD/g, ' '); // 替换Unicode替换字符 313 | 314 | return cleanedStr; 315 | } catch (e) { 316 | console.error('转换单元格到字符串时出错:', e); 317 | return ''; 318 | } 319 | }); 320 | } 321 | return []; 322 | }); 323 | } 324 | 325 | // 打印一些示例数据用于调试 326 | if (data.length > 0 && columns.length > 0) { 327 | console.log(`表格 ${name} 第一行数据示例:`); 328 | const sampleRow = data[0]; 329 | for (let i = 0; i < Math.min(5, columns.length); i++) { 330 | if (i < sampleRow.length) { 331 | console.log(` ${columns[i]}: ${sampleRow[i]}`); 332 | // 检查编码 333 | if (typeof sampleRow[i] === 'string' && sampleRow[i].length > 0) { 334 | console.log(` ${columns[i]} 的前10个字符编码:`, 335 | Array.from(sampleRow[i].slice(0, 10)).map(c => c.charCodeAt(0).toString(16)).join(' ')); 336 | } 337 | } 338 | } 339 | } 340 | 341 | return { name, columns, data }; 342 | }) 343 | }; 344 | 345 | console.log('验证后的表格数量:', validatedData.tables_metadata.length); 346 | 347 | // 计算数据大小并决定如何传输 348 | try { 349 | // 先估算数据大小 350 | const sampleTable = validatedData.tables_metadata[0]; 351 | const sampleSize = JSON.stringify(sampleTable).length; 352 | const estimatedSize = sampleSize * validatedData.tables_metadata.length; 353 | 354 | console.log('估计数据大小:', estimatedSize, '字节'); 355 | 356 | // 如果数据预计超过50MB,则使用文件传输方式 357 | if (estimatedSize > 50 * 1024 * 1024) { 358 | console.log('数据量较大,使用临时文件传输...'); 359 | 360 | // 创建临时文件 361 | const tempDataFile = createTempFile('vrcx_data'); 362 | 363 | // 流式写入JSON到临时文件 364 | await new Promise((resolve, reject) => { 365 | const writeStream = fs.createWriteStream(tempDataFile); 366 | 367 | writeStream.write('{"tables_metadata":['); 368 | 369 | let first = true; 370 | for (const table of validatedData.tables_metadata) { 371 | if (!first) { 372 | writeStream.write(','); 373 | } 374 | first = false; 375 | 376 | // 将表数据序列化为JSON字符串片段 377 | const tableString = JSON.stringify(table); 378 | writeStream.write(tableString); 379 | } 380 | 381 | writeStream.write(']}'); 382 | writeStream.end(); 383 | 384 | writeStream.on('finish', resolve); 385 | writeStream.on('error', (err) => { 386 | console.error('写入临时文件出错:', err); 387 | // 尝试清理可能部分写入的临时文件 388 | safeDeleteFile(tempDataFile); 389 | reject(err); 390 | }); 391 | }).catch(err => { 392 | console.error('写入临时文件失败:', err); 393 | // 确保在catch中也清理文件 394 | safeDeleteFile(tempDataFile); 395 | throw err; // 重新抛出以便外层catch处理 396 | }); 397 | 398 | console.log('数据已写入临时文件:', tempDataFile); 399 | console.log('文件大小:', fs.statSync(tempDataFile).size, '字节'); 400 | 401 | // 返回文件路径而不是实际数据 402 | return { 403 | success: true, 404 | useFile: true, 405 | filePath: tempDataFile, 406 | tableCount: validatedData.tables_metadata.length 407 | }; 408 | } 409 | 410 | // 数据量较小,直接返回数据 411 | return { success: true, data: validatedData }; 412 | } catch (err) { 413 | console.error('准备数据传输时出错:', err); 414 | return { success: false, message: err.message }; 415 | } 416 | } catch (err) { 417 | console.error('解析响应数据失败:', err); 418 | return { success: false, message: err.message }; 419 | } 420 | } catch (err) { 421 | console.error('自动加载VRCX数据库错误:', err); 422 | return { success: false, message: err.message }; 423 | } 424 | }); 425 | 426 | // 读取本地配置文件 config.ini 中的预设 prompt 427 | ipcMain.handle('read-config', async () => { 428 | try { 429 | const configPath = process.env.NODE_ENV === 'development' 430 | ? path.join(__dirname, 'config.ini') // 开发环境路径 431 | : path.join(__dirname, '../frontend/config.ini'); // 打包后路径 432 | if (!fs.existsSync(configPath)) { 433 | return { success: false, message: '配置文件不存在' }; 434 | } 435 | const content = fs.readFileSync(configPath, 'utf-8'); 436 | if (!content || content.trim().length === 0) { 437 | return { success: false, message: '配置文件为空' }; 438 | } 439 | const lines = content.split('\n'); 440 | const filterPrompts = []; 441 | const analysisPrompts = []; 442 | let currentSection = ''; 443 | 444 | for (const line of lines) { 445 | const trimmed = line.trim(); 446 | if (trimmed === '[filter_prompts]') { 447 | currentSection = 'filter'; 448 | } else if (trimmed === '[analysis_prompts]') { 449 | currentSection = 'analysis'; 450 | } else if (trimmed && !trimmed.startsWith(';')) { 451 | const parts = trimmed.split('='); 452 | if (parts.length >= 2) { 453 | const value = parts.slice(1).join('=').trim(); 454 | if (value) { 455 | if (currentSection === 'filter') { 456 | filterPrompts.push(value); 457 | } else if (currentSection === 'analysis') { 458 | analysisPrompts.push(value); 459 | } 460 | } 461 | } 462 | } 463 | } 464 | 465 | console.log('成功读取到筛选页面预设提示词:', filterPrompts); 466 | console.log('成功读取到分析结果页面预设提示词:', analysisPrompts); 467 | return { 468 | success: true, 469 | filterPrompts: filterPrompts, 470 | analysisPrompts: analysisPrompts 471 | }; 472 | } catch (err) { 473 | console.error('读取配置文件错误:', err); 474 | return { success: false, message: err.message }; 475 | } 476 | }); 477 | 478 | // 添加copy-to-clipboard的IPC处理程序 479 | ipcMain.handle('copy-to-clipboard', async (event, dataUrl) => { 480 | try { 481 | const image = nativeImage.createFromDataURL(dataUrl); 482 | clipboard.writeImage(image); 483 | return { success: true }; 484 | } catch (error) { 485 | console.error('写入剪贴板失败:', error); 486 | return { success: false, error: error.message }; 487 | } 488 | }); 489 | 490 | // 添加临时文件读取的IPC处理程序 491 | ipcMain.handle('load-temp-file', async (event, filePath) => { 492 | try { 493 | console.log('请求读取临时文件:', filePath); 494 | 495 | // 检查文件是否存在 496 | if (!fs.existsSync(filePath)) { 497 | console.error('临时文件不存在:', filePath); 498 | return { success: false, message: '临时文件不存在' }; 499 | } 500 | 501 | // 检查文件大小 502 | const stats = fs.statSync(filePath); 503 | console.log('临时文件大小:', stats.size, '字节'); 504 | 505 | // 使用流式解析JSON 506 | const { parser } = require('stream-json'); 507 | const { streamObject } = require('stream-json/streamers/StreamObject'); 508 | 509 | // 创建一个解析数据结构 510 | try { 511 | const result = await new Promise((resolve, reject) => { 512 | const pipeline = fs.createReadStream(filePath) 513 | .pipe(parser()) 514 | .pipe(streamObject()); 515 | 516 | const fileData = {}; 517 | 518 | pipeline.on('data', data => { 519 | // 存储JSON对象中的键值对 520 | fileData[data.key] = data.value; 521 | }); 522 | 523 | pipeline.on('end', () => { 524 | console.log('临时文件解析完成'); 525 | 526 | // 完成后删除临时文件 527 | safeDeleteFile(filePath); 528 | 529 | resolve(fileData); 530 | }); 531 | 532 | pipeline.on('error', err => { 533 | console.error('解析临时文件出错:', err); 534 | // 确保错误情况下也删除临时文件 535 | safeDeleteFile(filePath); 536 | reject(err); 537 | }); 538 | }); 539 | 540 | return { 541 | success: true, 542 | data: result 543 | }; 544 | } catch (parseError) { 545 | console.error('解析临时文件数据失败:', parseError); 546 | // 确保解析失败时也删除临时文件 547 | safeDeleteFile(filePath); 548 | return { success: false, message: `解析临时文件数据失败: ${parseError.message}` }; 549 | } 550 | } catch (error) { 551 | console.error('读取临时文件失败:', error); 552 | // 尝试删除临时文件 553 | try { 554 | safeDeleteFile(filePath); 555 | } catch (cleanupError) { 556 | console.error('删除临时文件失败:', cleanupError); 557 | } 558 | return { success: false, message: error.message }; 559 | } 560 | }); 561 | }); 562 | 563 | app.on('window-all-closed', () => { 564 | if (process.platform !== 'darwin') { 565 | app.quit(); 566 | } 567 | }); 568 | 569 | app.on('quit', () => { 570 | if (backendProcess) { 571 | require('node:child_process').exec('taskkill /F /IM app.exe'); 572 | } 573 | 574 | // 应用退出时清理所有临时文件 575 | cleanupAllTempFiles(); 576 | }); 577 | 578 | // 停止定时器 579 | app.on('will-quit', () => { 580 | if (cleanupInterval) { 581 | clearInterval(cleanupInterval); 582 | } 583 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vrcx-data-analyzer", 3 | "version": "1.1.1", 4 | "main": "main.js", 5 | "devDependencies": { 6 | "concurrently": "^9.1.2", 7 | "electron": "^35.0.1", 8 | "electron-builder": "^25.1.8", 9 | "electron-vite": "^3.0.0" 10 | }, 11 | "scripts": { 12 | "dev": "electron-vite dev", 13 | "build": "electron-vite build", 14 | "postbuild": "electron-builder", 15 | "dist": "npm run build && npm run postbuild" 16 | }, 17 | "build": { 18 | "appId": "com.oniya.vrcx-data-analyzer", 19 | "productName": "VRCX Data Analysis", 20 | "files": [ 21 | "package.json", 22 | "main.js", 23 | "preload.js" 24 | ], 25 | "extraResources": [ 26 | { 27 | "from": "dist/app", 28 | "to": "dist" 29 | }, 30 | { 31 | "from": "frontend/dist", 32 | "to": "frontend" 33 | }, 34 | { 35 | "from": "config.ini", 36 | "to": "frontend/config.ini" 37 | } 38 | ], 39 | "directories": { 40 | "buildResources": "assets" 41 | }, 42 | "win": { 43 | "target": "nsis", 44 | "icon": "assets/icon.ico" 45 | } 46 | }, 47 | "dependencies": { 48 | "axios": "^1.8.2", 49 | "blob-polyfill": "^9.0.20240710", 50 | "iconv-lite": "^0.6.3", 51 | "react-router-dom": "^7.3.0", 52 | "stream-json": "^1.9.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer, shell } = require('electron'); 2 | const { clipboard, nativeImage } = require('electron'); 3 | 4 | // 创建一个函数来处理控制台消息 5 | function handleConsoleMessage(event, { type, message }) { 6 | switch (type) { 7 | case 'error': 8 | console.error(message); 9 | break; 10 | case 'warn': 11 | console.warn(message); 12 | break; 13 | case 'info': 14 | console.info(message); 15 | break; 16 | default: 17 | console.log(message); 18 | } 19 | } 20 | 21 | // 监听console-message事件 22 | ipcRenderer.on('console-message', handleConsoleMessage); 23 | 24 | contextBridge.exposeInMainWorld('electronAPI', { 25 | ipcRenderer: { 26 | invoke: async (channel, ...args) => { 27 | const validChannels = ['auto-load-vrcx-db', 'read-config', 'copy-to-clipboard', 'open-external', 'load-temp-file']; 28 | if (validChannels.includes(channel)) { 29 | try { 30 | const result = await ipcRenderer.invoke(channel, ...args); 31 | console.log(`IPC 调用结果:`, result); 32 | return result; 33 | } catch (error) { 34 | console.error(`IPC 调用失败:`, error); 35 | throw error; 36 | } 37 | } 38 | throw new Error(`不允许访问 IPC 通道: ${channel}`); 39 | }, 40 | }, 41 | getDirname: () => __dirname, 42 | openExternal: async (url) => { 43 | try { 44 | const result = await ipcRenderer.invoke('open-external', url); 45 | if (!result.success) { 46 | throw new Error(result.error || '打开外部链接失败'); 47 | } 48 | return true; 49 | } catch (error) { 50 | console.error('打开外部链接失败:', error); 51 | throw error; 52 | } 53 | }, 54 | clipboard: { 55 | writeImage: (dataUrl) => { 56 | try { 57 | const image = nativeImage.createFromDataURL(dataUrl); 58 | clipboard.writeImage(image); 59 | return true; 60 | } catch (error) { 61 | console.error('写入剪贴板失败:', error); 62 | throw error; 63 | } 64 | } 65 | } 66 | }); -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.2 2 | flask-cors==4.0.0 3 | pandas==2.1.3 4 | Werkzeug==3.0.1 5 | itsdangerous==2.1.2 6 | click==8.1.7 7 | python-dotenv==1.0.0 --------------------------------------------------------------------------------