├── .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 | 
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 | }
1161 | fullWidth
1162 | sx={{ mb: 2, borderRadius: 2 }}
1163 | >
1164 | 上传 SQLite 文件
1165 |
1166 |
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 |
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 |
1543 |
1544 |
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 | }
231 | onClick={handleSaveAsImage}
232 | disabled={isCapturing}
233 | sx={{ color: 'rgba(255,255,255,0.7)' }}
234 | >
235 | {isCapturing ? '截图中...' : '保存为图片'}
236 |
237 |
238 | )}
239 |
240 |
241 |
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 |
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 |
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
--------------------------------------------------------------------------------